Compare commits

...

19 Commits

Author SHA1 Message Date
krau
336309fad0 chore: remove unuse comment 2025-06-20 22:29:37 +08:00
krau
394cdff865 feat: regex filter for batch save message 2025-06-20 22:25:46 +08:00
krau
40cb3dad9d feat: handle grouped msgs, close #72 2025-06-20 22:07:21 +08:00
krau
2979628cf7 docs(zh): add hook docs 2025-06-20 21:46:00 +08:00
krau
c82c2462bf feat: exec command hook , close #79 2025-06-20 21:30:50 +08:00
krau
88128ecac2 fix: cache init after config 2025-06-20 21:27:45 +08:00
krau
758564d436 feat(config): add cache configuration options for TTL, num counters, and max cost 2025-06-18 10:51:03 +08:00
krau
f5e33472eb feat: add IterMessages function for message iteration with error handling 2025-06-18 10:50:54 +08:00
krau
4df2c5a06d refactor: replace key package with ctxkey for context keys 2025-06-17 22:19:06 +08:00
krau
eb6f8675a4 fix(minio): pass the size to minio puitobject 2025-06-17 22:10:20 +08:00
krau
473a5b9413 chore: update help text 2025-06-16 17:31:34 +08:00
krau
6c2abe3025 chore: remove deprecated config 2025-06-16 17:11:26 +08:00
krau
e7e5b9f434 docs: add repo link 2025-06-16 17:05:31 +08:00
krau
d4d39d1c07 chore: update readme 2025-06-16 16:57:39 +08:00
krau
73b5f1b18e docs: add en translate 2025-06-16 16:30:45 +08:00
krau
837700bf63 docs: adjust font size 2025-06-16 16:08:03 +08:00
krau
53e6d7cc54 ci(docs): fix ci 2025-06-16 16:01:50 +08:00
krau
4206d1fe96 docs: refactor 2025-06-16 15:58:03 +08:00
krau
6566dbbf96 chore: add new storage configuration for Telegram channel 2025-06-16 00:24:38 +08:00
66 changed files with 1490 additions and 493 deletions

View File

@@ -6,17 +6,31 @@ on:
paths:
- "docs/**"
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- uses: actions/cache@v4
submodules: true # Fetch Hugo themes (true OR recursive)
fetch-depth: 0 # Fetch all history for .GitInfo and .Lastmod
- name: Setup Hugo
uses: peaceiris/actions-hugo@v3
with:
key: ${{ github.ref }}
path: .cache
- run: pip install mkdocs-material
- run: cd docs && mkdocs gh-deploy --force
hugo-version: '0.147.8'
extended: true
- name: Build
run: hugo --minify --destination public --source docs
- name: Deploy
uses: peaceiris/actions-gh-pages@v3
if: github.ref == 'refs/heads/main'
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./docs/public
publish_branch: gh-pages

3
.gitignore vendored
View File

@@ -6,4 +6,5 @@ downloads/
session.*
cache.db
.vscode/
temp/
temp/
.hugo_build.lock

View File

