Compare commits

...

16 Commits

Author SHA1 Message Date
Krau
8ea5be5b90 Merge pull request #65 from krau/upload-telegram
feat: send media to telegram, close #47
2025-05-28 15:57:43 +08:00
krau
7f483056e0 feat: send media to telegram, close #47 2025-05-28 15:57:10 +08:00
krau
a6f88d7f75 chore: fix typo in Dockerfile ARG variable name for GitCommit 2025-05-19 22:14:50 +08:00
krau
b757df0b5e chore: reorganize Docker build workflow and enhance build arguments for versioning 2025-05-19 22:02:17 +08:00
krau
b017046c8b chore: simplify Dockerfile by removing unnecessary user and permission setup 2025-05-19 21:43:47 +08:00
krau
a474fdf6ae chore: update .dockerignore and Dockerfile for improved build context and permissions 2025-05-19 09:23:24 +08:00
krau
729e688748 fix: cleaning up the cache folder caused permission issues 2025-05-19 09:23:10 +08:00
krau
9ea4857cd9 chore: update issue template labels for consistency 2025-05-18 18:14:33 +08:00
krau
8bf7bc0e85 chore: add .dockerignore to exclude unnecessary files from Docker context 2025-05-18 18:14:30 +08:00
krau
26e344a6f6 refactor: remove unused conversation handling code and simplify delDir function parameters 2025-05-18 14:29:59 +08:00
krau
8f0744077e fix: update success message for batch task addition in handle_save function 2025-05-18 14:28:20 +08:00
krau
ed99a37831 fix: add unique id to task struct to avoid duplicate file name overwrite, close #59 2025-05-09 08:58:30 +08:00
krau
488d709d85 chore: update contributors section in README to remove specific name 2025-05-08 21:04:56 +08:00
krau
66454b082a fix: improve logger initialization and reduce cache TTL 2025-05-08 21:03:48 +08:00
Krau
70e83e62d9 Merge pull request #58 from AHCorn/main
fix: docker cache permission issue (#57)
2025-05-08 20:44:46 +08:00
安和
d2ddb9193a fix: docker cache permission issue (#57) 2025-05-08 18:38:28 +08:00
21 changed files with 208 additions and 148 deletions

11
.dockerignore Normal file
View File

@@ -0,0 +1,11 @@
*.md
.git
.github/
.gitignore
.vscode/
downloads/
data/
cache/
docs
config.example.toml
docker-compose.*

View File

@@ -1,7 +1,7 @@
name: "👾 报告 bug"
description: "报告 bug"
labels:
- ":space_invader: Bug"
- "bug"
assignees:
- krau
body:

View File

@@ -1,7 +1,7 @@
name: "⭐️ 功能请求"
description: "功能请求"
labels:
- ":fire: Enhancement"
- "enhancement"
assignees:
- krau
body:

View File

@@ -20,19 +20,6 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@v5
@@ -50,11 +37,36 @@ jobs:
org.opencontainers.image.source=https://github.com/krau/SaveAny-Bot
org.opencontainers.image.url=https://github.com/krau/SaveAny-Bot
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract version from Git Ref
id: extract_version
run: |
VERSION=$(echo "${{ github.ref }}" | sed 's/refs\/tags\/v//')
echo "VERSION=${VERSION}" >> $GITHUB_ENV
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
VERSION=${{ steps.meta.outputs.version }}
GitCommit=${{ github.sha }}
BuildTime=${{ format(github.event.repository.updated_at, 'yyyy-MM-dd HH:mm:ss') }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View File

@@ -1,13 +1,22 @@
FROM golang:alpine AS builder
ARG VERSION="dev"
ARG GitCommit="Unknown"
ARG BuildTime="Unknown"
WORKDIR /app
COPY go.* ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o saveany-bot .
RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg \
CGO_ENABLED=0 \
go build -trimpath \
-ldflags "-s -w \
-X github.com/krau/SaveAny-Bot/common.Version=${VERSION} \
-X github.com/krau/SaveAny-Bot/common.GitCommit=${GiTCommit} \
-X github.com/krau/SaveAny-Bot/common.BuildTime=${BuildTime}" \
-o saveany-bot .
FROM alpine:latest
@@ -15,4 +24,4 @@ WORKDIR /app
COPY --from=builder /app/saveany-bot .
CMD ["./saveany-bot"]
ENTRYPOINT ["/app/saveany-bot"]

View File

@@ -26,7 +26,7 @@
## Contributors
<!-- readme: contributors,AHCorn -start -->
<!-- readme: contributors -start -->
<table>
<tbody>
<tr>
@@ -61,7 +61,7 @@
</tr>
<tbody>
</table>
<!-- readme: contributors,AHCorn -end -->
<!-- readme: contributors -end -->
## Thanks

View File

@@ -3,7 +3,6 @@ package bot
import (
"context"
"net/url"
"os"
"time"
"github.com/celestix/gotgproto"
@@ -90,12 +89,10 @@ func Init() {
select {
case <-ctx.Done():
common.Log.Fatal("初始化客户端失败: 超时")
os.Exit(1)
common.Log.Panic("初始化客户端失败: 超时")
case result := <-resultChan:
if result.err != nil {
common.Log.Fatalf("初始化客户端失败: %s", result.err)
os.Exit(1)
common.Log.Panicf("初始化客户端失败: %s", result.err)
}
Client = result.client
RegisterHandlers(Client.Dispatcher)

View File

@@ -22,7 +22,6 @@ import (
)
func AddToQueue(ctx *ext.Context, update *ext.Update) error {
// TODO: 回调数据用户独立鉴权 (处理 bot 在群聊中的情况)
if !slice.Contain(config.Cfg.GetUsersID(), update.CallbackQuery.UserID) {
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.QueryID,
@@ -117,7 +116,7 @@ func AddToQueue(ctx *ext.Context, update *ext.Update) error {
if update.CallbackQuery.MsgID != record.ReplyMessageID {
record.ReplyMessageID = update.CallbackQuery.MsgID
if err := dao.SaveReceivedFile(record); err != nil {
common.Log.Errorf("更新接收的文件失败: %s", err)
common.Log.Errorf("更新记录失败: %s", err)
}
}
@@ -169,6 +168,7 @@ func AddToQueue(ctx *ext.Context, update *ext.Update) error {
task = types.Task{
Ctx: ctx,
Status: types.Pending,
FileDBID: record.ID,
File: file,
StorageName: storageName,
FileChatID: record.ChatID,

View File

@@ -1,75 +0,0 @@
package bot
import (
"sync"
)
type ConversationType string
type ConversationState struct {
sync.Mutex
conversationType ConversationType
InConversation bool
data map[ConversationType]map[string]interface{}
}
func (c *ConversationState) Reset() {
c.Lock()
defer c.Unlock()
c.InConversation = false
c.conversationType = ""
c.data = make(map[ConversationType]map[string]interface{})
}
func (c *ConversationState) SetConversationType(t ConversationType) {
c.Lock()
defer c.Unlock()
c.conversationType = t
}
func (c *ConversationState) GetData(key string) interface{} {
if c.data == nil || c.data[c.conversationType] == nil {
return nil
}
return c.data[c.conversationType][key]
}
func (c *ConversationState) SetData(key string, value interface{}) {
c.Lock()
defer c.Unlock()
if c.data == nil {
c.data = make(map[ConversationType]map[string]interface{})
}
if c.data[c.conversationType] == nil {
c.data[c.conversationType] = make(map[string]interface{})
}
c.data[c.conversationType][key] = value
}
// TODO: Implement conversation handling
// var userConversationState = make(map[int64]*ConversationState)
// func handleConversation(ctx *ext.Context, update *ext.Update) error {
// userID := update.EffectiveUser().GetID()
// state, ok := userConversationState[userID]
// if !ok {
// return dispatcher.ContinueGroups
// }
// if update.EffectiveMessage.Text == "/cancel" {
// state.Reset()
// ctx.Reply(update, ext.ReplyTextString("已取消"), nil)
// return dispatcher.EndGroups
// }
// if !state.InConversation {
// return dispatcher.ContinueGroups
// }
// return handleConversationState(ctx, update, state)
// }
// func handleConversationState(ctx *ext.Context, update *ext.Update, state *ConversationState) error {
// switch state.conversationType {
// default:
// common.Log.Errorf("Unknown conversation type: %s", state.conversationType)
// }
// return dispatcher.EndGroups
// }

View File

@@ -77,7 +77,7 @@ func dirCmd(ctx *ext.Context, update *ext.Update) error {
ctx.Reply(update, ext.ReplyTextString("路径ID无效"), nil)
return dispatcher.EndGroups
}
return delDir(ctx, update, user, dirID)
return delDir(ctx, update, dirID)
default:
ctx.Reply(update, ext.ReplyTextString("未知操作"), nil)
return dispatcher.EndGroups
@@ -99,7 +99,7 @@ func addDir(ctx *ext.Context, update *ext.Update, user *dao.User, storageName, p
return dispatcher.EndGroups
}
func delDir(ctx *ext.Context, update *ext.Update, user *dao.User, dirID int) error {
func delDir(ctx *ext.Context, update *ext.Update, dirID int) error {
if err := dao.DeleteDirByID(uint(dirID)); err != nil {
common.Log.Errorf("删除路径失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("删除路径失败"), nil)

View File

@@ -256,7 +256,7 @@ func handleBatchSave(ctx *ext.Context, update *ext.Update, args []string) error
successadd++
}
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
Message: fmt.Sprintf("批量保存完成\n成功添加: %d/%d\n获取文件失败: %d\n获取消息失败: %d\n保存数据库失败: %d", successadd, total, failedGetFile, failedGetMsg, failedSaveDB),
Message: fmt.Sprintf("批量添加任务完成\n成功添加: %d/%d\n获取文件失败: %d\n获取消息失败: %d\n保存数据库失败: %d", successadd, total, failedGetFile, failedGetMsg, failedSaveDB),
ID: replied.ID,
})
return dispatcher.EndGroups

95
bot/handle_send.go Normal file
View File

@@ -0,0 +1,95 @@
package bot
import (
"fmt"
"strconv"
"strings"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
tgtypes "github.com/celestix/gotgproto/types"
"github.com/gotd/td/tg"
)
func copyMediaToChat(ctx *ext.Context, msg *tg.Message, chatID int64) (*tgtypes.Message, error) {
media, ok := msg.GetMedia()
if !ok {
return nil, fmt.Errorf("获取媒体失败")
}
req := &tg.MessagesSendMediaRequest{
InvertMedia: msg.InvertMedia,
Message: msg.Message,
}
switch m := media.(type) {
case *tg.MessageMediaDocument:
document, ok := m.Document.AsNotEmpty()
if !ok {
return nil, ErrEmptyDocument
}
inputMedia := &tg.InputMediaDocument{
ID: document.AsInput(),
}
inputMedia.SetFlags()
req.Media = inputMedia
case *tg.MessageMediaPhoto:
photo, ok := m.Photo.AsNotEmpty()
if !ok {
return nil, ErrEmptyPhoto
}
inputMedia := &tg.InputMediaPhoto{
ID: photo.AsInput(),
}
inputMedia.SetFlags()
req.Media = inputMedia
default:
return nil, fmt.Errorf("不支持的媒体类型: %T", media)
}
req.SetEntities(msg.Entities)
req.SetFlags()
return ctx.SendMedia(chatID, req)
}
func sendFileToTelegram(ctx *ext.Context, update *ext.Update) error {
args := strings.Split(string(update.CallbackQuery.Data), " ")
if len(args) < 3 {
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.QueryID,
Alert: true,
Message: "参数错误",
CacheTime: 5,
})
return dispatcher.EndGroups
}
fileChatID, _ := strconv.Atoi(args[1])
fileMessageID, _ := strconv.Atoi(args[2])
fileMessage, err := GetTGMessage(ctx, int64(fileChatID), fileMessageID)
if err != nil {
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.QueryID,
Alert: true,
Message: "无法获取文件消息",
CacheTime: 5,
})
return dispatcher.EndGroups
}
_, err = copyMediaToChat(ctx, fileMessage, update.EffectiveChat().GetID())
if err != nil {
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.QueryID,
Alert: true,
Message: fmt.Sprintf("发送文件失败: %s", err),
CacheTime: 5,
})
} else {
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.QueryID,
})
}
return dispatcher.EndGroups
}

View File

@@ -29,5 +29,6 @@ func RegisterHandlers(dispatcher dispatcher.Dispatcher) {
dispatcher.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix("add"), AddToQueue))
dispatcher.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix("set_default"), setDefaultStorage))
dispatcher.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix("cancel"), cancelTask))
dispatcher.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix("send_here"), sendFileToTelegram))
dispatcher.AddHandler(handlers.NewMessage(filters.Message.Media, handleFileMessage))
}

