mirror of
https://github.com/krau/SaveAny-Bot.git
synced 2026-05-11 23:09:47 +08:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b389a58d5 | ||
|
|
25ad9befa0 | ||
|
|
e824b210d1 | ||
|
|
ae0aa7db3f | ||
|
|
226c15ef08 | ||
|
|
9b3f955e48 | ||
|
|
4997ec408f | ||
|
|
0756cc9eb1 | ||
|
|
37c32a23d4 | ||
|
|
3aa1e2eaed | ||
|
|
b87dd68880 | ||
|
|
68e5a51300 | ||
|
|
7300e54c40 | ||
|
|
94f796d0e8 | ||
|
|
c023fd869d |
6
.github/workflows/build-release.yml
vendored
6
.github/workflows/build-release.yml
vendored
@@ -63,9 +63,9 @@ jobs:
|
|||||||
README.md
|
README.md
|
||||||
ldflags: >-
|
ldflags: >-
|
||||||
-s -w
|
-s -w
|
||||||
-X "github.com/krau/SaveAny-Bot/pkg/consts.Version=${{ env.VERSION }}"
|
-X "github.com/krau/SaveAny-Bot/config.Version=${{ env.VERSION }}"
|
||||||
-X "github.com/krau/SaveAny-Bot/pkg/consts.BuildTime=${{ format(github.event.repository.updated_at, 'yyyy-MM-dd HH:mm:ss') }}"
|
-X "github.com/krau/SaveAny-Bot/config.BuildTime=${{ format(github.event.repository.updated_at, 'yyyy-MM-dd HH:mm:ss') }}"
|
||||||
-X "github.com/krau/SaveAny-Bot/pkg/consts.GitCommit=${{ github.sha }}"
|
-X "github.com/krau/SaveAny-Bot/config.GitCommit=${{ github.sha }}"
|
||||||
binary_name: saveany-bot
|
binary_name: saveany-bot
|
||||||
env:
|
env:
|
||||||
VERSION: ${{ env.VERSION }}
|
VERSION: ${{ env.VERSION }}
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
|
|||||||
go build -trimpath \
|
go build -trimpath \
|
||||||
-ldflags=" \
|
-ldflags=" \
|
||||||
-s -w \
|
-s -w \
|
||||||
-X 'github.com/krau/SaveAny-Bot/common.Version=${VERSION}' \
|
-X 'github.com/krau/SaveAny-Bot/config.Version=${VERSION}' \
|
||||||
-X 'github.com/krau/SaveAny-Bot/common.GitCommit=${GitCommit}' \
|
-X 'github.com/krau/SaveAny-Bot/config.GitCommit=${GitCommit}' \
|
||||||
-X 'github.com/krau/SaveAny-Bot/common.BuildTime=${BuildTime}' \
|
-X 'github.com/krau/SaveAny-Bot/config.BuildTime=${BuildTime}' \
|
||||||
" \
|
" \
|
||||||
-o saveany-bot .
|
-o saveany-bot .
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/celestix/gotgproto/dispatcher"
|
"github.com/celestix/gotgproto/dispatcher"
|
||||||
@@ -11,6 +12,7 @@ import (
|
|||||||
"github.com/gotd/td/tg"
|
"github.com/gotd/td/tg"
|
||||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
"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/client/bot/handlers/utils/shortcut"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/fsutil"
|
||||||
"github.com/krau/SaveAny-Bot/database"
|
"github.com/krau/SaveAny-Bot/database"
|
||||||
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
|
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
|
||||||
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
||||||
@@ -74,6 +76,9 @@ func handleAddCallback(ctx *ext.Context, update *ext.Update) error {
|
|||||||
case tasktype.TaskTypeTphpics:
|
case tasktype.TaskTypeTphpics:
|
||||||
return shortcut.CreateAndAddtelegraphWithEdit(ctx, userID, data.TphPageNode, data.TphDirPath, data.TphPics, selectedStorage, msgID)
|
return shortcut.CreateAndAddtelegraphWithEdit(ctx, userID, data.TphPageNode, data.TphDirPath, data.TphPics, selectedStorage, msgID)
|
||||||
case tasktype.TaskTypeParseditem:
|
case tasktype.TaskTypeParseditem:
|
||||||
|
if len(data.ParsedItem.Resources) > 1 {
|
||||||
|
dirPath = path.Join(dirPath, fsutil.NormalizePathname(data.ParsedItem.Title))
|
||||||
|
}
|
||||||
shortcut.CreateAndAddParsedTaskWithEdit(ctx, selectedStorage, dirPath, data.ParsedItem, msgID, userID)
|
shortcut.CreateAndAddParsedTaskWithEdit(ctx, selectedStorage, dirPath, data.ParsedItem, msgID, userID)
|
||||||
default:
|
default:
|
||||||
log.FromContext(ctx).Errorf("Unsupported task type: %s", data.TaskType)
|
log.FromContext(ctx).Errorf("Unsupported task type: %s", data.TaskType)
|
||||||
|
|||||||
103
client/bot/handlers/config.go
Normal file
103
client/bot/handlers/config.go
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/celestix/gotgproto/dispatcher"
|
||||||
|
"github.com/celestix/gotgproto/ext"
|
||||||
|
"github.com/gotd/td/tg"
|
||||||
|
"github.com/krau/SaveAny-Bot/database"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/enums/fnamest"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleConfigCmd(ctx *ext.Context, update *ext.Update) error {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("请选择要配置的选项"), &ext.ReplyOpts{
|
||||||
|
Markup: &tg.ReplyInlineMarkup{
|
||||||
|
Rows: []tg.KeyboardButtonRow{
|
||||||
|
{
|
||||||
|
Buttons: []tg.KeyboardButtonClass{
|
||||||
|
&tg.KeyboardButtonCallback{
|
||||||
|
Text: "文件名策略",
|
||||||
|
Data: fmt.Appendf(nil, "%s %s", tcbdata.TypeConfig, "fnamest"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleConfigCallback(ctx *ext.Context, update *ext.Update) error {
|
||||||
|
args := strings.Fields(string(update.CallbackQuery.Data))
|
||||||
|
invaildDataAnswer := func() error {
|
||||||
|
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
|
||||||
|
QueryID: update.CallbackQuery.GetQueryID(),
|
||||||
|
Alert: true,
|
||||||
|
Message: "无效的回调数据",
|
||||||
|
CacheTime: 5,
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
if len(args) < 2 {
|
||||||
|
return invaildDataAnswer()
|
||||||
|
}
|
||||||
|
switch args[1] {
|
||||||
|
case "fnamest":
|
||||||
|
return handleConfigFnameSTCallback(ctx, update)
|
||||||
|
default:
|
||||||
|
return invaildDataAnswer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleConfigFnameSTCallback(ctx *ext.Context, update *ext.Update) error {
|
||||||
|
userID := update.CallbackQuery.GetUserID()
|
||||||
|
user, err := database.GetUserByChatID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
args := strings.Fields(string(update.CallbackQuery.Data))
|
||||||
|
if len(args) == 3 {
|
||||||
|
selected := args[2]
|
||||||
|
st, err := fnamest.ParseFnameST(selected)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
user.FilenameStrategy = st.String()
|
||||||
|
if err := database.UpdateUser(ctx, user); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
|
ID: update.CallbackQuery.GetMsgID(),
|
||||||
|
Message: fmt.Sprintf("已将文件名策略设置为: %s", fnamest.FnameSTDisplay[st]),
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
opts := fnamest.FnameSTValues()
|
||||||
|
buttons := make([]tg.KeyboardButtonClass, 0, len(opts))
|
||||||
|
for _, opt := range opts {
|
||||||
|
buttons = append(buttons, &tg.KeyboardButtonCallback{
|
||||||
|
Text: fnamest.FnameSTDisplay[opt],
|
||||||
|
Data: fmt.Appendf(nil, "%s %s %s", tcbdata.TypeConfig, "fnamest", opt),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
markup := &tg.ReplyInlineMarkup{Rows: []tg.KeyboardButtonRow{
|
||||||
|
{Buttons: buttons},
|
||||||
|
}}
|
||||||
|
currentStStr := user.FilenameStrategy
|
||||||
|
if currentStStr == "" {
|
||||||
|
currentStStr = fnamest.Default.String()
|
||||||
|
}
|
||||||
|
currentSt, err := fnamest.ParseFnameST(currentStStr)
|
||||||
|
if err != nil {
|
||||||
|
currentSt = fnamest.Default
|
||||||
|
}
|
||||||
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
|
ID: update.CallbackQuery.GetMsgID(),
|
||||||
|
Message: fmt.Sprintf("请选择文件名策略, 当前策略: %s", fnamest.FnameSTDisplay[currentSt]),
|
||||||
|
ReplyMarkup: markup,
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
|
|
||||||
"github.com/celestix/gotgproto/dispatcher"
|
"github.com/celestix/gotgproto/dispatcher"
|
||||||
"github.com/celestix/gotgproto/ext"
|
"github.com/celestix/gotgproto/ext"
|
||||||
"github.com/krau/SaveAny-Bot/pkg/consts"
|
"github.com/krau/SaveAny-Bot/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func handleHelpCmd(ctx *ext.Context, update *ext.Update) error {
|
func handleHelpCmd(ctx *ext.Context, update *ext.Update) error {
|
||||||
@@ -24,10 +24,10 @@ Save Any Bot - 转存你的 Telegram 文件
|
|||||||
|
|
||||||
使用帮助: https://sabot.unv.app/usage/
|
使用帮助: https://sabot.unv.app/usage/
|
||||||
`
|
`
|
||||||
shortHash := consts.GitCommit
|
shortHash := config.GitCommit
|
||||||
if len(shortHash) > 7 {
|
if len(shortHash) > 7 {
|
||||||
shortHash = shortHash[:7]
|
shortHash = shortHash[:7]
|
||||||
}
|
}
|
||||||
ctx.Reply(update, ext.ReplyTextString(fmt.Sprintf(helpText, consts.Version, shortHash)), nil)
|
ctx.Reply(update, ext.ReplyTextString(fmt.Sprintf(helpText, config.Version, shortHash)), nil)
|
||||||
return dispatcher.EndGroups
|
return dispatcher.EndGroups
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import (
|
|||||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
"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/client/bot/handlers/utils/shortcut"
|
||||||
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/database"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/enums/fnamest"
|
||||||
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
||||||
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||||
"github.com/krau/SaveAny-Bot/storage"
|
"github.com/krau/SaveAny-Bot/storage"
|
||||||
@@ -26,12 +28,22 @@ func handleMediaMessage(ctx *ext.Context, update *ext.Update) error {
|
|||||||
return handleGroupMediaMessage(ctx, update, message, groupID)
|
return handleGroupMediaMessage(ctx, update, message, groupID)
|
||||||
}
|
}
|
||||||
logger.Debugf("Got media: %s", message.Media.TypeName())
|
logger.Debugf("Got media: %s", message.Media.TypeName())
|
||||||
|
userId := update.GetUserChat().GetID()
|
||||||
msg, file, err := shortcut.GetFileFromMessageWithReply(ctx, update, message)
|
userDB, err := database.GetUserByChatID(ctx, userId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
userId := update.GetUserChat().GetID()
|
tfOpts := make([]tfile.TGFileOption, 0)
|
||||||
|
switch userDB.FilenameStrategy {
|
||||||
|
case fnamest.Message.String():
|
||||||
|
tfOpts = append(tfOpts, tfile.WithName(tgutil.GenFileNameFromMessage(*message)))
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
msg, file, err := shortcut.GetFileFromMessageWithReply(ctx, update, message, tfOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
stors := storage.GetUserStorages(ctx, userId)
|
stors := storage.GetUserStorages(ctx, userId)
|
||||||
req, err := msgelem.BuildAddOneSelectStorageMessage(ctx, stors, file, msg.ID)
|
req, err := msgelem.BuildAddOneSelectStorageMessage(ctx, stors, file, msg.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -58,7 +70,17 @@ func handleSilentSaveMedia(ctx *ext.Context, update *ext.Update) error {
|
|||||||
}
|
}
|
||||||
logger.Debugf("Got media: %s", message.Media.TypeName())
|
logger.Debugf("Got media: %s", message.Media.TypeName())
|
||||||
userID := update.GetUserChat().GetID()
|
userID := update.GetUserChat().GetID()
|
||||||
msg, file, err := shortcut.GetFileFromMessageWithReply(ctx, update, message)
|
userDB, err := database.GetUserByChatID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tfOpts := make([]tfile.TGFileOption, 0)
|
||||||
|
switch userDB.FilenameStrategy {
|
||||||
|
case fnamest.Message.String():
|
||||||
|
tfOpts = append(tfOpts, tfile.WithName(tgutil.GenFileNameFromMessage(*message)))
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
msg, file, err := shortcut.GetFileFromMessageWithReply(ctx, update, message, tfOpts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"github.com/gotd/td/tg"
|
"github.com/gotd/td/tg"
|
||||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
"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/client/bot/handlers/utils/shortcut"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/fsutil"
|
||||||
"github.com/krau/SaveAny-Bot/parsers"
|
"github.com/krau/SaveAny-Bot/parsers"
|
||||||
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
|
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
|
||||||
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
||||||
@@ -20,7 +21,16 @@ import (
|
|||||||
func handleTextMessage(ctx *ext.Context, u *ext.Update) error {
|
func handleTextMessage(ctx *ext.Context, u *ext.Update) error {
|
||||||
logger := log.FromContext(ctx)
|
logger := log.FromContext(ctx)
|
||||||
text := u.EffectiveMessage.Text
|
text := u.EffectiveMessage.Text
|
||||||
item, err := parsers.ParseWithContext(ctx, text)
|
ok, pser := parsers.CanHandle(text)
|
||||||
|
if !ok {
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
msg, err := ctx.Reply(u, ext.ReplyTextString("正在解析..."), nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
item, err := pser.Parse(ctx, text)
|
||||||
if errors.Is(err, parsers.ErrNoParserFound) {
|
if errors.Is(err, parsers.ErrNoParserFound) {
|
||||||
return dispatcher.EndGroups
|
return dispatcher.EndGroups
|
||||||
}
|
}
|
||||||
@@ -29,7 +39,7 @@ func handleTextMessage(ctx *ext.Context, u *ext.Update) error {
|
|||||||
ctx.Reply(u, ext.ReplyTextString("Failed to parse text: "+err.Error()), nil)
|
ctx.Reply(u, ext.ReplyTextString("Failed to parse text: "+err.Error()), nil)
|
||||||
return dispatcher.EndGroups
|
return dispatcher.EndGroups
|
||||||
}
|
}
|
||||||
logger.Debug("Parsed item from text message", "text", text, "item", item)
|
logger.Debug("Parsed item from text message", "title", item.Title, "url", item.URL)
|
||||||
userID := u.GetUserChat().GetID()
|
userID := u.GetUserChat().GetID()
|
||||||
markup, err := msgelem.BuildAddSelectStorageKeyboard(storage.GetUserStorages(ctx, userID), tcbdata.Add{
|
markup, err := msgelem.BuildAddSelectStorageKeyboard(storage.GetUserStorages(ctx, userID), tcbdata.Add{
|
||||||
TaskType: tasktype.TaskTypeParseditem,
|
TaskType: tasktype.TaskTypeParseditem,
|
||||||
@@ -46,14 +56,11 @@ func handleTextMessage(ctx *ext.Context, u *ext.Update) error {
|
|||||||
ctx.Reply(u, ext.ReplyTextString("Failed to build parsed text entity: "+err.Error()), nil)
|
ctx.Reply(u, ext.ReplyTextString("Failed to build parsed text entity: "+err.Error()), nil)
|
||||||
return dispatcher.EndGroups
|
return dispatcher.EndGroups
|
||||||
}
|
}
|
||||||
ctx.SendMessage(userID, &tg.MessagesSendMessageRequest{
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
Message: text,
|
Message: text,
|
||||||
ReplyMarkup: markup,
|
ReplyMarkup: markup,
|
||||||
Entities: entities,
|
Entities: entities,
|
||||||
ReplyTo: &tg.InputReplyToMessage{
|
ID: msg.ID,
|
||||||
ReplyToMsgID: u.EffectiveMessage.ID,
|
|
||||||
ReplyToPeerID: u.GetUserChat().AsInputPeer(),
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return dispatcher.EndGroups
|
return dispatcher.EndGroups
|
||||||
@@ -80,7 +87,7 @@ func handleSilentSaveText(ctx *ext.Context, u *ext.Update) error {
|
|||||||
ctx.Reply(u, ext.ReplyTextString("Failed to parse text: "+err.Error()), nil)
|
ctx.Reply(u, ext.ReplyTextString("Failed to parse text: "+err.Error()), nil)
|
||||||
return dispatcher.EndGroups
|
return dispatcher.EndGroups
|
||||||
}
|
}
|
||||||
logger.Debug("Parsed item from text message", "text", text, "item", item)
|
logger.Debug("Parsed item from text message", "title", item.Title, "url", item.URL)
|
||||||
userID := u.GetUserChat().GetID()
|
userID := u.GetUserChat().GetID()
|
||||||
text, entities, err := msgelem.BuildParsedTextEntity(*item)
|
text, entities, err := msgelem.BuildParsedTextEntity(*item)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -100,5 +107,9 @@ func handleSilentSaveText(ctx *ext.Context, u *ext.Update) error {
|
|||||||
logger.Errorf("Failed to send message: %s", err)
|
logger.Errorf("Failed to send message: %s", err)
|
||||||
return dispatcher.EndGroups
|
return dispatcher.EndGroups
|
||||||
}
|
}
|
||||||
return shortcut.CreateAndAddParsedTaskWithEdit(ctx, stor, "", item, msg.ID, userID)
|
dirPath := ""
|
||||||
|
if len(item.Resources) > 1 {
|
||||||
|
dirPath = fsutil.NormalizePathname(item.Title)
|
||||||
|
}
|
||||||
|
return shortcut.CreateAndAddParsedTaskWithEdit(ctx, stor, dirPath, item, msg.ID, userID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,9 +40,11 @@ func Register(disp dispatcher.Dispatcher) {
|
|||||||
disp.AddHandler(handlers.NewCommand("watch", handleWatchCmd))
|
disp.AddHandler(handlers.NewCommand("watch", handleWatchCmd))
|
||||||
disp.AddHandler(handlers.NewCommand("unwatch", handleUnwatchCmd))
|
disp.AddHandler(handlers.NewCommand("unwatch", handleUnwatchCmd))
|
||||||
disp.AddHandler(handlers.NewCommand("save", handleSilentMode(handleSaveCmd, handleSilentSaveReplied)))
|
disp.AddHandler(handlers.NewCommand("save", handleSilentMode(handleSaveCmd, handleSilentSaveReplied)))
|
||||||
|
disp.AddHandler(handlers.NewCommand("config", handleConfigCmd))
|
||||||
disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix(tcbdata.TypeAdd), handleAddCallback))
|
disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix(tcbdata.TypeAdd), handleAddCallback))
|
||||||
disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix(tcbdata.TypeSetDefault), handleSetDefaultCallback))
|
disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix(tcbdata.TypeSetDefault), handleSetDefaultCallback))
|
||||||
disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix("cancel"), handleCancelCallback))
|
disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix(tcbdata.TypeCancel), handleCancelCallback))
|
||||||
|
disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix(tcbdata.TypeConfig), handleConfigCallback))
|
||||||
linkRegexFilter, err := filters.Message.Regex(re.TgMessageLinkRegexString)
|
linkRegexFilter, err := filters.Message.Regex(re.TgMessageLinkRegexString)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic("failed to create regex filter: " + err.Error())
|
panic("failed to create regex filter: " + err.Error())
|
||||||
@@ -110,7 +112,10 @@ func listenMediaMessageEvent(ch chan userclient.MediaMessageEvent) {
|
|||||||
}
|
}
|
||||||
var dirPath string
|
var dirPath string
|
||||||
if user.ApplyRule && user.Rules != nil {
|
if user.ApplyRule && user.Rules != nil {
|
||||||
matchedStorageName, matchedDirPath := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file))
|
matched, matchedStorageName, matchedDirPath := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file))
|
||||||
|
if !matched {
|
||||||
|
goto startCreateTask
|
||||||
|
}
|
||||||
dirPath = matchedDirPath.String()
|
dirPath = matchedDirPath.String()
|
||||||
if matchedStorageName.IsUsable() {
|
if matchedStorageName.IsUsable() {
|
||||||
stor, err = storage.GetStorageByUserIDAndName(ctx, user.ChatID, matchedStorageName.String())
|
stor, err = storage.GetStorageByUserIDAndName(ctx, user.ChatID, matchedStorageName.String())
|
||||||
@@ -120,6 +125,7 @@ func listenMediaMessageEvent(ch chan userclient.MediaMessageEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
startCreateTask:
|
||||||
storagePath := stor.JoinStoragePath(path.Join(dirPath, file.Name()))
|
storagePath := stor.JoinStoragePath(path.Join(dirPath, file.Name()))
|
||||||
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
|
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
|
||||||
taskid := xid.New().String()
|
taskid := xid.New().String()
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"github.com/duke-git/lancet/v2/slice"
|
"github.com/duke-git/lancet/v2/slice"
|
||||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
||||||
"github.com/krau/SaveAny-Bot/database"
|
"github.com/krau/SaveAny-Bot/database"
|
||||||
"github.com/krau/SaveAny-Bot/pkg/enums/rule"
|
"github.com/krau/SaveAny-Bot/pkg/rule"
|
||||||
)
|
)
|
||||||
|
|
||||||
func handleRuleCmd(ctx *ext.Context, update *ext.Update) error {
|
func handleRuleCmd(ctx *ext.Context, update *ext.Update) error {
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ import (
|
|||||||
|
|
||||||
"github.com/charmbracelet/log"
|
"github.com/charmbracelet/log"
|
||||||
"github.com/krau/SaveAny-Bot/database"
|
"github.com/krau/SaveAny-Bot/database"
|
||||||
"github.com/krau/SaveAny-Bot/pkg/consts"
|
|
||||||
ruleenum "github.com/krau/SaveAny-Bot/pkg/enums/rule"
|
|
||||||
"github.com/krau/SaveAny-Bot/pkg/rule"
|
"github.com/krau/SaveAny-Bot/pkg/rule"
|
||||||
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||||
)
|
)
|
||||||
@@ -37,7 +35,7 @@ func (m matchedStorName) String() string {
|
|||||||
|
|
||||||
// can we use this storage name directly?
|
// can we use this storage name directly?
|
||||||
func (m matchedStorName) IsUsable() bool {
|
func (m matchedStorName) IsUsable() bool {
|
||||||
return m != "" && m != consts.RuleStorNameChosen
|
return m != "" && m != rule.RuleStorNameChosen
|
||||||
}
|
}
|
||||||
|
|
||||||
type MatchedDirPath string
|
type MatchedDirPath string
|
||||||
@@ -47,17 +45,17 @@ func (m MatchedDirPath) String() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m MatchedDirPath) NeedNewForAlbum() bool {
|
func (m MatchedDirPath) NeedNewForAlbum() bool {
|
||||||
return m != "" && m == consts.RuleDirPathNewForAlbum
|
return m != "" && m == rule.RuleDirPathNewForAlbum
|
||||||
}
|
}
|
||||||
|
|
||||||
func ApplyRule(ctx context.Context, rules []database.Rule, inputs *ruleInput) (matchedStorageName matchedStorName, dirPath MatchedDirPath) {
|
func ApplyRule(ctx context.Context, rules []database.Rule, inputs *ruleInput) (matched bool, matchedStorageName matchedStorName, dirPath MatchedDirPath) {
|
||||||
if inputs == nil || len(rules) == 0 {
|
if inputs == nil || len(rules) == 0 {
|
||||||
return "", ""
|
return false, "", ""
|
||||||
}
|
}
|
||||||
logger := log.FromContext(ctx)
|
logger := log.FromContext(ctx)
|
||||||
for _, ur := range rules {
|
for _, ur := range rules {
|
||||||
switch ur.Type {
|
switch ur.Type {
|
||||||
case ruleenum.FileNameRegex.String():
|
case rule.FileNameRegex.String():
|
||||||
ru, err := rule.NewRuleFileNameRegex(ur.StorageName, ur.DirPath, ur.Data)
|
ru, err := rule.NewRuleFileNameRegex(ur.StorageName, ur.DirPath, ur.Data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("Failed to create rule: %s", err)
|
logger.Errorf("Failed to create rule: %s", err)
|
||||||
@@ -72,7 +70,7 @@ func ApplyRule(ctx context.Context, rules []database.Rule, inputs *ruleInput) (m
|
|||||||
dirPath = MatchedDirPath(ru.StoragePath())
|
dirPath = MatchedDirPath(ru.StoragePath())
|
||||||
matchedStorageName = matchedStorName(ru.StorageName())
|
matchedStorageName = matchedStorName(ru.StorageName())
|
||||||
}
|
}
|
||||||
case ruleenum.MessageRegex.String():
|
case rule.MessageRegex.String():
|
||||||
ru, err := rule.NewRuleMessageRegex(ur.StorageName, ur.DirPath, ur.Data)
|
ru, err := rule.NewRuleMessageRegex(ur.StorageName, ur.DirPath, ur.Data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("Failed to create rule: %s", err)
|
logger.Errorf("Failed to create rule: %s", err)
|
||||||
@@ -87,7 +85,7 @@ func ApplyRule(ctx context.Context, rules []database.Rule, inputs *ruleInput) (m
|
|||||||
dirPath = MatchedDirPath(ru.StoragePath())
|
dirPath = MatchedDirPath(ru.StoragePath())
|
||||||
matchedStorageName = matchedStorName(ru.StorageName())
|
matchedStorageName = matchedStorName(ru.StorageName())
|
||||||
}
|
}
|
||||||
case ruleenum.IsAlbum.String():
|
case rule.IsAlbum.String():
|
||||||
matchAlbum, err := convertor.ToBool(ur.Data)
|
matchAlbum, err := convertor.ToBool(ur.Data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
matchAlbum = false
|
matchAlbum = false
|
||||||
@@ -108,5 +106,8 @@ func ApplyRule(ctx context.Context, rules []database.Rule, inputs *ruleInput) (m
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return
|
if matchedStorageName != "" || dirPath != "" {
|
||||||
|
return true, matchedStorageName, dirPath
|
||||||
|
}
|
||||||
|
return false, "", ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,12 +20,14 @@ import (
|
|||||||
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||||
"github.com/krau/SaveAny-Bot/common/utils/tphutil"
|
"github.com/krau/SaveAny-Bot/common/utils/tphutil"
|
||||||
"github.com/krau/SaveAny-Bot/config"
|
"github.com/krau/SaveAny-Bot/config"
|
||||||
|
"github.com/krau/SaveAny-Bot/database"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/enums/fnamest"
|
||||||
"github.com/krau/SaveAny-Bot/pkg/telegraph"
|
"github.com/krau/SaveAny-Bot/pkg/telegraph"
|
||||||
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 获取消息中的文件并回复等待消息, 返回等待消息, 获取到的文件
|
// 获取消息中的文件并回复等待消息, 返回等待消息, 获取到的文件
|
||||||
func GetFileFromMessageWithReply(ctx *ext.Context, update *ext.Update, message *tg.Message, tfileopts ...tfile.TGFileOptions) (replied *types.Message,
|
func GetFileFromMessageWithReply(ctx *ext.Context, update *ext.Update, message *tg.Message, tfileopts ...tfile.TGFileOption) (replied *types.Message,
|
||||||
file tfile.TGFileMessage, err error,
|
file tfile.TGFileMessage, err error,
|
||||||
) {
|
) {
|
||||||
logger := log.FromContext(ctx)
|
logger := log.FromContext(ctx)
|
||||||
@@ -40,7 +42,7 @@ func GetFileFromMessageWithReply(ctx *ext.Context, update *ext.Update, message *
|
|||||||
logger.Errorf("Failed to reply: %s", err)
|
logger.Errorf("Failed to reply: %s", err)
|
||||||
return nil, nil, dispatcher.EndGroups
|
return nil, nil, dispatcher.EndGroups
|
||||||
}
|
}
|
||||||
options := []tfile.TGFileOptions{
|
options := []tfile.TGFileOption{
|
||||||
tfile.WithMessage(message),
|
tfile.WithMessage(message),
|
||||||
}
|
}
|
||||||
if len(tfileopts) > 0 {
|
if len(tfileopts) > 0 {
|
||||||
@@ -81,7 +83,12 @@ func GetFilesFromUpdateLinkMessageWithReplyEdit(ctx *ext.Context, update *ext.Up
|
|||||||
logger.Errorf("failed to edit message: %s", err)
|
logger.Errorf("failed to edit message: %s", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
user, err := database.GetUserByChatID(ctx, update.GetUserChat().GetID())
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("failed to get user from db: %s", err)
|
||||||
|
editReplied("获取用户信息失败: "+err.Error(), nil)
|
||||||
|
return nil, nil, nil, dispatcher.EndGroups
|
||||||
|
}
|
||||||
files = make([]tfile.TGFileMessage, 0, len(msgLinks))
|
files = make([]tfile.TGFileMessage, 0, len(msgLinks))
|
||||||
addFile := func(client downloader.Client, msg *tg.Message) {
|
addFile := func(client downloader.Client, msg *tg.Message) {
|
||||||
if msg == nil || msg.Media == nil {
|
if msg == nil || msg.Media == nil {
|
||||||
@@ -93,7 +100,14 @@ func GetFilesFromUpdateLinkMessageWithReplyEdit(ctx *ext.Context, update *ext.Up
|
|||||||
logger.Debugf("message %d has no media", msg.GetID())
|
logger.Debugf("message %d has no media", msg.GetID())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
file, err := tfile.FromMediaMessage(media, client, msg, tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*msg)))
|
var opt tfile.TGFileOption
|
||||||
|
switch user.FilenameStrategy {
|
||||||
|
case fnamest.Message.String():
|
||||||
|
opt = tfile.WithName(tgutil.GenFileNameFromMessage(*msg))
|
||||||
|
default:
|
||||||
|
opt = tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*msg))
|
||||||
|
}
|
||||||
|
file, err := tfile.FromMediaMessage(media, client, msg, opt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("failed to create file from media: %s", err)
|
logger.Errorf("failed to create file from media: %s", err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -34,8 +34,13 @@ func CreateAndAddTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor storage
|
|||||||
return dispatcher.EndGroups
|
return dispatcher.EndGroups
|
||||||
}
|
}
|
||||||
if user.ApplyRule && user.Rules != nil {
|
if user.ApplyRule && user.Rules != nil {
|
||||||
matchedStorageName, matchedDirPath := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file))
|
matched, matchedStorageName, matchedDirPath := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file))
|
||||||
dirPath = matchedDirPath.String()
|
if !matched {
|
||||||
|
goto startCreateTask
|
||||||
|
}
|
||||||
|
if matchedDirPath != "" {
|
||||||
|
dirPath = matchedDirPath.String()
|
||||||
|
}
|
||||||
if matchedStorageName.IsUsable() {
|
if matchedStorageName.IsUsable() {
|
||||||
stor, err = storage.GetStorageByUserIDAndName(ctx, user.ChatID, matchedStorageName.String())
|
stor, err = storage.GetStorageByUserIDAndName(ctx, user.ChatID, matchedStorageName.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -48,7 +53,7 @@ func CreateAndAddTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor storage
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
startCreateTask:
|
||||||
storagePath := stor.JoinStoragePath(path.Join(dirPath, file.Name()))
|
storagePath := stor.JoinStoragePath(path.Join(dirPath, file.Name()))
|
||||||
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
|
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
|
||||||
taskid := xid.New().String()
|
taskid := xid.New().String()
|
||||||
@@ -101,8 +106,10 @@ func CreateAndAddBatchTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor st
|
|||||||
if !useRule {
|
if !useRule {
|
||||||
return stor.Name(), ruleutil.MatchedDirPath(dirPath)
|
return stor.Name(), ruleutil.MatchedDirPath(dirPath)
|
||||||
}
|
}
|
||||||
storName, dirP := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file))
|
matched, storName, dirP := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file))
|
||||||
|
if !matched {
|
||||||
|
return stor.Name(), ruleutil.MatchedDirPath(dirPath)
|
||||||
|
}
|
||||||
storname := storName.String()
|
storname := storName.String()
|
||||||
if !storName.IsUsable() {
|
if !storName.IsUsable() {
|
||||||
storname = stor.Name()
|
storname = stor.Name()
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
|
|
||||||
func handleWatchCmd(ctx *ext.Context, update *ext.Update) error {
|
func handleWatchCmd(ctx *ext.Context, update *ext.Update) error {
|
||||||
logger := log.FromContext(ctx)
|
logger := log.FromContext(ctx)
|
||||||
args := strings.Split(string(update.EffectiveMessage.Text), " ")
|
args := strings.Split(update.EffectiveMessage.Text, " ")
|
||||||
if len(args) < 2 {
|
if len(args) < 2 {
|
||||||
ctx.Reply(update, ext.ReplyTextString(msgelem.WatchHelpText), nil)
|
ctx.Reply(update, ext.ReplyTextString(msgelem.WatchHelpText), nil)
|
||||||
return dispatcher.EndGroups
|
return dispatcher.EndGroups
|
||||||
@@ -82,7 +82,7 @@ func handleWatchCmd(ctx *ext.Context, update *ext.Update) error {
|
|||||||
|
|
||||||
func handleUnwatchCmd(ctx *ext.Context, update *ext.Update) error {
|
func handleUnwatchCmd(ctx *ext.Context, update *ext.Update) error {
|
||||||
logger := log.FromContext(ctx)
|
logger := log.FromContext(ctx)
|
||||||
args := strings.Split(string(update.EffectiveMessage.Text), " ")
|
args := strings.Split(update.EffectiveMessage.Text, " ")
|
||||||
if len(args) < 2 {
|
if len(args) < 2 {
|
||||||
ctx.Reply(update, ext.ReplyTextString("请提供要取消监听的聊天ID或用户名"), nil)
|
ctx.Reply(update, ext.ReplyTextString("请提供要取消监听的聊天ID或用户名"), nil)
|
||||||
return dispatcher.EndGroups
|
return dispatcher.EndGroups
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
|
||||||
"github.com/krau/SaveAny-Bot/pkg/consts"
|
"github.com/krau/SaveAny-Bot/config"
|
||||||
"github.com/rhysd/go-github-selfupdate/selfupdate"
|
"github.com/rhysd/go-github-selfupdate/selfupdate"
|
||||||
|
|
||||||
"github.com/blang/semver"
|
"github.com/blang/semver"
|
||||||
@@ -16,7 +16,7 @@ var VersionCmd = &cobra.Command{
|
|||||||
Aliases: []string{"v"},
|
Aliases: []string{"v"},
|
||||||
Short: "Print the version number of saveany-bot",
|
Short: "Print the version number of saveany-bot",
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
fmt.Printf("saveany-bot version: %s %s/%s\nBuildTime: %s, Commit: %s\n", consts.Version, runtime.GOOS, runtime.GOARCH, consts.BuildTime, consts.GitCommit)
|
fmt.Printf("saveany-bot version: %s %s/%s\nBuildTime: %s, Commit: %s\n", config.Version, runtime.GOOS, runtime.GOARCH, config.BuildTime, config.GitCommit)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,14 +25,14 @@ var upgradeCmd = &cobra.Command{
|
|||||||
Aliases: []string{"up"},
|
Aliases: []string{"up"},
|
||||||
Short: "Upgrade saveany-bot to the latest version",
|
Short: "Upgrade saveany-bot to the latest version",
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
v := semver.MustParse(consts.Version)
|
v := semver.MustParse(config.Version)
|
||||||
latest, err := selfupdate.UpdateSelf(v, "krau/SaveAny-Bot")
|
latest, err := selfupdate.UpdateSelf(v, "krau/SaveAny-Bot")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println("Binary update failed:", err)
|
fmt.Println("Binary update failed:", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if latest.Version.Equals(v) {
|
if latest.Version.Equals(v) {
|
||||||
fmt.Println("Current binary is the latest version", consts.Version)
|
fmt.Println("Current binary is the latest version", config.Version)
|
||||||
} else {
|
} else {
|
||||||
fmt.Println("Successfully updated to version", latest.Version)
|
fmt.Println("Successfully updated to version", latest.Version)
|
||||||
fmt.Println("Release note:\n", latest.ReleaseNotes)
|
fmt.Println("Release note:\n", latest.ReleaseNotes)
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package fsutil
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
"github.com/gabriel-vasile/mimetype"
|
"github.com/gabriel-vasile/mimetype"
|
||||||
)
|
)
|
||||||
@@ -55,3 +57,21 @@ func CreateFile(fp string) (*File, error) {
|
|||||||
}
|
}
|
||||||
return &File{File: file}, nil
|
return &File{File: file}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NormalizePathname(s string) string {
|
||||||
|
specials := `\/:*?"<>|` + "\n\r\t"
|
||||||
|
var builder strings.Builder
|
||||||
|
for _, ch := range s {
|
||||||
|
if strings.ContainsRune(specials, ch) || unicode.IsControl(ch) {
|
||||||
|
builder.WriteRune('_')
|
||||||
|
} else {
|
||||||
|
builder.WriteRune(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := strings.TrimRightFunc(builder.String(), func(r rune) bool {
|
||||||
|
return r == '.' || r == '_' || unicode.IsSpace(r)
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|||||||
46
common/utils/fsutil/normalize_pathname_test.go
Normal file
46
common/utils/fsutil/normalize_pathname_test.go
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
package fsutil_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/fsutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNormalizePathname(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
input: "hello/world?.txt ",
|
||||||
|
expected: "hello_world_.txt",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "bad|name:\nfile\r.",
|
||||||
|
expected: "bad_name__file",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "normal.txt",
|
||||||
|
expected: "normal.txt",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "test.... ",
|
||||||
|
expected: "test",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "abc<>def",
|
||||||
|
expected: "abc__def",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "with\tcontrol",
|
||||||
|
expected: "with_control",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
got := fsutil.NormalizePathname(tc.input)
|
||||||
|
if got != tc.expected {
|
||||||
|
t.Errorf("NormalizePathname(%q) = %q; want %q", tc.input, got, tc.expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,10 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/krau/SaveAny-Bot/config"
|
||||||
"golang.org/x/net/proxy"
|
"golang.org/x/net/proxy"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -20,7 +23,11 @@ func NewProxyDialer(proxyUrl string) (proxy.Dialer, error) {
|
|||||||
|
|
||||||
func NewProxyHTTPClient(proxyUrl string) (*http.Client, error) {
|
func NewProxyHTTPClient(proxyUrl string) (*http.Client, error) {
|
||||||
if proxyUrl == "" {
|
if proxyUrl == "" {
|
||||||
return http.DefaultClient, nil
|
return &http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
Proxy: http.ProxyFromEnvironment,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
u, err := url.Parse(proxyUrl)
|
u, err := url.Parse(proxyUrl)
|
||||||
@@ -52,3 +59,21 @@ func NewProxyHTTPClient(proxyUrl string) (*http.Client, error) {
|
|||||||
return nil, fmt.Errorf("unsupported proxy scheme: %s", u.Scheme)
|
return nil, fmt.Errorf("unsupported proxy scheme: %s", u.Scheme)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
defaultProxyHttpClient *http.Client
|
||||||
|
onceLoadDefaultProxyHttpClient sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
func DefaultParserHTTPClient() *http.Client {
|
||||||
|
onceLoadDefaultProxyHttpClient.Do(func() {
|
||||||
|
client, err := NewProxyHTTPClient(config.C().Parser.Proxy)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn("Failed to create default proxy HTTP client, using http.DefaultClient", "error", err)
|
||||||
|
defaultProxyHttpClient = http.DefaultClient
|
||||||
|
} else {
|
||||||
|
defaultProxyHttpClient = client
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return defaultProxyHttpClient
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
"github.com/celestix/gotgproto/ext"
|
"github.com/celestix/gotgproto/ext"
|
||||||
"github.com/duke-git/lancet/v2/maputil"
|
"github.com/duke-git/lancet/v2/maputil"
|
||||||
@@ -61,16 +62,12 @@ func GenFileNameFromMessage(message tg.Message) string {
|
|||||||
return fmt.Sprintf("%s_%s", tagStr, strconv.Itoa(message.GetID()))
|
return fmt.Sprintf("%s_%s", tagStr, strconv.Itoa(message.GetID()))
|
||||||
}
|
}
|
||||||
text = lcstrutil.Substring(strings.Map(func(r rune) rune {
|
text = lcstrutil.Substring(strings.Map(func(r rune) rune {
|
||||||
if r < 0x20 || r == 0x7F {
|
|
||||||
return '_'
|
|
||||||
}
|
|
||||||
switch r {
|
switch r {
|
||||||
// invalid characters
|
|
||||||
case '/', '\\',
|
case '/', '\\',
|
||||||
':', '*', '?', '"', '<', '>', '|':
|
':', '*', '?', '"', '<', '>', '|':
|
||||||
return '_'
|
return '_'
|
||||||
// empty
|
}
|
||||||
case ' ', '\t', '\r', '\n':
|
if unicode.IsControl(r) || unicode.IsSpace(r) {
|
||||||
return '_'
|
return '_'
|
||||||
}
|
}
|
||||||
if validator.IsPrintable(string(r)) {
|
if validator.IsPrintable(string(r)) {
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ func ParseMessageLink(ctx *ext.Context, link string) (int64, int, error) {
|
|||||||
return chatID, msgID, nil
|
return chatID, msgID, nil
|
||||||
case 3:
|
case 3:
|
||||||
// https://t.me/c/123456789/123
|
// https://t.me/c/123456789/123
|
||||||
// https://t.me/acherkrau/123/456 , 456: message thread ID
|
// https://t.me/acherkrau/123/456 , 123: topic id
|
||||||
chatPart, msgPart := paths[1], paths[2]
|
chatPart, msgPart := paths[1], paths[2]
|
||||||
if paths[0] != "c" {
|
if paths[0] != "c" {
|
||||||
chatPart = paths[0]
|
chatPart = paths[0]
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
type parserConfig struct {
|
type parserConfig struct {
|
||||||
PluginEnable bool `toml:"plugin_enable" mapstructure:"plugin_enable" json:"plugin_enable"`
|
PluginEnable bool `toml:"plugin_enable" mapstructure:"plugin_enable" json:"plugin_enable"`
|
||||||
PluginDirs []string `toml:"plugin_dirs" mapstructure:"plugin_dirs" json:"plugin_dirs"`
|
PluginDirs []string `toml:"plugin_dirs" mapstructure:"plugin_dirs" json:"plugin_dirs"`
|
||||||
|
Proxy string `toml:"proxy" mapstructure:"proxy" json:"proxy"`
|
||||||
ParserCfgs map[string]map[string]any `mapstructure:",remain"`
|
ParserCfgs map[string]map[string]any `mapstructure:",remain"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c Config) GetParserConfigByName(name string) map[string]any {
|
func (c Config) GetParserConfigByName(name string) map[string]any {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
type TelegramStorageConfig struct {
|
type TelegramStorageConfig struct {
|
||||||
BaseConfig
|
BaseConfig
|
||||||
ChatID int64 `toml:"chat_id" mapstructure:"chat_id" json:"chat_id"`
|
ChatID int64 `toml:"chat_id" mapstructure:"chat_id" json:"chat_id"`
|
||||||
|
ForceFile bool `toml:"force_file" mapstructure:"force_file" json:"force_file"`
|
||||||
RateLimit int `toml:"rate_limit" mapstructure:"rate_limit" json:"rate_limit"`
|
RateLimit int `toml:"rate_limit" mapstructure:"rate_limit" json:"rate_limit"`
|
||||||
RateBurst int `toml:"rate_burst" mapstructure:"rate_burst" json:"rate_burst"`
|
RateBurst int `toml:"rate_burst" mapstructure:"rate_burst" json:"rate_burst"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package consts
|
package config
|
||||||
|
|
||||||
// inject version by '-X' flag
|
// inject version by '-X' flag
|
||||||
// go build -ldflags "-X github.com/krau/SaveAny-Bot/pkg/consts.Version=${{ env.VERSION }}"
|
// go build -ldflags "-X github.com/krau/SaveAny-Bot/config.Version=${{ env.VERSION }}"
|
||||||
var (
|
var (
|
||||||
Version string = "dev"
|
Version string = "dev"
|
||||||
BuildTime string = "unknown"
|
BuildTime string = "unknown"
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/netutil"
|
||||||
"github.com/krau/SaveAny-Bot/config"
|
"github.com/krau/SaveAny-Bot/config"
|
||||||
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
|
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
|
||||||
"github.com/krau/SaveAny-Bot/pkg/parser"
|
"github.com/krau/SaveAny-Bot/pkg/parser"
|
||||||
@@ -47,12 +48,7 @@ func NewTask(
|
|||||||
item *parser.Item,
|
item *parser.Item,
|
||||||
progressTracker ProgressTracker,
|
progressTracker ProgressTracker,
|
||||||
) *Task {
|
) *Task {
|
||||||
client := &http.Client{
|
client := netutil.DefaultParserHTTPClient()
|
||||||
Transport: &http.Transport{
|
|
||||||
// [TODO] configure it via config
|
|
||||||
Proxy: http.ProxyFromEnvironment,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
_, ok := stor.(storage.StorageCannotStream)
|
_, ok := stor.(storage.StorageCannotStream)
|
||||||
stream := config.C().Stream && !ok
|
stream := config.C().Stream && !ok
|
||||||
return &Task{
|
return &Task{
|
||||||
|
|||||||
@@ -6,13 +6,14 @@ import (
|
|||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
gorm.Model
|
gorm.Model
|
||||||
ChatID int64 `gorm:"uniqueIndex;not null"`
|
ChatID int64 `gorm:"uniqueIndex;not null"`
|
||||||
Silent bool
|
Silent bool
|
||||||
DefaultStorage string
|
DefaultStorage string
|
||||||
Dirs []Dir
|
Dirs []Dir
|
||||||
ApplyRule bool
|
ApplyRule bool
|
||||||
Rules []Rule
|
Rules []Rule
|
||||||
WatchChats []WatchChat
|
WatchChats []WatchChat
|
||||||
|
FilenameStrategy string
|
||||||
}
|
}
|
||||||
|
|
||||||
type WatchChat struct {
|
type WatchChat struct {
|
||||||
|
|||||||
@@ -29,6 +29,6 @@ weight: 20
|
|||||||
1. 在 `parsers` 目录下新建一个包, 编写解析器实现
|
1. 在 `parsers` 目录下新建一个包, 编写解析器实现
|
||||||
2. 在 `parsers/parser.go` 的 `init` 中注册解析器
|
2. 在 `parsers/parser.go` 的 `init` 中注册解析器
|
||||||
|
|
||||||
如果使用 JavaScript 编写, 请参考 `plugins/example_parser.js` 的实现, 并在该文件夹下新建一个 js 文件, 实现你的解析逻辑.
|
如果使用 JavaScript 编写, 请参考 `plugins/example_parser_basic.js` 的实现, 并在该文件夹下新建一个 js 文件, 实现你的解析逻辑.
|
||||||
|
|
||||||
需要注意, `plugins` 目录下解析器默认不会被编译到二进制文件中, 用户需要手动下载它们并放到本地指定目录下以启用它们.
|
需要注意, `plugins` 目录下解析器默认不会被编译到二进制文件中, 用户需要手动下载它们并放到本地指定目录下以启用它们.
|
||||||
@@ -120,4 +120,5 @@ IS-ALBUM true MyWebdav NEW-FOR-ALBUM
|
|||||||
|
|
||||||
只需向 Bot 发送符合解析器要求的链接即可使用, 当前内置的解析器:
|
只需向 Bot 发送符合解析器要求的链接即可使用, 当前内置的解析器:
|
||||||
|
|
||||||
- Twitter
|
- Twitter
|
||||||
|
- Kemono
|
||||||
@@ -15,18 +15,6 @@ import (
|
|||||||
"github.com/krau/SaveAny-Bot/pkg/parser"
|
"github.com/krau/SaveAny-Bot/pkg/parser"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
|
||||||
LatestParserVersion = semver.MustParse("1.0.0")
|
|
||||||
MinimumParserVersion = semver.MustParse("1.0.0")
|
|
||||||
)
|
|
||||||
|
|
||||||
type PluginMeta struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Version string `json:"version"` // [TODO] 分版本解析, 但是我们现在只有 v1 所以先不写
|
|
||||||
Description string `json:"description"`
|
|
||||||
Author string `json:"author"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type jsParser struct {
|
type jsParser struct {
|
||||||
meta PluginMeta
|
meta PluginMeta
|
||||||
vm *goja.Runtime
|
vm *goja.Runtime
|
||||||
@@ -34,7 +22,7 @@ type jsParser struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type jsParserReq struct {
|
type jsParserReq struct {
|
||||||
method string
|
method ParserMethod
|
||||||
url string
|
url string
|
||||||
respCh chan jsParserResp
|
respCh chan jsParserResp
|
||||||
}
|
}
|
||||||
@@ -47,14 +35,14 @@ type jsParserResp struct {
|
|||||||
|
|
||||||
func (p *jsParser) CanHandle(url string) bool {
|
func (p *jsParser) CanHandle(url string) bool {
|
||||||
respCh := make(chan jsParserResp, 1)
|
respCh := make(chan jsParserResp, 1)
|
||||||
p.reqCh <- jsParserReq{method: "canHandle", url: url, respCh: respCh}
|
p.reqCh <- jsParserReq{method: ParserMethodCanHandle, url: url, respCh: respCh}
|
||||||
resp := <-respCh
|
resp := <-respCh
|
||||||
return resp.ok && resp.err == nil
|
return resp.ok && resp.err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *jsParser) Parse(ctx context.Context, url string) (*parser.Item, error) {
|
func (p *jsParser) Parse(ctx context.Context, url string) (*parser.Item, error) {
|
||||||
respCh := make(chan jsParserResp, 1)
|
respCh := make(chan jsParserResp, 1)
|
||||||
p.reqCh <- jsParserReq{method: "parse", url: url, respCh: respCh}
|
p.reqCh <- jsParserReq{method: ParserMethodParse, url: url, respCh: respCh}
|
||||||
select {
|
select {
|
||||||
case resp := <-respCh:
|
case resp := <-respCh:
|
||||||
return resp.item, resp.err
|
return resp.item, resp.err
|
||||||
@@ -73,7 +61,7 @@ func newJSParser(vm *goja.Runtime, canHandleFunc, parseFunc goja.Value, metadata
|
|||||||
go func() {
|
go func() {
|
||||||
for req := range p.reqCh {
|
for req := range p.reqCh {
|
||||||
switch req.method {
|
switch req.method {
|
||||||
case "canHandle":
|
case ParserMethodCanHandle:
|
||||||
fn, _ := goja.AssertFunction(canHandleFunc)
|
fn, _ := goja.AssertFunction(canHandleFunc)
|
||||||
res, err := fn(goja.Undefined(), p.vm.ToValue(req.url))
|
res, err := fn(goja.Undefined(), p.vm.ToValue(req.url))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -81,7 +69,7 @@ func newJSParser(vm *goja.Runtime, canHandleFunc, parseFunc goja.Value, metadata
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
req.respCh <- jsParserResp{ok: res.ToBoolean()}
|
req.respCh <- jsParserResp{ok: res.ToBoolean()}
|
||||||
case "parse":
|
case ParserMethodParse:
|
||||||
fn, _ := goja.AssertFunction(parseFunc)
|
fn, _ := goja.AssertFunction(parseFunc)
|
||||||
result, err := fn(goja.Undefined(), p.vm.ToValue(req.url))
|
result, err := fn(goja.Undefined(), p.vm.ToValue(req.url))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
36
parsers/kemono/download.go
Normal file
36
parsers/kemono/download.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package kemono
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DownloadInfo struct {
|
||||||
|
ServiceName string
|
||||||
|
UserID string
|
||||||
|
PostID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractDownloadInfoFromURL(u string) *DownloadInfo {
|
||||||
|
if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") {
|
||||||
|
u = "https://" + u
|
||||||
|
}
|
||||||
|
url, err := url.Parse(u)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parts := strings.Split(strings.Trim(url.Path, "/"), "/")
|
||||||
|
if len(parts) == 3 {
|
||||||
|
return &DownloadInfo{
|
||||||
|
ServiceName: parts[0],
|
||||||
|
UserID: parts[2],
|
||||||
|
}
|
||||||
|
} else if len(parts) == 5 && parts[3] == "post" {
|
||||||
|
return &DownloadInfo{
|
||||||
|
ServiceName: parts[0],
|
||||||
|
UserID: parts[2],
|
||||||
|
PostID: parts[4],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
173
parsers/kemono/kemono.go
Normal file
173
parsers/kemono/kemono.go
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
package kemono
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/duke-git/lancet/v2/strutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/netutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/parser"
|
||||||
|
)
|
||||||
|
|
||||||
|
type KemonoParser struct{}
|
||||||
|
|
||||||
|
var (
|
||||||
|
kemonoDomains = []string{
|
||||||
|
"kemono.su",
|
||||||
|
"kemono.cr",
|
||||||
|
}
|
||||||
|
ErrFailedToExtractInfo = errors.New("failed to extract download info from URL")
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
kemonoApiBase = "https://kemono.cr/api/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (k *KemonoParser) CanHandle(text string) bool {
|
||||||
|
text = strings.TrimPrefix(text, "https://")
|
||||||
|
text = strings.TrimPrefix(text, "http://")
|
||||||
|
|
||||||
|
var matchesDomain bool
|
||||||
|
for _, domain := range kemonoDomains {
|
||||||
|
if strings.Contains(text, domain) {
|
||||||
|
matchesDomain = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !matchesDomain {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var path string
|
||||||
|
for _, domain := range kemonoDomains {
|
||||||
|
if idx := strings.Index(text, domain); idx != -1 {
|
||||||
|
remaining := text[idx+len(domain):]
|
||||||
|
if len(remaining) > 0 && remaining[0] == '/' {
|
||||||
|
path = remaining[1:]
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if path == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(path, "/")
|
||||||
|
// servicename/user/id (user profile page)
|
||||||
|
// servicename/user/id/post/id (post page)
|
||||||
|
return len(parts) == 3 || (len(parts) == 5 && parts[3] == "post")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *KemonoParser) Parse(ctx context.Context, u string) (*parser.Item, error) {
|
||||||
|
info := extractDownloadInfoFromURL(u)
|
||||||
|
if info == nil {
|
||||||
|
return nil, ErrFailedToExtractInfo
|
||||||
|
}
|
||||||
|
if info.PostID != "" {
|
||||||
|
return k.parseOne(ctx, info)
|
||||||
|
}
|
||||||
|
return k.parseUserPage(ctx, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *KemonoParser) parseOne(ctx context.Context, info *DownloadInfo) (*parser.Item, error) {
|
||||||
|
client := netutil.DefaultParserHTTPClient()
|
||||||
|
endpoint := fmt.Sprintf("%s/%s/user/%s/post/%s", kemonoApiBase, info.ServiceName, info.UserID, info.PostID)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request to Kemono API: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "text/css")
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to fetch Kemono API: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("failed to fetch Kemono API, status code: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var postInfo PostInfo
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&postInfo); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode Kemono API response: %w", err)
|
||||||
|
}
|
||||||
|
item := &parser.Item{
|
||||||
|
Site: "kemono",
|
||||||
|
Title: postInfo.Post.Title,
|
||||||
|
URL: fmt.Sprintf("https://kemono.cr/%s/user/%s/post/%s", info.ServiceName, info.UserID, info.PostID),
|
||||||
|
Author: postInfo.Post.User, // [TODO] request user profile
|
||||||
|
Description: postInfo.Post.Content,
|
||||||
|
Tags: func() []string {
|
||||||
|
if postInfo.Post.Tags != nil {
|
||||||
|
return *postInfo.Post.Tags
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}(),
|
||||||
|
}
|
||||||
|
resources := make([]parser.Resource, 0)
|
||||||
|
for _, attachment := range postInfo.Attachments {
|
||||||
|
if attachment.Server == nil || attachment.Path == nil || attachment.Name == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var size int64
|
||||||
|
fileUrl := fmt.Sprintf("%s/data%s", *attachment.Server, *attachment.Path)
|
||||||
|
headReq, err := http.NewRequestWithContext(ctx, http.MethodHead, fileUrl, nil)
|
||||||
|
if err == nil {
|
||||||
|
resp, err := client.Do(headReq)
|
||||||
|
if err == nil {
|
||||||
|
size = resp.ContentLength
|
||||||
|
resp.Body.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resources = append(resources, parser.Resource{
|
||||||
|
URL: fmt.Sprintf("%s/data%s", *attachment.Server, *attachment.Path),
|
||||||
|
Filename: *attachment.Name,
|
||||||
|
Size: size,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
picCdnMap := make(map[string]string)
|
||||||
|
for _, preview := range postInfo.Previews {
|
||||||
|
if preview.Type == nil || *preview.Type != "thumbnail" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
picCdnMap[*preview.Path] = *preview.Server
|
||||||
|
}
|
||||||
|
for _, attachment := range postInfo.Post.Attachments {
|
||||||
|
if !isImageExt(*attachment.Path) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
picUrl, err := url.JoinPath(picCdnMap[*attachment.Path], "data", *attachment.Path)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var size int64
|
||||||
|
headReq, err := http.NewRequestWithContext(ctx, http.MethodHead, picUrl, nil)
|
||||||
|
if err == nil {
|
||||||
|
resp, err := client.Do(headReq)
|
||||||
|
if err == nil {
|
||||||
|
size = resp.ContentLength
|
||||||
|
resp.Body.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resources = append(resources, parser.Resource{
|
||||||
|
URL: picUrl,
|
||||||
|
Filename: *attachment.Name,
|
||||||
|
Size: size,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
item.Resources = resources
|
||||||
|
return item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *KemonoParser) parseUserPage(_ context.Context, _ *DownloadInfo) (*parser.Item, error) {
|
||||||
|
return nil, errors.New("kemono user page not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func isImageExt(attachmentPath string) bool {
|
||||||
|
return strutil.HasSuffixAny(path.Ext(strings.Split(attachmentPath, "?")[0]), []string{".jpg", ".jpeg", ".png", ".webp"})
|
||||||
|
}
|
||||||
62
parsers/kemono/post_info.go
Normal file
62
parsers/kemono/post_info.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
// https://github.com/kemono-rs/kemono
|
||||||
|
|
||||||
|
package kemono
|
||||||
|
|
||||||
|
type PostInfo struct {
|
||||||
|
Post Post `json:"post"`
|
||||||
|
Attachments []AttachmentLike `json:"attachments"`
|
||||||
|
Previews []AttachmentLike `json:"previews"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AttachmentLike struct {
|
||||||
|
Type *string `json:"type,omitempty"`
|
||||||
|
Server *string `json:"server,omitempty"`
|
||||||
|
Name *string `json:"name,omitempty"`
|
||||||
|
Path *string `json:"path,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Post struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
User string `json:"user"`
|
||||||
|
Service string `json:"service"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Embed Embed `json:"embed"`
|
||||||
|
SharedFile bool `json:"shared_file"`
|
||||||
|
Added *string `json:"added,omitempty"`
|
||||||
|
Published string `json:"published"`
|
||||||
|
Edited *string `json:"edited,omitempty"`
|
||||||
|
File File `json:"file"`
|
||||||
|
Attachments []AttachmentLike `json:"attachments"`
|
||||||
|
Poll *Poll `json:"poll,omitempty"`
|
||||||
|
Captions *string `json:"captions,omitempty"`
|
||||||
|
Tags *[]string `json:"tags,omitempty"`
|
||||||
|
Next *string `json:"next,omitempty"`
|
||||||
|
Prev *string `json:"prev,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type File struct {
|
||||||
|
Name *string `json:"name,omitempty"`
|
||||||
|
Path *string `json:"path,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Embed struct {
|
||||||
|
URL *string `json:"url,omitempty"`
|
||||||
|
Subject *string `json:"subject,omitempty"`
|
||||||
|
Description *string `json:"description,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Poll struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Choices []Choice `json:"choices"`
|
||||||
|
ClosesAt *string `json:"closes_at,omitempty"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
Description *string `json:"description,omitempty"`
|
||||||
|
AllowsMultiple bool `json:"allows_multiple"`
|
||||||
|
TotalVotes int64 `json:"total_votes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Choice struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
Votes int64 `json:"votes"`
|
||||||
|
}
|
||||||
16
parsers/kemono/post_legacy.go
Normal file
16
parsers/kemono/post_legacy.go
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
package kemono
|
||||||
|
|
||||||
|
type PostLegacy struct {
|
||||||
|
Props Props `json:"props"`
|
||||||
|
Results []Result `json:"results"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props struct {
|
||||||
|
Count uint `json:"count"`
|
||||||
|
Limit uint `json:"limit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Result struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
}
|
||||||
8
parsers/kemono/user_profile.go
Normal file
8
parsers/kemono/user_profile.go
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
package kemono
|
||||||
|
|
||||||
|
type UserProfile struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Service string `json:"service"`
|
||||||
|
PublicID *string `json:"public_id,omitempty"`
|
||||||
|
}
|
||||||
@@ -6,14 +6,28 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/krau/SaveAny-Bot/config"
|
"github.com/krau/SaveAny-Bot/config"
|
||||||
|
"github.com/krau/SaveAny-Bot/parsers/kemono"
|
||||||
"github.com/krau/SaveAny-Bot/parsers/twitter"
|
"github.com/krau/SaveAny-Bot/parsers/twitter"
|
||||||
"github.com/krau/SaveAny-Bot/pkg/parser"
|
"github.com/krau/SaveAny-Bot/pkg/parser"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
parsers []parser.Parser
|
parsers []parser.Parser
|
||||||
parsersMu sync.Mutex
|
parsersMu sync.Mutex
|
||||||
doConfig sync.Once
|
doConfig sync.Once
|
||||||
|
configParsers = func() {
|
||||||
|
if len(parsers) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, pser := range parsers {
|
||||||
|
if configurable, ok := pser.(parser.ConfigurableParser); ok {
|
||||||
|
cfg := config.C().GetParserConfigByName(configurable.Name())
|
||||||
|
if err := configurable.Configure(cfg); err != nil {
|
||||||
|
fmt.Printf("Error configuring parser %s: %v\n", configurable.Name(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
func AddParser(p ...parser.Parser) {
|
func AddParser(p ...parser.Parser) {
|
||||||
@@ -23,7 +37,7 @@ func AddParser(p ...parser.Parser) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
AddParser(new(twitter.TwitterParser))
|
AddParser(new(twitter.TwitterParser), new(kemono.KemonoParser))
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -31,23 +45,7 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func ParseWithContext(ctx context.Context, url string) (*parser.Item, error) {
|
func ParseWithContext(ctx context.Context, url string) (*parser.Item, error) {
|
||||||
doConfig.Do(func() {
|
doConfig.Do(configParsers)
|
||||||
parsersMu.Lock()
|
|
||||||
defer parsersMu.Unlock()
|
|
||||||
if len(parsers) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, pser := range parsers {
|
|
||||||
if configurable, ok := pser.(parser.ConfigurableParser); ok {
|
|
||||||
cfg := config.C().GetParserConfigByName(configurable.Name())
|
|
||||||
if cfg != nil {
|
|
||||||
if err := configurable.Configure(cfg); err != nil {
|
|
||||||
fmt.Printf("Error configuring parser %s: %v\n", configurable.Name(), err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
ch := make(chan *parser.Item, 1)
|
ch := make(chan *parser.Item, 1)
|
||||||
errCh := make(chan error, 1)
|
errCh := make(chan error, 1)
|
||||||
|
|
||||||
@@ -76,3 +74,13 @@ func ParseWithContext(ctx context.Context, url string) (*parser.Item, error) {
|
|||||||
return nil, ctx.Err()
|
return nil, ctx.Err()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func CanHandle(url string) (bool, parser.Parser) {
|
||||||
|
doConfig.Do(configParsers)
|
||||||
|
for _, pser := range parsers {
|
||||||
|
if pser.CanHandle(url) {
|
||||||
|
return true, pser
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|||||||
23
parsers/plugin.go
Normal file
23
parsers/plugin.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package parsers
|
||||||
|
|
||||||
|
import "github.com/blang/semver"
|
||||||
|
|
||||||
|
var (
|
||||||
|
LatestParserVersion = semver.MustParse("1.0.0")
|
||||||
|
MinimumParserVersion = semver.MustParse("1.0.0")
|
||||||
|
)
|
||||||
|
|
||||||
|
type PluginMeta struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Version string `json:"version"` // [TODO] 分版本解析, 但是我们现在只有 v1 所以先不写
|
||||||
|
Description string `json:"description"`
|
||||||
|
Author string `json:"author"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ParserMethod uint
|
||||||
|
|
||||||
|
const (
|
||||||
|
_ ParserMethod = iota
|
||||||
|
ParserMethodCanHandle
|
||||||
|
ParserMethodParse
|
||||||
|
)
|
||||||
@@ -68,7 +68,7 @@ func (p *TwitterParser) Parse(ctx context.Context, u string) (*parser.Item, erro
|
|||||||
resources := make([]parser.Resource, 0, len(fxResp.Tweet.Media.All))
|
resources := make([]parser.Resource, 0, len(fxResp.Tweet.Media.All))
|
||||||
for _, media := range fxResp.Tweet.Media.All {
|
for _, media := range fxResp.Tweet.Media.All {
|
||||||
var size int64
|
var size int64
|
||||||
resp, err := p.client.Get(media.URL)
|
resp, err := p.client.Head(media.URL)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
size = resp.ContentLength
|
size = resp.ContentLength
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
@@ -101,6 +101,11 @@ func (p *TwitterParser) Name() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *TwitterParser) Configure(config map[string]any) error {
|
func (p *TwitterParser) Configure(config map[string]any) error {
|
||||||
|
if config == nil {
|
||||||
|
p.apiDomain = fxTwitterApi
|
||||||
|
p.client = *netutil.DefaultParserHTTPClient()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
if domain, ok := config["api_domain"].(string); ok && domain != "" {
|
if domain, ok := config["api_domain"].(string); ok && domain != "" {
|
||||||
p.apiDomain = domain
|
p.apiDomain = domain
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
14
pkg/enums/fnamest/filename_srategy.go
Normal file
14
pkg/enums/fnamest/filename_srategy.go
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
package fnamest
|
||||||
|
|
||||||
|
//go:generate go-enum --values --names --noprefix --flag --nocase
|
||||||
|
|
||||||
|
// FnameST
|
||||||
|
/* ENUM(
|
||||||
|
default, message
|
||||||
|
) */
|
||||||
|
type FnameST string
|
||||||
|
|
||||||
|
var FnameSTDisplay = map[FnameST]string{
|
||||||
|
Default: "默认",
|
||||||
|
Message: "优先从消息生成",
|
||||||
|
}
|
||||||
87
pkg/enums/fnamest/filename_srategy_enum.go
Normal file
87
pkg/enums/fnamest/filename_srategy_enum.go
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
// Code generated by go-enum DO NOT EDIT.
|
||||||
|
// Version: 0.6.1
|
||||||
|
// Revision: a6f63bddde05aca4221df9c8e9e6d7d9674b1cb4
|
||||||
|
// Build Date: 2025-03-18T23:42:14Z
|
||||||
|
// Built By: goreleaser
|
||||||
|
|
||||||
|
package fnamest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Default is a FnameST of type default.
|
||||||
|
Default FnameST = "default"
|
||||||
|
// Message is a FnameST of type message.
|
||||||
|
Message FnameST = "message"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrInvalidFnameST = fmt.Errorf("not a valid FnameST, try [%s]", strings.Join(_FnameSTNames, ", "))
|
||||||
|
|
||||||
|
var _FnameSTNames = []string{
|
||||||
|
string(Default),
|
||||||
|
string(Message),
|
||||||
|
}
|
||||||
|
|
||||||
|
// FnameSTNames returns a list of possible string values of FnameST.
|
||||||
|
func FnameSTNames() []string {
|
||||||
|
tmp := make([]string, len(_FnameSTNames))
|
||||||
|
copy(tmp, _FnameSTNames)
|
||||||
|
return tmp
|
||||||
|
}
|
||||||
|
|
||||||
|
// FnameSTValues returns a list of the values for FnameST
|
||||||
|
func FnameSTValues() []FnameST {
|
||||||
|
return []FnameST{
|
||||||
|
Default,
|
||||||
|
Message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// String implements the Stringer interface.
|
||||||
|
func (x FnameST) String() string {
|
||||||
|
return string(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsValid provides a quick way to determine if the typed value is
|
||||||
|
// part of the allowed enumerated values
|
||||||
|
func (x FnameST) IsValid() bool {
|
||||||
|
_, err := ParseFnameST(string(x))
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var _FnameSTValue = map[string]FnameST{
|
||||||
|
"default": Default,
|
||||||
|
"message": Message,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseFnameST attempts to convert a string to a FnameST.
|
||||||
|
func ParseFnameST(name string) (FnameST, error) {
|
||||||
|
if x, ok := _FnameSTValue[name]; ok {
|
||||||
|
return x, nil
|
||||||
|
}
|
||||||
|
// Case insensitive parse, do a separate lookup to prevent unnecessary cost of lowercasing a string if we don't need to.
|
||||||
|
if x, ok := _FnameSTValue[strings.ToLower(name)]; ok {
|
||||||
|
return x, nil
|
||||||
|
}
|
||||||
|
return FnameST(""), fmt.Errorf("%s is %w", name, ErrInvalidFnameST)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set implements the Golang flag.Value interface func.
|
||||||
|
func (x *FnameST) Set(val string) error {
|
||||||
|
v, err := ParseFnameST(val)
|
||||||
|
*x = v
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get implements the Golang flag.Getter interface func.
|
||||||
|
func (x *FnameST) Get() interface{} {
|
||||||
|
return *x
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type implements the github.com/spf13/pFlag Value interface.
|
||||||
|
func (x *FnameST) Type() string {
|
||||||
|
return "FnameST"
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package consts
|
package rule
|
||||||
|
|
||||||
const (
|
const (
|
||||||
RuleStorNameChosen = "CHOSEN"
|
RuleStorNameChosen = "CHOSEN"
|
||||||
@@ -3,7 +3,6 @@ package rule
|
|||||||
import (
|
import (
|
||||||
"regexp"
|
"regexp"
|
||||||
|
|
||||||
ruleenum "github.com/krau/SaveAny-Bot/pkg/enums/rule"
|
|
||||||
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -14,8 +13,8 @@ type RuleFileNameRegex struct {
|
|||||||
|
|
||||||
var _ RuleClass[tfile.TGFile] = (*RuleFileNameRegex)(nil)
|
var _ RuleClass[tfile.TGFile] = (*RuleFileNameRegex)(nil)
|
||||||
|
|
||||||
func (r RuleFileNameRegex) Type() ruleenum.RuleType {
|
func (r RuleFileNameRegex) Type() RuleType {
|
||||||
return ruleenum.FileNameRegex
|
return FileNameRegex
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r RuleFileNameRegex) Match(input tfile.TGFile) (bool, error) {
|
func (r RuleFileNameRegex) Match(input tfile.TGFile) (bool, error) {
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
package rule
|
package rule
|
||||||
|
|
||||||
import (
|
|
||||||
ruleenum "github.com/krau/SaveAny-Bot/pkg/enums/rule"
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ RuleClass[bool] = (*RuleMediaType)(nil)
|
var _ RuleClass[bool] = (*RuleMediaType)(nil)
|
||||||
|
|
||||||
type RuleMediaType struct {
|
type RuleMediaType struct {
|
||||||
@@ -11,8 +7,8 @@ type RuleMediaType struct {
|
|||||||
matchAlbum bool
|
matchAlbum bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r RuleMediaType) Type() ruleenum.RuleType {
|
func (r RuleMediaType) Type() RuleType {
|
||||||
return ruleenum.IsAlbum
|
return IsAlbum
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r RuleMediaType) Match(input bool) (bool, error) {
|
func (r RuleMediaType) Match(input bool) (bool, error) {
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ package rule
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"regexp"
|
"regexp"
|
||||||
|
|
||||||
ruleenum "github.com/krau/SaveAny-Bot/pkg/enums/rule"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var _ RuleClass[string] = (*RuleMessageRegex)(nil)
|
var _ RuleClass[string] = (*RuleMessageRegex)(nil)
|
||||||
@@ -13,8 +11,8 @@ type RuleMessageRegex struct {
|
|||||||
regex *regexp.Regexp
|
regex *regexp.Regexp
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r RuleMessageRegex) Type() ruleenum.RuleType {
|
func (r RuleMessageRegex) Type() RuleType {
|
||||||
return ruleenum.MessageRegex
|
return MessageRegex
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r RuleMessageRegex) Match(input string) (bool, error) {
|
func (r RuleMessageRegex) Match(input string) (bool, error) {
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
package rule
|
package rule
|
||||||
|
|
||||||
import (
|
|
||||||
ruleenum "github.com/krau/SaveAny-Bot/pkg/enums/rule"
|
|
||||||
)
|
|
||||||
|
|
||||||
type RuleClass[InputType any] interface {
|
type RuleClass[InputType any] interface {
|
||||||
Type() ruleenum.RuleType
|
Type() RuleType
|
||||||
Match(input InputType) (bool, error)
|
Match(input InputType) (bool, error)
|
||||||
StorageName() string
|
StorageName() string
|
||||||
StoragePath() string
|
StoragePath() string
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import (
|
|||||||
const (
|
const (
|
||||||
TypeAdd = "add"
|
TypeAdd = "add"
|
||||||
TypeSetDefault = "setdefault"
|
TypeSetDefault = "setdefault"
|
||||||
|
TypeConfig = "config"
|
||||||
|
TypeCancel = "cancel"
|
||||||
)
|
)
|
||||||
|
|
||||||
// type TaskDataTGFiles struct {
|
// type TaskDataTGFiles struct {
|
||||||
|
|||||||
@@ -2,20 +2,21 @@ package tfile
|
|||||||
|
|
||||||
import "github.com/gotd/td/tg"
|
import "github.com/gotd/td/tg"
|
||||||
|
|
||||||
type TGFileOptions func(*tgFile)
|
type TGFileOption func(*tgFile)
|
||||||
|
|
||||||
func WithMessage(msg *tg.Message) TGFileOptions {
|
func WithMessage(msg *tg.Message) TGFileOption {
|
||||||
return func(f *tgFile) {
|
return func(f *tgFile) {
|
||||||
f.message = msg
|
f.message = msg
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
func WithName(name string) TGFileOptions {
|
|
||||||
|
func WithName(name string) TGFileOption {
|
||||||
return func(f *tgFile) {
|
return func(f *tgFile) {
|
||||||
f.name = name
|
f.name = name
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func WithNameIfEmpty(name string) TGFileOptions {
|
func WithNameIfEmpty(name string) TGFileOption {
|
||||||
return func(f *tgFile) {
|
return func(f *tgFile) {
|
||||||
if f.name == "" {
|
if f.name == "" {
|
||||||
f.name = name
|
f.name = name
|
||||||
@@ -23,13 +24,13 @@ func WithNameIfEmpty(name string) TGFileOptions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func WithSize(size int64) TGFileOptions {
|
func WithSize(size int64) TGFileOption {
|
||||||
return func(f *tgFile) {
|
return func(f *tgFile) {
|
||||||
f.size = size
|
f.size = size
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func WithSizeIfZero(size int64) TGFileOptions {
|
func WithSizeIfZero(size int64) TGFileOption {
|
||||||
return func(f *tgFile) {
|
return func(f *tgFile) {
|
||||||
if f.size == 0 {
|
if f.size == 0 {
|
||||||
f.size = size
|
f.size = size
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ func NewTGFile(
|
|||||||
dler downloader.Client,
|
dler downloader.Client,
|
||||||
size int64,
|
size int64,
|
||||||
name string,
|
name string,
|
||||||
opts ...TGFileOptions,
|
opts ...TGFileOption,
|
||||||
) TGFile {
|
) TGFile {
|
||||||
f := &tgFile{
|
f := &tgFile{
|
||||||
location: location,
|
location: location,
|
||||||
@@ -68,7 +68,7 @@ func NewTGFile(
|
|||||||
return f
|
return f
|
||||||
}
|
}
|
||||||
|
|
||||||
func FromMedia(media tg.MessageMediaClass, client downloader.Client, opts ...TGFileOptions) (TGFile, error) {
|
func FromMedia(media tg.MessageMediaClass, client downloader.Client, opts ...TGFileOption) (TGFile, error) {
|
||||||
switch m := media.(type) {
|
switch m := media.(type) {
|
||||||
case *tg.MessageMediaDocument:
|
case *tg.MessageMediaDocument:
|
||||||
document, ok := m.Document.AsNotEmpty()
|
document, ok := m.Document.AsNotEmpty()
|
||||||
@@ -125,7 +125,7 @@ func FromMedia(media tg.MessageMediaClass, client downloader.Client, opts ...TGF
|
|||||||
return nil, fmt.Errorf("unsupported media type: %T", media)
|
return nil, fmt.Errorf("unsupported media type: %T", media)
|
||||||
}
|
}
|
||||||
|
|
||||||
func FromMediaMessage(media tg.MessageMediaClass, client downloader.Client, msg *tg.Message, opts ...TGFileOptions) (TGFileMessage, error) {
|
func FromMediaMessage(media tg.MessageMediaClass, client downloader.Client, msg *tg.Message, opts ...TGFileOption) (TGFileMessage, error) {
|
||||||
file, err := FromMedia(media, client, opts...)
|
file, err := FromMedia(media, client, opts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"path"
|
"path"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/duke-git/lancet/v2/convertor"
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/duke-git/lancet/v2/slice"
|
||||||
"github.com/gabriel-vasile/mimetype"
|
"github.com/gabriel-vasile/mimetype"
|
||||||
|
"github.com/gotd/td/constant"
|
||||||
"github.com/gotd/td/telegram/message"
|
"github.com/gotd/td/telegram/message"
|
||||||
"github.com/gotd/td/telegram/message/styling"
|
"github.com/gotd/td/telegram/message/styling"
|
||||||
"github.com/gotd/td/telegram/uploader"
|
"github.com/gotd/td/telegram/uploader"
|
||||||
@@ -75,26 +76,46 @@ func (t *Telegram) Save(ctx context.Context, r io.Reader, storagePath string) er
|
|||||||
if tctx == nil {
|
if tctx == nil {
|
||||||
return fmt.Errorf("failed to get telegram context")
|
return fmt.Errorf("failed to get telegram context")
|
||||||
}
|
}
|
||||||
|
// 去除前导斜杠并分隔路径, 当 len(parts):
|
||||||
|
// ==0, 存储到配置文件中的 chat_id, 随机文件名
|
||||||
|
// ==1, 视作只有文件名, 存储到配置文件中的 chat_id
|
||||||
|
// ==2, parts[0]: 视作要存储到的 chat_id, parts[1]: filename
|
||||||
|
|
||||||
|
parts := slice.Compact(strings.Split(strings.TrimPrefix(storagePath, "/"), "/"))
|
||||||
|
filename := ""
|
||||||
chatID := t.config.ChatID
|
chatID := t.config.ChatID
|
||||||
if after, ok0 := strings.CutPrefix(convertor.ToString(chatID), "-100"); ok0 {
|
if len(parts) >= 1 {
|
||||||
cid, err := strconv.ParseInt(after, 10, 64)
|
filename = parts[len(parts)-1]
|
||||||
|
}
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
cid, err := tgutil.ParseChatID(tctx, parts[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to parse chat ID: %w", err)
|
// id不合法时使用配置文件中的 chat_id
|
||||||
|
log.FromContext(ctx).Warnf("Failed to parse chat ID from path, using configured chat_id: %s", err)
|
||||||
|
cid = chatID
|
||||||
|
} else {
|
||||||
|
if cid > constant.MaxTDLibChannelID || cid > constant.MaxTDLibChatID || cid > constant.MaxTDLibUserID {
|
||||||
|
cid = chatID
|
||||||
|
}
|
||||||
}
|
}
|
||||||
chatID = cid
|
chatID = cid
|
||||||
}
|
}
|
||||||
peer := tctx.PeerStorage.GetInputPeerById(chatID)
|
|
||||||
if peer == nil {
|
|
||||||
return fmt.Errorf("failed to get input peer for chat ID %d", chatID)
|
|
||||||
}
|
|
||||||
mtype, err := mimetype.DetectReader(rs)
|
mtype, err := mimetype.DetectReader(rs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to detect mimetype: %w", err)
|
return fmt.Errorf("failed to detect mimetype: %w", err)
|
||||||
}
|
}
|
||||||
filename := path.Base(storagePath)
|
|
||||||
if filename == "" {
|
if filename == "" {
|
||||||
filename = xid.New().String() + mtype.Extension()
|
filename = xid.New().String() + mtype.Extension()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if chatID < 0 {
|
||||||
|
chatID = chatID - constant.ZeroTDLibChannelID
|
||||||
|
}
|
||||||
|
peer := tctx.PeerStorage.GetInputPeerById(chatID)
|
||||||
|
if peer == nil {
|
||||||
|
return fmt.Errorf("failed to get input peer for chat ID %d", chatID)
|
||||||
|
}
|
||||||
|
|
||||||
if _, err := rs.Seek(0, io.SeekStart); err != nil {
|
if _, err := rs.Seek(0, io.SeekStart); err != nil {
|
||||||
return fmt.Errorf("failed to seek reader: %w", err)
|
return fmt.Errorf("failed to seek reader: %w", err)
|
||||||
}
|
}
|
||||||
@@ -122,7 +143,7 @@ func (t *Telegram) Save(ctx context.Context, r io.Reader, storagePath string) er
|
|||||||
caption := styling.Plain(filename)
|
caption := styling.Plain(filename)
|
||||||
docb := message.UploadedDocument(file, caption).
|
docb := message.UploadedDocument(file, caption).
|
||||||
Filename(filename).
|
Filename(filename).
|
||||||
ForceFile(false).
|
ForceFile(t.config.ForceFile).
|
||||||
MIME(mtype.String())
|
MIME(mtype.String())
|
||||||
|
|
||||||
var media message.MediaOption = docb
|
var media message.MediaOption = docb
|
||||||
@@ -135,7 +156,6 @@ func (t *Telegram) Save(ctx context.Context, r io.Reader, storagePath string) er
|
|||||||
case strings.HasPrefix(mtypeStr, "image/") && !strings.HasSuffix(mtypeStr, "webp"):
|
case strings.HasPrefix(mtypeStr, "image/") && !strings.HasSuffix(mtypeStr, "webp"):
|
||||||
media = message.UploadedPhoto(file, caption)
|
media = message.UploadedPhoto(file, caption)
|
||||||
}
|
}
|
||||||
|
|
||||||
sender := tctx.Sender
|
sender := tctx.Sender
|
||||||
_, err = sender.WithUploader(upler).To(peer).Media(ctx, media)
|
_, err = sender.WithUploader(upler).To(peer).Media(ctx, media)
|
||||||
return err
|
return err
|
||||||
|
|||||||
Reference in New Issue
Block a user