@@ -1,28 +1,39 @@
<div align="center">
# <img src="docs/logo.jpg" width="45" align="center"> Save Any Bot
# <img src="docs/static/logo.png" width="45" align="center"> Save Any Bot
**简体中文** | [English](README_EN.md)
**简体中文** | [English](https://sabot.unv.app/en/)
把 Telegram 的文件存到各类存储端.
> _就像 PikPak Bot 一样_
把 Telegram 的文件存到多种存储端.
</div>
## [部署](https://sabot.unv.app/deploy/)
## 部署
## [参与开发](https://sabot.unv.app/contribute/)
请参考 [部署文档](https://sabot.unv.app/deployment/installation/)
---
## Features
## 赞助
- 支持文档/视频/图片/贴纸… 甚至还有 Telegraph
- 破解禁止保存的文件
- 批量下载
- 流式传输
- 多用户
- 基于存储规则的自动整理
- 支持多种存储端:
- Alist
- Minio (S3 兼容)
- WebDAV
- Telegram (重传回指定聊天)
- 本地磁盘
## Sponsors
本项目受到 [YxVM](https://yxvm.com/) 与 [NodeSupport](https://github.com/NodeSeekDev/NodeSupport) 的支持.
如果这个项目对你有帮助, 你可以考虑通过以下方式赞助我:
- [爱发电](https://afdian.com/a/acherkrau)
- [爱发电](https://afdian.com/a/unvapp)
## Contributors
@@ -68,4 +79,5 @@
- [gotd](https://github.com/gotd/td)
- [TG-FileStreamBot](https://github.com/EverythingSuckz/TG-FileStreamBot)
- [gotgproto](https://github.com/celestix/gotgproto)
- [tdl](https://github.com/iyear/tdl)
- All the dependencies

View File

@@ -1,108 +0,0 @@
<div align="center">
# <img src="docs/logo.jpg" width="45" align="center"> Save Any Bot
[简体中文](README.md) | **English**
Save Telegram files to various storage endpoints.
> _Just like PikPak Bot_
</div>
## Deployment
### Deploy from Binary
Download the binary file for your platform from the [Release](https://github.com/krau/SaveAny-Bot/releases) page.
Create a `config.toml` file in the extracted directory, refer to [config.example.toml](https://github.com/krau/SaveAny-Bot/blob/main/config.example.toml) for configuration.
Run:
```bash
chmod +x saveany-bot
./saveany-bot
```
#### Add as systemd Service
Create file `/etc/systemd/system/saveany-bot.service` and write the following content:
```
[Unit]
Description=SaveAnyBot
After=systemd-user-sessions.service
[Service]
Type=simple
WorkingDirectory=/yourpath/
ExecStart=/yourpath/saveany-bot
Restart=on-failure
[Install]
WantedBy=multi-user.target
```
Enable auto-start and start the service:
```bash
systemctl enable --now saveany-bot
```
### Deploy with Docker
#### Docker Compose
Download [docker-compose.yml](https://github.com/krau/SaveAny-Bot/blob/main/docker-compose.yml) file and create a `config.toml` file in the same directory, refer to [config.example.toml](https://github.com/krau/SaveAny-Bot/blob/main/config.example.toml) for configuration.
Run:
```bash
docker compose up -d
```
#### Docker
```shell
docker run -d --name saveany-bot \
-v /path/to/config.toml:/app/config.toml \
-v /path/to/downloads:/app/downloads \
ghcr.io/krau/saveany-bot:latest
```
## Update
Use `upgrade` or `up` command to upgrade to the latest version:
```bash
./saveany-bot upgrade
```
If deployed with Docker, use the following commands to update:
```bash
docker pull ghcr.io/krau/saveany-bot:latest
docker restart saveany-bot
```
## Usage
Send (forward) files to the Bot and follow the prompts.
---
## Sponsors
This project is supported by [YxVM](https://yxvm.com/) and [NodeSupport](https://github.com/NodeSeekDev/NodeSupport).
You can consider sponsoring me if this project helps you:
- [Afdian](https://afdian.com/a/acherkrau)
## Thanks
- [gotd](https://github.com/gotd/td)
- [TG-FileStreamBot](https://github.com/EverythingSuckz/TG-FileStreamBot)
- [gotgproto](https://github.com/celestix/gotgproto)
- All the dependencies

View File

@@ -12,6 +12,7 @@ func handleHelpCmd(ctx *ext.Context, update *ext.Update) error {
const helpText string = `
Save Any Bot - 转存你的 Telegram 文件
版本: %s , 提交: %s
命令:
/start - 开始使用
/help - 显示帮助
@@ -19,12 +20,12 @@ Save Any Bot - 转存你的 Telegram 文件
/storage - 设置默认存储位置
/save [自定义文件名] - 保存文件
静默模式: 开启后 Bot 直接保存到收到的文件到默认位置, 不再询问
默认存储位置: 在静默模式下保存到的位置
向 Bot 发送(转发)文件, 或发送一个公开频道的消息链接以保存文件
使用帮助: https://sabot.unv.app/usage/
`
ctx.Reply(update, ext.ReplyTextString(fmt.Sprintf(helpText, consts.Version, consts.GitCommit)), nil)
shortHash := consts.GitCommit
if len(shortHash) > 7 {
shortHash = shortHash[:7]
}
ctx.Reply(update, ext.ReplyTextString(fmt.Sprintf(helpText, consts.Version, shortHash)), nil)
return dispatcher.EndGroups
}

View File

@@ -2,10 +2,12 @@ package handlers
import (
"fmt"
"regexp"
"strings"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/celestix/gotgproto/functions"
"github.com/charmbracelet/log"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/mediautil"
@@ -23,7 +25,7 @@ func handleSaveCmd(ctx *ext.Context, update *ext.Update) error {
logger := log.FromContext(ctx)
args := strings.Split(string(update.EffectiveMessage.Text), " ")
if len(args) >= 3 {
return handleBatchSave(ctx, update, args[1], args[2])
return handleBatchSave(ctx, update, args[1:])
}
replyTo := update.EffectiveMessage.ReplyToMessage
if replyTo == nil || replyTo.Message == nil {
@@ -60,7 +62,7 @@ func handleSaveCmd(ctx *ext.Context, update *ext.Update) error {
func handleSilentSaveReplied(ctx *ext.Context, update *ext.Update) error {
args := strings.Split(string(update.EffectiveMessage.Text), " ")
if len(args) >= 3 {
return handleBatchSave(ctx, update, args[1], args[2])
return handleBatchSave(ctx, update, args[1:])
}
logger := log.FromContext(ctx)
stor := storage.FromContext(ctx)
@@ -92,7 +94,20 @@ func handleSilentSaveReplied(ctx *ext.Context, update *ext.Update) error {
return shortcut.CreateAndAddTGFileTaskWithEdit(ctx, update.GetUserChat().GetID(), stor, "", file, msg.GetID())
}
func handleBatchSave(ctx *ext.Context, update *ext.Update, chatArg string, msgIdRangeArg string) error {
func handleBatchSave(ctx *ext.Context, update *ext.Update, args []string) error {
chatArg := args[0]
msgIdRangeArg := args[1]
var filterStr string
var filter *regexp.Regexp
if len(args) > 2 {
filterStr = args[2]
var err error
filter, err = regexp.Compile(filterStr)
if err != nil {
ctx.Reply(update, ext.ReplyTextString("无效的正则表达式: "+err.Error()), nil)
return dispatcher.EndGroups
}
}
startID, endID, err := strutil.ParseIntStrRange(msgIdRangeArg, "-")
if err != nil {
ctx.Reply(update, ext.ReplyTextString("无效的消息ID范围: "+err.Error()), nil)
@@ -121,7 +136,11 @@ func handleBatchSave(ctx *ext.Context, update *ext.Update, chatArg string, msgId
return dispatcher.EndGroups
}
files := make([]tfile.TGFileMessage, 0, len(msgs))
sb := strings.Builder{}
for _, msg := range msgs {
if msg == nil {
continue
}
media, ok := msg.GetMedia()
if !ok {
continue
@@ -135,6 +154,17 @@ func handleBatchSave(ctx *ext.Context, update *ext.Update, chatArg string, msgId
log.FromContext(ctx).Errorf("获取文件失败: %s", err)
continue
}
if filter != nil {
sb.Reset()
sb.WriteString(msg.GetMessage())
sb.WriteString(" ")
fn, _ := functions.GetMediaFileNameWithId(media)
sb.WriteString(fn)
log.FromContext(ctx).Debugf("正在检查消息内容: %s", sb.String())
if !filter.MatchString(sb.String()) {
continue
}
}
files = append(files, file)
}
if len(files) == 0 {

View File

@@ -81,7 +81,29 @@ func GetFilesFromUpdateLinkMessageWithReplyEdit(ctx *ext.Context, update *ext.Up
}
files = make([]tfile.TGFileMessage, 0, len(msgLinks))
addFile := func(msg *tg.Message) {
if msg == nil {
logger.Warn("message is nil, skipping")
return
}
media, ok := msg.GetMedia()
if !ok {
logger.Debugf("message %d has no media", msg.GetID())
return
}
file, err := tfile.FromMediaMessage(media, msg, tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*msg)))
if err != nil {
logger.Errorf("failed to create file from media: %s", err)
return
}
files = append(files, file)
}
for _, link := range msgLinks {
linkUrl, err := url.Parse(link)
if err != nil {
logger.Errorf("failed to parse message link %s: %s", link, err)
continue
}
chatId, msgId, err := tgutil.ParseMessageLink(ctx, link)
if err != nil {
logger.Errorf("failed to parse message link %s: %s", link, err)
@@ -92,17 +114,19 @@ func GetFilesFromUpdateLinkMessageWithReplyEdit(ctx *ext.Context, update *ext.Up
logger.Errorf("failed to get message by ID: %s", err)
continue
}
media, ok := msg.GetMedia()
if !ok {
logger.Debugf("message %d has no media", msg.GetID())
continue
groupID, isGroup := msg.GetGroupedID()
if isGroup && groupID != 0 && !linkUrl.Query().Has("single") {
gmsgs, err := tgutil.GetGroupedMessages(ctx, chatId, msg)
if err != nil {
logger.Errorf("failed to get grouped messages: %s", err)
} else {
for _, gmsg := range gmsgs {
addFile(gmsg)
}
}
} else {
addFile(msg)
}
file, err := tfile.FromMediaMessage(media, msg, tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*msg)))
if err != nil {
logger.Errorf("failed to create file from media: %s", err)
continue
}
files = append(files, file)
}
if len(files) == 0 {
editReplied("没有找到可保存的文件", nil)

View File

@@ -12,6 +12,7 @@ import (
"github.com/charmbracelet/log"
"github.com/krau/SaveAny-Bot/client/bot"
userclient "github.com/krau/SaveAny-Bot/client/user"
"github.com/krau/SaveAny-Bot/common/cache"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/common/utils/fsutil"
@@ -46,6 +47,7 @@ func initAll(ctx context.Context) {
fmt.Println("Failed to load config:", err)
os.Exit(1)
}
cache.Init()
logger := log.FromContext(ctx)
i18n.Init(config.Cfg.Lang)
logger.Info(i18n.T(i18nk.Initing))

View File

@@ -2,19 +2,22 @@ package cache
import (
"fmt"
"time"
"github.com/charmbracelet/log"
"github.com/dgraph-io/ristretto/v2"
"github.com/krau/SaveAny-Bot/config"
)
var cache *ristretto.Cache[string, any]
// TODO: maybe we should use simple ttl cache instead of ristretto...
func init() {
func Init() {
if cache != nil {
panic("cache already initialized")
}
c, err := ristretto.NewCache(&ristretto.Config[string, any]{
NumCounters: 1e5,
MaxCost: 1e6, // 1000000 / 112 ≈ 8928
NumCounters: config.Cfg.Cache.NumCounters,
MaxCost: config.Cfg.Cache.MaxCost,
BufferItems: 64,
OnReject: func(item *ristretto.Item[any]) {
log.Warnf("Cache item rejected: key=%d, value=%v", item.Key, item.Value)
@@ -27,7 +30,7 @@ func init() {
}
func Set(key string, value any) error {
ok := cache.Set(key, value, 0)
ok := cache.SetWithTTL(key, value, 0, time.Duration(config.Cfg.Cache.TTL)*time.Second)
if !ok {
return fmt.Errorf("failed to set value in cache")
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/celestix/gotgproto/ext"
"github.com/duke-git/lancet/v2/maputil"
"github.com/duke-git/lancet/v2/mathutil"
"github.com/duke-git/lancet/v2/slice"
lcstrutil "github.com/duke-git/lancet/v2/strutil"
@@ -159,6 +160,96 @@ func GetMessagesRange(ctx *ext.Context, chatID int64, minId, maxId int) ([]*tg.M
return result, nil
}
type MessageItem struct {
Message *tg.Message
Error error
}
func IterMessages(ctx *ext.Context, chatID int64, minId, maxId int) (<-chan MessageItem, error) {
total := maxId - minId + 1
ch := make(chan MessageItem, 100)
go func() {
defer close(ch)
if !ctx.Self.Bot {
perr := ctx.PeerStorage.GetInputPeerById(chatID)
if perr == nil || perr.(*tg.InputPeerEmpty) != nil {
ch <- MessageItem{
Error: fmt.Errorf("peer not found: %d", chatID),
}
return
}
for i := 0; i < total; i += 100 {
start := minId + i
end := min(start+100, maxId)
msgs, err := ctx.Raw.MessagesGetHistory(ctx, &tg.MessagesGetHistoryRequest{
Peer: perr,
OffsetID: start,
AddOffset: start - end,
Limit: 100,
})
if err != nil {
ch <- MessageItem{
Error: fmt.Errorf("failed to get messages: %w", err),
}
return
}
var msgClass []tg.MessageClass
switch msgsv := msgs.(type) {
case *tg.MessagesMessages:
msgClass = msgsv.GetMessages()
case *tg.MessagesMessagesSlice:
msgClass = msgsv.GetMessages()
case *tg.MessagesChannelMessages:
msgClass = msgsv.GetMessages()
default:
ch <- MessageItem{
Error: fmt.Errorf("unsupported message type: %T", msgsv),
}
continue
}
for _, msg := range msgClass {
msg, ok := msg.AsNotEmpty()
if !ok {
continue
}
switch msg := msg.(type) {
case *tg.Message:
key := fmt.Sprintf("tgmsg:%d:%d:%d", ctx.Self.ID, chatID, msg.GetID())
cache.Set(key, msg)
ch <- MessageItem{
Message: msg,
}
}
}
}
} else {
for i := 0; i < total; i += 100 {
start := minId + i
end := min(start+100, maxId)
msgs, err := GetMessagesRange(ctx, chatID, start, end)
if err != nil {
ch <- MessageItem{
Error: fmt.Errorf("failed to get messages: %w", err),
}
return
}
for _, msg := range msgs {
if msg == nil {
continue
}
ch <- MessageItem{
Message: msg,
}
}
}
}
}()
return ch, nil
}
func GetMessageByID(ctx *ext.Context, chatID int64, msgID int) (*tg.Message, error) {
key := fmt.Sprintf("tgmsg:%d:%d:%d", ctx.Self.ID, chatID, msgID)
if msg, ok := cache.Get[*tg.Message](key); ok {
@@ -181,3 +272,31 @@ func GetMessageByID(ctx *ext.Context, chatID int64, msgID int) (*tg.Message, err
cache.Set(key, tgm)
return tgm, nil
}
func GetGroupedMessages(ctx *ext.Context, chatID int64, msg *tg.Message) ([]*tg.Message, error) {
groupID, isGroup := msg.GetGroupedID()
if !isGroup || groupID == 0 {
return nil, fmt.Errorf("message %d is not grouped", msg.GetID())
}
msgID := msg.GetID()
minID := msgID - 10
maxID := msgID + 10
if minID < 1 {
minID = 1
}
msgs, err := GetMessagesRange(ctx, chatID, minID, maxID)
if err != nil {
return nil, fmt.Errorf("failed to get grouped messages: %w", err)
}
groupedMessages := make([]*tg.Message, 0, len(msgs))
for _, m := range msgs {
if m == nil {
continue
}
mgid, isGroup := m.GetGroupedID()
if isGroup && mgid == groupID {
groupedMessages = append(groupedMessages, m)
}
}
return groupedMessages, nil
}

View File

@@ -15,7 +15,6 @@ token = ""
# 初始化超时时间, 单位: 秒
timeout = 60
# flood_retry = 5
# rpc_retry = 5
@@ -85,18 +84,15 @@ secret_access_key = 'zuf+tfteSlswRu7BJ86wekitnifILbZam1KYY3TG'
bucket_name = 'saveanybot'
base_path = '/path/telegram'
# 其他配置
# [log]
# # 日志等级
# level = "DEBUG"
[[storages]]
name = "mychannel"
type = "telegram"
enable = true
chat_id = 1820371480
# [temp]
# # 下载文件临时目录, 请不要在此目录下存放任何其他文件
# base_path = "cache/"
# # 临时文件保存时间, 单位: 秒
# cache_ttl = 30
# [db]
# path = "data/data.db" # 数据库文件路径

7
config/cache.go Normal file
View File

@@ -0,0 +1,7 @@
package config
type cacheConfig struct {
TTL int64 `toml:"ttl" mapstructure:"ttl" json:"ttl"`
NumCounters int64 `toml:"num_counters" mapstructure:"num_counters" json:"num_counters"`
MaxCost int64 `toml:"max_cost" mapstructure:"max_cost" json:"max_cost"`
}

6
config/db.go Normal file
View File

@@ -0,0 +1,6 @@
package config
type dbConfig struct {
Path string `toml:"path" mapstructure:"path"`
Session string `toml:"session" mapstructure:"session"`
}

22
config/hook.go Normal file
View File

@@ -0,0 +1,22 @@
package config
type hookConfig struct {
Exec hookExecConfig `toml:"exec" mapstructure:"exec" json:"exec"`
}
type hookExecConfig struct {
// command to execute, for all task types
TaskBeforeStart string `toml:"task_before_start" mapstructure:"task_before_start" json:"task_before_start"`
TaskSuccess string `toml:"task_success" mapstructure:"task_success" json:"task_success"`
TaskFail string `toml:"task_fail" mapstructure:"task_fail" json:"task_fail"`
TaskCancel string `toml:"task_cancel" mapstructure:"task_cancel" json:"task_cancel"`
// TaskTypes map[string]hookExecOnTypeConfig `toml:"task_types" mapstructure:"task_types" json:"task_types"` // [TODO]
}
// type hookExecOnTypeConfig struct {
// TaskBeforeStart string `toml:"task_before_start" mapstructure:"task_before_start" json:"task_before_start"`
// TaskSuccess string `toml:"task_success" mapstructure:"task_success" json:"task_success"`
// TaskFail string `toml:"task_fail" mapstructure:"task_fail" json:"task_fail"`
// TaskCancel string `toml:"task_cancel" mapstructure:"task_cancel" json:"task_cancel"`
// }

5
config/temp.go Normal file
View File

@@ -0,0 +1,5 @@
package config
type tempConfig struct {
BasePath string `toml:"base_path" mapstructure:"base_path" json:"base_path"`
}

20
config/tg.go Normal file
View File

@@ -0,0 +1,20 @@
package config
type telegramConfig struct {
Token string `toml:"token" mapstructure:"token"`
AppID int `toml:"app_id" mapstructure:"app_id" json:"app_id"`
AppHash string `toml:"app_hash" mapstructure:"app_hash" json:"app_hash"`
Proxy tgProxyConfig `toml:"proxy" mapstructure:"proxy"`
RpcRetry int `toml:"rpc_retry" mapstructure:"rpc_retry" json:"rpc_retry"`
Userbot userbotConfig `toml:"userbot" mapstructure:"userbot" json:"userbot"` // [TODO]
}
type userbotConfig struct {
Enable bool `toml:"enable" mapstructure:"enable"`
Session string `toml:"session" mapstructure:"session"`
}
type tgProxyConfig struct {
Enable bool `toml:"enable" mapstructure:"enable"`
URL string `toml:"url" mapstructure:"url"`
}

View File

@@ -22,53 +22,16 @@ type Config struct {
Threads int `toml:"threads" mapstructure:"threads" json:"threads"`
Stream bool `toml:"stream" mapstructure:"stream" json:"stream"`
Users []userConfig `toml:"users" mapstructure:"users" json:"users"`
Cache cacheConfig `toml:"cache" mapstructure:"cache" json:"cache"`
Users []userConfig `toml:"users" mapstructure:"users" json:"users"`
Temp tempConfig `toml:"temp" mapstructure:"temp"`
Log logConfig `toml:"log" mapstructure:"log"`
DB dbConfig `toml:"db" mapstructure:"db"`
Telegram telegramConfig `toml:"telegram" mapstructure:"telegram"`
Storages []storage.StorageConfig `toml:"-" mapstructure:"-" json:"storages"`
Hook hookConfig `toml:"hook" mapstructure:"hook" json:"hook"`
}
type tempConfig struct {
BasePath string `toml:"base_path" mapstructure:"base_path" json:"base_path"`
CacheTTL int64 `toml:"cache_ttl" mapstructure:"cache_ttl" json:"cache_ttl"`
}
type logConfig struct {
Level string `toml:"level" mapstructure:"level"`
File string `toml:"file" mapstructure:"file"`
BackupCount uint `toml:"backup_count" mapstructure:"backup_count" json:"backup_count"`
}
type dbConfig struct {
Path string `toml:"path" mapstructure:"path"`
Session string `toml:"session" mapstructure:"session"`
Expire int64 `toml:"expire" mapstructure:"expire"`
}
type telegramConfig struct {
Token string `toml:"token" mapstructure:"token"`
AppID int `toml:"app_id" mapstructure:"app_id" json:"app_id"`
AppHash string `toml:"app_hash" mapstructure:"app_hash" json:"app_hash"`
Timeout int `toml:"timeout" mapstructure:"timeout" json:"timeout"`
Proxy proxyConfig `toml:"proxy" mapstructure:"proxy"`
RpcRetry int `toml:"rpc_retry" mapstructure:"rpc_retry" json:"rpc_retry"`
Userbot userbotConfig `toml:"userbot" mapstructure:"userbot" json:"userbot"`
}
type userbotConfig struct {
Enable bool `toml:"enable" mapstructure:"enable"`
Session string `toml:"session" mapstructure:"session"`
}
type proxyConfig struct {
Enable bool `toml:"enable" mapstructure:"enable"`
URL string `toml:"url" mapstructure:"url"`
}
var Cfg *Config
var Cfg *Config = &Config{}
func (c Config) GetStorageByName(name string) storage.StorageConfig {
for _, storage := range c.Storages {
@@ -89,28 +52,36 @@ func Init(ctx context.Context) error {
replacer := strings.NewReplacer(".", "_")
viper.SetEnvKeyReplacer(replacer)
viper.SetDefault("lang", "zh-Hans")
defaultConfigs := map[string]any{
// 基础配置
"lang": "zh-Hans",
"workers": 3,
"retry": 3,
"threads": 4,
viper.SetDefault("workers", 3)
viper.SetDefault("retry", 3)
viper.SetDefault("threads", 4)
// 缓存配置
"cache.ttl": 86400,
"cache.num_counters": 1e5,
"cache.max_cost": 1e6,
viper.SetDefault("telegram.app_id", 1025907)
viper.SetDefault("telegram.app_hash", "452b0359b988148995f22ff0f4229750")
viper.SetDefault("telegram.timeout", 60)
viper.SetDefault("telegram.flood_retry", 5)
viper.SetDefault("telegram.rpc_retry", 5)
viper.SetDefault("telegram.userbot.enable", false)
viper.SetDefault("telegram.userbot.session", "data/usersession.db")
// Telegram
"telegram.app_id": 1025907,
"telegram.app_hash": "452b0359b988148995f22ff0f4229750",
"telegram.rpc_retry": 5,
"telegram.userbot.enable": false,
"telegram.userbot.session": "data/usersession.db",
viper.SetDefault("temp.base_path", "cache/")
viper.SetDefault("temp.cache_ttl", 30)
// 临时目录
"temp.base_path": "cache/",
viper.SetDefault("log.level", "INFO")
// 数据库
"db.path": "data/saveany.db",
"db.session": "data/session.db",
}
viper.SetDefault("db.path", "data/saveany.db")
viper.SetDefault("db.session", "data/session.db")
viper.SetDefault("db.expire", 86400*5)
for key, value := range defaultConfigs {
viper.SetDefault(key, value)
}
if err := viper.SafeWriteConfigAs("config.toml"); err != nil {
if _, ok := err.(viper.ConfigFileAlreadyExistsError); !ok {
@@ -123,8 +94,6 @@ func Init(ctx context.Context) error {
os.Exit(1)
}
Cfg = &Config{}
if err := viper.Unmarshal(Cfg); err != nil {
fmt.Println("Error unmarshalling config file, ", err)
os.Exit(1)
@@ -171,7 +140,6 @@ func Init(ctx context.Context) error {
userStorages[user.ID] = user.Storages
}
}
return nil
}

View File

@@ -13,7 +13,7 @@ import (
"github.com/krau/SaveAny-Bot/common/utils/fsutil"
"github.com/krau/SaveAny-Bot/common/utils/ioutil"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/pkg/enums/key"
"github.com/krau/SaveAny-Bot/pkg/enums/ctxkey"
"golang.org/x/sync/errgroup"
)
@@ -104,7 +104,7 @@ func (t *Task) processElement(ctx context.Context, elem TaskElement) error {
if err != nil {
return fmt.Errorf("failed to get file stat: %w", err)
}
vctx := context.WithValue(ctx, key.ContextKeyContentLength, fileStat.Size())
vctx := context.WithValue(ctx, ctxkey.ContentLength, fileStat.Size())
err = retry.Retry(func() error {
var file *os.File
file, err = os.Open(elem.localPath)

View File

@@ -8,6 +8,7 @@ import (
"github.com/krau/SaveAny-Bot/common/tdler"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
"github.com/krau/SaveAny-Bot/pkg/tfile"
"github.com/krau/SaveAny-Bot/storage"
"github.com/rs/xid"
@@ -35,6 +36,10 @@ type Task struct {
failed map[string]error // errors for each element
}
func (t *Task) Type() tasktype.TaskType {
return tasktype.TaskTypeTgfiles
}
func NewTaskElement(
stor storage.Storage,
path string,

View File

@@ -2,32 +2,54 @@ package core
import (
"context"
"errors"
"github.com/charmbracelet/log"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
"github.com/krau/SaveAny-Bot/pkg/queue"
)
var queueInstance *queue.TaskQueue[Exectable]
type Exectable interface {
Type() tasktype.TaskType
TaskID() string
Execute(ctx context.Context) error
}
func worker(ctx context.Context, qe *queue.TaskQueue[Exectable], semaphore chan struct{}) {
logger := log.FromContext(ctx)
execHooks := config.Cfg.Hook.Exec
for {
semaphore <- struct{}{}
qtask, err := qe.Get()
if err != nil {
logger.Error("Failed to get task from queue:", err)
break // queue closed and empty
}
log.FromContext(ctx).Infof("Processing task: %s", qtask.ID)
task := qtask.Data
logger.Infof("Processing task: %s", task.TaskID())
if err := ExecCommandString(qtask.Context(), execHooks.TaskBeforeStart); err != nil {
logger.Errorf("Failed to execute before start hook for task %s: %v", task.TaskID(), err)
}
if err := task.Execute(qtask.Context()); err != nil {
log.FromContext(ctx).Errorf("Failed to execute task %s: %v", qtask.ID, err)
if errors.Is(err, context.Canceled) {
logger.Infof("Task %s was canceled", task.TaskID())
if err := ExecCommandString(ctx, execHooks.TaskCancel); err != nil {
logger.Errorf("Failed to execute cancel hook for task %s: %v", task.TaskID(), err)
}
} else {
logger.Errorf("Failed to execute task %s: %v", task.TaskID(), err)
if err := ExecCommandString(ctx, execHooks.TaskFail); err != nil {
logger.Errorf("Failed to execute fail hook for task %s: %v", task.TaskID(), err)
}
}
} else {
log.FromContext(ctx).Infof("Task %s completed successfully", qtask.ID)
logger.Infof("Task %s completed successfully", task.TaskID())
if err := ExecCommandString(ctx, execHooks.TaskSuccess); err != nil {
logger.Errorf("Failed to execute success hook for task %s: %v", task.TaskID(), err)
}
}
qe.Done(qtask.ID)
<-semaphore

23
core/hookutil.go Normal file
View File

@@ -0,0 +1,23 @@
package core
import (
"context"
"os"
"os/exec"
"runtime"
)
func ExecCommandString(ctx context.Context, cmd string) error {
if cmd == "" {
return nil
}
var execCmd *exec.Cmd
if runtime.GOOS == "windows" {
execCmd = exec.CommandContext(ctx, "cmd.exe", "/C", cmd)
} else {
execCmd = exec.CommandContext(ctx, "sh", "-c", cmd)
}
execCmd.Stdout = os.Stdout
execCmd.Stderr = os.Stderr
return execCmd.Run()
}

View File

@@ -11,10 +11,10 @@ import (
"github.com/krau/SaveAny-Bot/common/tdler"
"github.com/krau/SaveAny-Bot/common/utils/fsutil"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/pkg/enums/key"
"github.com/krau/SaveAny-Bot/pkg/enums/ctxkey"
)
func (t *TGFileTask) Execute(ctx context.Context) error {
func (t *Task) Execute(ctx context.Context) error {
logger := log.FromContext(ctx).WithPrefix(fmt.Sprintf("file[%s]", t.File.Name()))
t.Progress.OnStart(ctx, t)
if t.stream {
@@ -52,7 +52,7 @@ func (t *TGFileTask) Execute(ctx context.Context) error {
if err != nil {
return fmt.Errorf("failed to get file stat: %w", err)
}
vctx := context.WithValue(ctx, key.ContextKeyContentLength, fileStat.Size())
vctx := context.WithValue(ctx, ctxkey.ContentLength, fileStat.Size())
for i := range config.Cfg.Retry + 1 {
if err = vctx.Err(); err != nil {
return fmt.Errorf("context canceled while saving file: %w", err)

View File

@@ -10,7 +10,7 @@ import (
"golang.org/x/sync/errgroup"
)
func executeStream(ctx context.Context, task *TGFileTask) error {
func executeStream(ctx context.Context, task *Task) error {
logger := log.FromContext(ctx).WithPrefix(fmt.Sprintf("file[%s]", task.File.Name()))
pr, pw := io.Pipe()

View File

@@ -8,22 +8,22 @@ type TaskInfo interface {
StorageName() string
}
func (t *TGFileTask) TaskID() string {
func (t *Task) TaskID() string {
return t.ID
}
func (t *TGFileTask) FileName() string {
func (t *Task) FileName() string {
return t.File.Name()
}
func (t *TGFileTask) FileSize() int64 {
func (t *Task) FileSize() int64 {
return t.File.Size()
}
func (t *TGFileTask) StoragePath() string {
func (t *Task) StoragePath() string {
return t.Path
}
func (t *TGFileTask) StorageName() string {
func (t *Task) StorageName() string {
return t.Storage.Name()
}

View File

@@ -7,11 +7,12 @@ import (
"github.com/krau/SaveAny-Bot/common/tdler"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
"github.com/krau/SaveAny-Bot/pkg/tfile"
"github.com/krau/SaveAny-Bot/storage"
)
type TGFileTask struct {
type Task struct {
ID string
Ctx context.Context
File tfile.TGFile
@@ -23,6 +24,10 @@ type TGFileTask struct {
localPath string
}
func (t *Task) Type() tasktype.TaskType {
return tasktype.TaskTypeTgfiles
}
func NewTGFileTask(
id string,
ctx context.Context,
@@ -31,14 +36,14 @@ func NewTGFileTask(
stor storage.Storage,
path string,
progress ProgressTracker,
) (*TGFileTask, error) {
) (*Task, error) {
_, ok := stor.(storage.StorageCannotStream)
if !config.Cfg.Stream || ok {
cachePath, err := filepath.Abs(filepath.Join(config.Cfg.Temp.BasePath, fmt.Sprintf("%s_%s", id, file.Name())))
if err != nil {
return nil, fmt.Errorf("failed to get absolute path for cache: %w", err)
}
tftask := &TGFileTask{
tftask := &Task{
ID: id,
Ctx: ctx,
client: client,
@@ -50,7 +55,7 @@ func NewTGFileTask(
}
return tftask, nil
}
tfileTask := &TGFileTask{
tfileTask := &Task{
ID: id,
Ctx: ctx,
client: client,

View File

@@ -4,6 +4,7 @@ import (
"context"
"sync/atomic"
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
"github.com/krau/SaveAny-Bot/pkg/telegraph"
"github.com/krau/SaveAny-Bot/storage"
)
@@ -23,6 +24,10 @@ type Task struct {
downloaded atomic.Int64
}
func (t *Task) Type() tasktype.TaskType {
return tasktype.TaskTypeTphpics
}
func NewTask(
id string,
ctx context.Context,

1
docs/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
public/

View File

@@ -0,0 +1,5 @@
+++
date = '{{ .Date }}'
draft = true
title = '{{ replace .File.ContentBaseName "-" " " | title }}'
+++

View File

@@ -0,0 +1 @@
$font-size-base: 18px;

31
docs/content/en/_index.md Normal file
View File

@@ -0,0 +1,31 @@
---
title: Introduction
---
# Save Any Bot
![](https://img.shields.io/github/go-mod/go-version/krau/SaveAny-Bot?style=flat-square)
![](https://img.shields.io/github/license/krau/SaveAny-Bot?style=flat-square)
![](https://img.shields.io/github/v/release/krau/SaveAny-Bot?color=cyan&style=flat-square)
![](https://img.shields.io/github/downloads/krau/SaveAny-Bot/total?style=flat-square)
Save Any Bot is a tool that allows you to save files from Telegram to various storage backends.
## Features
- Supports documents/videos/images/stickers... and even Telegraph
- Breaks restrictions on saving files
- Batch download
- Streaming
- Multi-user
- Automatic organization based on storage rules
- Supports multiple storage backends:
- Alist
- Minio (S3 compatible)
- WebDAV
- Telegram (re-upload to specified chat)
- Local disk
## [Contributors](https://github.com/krau/SaveAny-Bot/graphs/contributors)
![Contributors](https://contrib.rocks/image?repo=krau/SaveAny-Bot&max=750&columns=20)

View File

@@ -0,0 +1,14 @@
---
title: "Contributing"
weight: 20
---
# Contributing
## Contributing New Storage Backend
1. Fork this repository and clone it to your local machine.
2. Add the new storage backend type in `pkg/enums/storage/storages.go` and run code generation.
3. Define the storage backend configuration in the `config/storage` directory and add it to `config/storage/factory.go`.
4. Create a new package in the `storage` directory, implement the storage backend, and import it in `storage/storage.go`.
5. Update the documentation to include configuration details for the new storage backend.

View File

@@ -0,0 +1,4 @@
---
title: "Deployment Guide"
weight: 5
---

View File

@@ -0,0 +1,141 @@
---
title: "Configuration Guide"
---
# Configuration Guide
SaveAnyBot uses the toml format for its configuration files. You can learn more about toml syntax on the [TOML official website](https://toml.io/).
SaveAnyBot needs to read a `config.toml` file in the working directory as its configuration file. If this file is missing, a default file will be created, and the bot will attempt to load configuration from environment variables.
Here is an example of a minimal configuration file:
```toml
[telegram]
token = "1234567890:ABCDEFGHIJKLMNOPQRSTUVWXYZ"
[[users]]
# telegram user id
id = 777000
blacklist = true
[[storages]]
name = "Local Storage"
type = "local"
enable = true
base_path = "./downloads"
```
## Detailed Configuration
### Global Configuration
- `stream`: Whether to enable Stream mode, default is `false`. When enabled, the Bot will stream files directly to storage endpoints (if supported), without downloading them locally.
{{< hint warning >}}
Stream mode is very useful for deployment environments with limited disk space, but it also has some drawbacks:
<br />
<ul>
<li>Cannot use multi-threading to download files from Telegram, resulting in slower speeds.</li>
<li>Higher task failure rate when the network is unstable.</li>
<li>Cannot process files in the middle layer, such as automatic file type identification.</li>
<li>Not supported by all storage endpoints; unsupported endpoints may downgrade to normal mode or fail to upload.</li>
</ul>
{{< /hint >}}
- `workers`: Number of tasks to process simultaneously, default is 3.
- `threads`: Number of threads used when downloading files, default is 4. Only effective when Stream mode is not enabled.
- `retry`: Number of retries when a task fails, default is 3.
### Telegram Configuration
- `token`: Your Telegram Bot Token, which can be obtained by creating a Bot through [BotFather](https://t.me/botfather).
- `app_id`, `app_hash`: Telegram API ID & Hash, obtained by creating an application at [Telegram API](https://my.telegram.org/apps). Default values will be used if not provided.
- `flood_retry`: Number of retries for flood control, default is 5.
- `rpc_retry`: Number of retries for RPC requests, default is 5.
- `proxy`: Proxy configuration, optional.
- `enable`: Whether to enable the proxy.
- `url`: Proxy address, only supports `socks5://`
```toml
[telegram]
token = "1234567890:ABCDEFGHIJKLMNOPQRSTUVWXYZ"
app_id = 1025907
app_hash = "452b0359b988148995f22ff0f4229750"
flood_retry = 5
rpc_retry = 5
[telegram.proxy]
enable = false
url = "socks5://127.0.0.1:7890"
```
### Storage Endpoints List
The storage endpoints list is used to define the storage locations supported by the Bot. Each storage endpoint needs to specify a name, type, and related configuration, using the double bracket syntax `[[storages]]`.
Each storage endpoint requires at least the following fields:
- `name`: Storage endpoint name, used for identification in the Bot, must be unique.
- `enable`: Whether to enable this storage endpoint, default is `true`.
- `type`: Storage endpoint type, currently supports the following types:
- `local`: Local disk
- `alist`: Alist
- `webdav`: WebDAV
- `minio`: MinIO (compatible with S3 API)
- `telegram`: Upload to Telegram
Example, this is a configuration that includes local storage and webdav storage:
```toml
[[storages]]
name = "Local Storage"
type = "local"
enable = true
# Custom configuration for local type storage
base_path = "./downloads"
[[storages]]
name = "WebDAV"
type = "webdav"
enable = true
# Custom configuration for webdav type storage
url = "https://example.com/webdav"
base_path = "/path/to/webdav"
username = "your_username"
password = "your_password"
```
For custom configuration items for all storage endpoints, see [Storage Configuration](./storages)
### User List
The user list is used to define access control for storage endpoints. Each user needs to specify a Telegram User ID, defined using the double bracket syntax `[[users]]`.
- `id`: The user's Telegram User ID
- `storages`: Filtered list of storage endpoints, defined by storage endpoint names, default is whitelist mode (i.e., only allows access to storage endpoints in the list)
- `blacklist`: Whether to enable blacklist mode, default is `false`. If blacklist mode is enabled, the user is allowed to access only storage endpoints that are **not** in the list.
Example, this is a configuration containing three users: user `123123` can only access local storage, user `456456` can only access storage other than WebDAV, and user `789789` has blacklist mode enabled but no storage endpoints specified, so they can access all storage:
```toml
[[users]]
id = 123123
storages = ["Local Storage"]
[[users]]
id = 456456
storages = ["WebDAV"]
blacklist = true
[[users]]
id = 789789
storages = []
blacklist = true
```
### Miscellaneous
```toml
no_clean_cache = false # Whether not to clear the cache folder when exiting
# Temporary download folder configuration
[temp]
base_path = "./cache"
```

View File

@@ -0,0 +1,65 @@
---
title: "Storage Configuration"
---
# Storage Configuration
Please first read the [Configuration Guide](../) to understand the basic format of the configuration file.
## Alist
`type=alist`
Stream mode is not supported.
```toml
url = "https://alist.example.com" # URL of Alist
username = "your_username" # Username for Alist
password = "your_password" # Password for Alist
base_path = "/path/saveanybot" # Base path in Alist, all files will be stored under this path
token_exp = 3600 # Auto-refresh time for Alist access token, in seconds
token = "your_token"
# Access token for Alist, optional, if not set, username and password will be used for authentication.
# When using token authentication, the token cannot be automatically refreshed
```
## Local Disk
`type=local`
```toml
base_path = "./downloads" # Base path for local storage, all files will be stored under this path
```
## WebDAV
`type=webdav`
```toml
url = "https://webdav.example.com" # URL of WebDAV
username = "your_username" # Username for WebDAV
password = "your_password" # Password for WebDAV
base_path = "/path/to/webdav" # Base path in WebDAV, all files will be stored under this path
```
## MinIO (S3)
`type=minio`
```toml
endpoint = "minio.example.com" # Endpoint for MinIO or S3
access_key_id = "your_access_key_id" # Access key ID for MinIO or S3
secret_access_key = "your_secret_access_key" # Secret access key for MinIO or S3
bucket_name = "your_bucket_name" # Bucket name for MinIO or S3
use_ssl = true # Whether to use SSL, default is true
base_path = "/path/to/minio" # Base path in MinIO, all files will be stored under this path
```
## Telegram
`type=telegram`
Stream mode is not supported.
```toml
chat_id = "123456789" # Telegram chat ID, the Bot will send files to this chat
```

View File

@@ -0,0 +1,145 @@
---
title: "Installation and Updates"
---
# Installation and Updates
## Deploy from Pre-compiled Files
Download the binary file for your platform from the [Release](https://github.com/krau/SaveAny-Bot/releases) page.
Create a `config.toml` file in the extracted directory, refer to the [Configuration Guide](../configuration) to edit the configuration file.
Run:
```bash
chmod +x saveany-bot
./saveany-bot
```
### Process Monitoring
{{< tabs "daemon" >}}
{{< tab "systemd (Regular Linux)" >}}
Create a file <code>/etc/systemd/system/saveany-bot.service</code> and write the following content:
{{< codeblock >}}
[Unit]
Description=SaveAnyBot
After=systemd-user-sessions.service
[Service]
Type=simple
WorkingDirectory=/yourpath/
ExecStart=/yourpath/saveany-bot
Restart=on-failure
[Install]
WantedBy=multi-user.target
{{< /codeblock >}}
Enable startup on boot and start the service:
{{< codeblock >}}
systemctl enable --now saveany-bot
{{< /codeblock >}}
{{< /tab >}}
{{< tab "procd (OpenWrt)" >}}
<h4>Add Boot Autostart Service</h4>
Create a file <code>/etc/init.d/saveanybot</code>, refer to <a href="https://github.com/krau/SaveAny-Bot/blob/main/docs/confs/wrt_init" target="_blank">wrt_init</a> and modify as needed:
{{< codeblock >}}
#!/bin/sh /etc/rc.common
#This is the OpenWRT init.d script for SaveAnyBot
START=99
STOP=10
description="SaveAnyBot"
WORKING_DIR="/mnt/mmc1-1/SaveAnyBot"
EXEC_PATH="$WORKING_DIR/saveany-bot"
start() {
echo "Starting SaveAnyBot..."
cd $WORKING_DIR
$EXEC_PATH &
}
stop() {
echo "Stopping SaveAnyBot..."
killall saveany-bot
}
reload() {
stop
start
}
{{< /codeblock >}}
Set permissions:
{{< codeblock >}}
chmod +x /etc/init.d/saveanybot
{{< /codeblock >}}
Then copy the file to <code>/etc/rc.d</code> and rename it to <code>S99saveanybot</code>, also set permissions:
{{< codeblock >}}
chmod +x /etc/rc.d/S99saveanybot
{{< /codeblock >}}
<h4>Add Shortcut Commands</h4>
Create a file <code>/usr/bin/sabot</code>, refer to <a href="https://github.com/krau/SaveAny-Bot/blob/main/docs/confs/wrt_bin" target="_blank">wrt_bin</a> and modify as needed. Note that the file encoding here only supports ANSI 936.
Then set permissions:
{{< codeblock >}}
chmod +x /usr/bin/sabot
{{< /codeblock >}}
Usage: <code>sudo sabot start|stop|restart|status|enable|disable</code>
{{< /tab >}}
{{< /tabs >}}
## Deploy Using Docker
### Docker Compose
Download the [docker-compose.yml](https://github.com/krau/SaveAny-Bot/blob/main/docker-compose.yml) file, create a new `config.toml` file in the same directory, refer to [config.example.toml](https://github.com/krau/SaveAny-Bot/blob/main/config.example.toml) to edit the configuration file.
Start:
```bash
docker compose up -d
```
### Docker
```shell
docker run -d --name saveany-bot \
-v /path/to/config.toml:/app/config.toml \
-v /path/to/downloads:/app/downloads \
ghcr.io/krau/saveany-bot:latest
```
## Updates
Use `upgrade` or `up` to upgrade to the latest version
```bash
./saveany-bot upgrade
```
If you deployed with Docker, use the following commands to update:
```bash
docker pull ghcr.io/krau/saveany-bot:latest
docker restart saveany-bot
```

View File

@@ -0,0 +1,18 @@
---
title: "Frequently Asked Questions"
weight: 15
---
# Frequently Asked Questions
## Upload to AList shows success but actually fails
Adjust the upload chunk size in the AList management page, and deploy AList in a more stable network environment to reduce the occurrence of this issue.
## Bot indicates successful download but files don't show up in AList
AList caches directory structures. Refer to the <a href="https://alist.nn.ci/guide/drivers/common.html#cache-expiration" target="_blank">documentation</a> to adjust cache expiration time.
## Docker deployment still can't connect to Telegram despite proxy configuration (client initialization timeout)
Docker cannot directly access the host network. If you're not familiar with its usage, please set the container to host mode.

View File

@@ -0,0 +1,65 @@
---
title: "Usage"
weight: 10
---
# Usage
## File Transfer
The bot accepts two types of messages: files and links.
Supported links:
1. Telegram message links, for example: `https://t.me/acherkrau/1097`. **Even if the channel prohibits forwarding and saving, the bot can still download its files.**
2. Telegra.ph article links, the bot will download all images within.
## Silent Mode
Use the `/silent` command to toggle silent mode.
By default, silent mode is off, and the bot will ask you for the save location of each file.
When silent mode is enabled, the bot will save files directly to the default location without confirmation.
Before enabling silent mode, you need to set the default save location using the `/storage` command.
## Storage Rules
Allows you to set some redirection rules for the bot when uploading files to storage, for automatic organization of saved files.
See: <a href="https://github.com/krau/SaveAny-Bot/issues/28" target="_blank">#28</a>
Currently supported rule types:
1. FILENAME-REGEX
2. MESSAGE-REGEX
Basic syntax for adding rules:
"Rule Type Rule Content Storage Name Path"
Pay attention to the use of spaces; the bot can only parse correctly formatted syntax. Below is an example of a valid rule command:
```
/rule add FILENAME-REGEX (?i)\.(mp4|mkv|ts|avi|flv)$ MyAlist /videos
```
Additionally, if "CHOSEN" is used as the storage name in the rule, it means the file will be stored in the path of the storage selected via button click.
Rule descriptions:
### FILENAME-REGEX
Matches based on filename regex. The rule content must be a valid regular expression, such as:
```
FILENAME-REGEX (?i)\.(mp4|mkv|ts|avi|flv)$ MyAlist /videos
```
This means files with extensions mp4, mkv, ts, avi, flv will be saved to the /videos directory in the storage named MyAlist (also affected by the `base_path` in the configuration file).
### MESSAGE-REGEX
Similar to the above, but matches based on the text content of the message itself.

31
docs/content/zh/_index.md Normal file
View File

@@ -0,0 +1,31 @@
---
title: 介绍
---
# Save Any Bot
![](https://img.shields.io/github/go-mod/go-version/krau/SaveAny-Bot?style=flat-square)
![](https://img.shields.io/github/license/krau/SaveAny-Bot?style=flat-square)
![](https://img.shields.io/github/v/release/krau/SaveAny-Bot?color=cyan&style=flat-square)
![](https://img.shields.io/github/downloads/krau/SaveAny-Bot/total?style=flat-square)
把 Telegram 上的文件转存到多种存储端.
## 特性
- 支持文档/视频/图片/贴纸... 甚至还有 Telegraph
- 破解禁止保存的文件
- 批量下载
- 流式传输
- 多用户
- 基于存储规则的自动整理
- 支持多种存储端:
- Alist
- Minio (S3 兼容)
- WebDAV
- Telegram (重传回指定聊天)
- 本地磁盘
## [贡献者](https://github.com/krau/SaveAny-Bot/graphs/contributors)
![Contributors](https://contrib.rocks/image?repo=krau/SaveAny-Bot&max=750&columns=20)

View File

@@ -0,0 +1,14 @@
---
title: "参与开发"
weight: 20
---
# 参与开发
## 贡献新存储端
1. Fork 本项目, 克隆到本地
2.`pkg/enums/storage/storages.go` 中添加新的存储端类型, 并运行代码生成
3.`config/storage` 目录下定义存储端配置, 并添加到 `config/storage/factory.go`
4.`storage` 目录下新建一个包, 编写存储端实现, 然后在 `storage/storage.go` 中导入并添加它
5. 更新文档, 添加配置说明

View File

@@ -0,0 +1,4 @@
---
title: "部署指南"
weight: 5
---

View File

@@ -0,0 +1,162 @@
---
title: "配置说明"
---
# 配置说明
SaveAnyBot 的配置文件使用 toml 格式, 你可以在 [TOML 官方网站](https://toml.io/) 上了解更多关于 toml 的语法.
SaveAnyBot 需要读取工作目录下的 `config.toml` 文件作为配置文件, 若缺少该文件则会创建默认文件, 并尝试从环境变量中加载配置.
以下是一个最简的配置文件示例:
```toml
[telegram]
token = "1234567890:ABCDEFGHIJKLMNOPQRSTUVWXYZ"
[[users]]
# telegram user id
id = 777000
blacklist = true
[[storages]]
name = "本机存储"
type = "local"
enable = true
base_path = "./downloads"
```
## 详细配置
### 全局配置
- `stream`: 是否启用 Stream 模式, 默认为 `false`. 启用后 Bot 将直接将文件流式传输到存储端(若存储端支持), 不需要下载到本地
{{< hint warning >}}
Stream 模式对于磁盘空间有限的部署环境十分有用, 但也有一些弊端:
<br />
<ul>
<li>无法使用多线程从 Telegram 下载文件, 速度较慢.</li>
<li>网络不稳定时, 任务失败率高.</li>
<li>无法在中间层对文件进行处理, 例如自动文件类型识别.</li>
<li>并非支持所有存储端, 不支持的存储端可能会降级为普通模式或无法上传.</li>
</ul>
{{< /hint >}}
- `workers`: 同时处理任务数量, 默认为 3
- `threads`: 下载文件时使用的线程数, 默认为 4. 仅在未启用 Stream 模式时生效.
- `retry`: 任务失败时的重试次数, 默认为 3.
### Telegram 配置
- `token`: 你的 Telegram Bot Token, 可以通过 [BotFather](https://t.me/botfather) 创建 Bot 并获取 Token.
- `app_id`, `app_hash`: Telegram API ID & Hash, 在 [Telegram API](https://my.telegram.org/apps) 创建应用获取, 若不提供则使用默认值.
- `flood_retry`: Flood 控制重试次数, 默认为 5.
- `rpc_retry`: RPC 请求重试次数, 默认为 5.
- `proxy`: 代理配置, 可选项.
- `enable`: 是否启用代理.
- `url`: 代理地址, 只支持 `socks5://`
```toml
[telegram]
token = "1234567890:ABCDEFGHIJKLMNOPQRSTUVWXYZ"
app_id = 1025907
app_hash = "452b0359b988148995f22ff0f4229750"
flood_retry = 5
rpc_retry = 5
[telegram.proxy]
enable = false
url = "socks5://127.0.0.1:7890"
```
### 存储端列表
存储端列表用于定义 Bot 支持的存储位置, 每个存储端需要指定名称、类型和相关配置, 使用双中括号语法 `[[storages]]` 定义.
每一个存储端至少需要以下字段:
- `name`: 存储端名称, 用于在 Bot 中识别, 需要唯一
- `enable`: 是否启用该存储端, 默认为 `true`
- `type`: 存储端类型, 目前支持以下类型:
- `local`: 本地磁盘
- `alist`: Alist
- `webdav`: WebDAV
- `minio`: MinIO (兼容 S3 API)
- `telegram`: 上传到 Telegram
示例, 这是一个包含本地存储和 webdav 存储的配置:
```toml
[[storages]]
name = "本地存储"
type = "local"
enable = true
# 以下是 local 类型存储的自定义配置
base_path = "./downloads"
[[storages]]
name = "WebDAV"
type = "webdav"
enable = true
# 以下是 webdav 类型存储的自定义配置
url = "https://example.com/webdav"
base_path = "/path/to/webdav"
username = "your_username"
password = "your_password"
```
所有存储端的自定义配置项可查看 [存储端配置](./storages)
### 用户列表
用户列表用于定义对存储端的访问控制, 每个用户需要指定 Telegram 上的用户 ID, 使用双中括号语法 `[[users]]` 定义.
- `id`: 用户的 Telegram User ID
- `storages`: 过滤的存储端列表, 使用存储端名称定义, 默认为白名单模式 (即只允许访问列表中的存储端)
- `blacklist`: 是否启用黑名单模式, 默认为 `false`. 若启用黑名单模式, 则仅允许访问**没有**在列表中的存储端.
示例, 这是一个包含三个用户的配置, 用户 `123123` 只能访问本地存储, 用户 `456456` 只能访问除 WebDAV 以外的存储, 用户 `789789` 启用黑名单模式但没有指定存储端, 因此可以访问所有存储:
```toml
[[users]]
id = 123123
storages = ["本地存储"]
[[users]]
id = 456456
storages = ["WebDAV"]
blacklist = true
[[users]]
id = 789789
storages = []
blacklist = true
```
### 事件触发
事件触发提供了在 Bot 处理任务时根据任务状态执行自定义操作的能力, 目前仅支持任意命令执行. 使用 `[hook.exec]` 配置.
目前具有以下几种事件类型:
- `task_before_start`: 任务即将开始前
- `task_success`: 任务成功完成后
- `task_fail`: 任务失败后
- `task_cancel`: 任务被取消后
提供的配置值需要为完整的命令行命令, Bot 会在事件发生时执行该命令. 示例:
```toml
[hook.exec]
task_before_start = "echo '任务即将开始'"
task_success = "bash /path/to/success_script.sh"
task_fail = "curl -X POST https://example.com/api/notify -d '任务失败'"
task_cancel = "bash /path/to/cancel_script.sh"
```
### 杂项
```toml
no_clean_cache = false # 是否在退出时不清空缓存文件夹
# 临时下载文件夹配置
[temp]
base_path = "./cache"
```

View File

@@ -0,0 +1,65 @@
---
title: "存储端配置"
---
# 存储端配置
请先阅读 [配置说明](../) 了解配置文件的基本格式.
## Alist
`type=alist`
不支持 Stream 模式.
```toml
url = "https://alist.example.com" # Alist 的 URL
username = "your_username" # Alist 的用户名
password = "your_password" # Alist 的密码
base_path = "/path/saveanybot" # Alist 中的基础路径, 所有文件将存储在此路径下
token_exp = 3600 # Alist 访问令牌的自动刷新时间, 单位秒
token = "your_token"
# Alist 的访问令牌, 可选, 如果不设置则使用用户名和密码进行身份验证.
# 使用 token 验证时无法自动刷新 token
```
## 本地磁盘
`type=local`
```toml
base_path = "./downloads" # 本地存储的基础路径, 所有文件将存储在此路径下
```
## WebDAV
`type=webdav`
```toml
url = "https://webdav.example.com" # WebDAV 的 URL
username = "your_username" # WebDAV
password = "your_password" # WebDAV 的密码
base_path = "/path/to/webdav" # WebDAV 中的基础路径, 所有文件将存储在此路径下
```
## MinIO (S3)
`type=minio`
```toml
endpoint = "minio.example.com" # MinIO 或 S3 的端点
access_key_id = "your_access_key_id" # MinIO 或 S3 的访问密钥 ID
secret_access_key = "your_secret_access_key" # MinIO 或 S3 的秘密访问密钥
bucket_name = "your_bucket_name" # MinIO 或 S3 的存储桶名称
use_ssl = true # 是否使用 SSL, 默认为 true
base_path = "/path/to/minio" # MinIO 中的基础路径, 所有文件将存储在此路径下
```
## Telegram
`type=telegram`
不支持 Stream 模式.
```toml
chat_id = "123456789" # Telegram 聊天 ID, Bot 将把文件发送到这个聊天
```

View File

@@ -0,0 +1,145 @@
---
title: "安装与更新"
---
# 安装与更新
## 从预编译文件部署
在 [Release](https://github.com/krau/SaveAny-Bot/releases) 页面下载对应平台的二进制文件.
在解压后目录新建 `config.toml` 文件, 参考 [配置说明](../configuration) 编辑配置文件
运行:
```bash
chmod +x saveany-bot
./saveany-bot
```
### 进程守护
{{< tabs "daemon" >}}
{{< tab "systemd (常规 Linux)" >}}
创建文件 <code>/etc/systemd/system/saveany-bot.service</code> 并写入以下内容:
{{< codeblock >}}
[Unit]
Description=SaveAnyBot
After=systemd-user-sessions.service
[Service]
Type=simple
WorkingDirectory=/yourpath/
ExecStart=/yourpath/saveany-bot
Restart=on-failure
[Install]
WantedBy=multi-user.target
{{< /codeblock >}}
设为开机启动并启动服务:
{{< codeblock >}}
systemctl enable --now saveany-bot
{{< /codeblock >}}
{{< /tab >}}
{{< tab "procd (OpenWrt)" >}}
<h4>添加开机自启动服务</h4>
创建文件 <code>/etc/init.d/saveanybot</code> ,参考 <a href="https://github.com/krau/SaveAny-Bot/blob/main/docs/confs/wrt_init" target="_blank">wrt_init</a> 并自行修改:
{{< codeblock >}}
#!/bin/sh /etc/rc.common
#This is the OpenWRT init.d script for SaveAnyBot
START=99
STOP=10
description="SaveAnyBot"
WORKING_DIR="/mnt/mmc1-1/SaveAnyBot"
EXEC_PATH="$WORKING_DIR/saveany-bot"
start() {
echo "Starting SaveAnyBot..."
cd $WORKING_DIR
$EXEC_PATH &
}
stop() {
echo "Stopping SaveAnyBot..."
killall saveany-bot
}
reload() {
stop
start
}
{{< /codeblock >}}
赋予权限:
{{< codeblock >}}
chmod +x /etc/init.d/saveanybot
{{< /codeblock >}}
然后将文件复制到 <code>/etc/rc.d</code> 并重命名为 <code>S99saveanybot</code>, 同样赋予权限:
{{< codeblock >}}
chmod +x /etc/rc.d/S99saveanybot
{{< /codeblock >}}
<h4>添加快捷指令</h4>
创建文件 <code>/usr/bin/sabot</code> ,参考 <a href="https://github.com/krau/SaveAny-Bot/blob/main/docs/confs/wrt_bin" target="_blank">wrt_bin</a> 并自行修改,注意此处文件编码仅支持 ANSI 936 .
随后赋予权限:
{{< codeblock >}}
chmod +x /usr/bin/sabot
{{< /codeblock >}}
使用: <code>sudo sabot start|stop|restart|status|enable|disable</code>
{{< /tab >}}
{{< /tabs >}}
## 使用 Docker 部署
### Docker Compose
下载 [docker-compose.yml](https://github.com/krau/SaveAny-Bot/blob/main/docker-compose.yml) 文件, 在同目录下新建 `config.toml` 文件, 参考 [config.example.toml](https://github.com/krau/SaveAny-Bot/blob/main/config.example.toml) 编辑配置文件.
启动:
```bash
docker compose up -d
```
### Docker
```shell
docker run -d --name saveany-bot \
-v /path/to/config.toml:/app/config.toml \
-v /path/to/downloads:/app/downloads \
ghcr.io/krau/saveany-bot:latest
```
## 更新
使用 `upgrade``up` 升级到最新版
```bash
./saveany-bot upgrade
```
如果是 Docker 部署, 使用以下命令更新:
```bash
docker pull ghcr.io/krau/saveany-bot:latest
docker restart saveany-bot
```

View File

@@ -1,3 +1,8 @@
---
title: "常见问题"
weight: 15
---
# 常见问题
## 上传 alist 失败也会显示成功
@@ -6,11 +11,8 @@
## Bot 提示下载成功但是 alist 未显示
alist 缓存了目录结构, 参考文档可以调整缓存时间
https://alist.nn.ci/zh/guide/drivers/common.html#缓存过期
alist 缓存了目录结构, 参考 <a href="https://alist.nn.ci/zh/guide/drivers/common.html#缓存过期" target="_blank">文档</a> 可以调整缓存时间
## docker部署配置了代理后仍无法连接 telegram (初始化客户端超时)
docker 不能直接访问宿主机网络, 如果你不熟悉其用法, 请将容器设为 host 模式:
docker 不能直接访问宿主机网络, 如果你不熟悉其用法, 请将容器设为 host 模式.

View File

@@ -0,0 +1,67 @@
---
title: "使用帮助"
weight: 10
---
# 使用帮助
这里介绍 Save Any Bot 的一些功能和使用方法, 如果你没有在这里找到你需要的内容, 另请参阅 [配置说明](../deployment/configuration) 或前往 Github [Discussions](https://github.com/krau/SaveAny-Bot/discussions) 提问.
## 转存文件
Bot 接受两种消息: 文件和链接.
对于链接, 目前支持以下类型的链接:
1. Telegram 消息链接, 例如: `https://t.me/acherkrau/1097`. **即使频道禁止了转发和保存, Bot 依然可以下载其文件.**
2. Telegra.ph 的文章链接, Bot 将下载其中的所有图片
## 静默模式 (silent)
使用 `/silent` 命令可以开关静默模式.
默认情况下不开启静默模式, Bot 会询问你每个文件的保存位置.
开启静默模式后, Bot 会直接保存文件到默认位置, 无需确认.
在开启静默模式之前, 需要使用 `/storage` 命令设置默认保存位置.
## 存储规则
允许你为 Bot 在上传文件到存储时设置一些重定向规则, 用于自动整理所保存的文件.
见: <a href="https://github.com/krau/SaveAny-Bot/issues/28" target="_blank">#28</a>
目前支持的规则类型:
1. FILENAME-REGEX
2. MESSAGE-REGEX
添加规则的基本语法:
"规则类型 规则内容 存储名 路径"
注意空格的使用, 语法正确 bot 才能解析, 以下是一条合法的添加规则命令:
```
/rule add FILENAME-REGEX (?i)\.(mp4|mkv|ts|avi|flv)$ MyAlist /视频
```
此外, 规则中的存储名若使用 "CHOSEN" , 则表示存储到点击按钮选择的存储端的路径下
规则介绍:
### FILENAME-REGEX
根据文件名正则匹配, 规则内容要求为一个合法的正则表达式, 如
```
FILENAME-REGEX (?i)\.(mp4|mkv|ts|avi|flv)$ MyAlist /视频
```
表示将文件名后缀为 mp4,mkv,ts,avi,flv 的文件放到名为 MyAlist 存储下的 /视频 目录内 (同时受配置文件中的 `base_path` 影响)
### MESSAGE-REGEX
同上, 但是是根据消息本身的文本内容正则匹配

View File

@@ -1,11 +0,0 @@
# 参与开发
## 贡献新存储端
1. Fork 本项目, 克隆到本地
2.`config/storage` 目录下定义存储端配置, 并添加到 `config/storage/factory.go`
3.`types/types.go` 中添加新的存储端类型
4.`storage` 目录下新建一个包, 编写存储端实现, 然后在 `storage/storage.go` 中导入并添加它
5. 更新 `config.example.toml` 文件, 添加新的示例配置
*可能确实有点麻烦了 = =*

View File

@@ -1,94 +0,0 @@
# 部署指南
## 从二进制文件部署
在 [Release](https://github.com/krau/SaveAny-Bot/releases) 页面下载对应平台的二进制文件.
在解压后目录新建 `config.toml` 文件, 参考 [config.example.toml](https://github.com/krau/SaveAny-Bot/blob/main/config.example.toml) 编辑配置文件.
运行:
```bash
chmod +x saveany-bot
./saveany-bot
```
### 添加为 systemd 服务
创建文件 `/etc/systemd/system/saveany-bot.service` 并写入以下内容:
```
[Unit]
Description=SaveAnyBot
After=systemd-user-sessions.service
[Service]
Type=simple
WorkingDirectory=/yourpath/
ExecStart=/yourpath/saveany-bot
Restart=on-failure
[Install]
WantedBy=multi-user.target
```
设为开机启动并启动服务:
```bash
systemctl enable --now saveany-bot
```
### 为OpenWrt及衍生系统添加开机自启动服务
创建文件 ` /etc/init.d/saveanybot` ,参考[saveanybot](https://github.com/krau/SaveAny-Bot/blob/main/docs/saveanybot)自行修改.
`chmod +x /etc/init.d/saveanybot`
完成后,将文件复制到 `/etc/rc.d`并重命名为`S99saveanybot`.
`chmod +x /etc/rc.d/S99saveanybot`
### 为OpenWrt及衍生系统添加快捷指令
创建文件` /usr/bin/sabot` ,参考[sabot](https://github.com/krau/SaveAny-Bot/blob/main/docs/sabot)自行配置修改,注意此处文件编码仅支持 ANSI 936 .
`chmod +x /usr/bin/sabot`
之后,终端输入`sabot start|stop|restart|status|enable|disable`即可.
## 使用 Docker 部署
### Docker Compose
下载 [docker-compose.yml](https://github.com/krau/SaveAny-Bot/blob/main/docker-compose.yml) 文件, 在同目录下新建 `config.toml` 文件, 参考 [config.example.toml](https://github.com/krau/SaveAny-Bot/blob/main/config.example.toml) 编辑配置文件.
启动:
```bash
docker compose up -d
```
### Docker
```shell
docker run -d --name saveany-bot \
-v /path/to/config.toml:/app/config.toml \
-v /path/to/downloads:/app/downloads \
ghcr.io/krau/saveany-bot:latest
```
## 更新
使用 `upgrade``up` 升级到最新版
```bash
./saveany-bot upgrade
```
如果是 Docker 部署, 使用以下命令更新:
```bash
docker pull ghcr.io/krau/saveany-bot:latest
docker restart saveany-bot
```

View File

@@ -1,46 +0,0 @@
# 实验性功能
这里的功能不太稳定, 且未来可能会被删除或修改。
## 存储规则
允许你为 Bot 在上传文件到存储时设置一些重定向规则, 用于自动整理所保存的文件.
见: https://github.com/krau/SaveAny-Bot/issues/28
目前支持的规则类型:
1. FILENAME-REGEX
2. MESSAGE-REGEX
添加规则的基本语法:
"规则类型 规则内容 存储名 路径"
注意空格的使用, 语法正确 bot 才能解析, 以下是一条合法的添加规则命令:
```
/rule add FILENAME-REGEX (?i)\.(mp4|mkv|ts|avi|flv)$ MyAlist /视频
```
此外, 规则中的存储名若使用 "CHOSEN" , 则表示存储到点击按钮选择的存储端的路径下
规则介绍:
### FILENAME-REGEX
根据文件名正则匹配, 规则内容要求为一个合法的正则表达式, 如
```
FILENAME-REGEX (?i)\.(mp4|mkv|ts|avi|flv)$ MyAlist /视频
```
表示将文件名后缀为 mp4,mkv,ts,avi,flv 的文件放到名为 MyAlist 存储下的 /视频 目录内 (同时受配置文件中的 `base_path` 影响)
### MESSAGE-REGEX
同上, 根据消息文本内容正则匹配
## 复制并发送媒体消息
将接收到的文件(媒体)消息, 或链接对应的消息原样发送到当前聊天, 点击选择存储按钮中的 "发送到当前聊天" 即可.

View File

@@ -1,38 +0,0 @@
# 使用帮助
## 保存文件
Bot 接受两种消息: 文件和链接.
支持以下链接:
1. 公开频道 (具有用户名) 的消息链接, 例如: `https://t.me/acherkrau/1097`. **即使频道禁止了转发和保存, Bot 依然可以下载其文件.**
2. Telegra.ph 的文章链接, Bot 将下载其中的所有图片
## 静默模式 (silent)
使用 `/silent` 命令可以开关静默模式.
默认情况下不开启静默模式, Bot 会询问你每个文件的保存位置.
开启静默模式后, Bot 会直接保存文件到默认位置, 无需确认.
在开启静默模式之前, 需要使用 `/storage` 命令设置默认保存位置.
## Stream 模式
在配置文件中将 `stream` 设置为 `true` 可以开启 Stream 模式.
未开启时, Bot 处理任务分为两步: 下载和上传. Bot 会将文件暂存到本地, 然后上传到对应存储位置, 最后删除本地文件.
开启后, Bot 将直接将文件流式传输到存储端, 不需要下载到本地.
该功能对于硬盘空间有限的部署环境十分有用, 然而相较于普通模式也具有一些弊端:
- 无法使用多线程从 telegram 下载文件, 速度较慢.
- 网络不稳定时, 任务失败率高.
- 无法在中间层对文件进行处理, 例如自动文件类型识别.
**不支持** Stream 模式的存储端:
- alist

View File

@@ -1,7 +0,0 @@
# SaveAnyBot 文档
SaveAnyBot 是一个可以保存 Telegram 上的文件到云存储的机器人, 就像 PikPak Bot 一样.
不同的是, SaveAnyBot 提供更灵活的存储端选择, 并实现一些更强大的功能.
本项目以 AGPL-3.0 协议开源, 请遵守协议使用.

5
docs/go.mod Normal file
View File

@@ -0,0 +1,5 @@
module github.com/krau/SaveAny-Bot/docs
go 1.24.4
require github.com/alex-shpak/hugo-book v0.0.0-20250530233833-f2c703e15588 // indirect

2
docs/go.sum Normal file
View File

@@ -0,0 +1,2 @@
github.com/alex-shpak/hugo-book v0.0.0-20250530233833-f2c703e15588 h1:pwxkzpzw/iJSxMBgQLWjYMQubhIemLG3UrNjeWoCkSM=
github.com/alex-shpak/hugo-book v0.0.0-20250530233833-f2c703e15588/go.mod h1:L4NMyzbn15fpLIpmmtDg9ZFFyTZzw87/lk7M2bMQ7ds=

47
docs/hugo.toml Normal file
View File

@@ -0,0 +1,47 @@
baseURL = 'https://sabot.unv.app/'
title = 'Save Any Bot'
disablePathToLower = true
enableGitInfo = true
defaultContentLanguage = 'zh'
[module]
[[module.imports]]
path = 'github.com/alex-shpak/hugo-book'
[params]
BookTheme = 'auto'
BookToC = true
BookLogo = 'logo.png'
BookSection = '*'
BookRepo = 'https://github.com/krau/saveany-bot'
BookCommitPath = 'commit'
BookEditPath = 'edit/main/docs'
BookDateFormat = '2006/01/02'
BookSearch = false
[languages]
[languages.zh]
languageName = "简体中文"
contentDir = "content/zh"
weight = 1
[[languages.zh.menu.before]]
name = "🔗 GitHub"
url = "https://github.com/krau/SaveAny-Bot"
weight = 10
[languages.en]
languageName = "English"
contentDir = "content/en"
weight = 2
[[languages.en.menu.before]]
name = "🔗 GitHub"
url = "https://github.com/krau/SaveAny-Bot"
weight = 10
[markup]
[markup.goldmark.renderer]
unsafe = true

View File

@@ -0,0 +1,3 @@
{{- $lang := .Get "lang" | default "text" -}}
{{- $content := .Inner | strings.TrimSpace | htmlEscape -}}
<pre><code class="language-{{ $lang }}">{{ $content }}</code></pre>

View File

@@ -1,35 +0,0 @@
site_name: SaveAnyBot 官方文档
site_author: Krau
site_description: SaveAnyBot 是一个可以保存 Telegram 上的文件到多种云存储的机器人, 本文档将帮助你了解如何部署和使用它.
repo_name: krau/saveany-bot
repo_url: https://github.com/krau/saveany-bot
copyright: CC BY-NC-SA 4.0
theme:
name: material
language: zh
highlightjs: true
palette:
- media: "(prefers-color-scheme)"
toggle:
icon: material/brightness-auto
name: 切换主题
- media: "(prefers-color-scheme: light)"
scheme: default
primary: indigo
toggle:
icon: material/brightness-7
name: 暗色模式
- media: "(prefers-color-scheme: dark)"
scheme: slate
primary: blue grey
toggle:
icon: material/brightness-4
name: 亮色模式
nav:
- index.md
- deploy.md
- help.md
- experimental.md
- faq.md
- contribute.md

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"Target":"book.min.a22f4c7d8c2bdc5e3d6e34ba11cb59ab50ea5772594e71305bfd5a595dc78b7e.css","MediaType":"text/css","Data":{"Integrity":"sha256-oi9MfYwr3F49bjS6EctZq1DqV3JZTnEwW/1aWV3Hi34="}}

View File

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -0,0 +1,5 @@
package ctxkey
//go:generate go-enum --values --names --flag --nocase --noprefix
// ENUM(content-length)
type ContextKey string

View File

@@ -4,7 +4,7 @@
// Build Date: 2025-03-18T23:42:14Z
// Built By: goreleaser
package key
package ctxkey
import (
"fmt"
@@ -12,14 +12,14 @@ import (
)
const (
// ContextKeyContentLength is a ContextKey of type content-length.
ContextKeyContentLength ContextKey = "content-length"
// ContentLength is a ContextKey of type content-length.
ContentLength ContextKey = "content-length"
)
var ErrInvalidContextKey = fmt.Errorf("not a valid ContextKey, try [%s]", strings.Join(_ContextKeyNames, ", "))
var _ContextKeyNames = []string{
string(ContextKeyContentLength),
string(ContentLength),
}
// ContextKeyNames returns a list of possible string values of ContextKey.
@@ -32,7 +32,7 @@ func ContextKeyNames() []string {
// ContextKeyValues returns a list of the values for ContextKey
func ContextKeyValues() []ContextKey {
return []ContextKey{
ContextKeyContentLength,
ContentLength,
}
}
@@ -49,7 +49,7 @@ func (x ContextKey) IsValid() bool {
}
var _ContextKeyValue = map[string]ContextKey{
"content-length": ContextKeyContentLength,
"content-length": ContentLength,
}
// ParseContextKey attempts to convert a string to a ContextKey.

View File

@@ -1,5 +0,0 @@
package key
//go:generate go-enum --values --names --flag --nocase
// ENUM(content-length)
type ContextKey string

View File

@@ -14,7 +14,7 @@ import (
"github.com/charmbracelet/log"
config "github.com/krau/SaveAny-Bot/config/storage"
"github.com/krau/SaveAny-Bot/pkg/enums/key"
"github.com/krau/SaveAny-Bot/pkg/enums/ctxkey"
storenum "github.com/krau/SaveAny-Bot/pkg/enums/storage"
)
@@ -118,7 +118,7 @@ func (a *Alist) Save(ctx context.Context, reader io.Reader, storagePath string)
req.Header.Set("Authorization", a.token)
req.Header.Set("File-Path", url.PathEscape(candidate))
req.Header.Set("Content-Type", "application/octet-stream")
if length := ctx.Value(key.ContextKeyContentLength); length != nil {
if length := ctx.Value(ctxkey.ContentLength); length != nil {
length, ok := length.(int64)
if ok {
req.ContentLength = length

View File

@@ -9,6 +9,7 @@ import (
"github.com/charmbracelet/log"
config "github.com/krau/SaveAny-Bot/config/storage"
"github.com/krau/SaveAny-Bot/pkg/enums/ctxkey"
storenum "github.com/krau/SaveAny-Bot/pkg/enums/storage"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
@@ -72,8 +73,14 @@ func (m *Minio) Save(ctx context.Context, r io.Reader, storagePath string) error
for i := 1; m.Exists(ctx, candidate); i++ {
candidate = fmt.Sprintf("%s_%d%s", base, i, ext)
}
_, err := m.client.PutObject(ctx, m.config.BucketName, candidate, r, -1, minio.PutObjectOptions{})
size := int64(-1)
if length := ctx.Value(ctxkey.ContentLength); length != nil {
length, ok := length.(int64)
if ok && length > 0 {
size = length
}
}
_, err := m.client.PutObject(ctx, m.config.BucketName, candidate, r, size, minio.PutObjectOptions{})
if err != nil {
return fmt.Errorf("failed to upload file to minio: %w", err)
}

View File

@@ -9,7 +9,7 @@ import (
"path"
"strings"
"github.com/krau/SaveAny-Bot/pkg/enums/key"
"github.com/krau/SaveAny-Bot/pkg/enums/ctxkey"
)
type Client struct {
@@ -54,7 +54,7 @@ func (c *Client) doRequest(ctx context.Context, method WebdavMethod, url string,
req.Header.Set("Depth", "1")
}
if method == WebdavMethodPut && ctx != nil {
if length := ctx.Value(key.ContextKeyContentLength); length != nil {
if length := ctx.Value(ctxkey.ContentLength); length != nil {
if l, ok := length.(int64); ok {
req.ContentLength = l
}