View File

@@ -71,6 +71,14 @@ func getSelectStorageMarkup(userChatID int64, fileChatID, fileMessageID int) (*t
row.Buttons = buttons[i:min(i+3, len(buttons))]
markup.Rows = append(markup.Rows, row)
}
markup.Rows = append(markup.Rows, tg.KeyboardButtonRow{
Buttons: []tg.KeyboardButtonClass{
&tg.KeyboardButtonCallback{
Text: "发送到当前聊天",
Data: []byte(fmt.Sprintf("send_here %d %d", fileChatID, fileMessageID)),
},
},
})
return markup, nil
}

View File

@@ -47,7 +47,7 @@ func Run(_ *cobra.Command, _ []string) {
return
}
common.Log.Info("正在清理缓存文件夹: ", cachePath)
if err := os.RemoveAll(cachePath); err != nil {
if err := common.RemoveAllInDir(cachePath); err != nil {
common.Log.Error("清理缓存失败: ", err)
}
}

View File

@@ -25,15 +25,16 @@ func InitLogger() {
}
}
consoleH := handler.NewConsoleHandler(logLevels)
fileH, err := handler.NewTimeRotateFile(
logFilePath,
rotatefile.EveryDay,
handler.WithLogLevels(slog.AllLevels),
handler.WithBackupNum(logBackupNum),
handler.WithBuffSize(0),
)
if err != nil {
panic(err)
Log.AddHandler(consoleH)
if logFilePath != "" && logBackupNum > 0 {
fileH, err := handler.NewTimeRotateFile(
logFilePath,
rotatefile.EveryDay,
handler.WithLogLevels(slog.AllLevels),
handler.WithBackupNum(logBackupNum))
if err != nil {
panic(err)
}
Log.AddHandler(fileH)
}
Log.AddHandlers(consoleH, fileH)
}

View File

@@ -1,31 +1,11 @@
package common
import (
"errors"
"os"
"path/filepath"
"time"
)
// 创建文件, 自动创建目录
func MkFile(path string, data []byte) error {
err := os.MkdirAll(filepath.Dir(path), os.ModePerm)
if err != nil {
return err
}
return os.WriteFile(path, data, os.ModePerm)
}
// 删除文件, 并清理空目录. 如果文件不存在则返回 nil
func PurgeFile(path string) error {
if err := os.Remove(path); err != nil {
if !errors.Is(err, os.ErrNotExist) {
return err
}
}
return RemoveEmptyDirectories(filepath.Dir(path))
}
func RmFileAfter(path string, td time.Duration) {
_, err := os.Stat(path)
if err != nil {
@@ -34,22 +14,23 @@ func RmFileAfter(path string, td time.Duration) {
}
Log.Debugf("Remove file after %s: %s", td, path)
time.AfterFunc(td, func() {
PurgeFile(path)
if err := os.Remove(path); err != nil {
Log.Errorf("Failed to remove file %s: %s", path, err)
}
})
}
// 递归删除目录
func RemoveEmptyDirectories(dirPath string) error {
// 删除目录下的所有内容, 但不删除目录本身
func RemoveAllInDir(dirPath string) error {
entries, err := os.ReadDir(dirPath)
if err != nil {
return err
}
if len(entries) == 0 {
err := os.Remove(dirPath)
if err != nil {
for _, entry := range entries {
entryPath := filepath.Join(dirPath, entry.Name())
if err := os.RemoveAll(entryPath); err != nil {
return err
}
return RemoveEmptyDirectories(filepath.Dir(dirPath))
}
return nil
}

View File

@@ -90,11 +90,9 @@ func Init() error {
viper.SetDefault("telegram.rpc_retry", 5)
viper.SetDefault("temp.base_path", "cache/")
viper.SetDefault("temp.cache_ttl", 3600)
viper.SetDefault("temp.cache_ttl", 30)
viper.SetDefault("log.level", "INFO")
viper.SetDefault("log.file", "logs/saveany.log")
viper.SetDefault("log.backup_count", 7)
viper.SetDefault("db.path", "data/saveany.db")
viper.SetDefault("db.session", "data/session.db")

View File

@@ -27,6 +27,13 @@ import (
func processPendingTask(task *types.Task) error {
common.Log.Debugf("Start processing task: %s", task.String())
if task.FileName() != "" && !task.IsTelegraph && task.File.FileSize != 0 && task.FileDBID != 0 {
ext := path.Ext(task.FileName())
name := task.FileName()[:len(task.FileName())-len(ext)]
task.File.FileName = fmt.Sprintf("%s_%d%s", name, task.FileDBID, ext)
}
if task.FileName() == "" {
task.File.FileName = fmt.Sprintf("%d_%d_%s", task.FileChatID, task.FileMessageID, task.File.Hash())
}
@@ -60,6 +67,7 @@ func processPendingTask(task *types.Task) error {
notsupportStreamStorage, notsupportStream := taskStorage.(storage.StorageNotSupportStream)
cancelMarkUp := getCancelTaskMarkup(task)
if config.Cfg.Stream {
if !notsupportStream {
text, entities := buildProgressMessageEntity(task, 0, task.StartTime, 0)

13
docker-compose.local.yml Normal file
View File

@@ -0,0 +1,13 @@
services:
saveany-bot:
build: .
container_name: saveany-bot
restart: unless-stopped
volumes:
- ./data:/app/data
- ./config.toml:/app/config.toml
- ./downloads:/app/downloads
- ./cache:/app/cache
# 使用 host 模式以便访问宿主机服务 (如代理)
# 如果你对 Docker 网络模式熟悉, 可以自行修改
network_mode: host

View File

@@ -20,6 +20,7 @@ type Task struct {
StorageName string
StoragePath string
StartTime time.Time
FileDBID uint
File *File
FileMessageID int