mirror of
https://github.com/krau/SaveAny-Bot.git
synced 2026-05-12 01:59:41 +08:00
Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2bfc96a8f | ||
|
|
0c5bb2ba77 | ||
|
|
9cc87380ff | ||
|
|
46afc14322 | ||
|
|
0c16650ea5 | ||
|
|
133453b5d4 | ||
|
|
8f9ef07d1c | ||
|
|
36285a0700 | ||
|
|
ccf206d176 | ||
|
|
4c851cbbaf | ||
|
|
b9d14f79c8 | ||
|
|
ee5e0b8ff0 | ||
|
|
6423fb25a7 | ||
|
|
03907f2d32 | ||
|
|
9e5042bda1 | ||
|
|
4ebacb02c1 | ||
|
|
818ac9b240 | ||
|
|
fc4a112f08 | ||
|
|
7a2274baa0 | ||
|
|
69a3ed6f4e | ||
|
|
36f3dd83fc | ||
|
|
501b9d844a | ||
|
|
03cec7ec01 | ||
|
|
dc0debcd1c | ||
|
|
4b136bd41e | ||
|
|
d703f11ea0 | ||
|
|
3ce9926967 | ||
|
|
80146176f0 | ||
|
|
14ba2afdf8 | ||
|
|
f4d427a1cb | ||
|
|
f84c83a7e2 | ||
|
|
cb6540c017 | ||
|
|
e7bab27543 | ||
|
|
f693bd6103 | ||
|
|
75f52569a0 | ||
|
|
c795f957a9 | ||
|
|
3b85911e3d | ||
|
|
336309fad0 | ||
|
|
394cdff865 | ||
|
|
40cb3dad9d | ||
|
|
2979628cf7 | ||
|
|
c82c2462bf | ||
|
|
88128ecac2 | ||
|
|
758564d436 | ||
|
|
f5e33472eb | ||
|
|
4df2c5a06d | ||
|
|
eb6f8675a4 | ||
|
|
473a5b9413 | ||
|
|
6c2abe3025 | ||
|
|
e7e5b9f434 | ||
|
|
d4d39d1c07 | ||
|
|
73b5f1b18e | ||
|
|
837700bf63 | ||
|
|
53e6d7cc54 | ||
|
|
4206d1fe96 | ||
|
|
6566dbbf96 |
@@ -6,6 +6,6 @@
|
|||||||
downloads/
|
downloads/
|
||||||
data/
|
data/
|
||||||
cache/
|
cache/
|
||||||
docs
|
docs/
|
||||||
config.example.toml
|
config.example.toml
|
||||||
docker-compose.*
|
docker-compose.*
|
||||||
|
|||||||
2
.github/FUNDING.yml
vendored
2
.github/FUNDING.yml
vendored
@@ -1,5 +1,5 @@
|
|||||||
# These are supported funding model platforms
|
# These are supported funding model platforms
|
||||||
|
|
||||||
custom: [
|
custom: [
|
||||||
"https://afdian.com/a/acherkrau"
|
"https://afdian.com/a/unvapp"
|
||||||
]
|
]
|
||||||
31
.github/workflows/build-docker.yml
vendored
31
.github/workflows/build-docker.yml
vendored
@@ -29,13 +29,7 @@ jobs:
|
|||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
type=sha
|
type=sha
|
||||||
type=raw,value=latest
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
type=ref,event=branch
|
|
||||||
type=ref,event=tag
|
|
||||||
labels: |
|
|
||||||
org.opencontainers.image.title=${{ env.IMAGE_NAME }}
|
|
||||||
org.opencontainers.image.source=https://github.com/krau/SaveAny-Bot
|
|
||||||
org.opencontainers.image.url=https://github.com/krau/SaveAny-Bot
|
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
@@ -50,23 +44,26 @@ jobs:
|
|||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Extract version from Git Ref
|
- name: Extract Dockerfile args
|
||||||
id: extract_version
|
id: args
|
||||||
run: |
|
run: |
|
||||||
VERSION=$(echo "${{ github.ref }}" | sed 's/refs\/tags\/v//')
|
echo "git_commit=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
|
||||||
echo "VERSION=${VERSION}" >> $GITHUB_ENV
|
echo "build_time=$(git show -s --format=%cI)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
|
id: build-and-push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
cache-from: type=gha
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: |
|
||||||
|
type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||||
|
type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
build-args: |
|
build-args: |
|
||||||
VERSION=${{ steps.meta.outputs.version }}
|
VERSION=${{ steps.meta.outputs.version }}
|
||||||
GitCommit=${{ github.sha }}
|
GitCommit=${{ steps.args.outputs.git_commit }}
|
||||||
BuildTime=${{ format(github.event.repository.updated_at, 'yyyy-MM-dd HH:mm:ss') }}
|
BuildTime=${{ steps.args.outputs.build_time }}
|
||||||
push: true
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
|
|||||||
30
.github/workflows/docs.yml
vendored
30
.github/workflows/docs.yml
vendored
@@ -6,17 +6,31 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- "docs/**"
|
- "docs/**"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-22.04
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
with:
|
||||||
python-version: "3.11"
|
submodules: true # Fetch Hugo themes (true OR recursive)
|
||||||
- uses: actions/cache@v4
|
fetch-depth: 0 # Fetch all history for .GitInfo and .Lastmod
|
||||||
|
|
||||||
|
- name: Setup Hugo
|
||||||
|
uses: peaceiris/actions-hugo@v3
|
||||||
with:
|
with:
|
||||||
key: ${{ github.ref }}
|
hugo-version: '0.147.8'
|
||||||
path: .cache
|
extended: true
|
||||||
- run: pip install mkdocs-material
|
|
||||||
- run: cd docs && mkdocs gh-deploy --force
|
- 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
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,3 +7,4 @@ session.*
|
|||||||
cache.db
|
cache.db
|
||||||
.vscode/
|
.vscode/
|
||||||
temp/
|
temp/
|
||||||
|
.hugo_build.lock
|
||||||
23
Dockerfile
23
Dockerfile
@@ -6,22 +6,33 @@ ARG BuildTime="Unknown"
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY . .
|
COPY go.mod go.sum ./
|
||||||
|
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||||
|
go mod download
|
||||||
|
|
||||||
|
COPY . .
|
||||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||||
--mount=type=cache,target=/go/pkg \
|
--mount=type=cache,target=/go/pkg \
|
||||||
CGO_ENABLED=0 \
|
CGO_ENABLED=0 \
|
||||||
go build -trimpath \
|
go build -trimpath \
|
||||||
-ldflags "-s -w \
|
-ldflags=" \
|
||||||
-X github.com/krau/SaveAny-Bot/common.Version=${VERSION} \
|
-s -w \
|
||||||
-X github.com/krau/SaveAny-Bot/common.GitCommit=${GiTCommit} \
|
-X 'github.com/krau/SaveAny-Bot/common.Version=${VERSION}' \
|
||||||
-X github.com/krau/SaveAny-Bot/common.BuildTime=${BuildTime}" \
|
-X 'github.com/krau/SaveAny-Bot/common.GitCommit=${GitCommit}' \
|
||||||
|
-X 'github.com/krau/SaveAny-Bot/common.BuildTime=${BuildTime}' \
|
||||||
|
" \
|
||||||
-o saveany-bot .
|
-o saveany-bot .
|
||||||
|
|
||||||
FROM alpine:latest
|
FROM alpine:latest
|
||||||
|
|
||||||
|
RUN apk add --no-cache curl
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY --from=builder /app/saveany-bot .
|
COPY --from=builder /app/saveany-bot .
|
||||||
|
COPY entrypoint.sh .
|
||||||
|
|
||||||
ENTRYPOINT ["/app/saveany-bot"]
|
RUN chmod +x /app/saveany-bot && \
|
||||||
|
chmod +x /app/entrypoint.sh
|
||||||
|
|
||||||
|
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||||
|
|||||||
43
README.md
43
README.md
@@ -1,28 +1,39 @@
|
|||||||
<div align="center">
|
<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 的文件保存到各类存储端.
|
把 Telegram 上的文件转存到多种存储端.
|
||||||
|
|
||||||
> _就像 PikPak Bot 一样_
|
|
||||||
|
|
||||||
</div>
|
</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) 的支持.
|
本项目受到 [YxVM](https://yxvm.com/) 与 [NodeSupport](https://github.com/NodeSeekDev/NodeSupport) 的支持.
|
||||||
|
|
||||||
如果这个项目对你有帮助, 你可以考虑通过以下方式赞助我:
|
如果这个项目对你有帮助, 你可以考虑通过以下方式赞助我:
|
||||||
|
|
||||||
- [爱发电](https://afdian.com/a/acherkrau)
|
- [爱发电](https://afdian.com/a/unvapp)
|
||||||
|
|
||||||
## Contributors
|
## Contributors
|
||||||
|
|
||||||
@@ -37,6 +48,13 @@
|
|||||||
<sub><b>Krau</b></sub>
|
<sub><b>Krau</b></sub>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/Silentely">
|
||||||
|
<img src="https://avatars.githubusercontent.com/u/22141172?v=4" width="100;" alt="Silentely"/>
|
||||||
|
<br />
|
||||||
|
<sub><b>Abner</b></sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<a href="https://github.com/TG-Twilight">
|
<a href="https://github.com/TG-Twilight">
|
||||||
<img src="https://avatars.githubusercontent.com/u/121682528?v=4" width="100;" alt="TG-Twilight"/>
|
<img src="https://avatars.githubusercontent.com/u/121682528?v=4" width="100;" alt="TG-Twilight"/>
|
||||||
@@ -52,8 +70,8 @@
|
|||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<a href="https://github.com/ahcorn">
|
<a href="https://github.com/AHCorn">
|
||||||
<img src="https://avatars.githubusercontent.com/u/42889600?v=4" width="100;" alt="ahcorn"/>
|
<img src="https://avatars.githubusercontent.com/u/42889600?v=4" width="100;" alt="AHCorn"/>
|
||||||
<br />
|
<br />
|
||||||
<sub><b>安和</b></sub>
|
<sub><b>安和</b></sub>
|
||||||
</a>
|
</a>
|
||||||
@@ -68,4 +86,5 @@
|
|||||||
- [gotd](https://github.com/gotd/td)
|
- [gotd](https://github.com/gotd/td)
|
||||||
- [TG-FileStreamBot](https://github.com/EverythingSuckz/TG-FileStreamBot)
|
- [TG-FileStreamBot](https://github.com/EverythingSuckz/TG-FileStreamBot)
|
||||||
- [gotgproto](https://github.com/celestix/gotgproto)
|
- [gotgproto](https://github.com/celestix/gotgproto)
|
||||||
|
- [tdl](https://github.com/iyear/tdl)
|
||||||
- All the dependencies
|
- All the dependencies
|
||||||
|
|||||||
108
README_EN.md
108
README_EN.md
@@ -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
|
|
||||||
@@ -2,7 +2,6 @@ package bot
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/url"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/celestix/gotgproto"
|
"github.com/celestix/gotgproto"
|
||||||
@@ -14,21 +13,12 @@ import (
|
|||||||
"github.com/gotd/td/tg"
|
"github.com/gotd/td/tg"
|
||||||
"github.com/krau/SaveAny-Bot/client/bot/handlers"
|
"github.com/krau/SaveAny-Bot/client/bot/handlers"
|
||||||
"github.com/krau/SaveAny-Bot/client/middleware"
|
"github.com/krau/SaveAny-Bot/client/middleware"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/netutil"
|
||||||
"github.com/krau/SaveAny-Bot/config"
|
"github.com/krau/SaveAny-Bot/config"
|
||||||
"github.com/ncruces/go-sqlite3/gormlite"
|
"github.com/ncruces/go-sqlite3/gormlite"
|
||||||
"golang.org/x/net/proxy"
|
"golang.org/x/net/proxy"
|
||||||
)
|
)
|
||||||
|
|
||||||
var Client *gotgproto.Client
|
|
||||||
|
|
||||||
func newProxyDialer(proxyUrl string) (proxy.Dialer, error) {
|
|
||||||
url, err := url.Parse(proxyUrl)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return proxy.FromURL(url, proxy.Direct)
|
|
||||||
}
|
|
||||||
|
|
||||||
func Init(ctx context.Context) {
|
func Init(ctx context.Context) {
|
||||||
log.FromContext(ctx).Info("初始化 Bot...")
|
log.FromContext(ctx).Info("初始化 Bot...")
|
||||||
resultChan := make(chan struct {
|
resultChan := make(chan struct {
|
||||||
@@ -38,7 +28,7 @@ func Init(ctx context.Context) {
|
|||||||
go func() {
|
go func() {
|
||||||
var resolver dcs.Resolver
|
var resolver dcs.Resolver
|
||||||
if config.Cfg.Telegram.Proxy.Enable && config.Cfg.Telegram.Proxy.URL != "" {
|
if config.Cfg.Telegram.Proxy.Enable && config.Cfg.Telegram.Proxy.URL != "" {
|
||||||
dialer, err := newProxyDialer(config.Cfg.Telegram.Proxy.URL)
|
dialer, err := netutil.NewProxyDialer(config.Cfg.Telegram.Proxy.URL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
resultChan <- struct {
|
resultChan <- struct {
|
||||||
client *gotgproto.Client
|
client *gotgproto.Client
|
||||||
@@ -52,7 +42,8 @@ func Init(ctx context.Context) {
|
|||||||
} else {
|
} else {
|
||||||
resolver = dcs.DefaultResolver()
|
resolver = dcs.DefaultResolver()
|
||||||
}
|
}
|
||||||
client, err := gotgproto.NewClient(config.Cfg.Telegram.AppID,
|
client, err := gotgproto.NewClient(
|
||||||
|
config.Cfg.Telegram.AppID,
|
||||||
config.Cfg.Telegram.AppHash,
|
config.Cfg.Telegram.AppHash,
|
||||||
gotgproto.ClientTypeBot(config.Cfg.Telegram.Token),
|
gotgproto.ClientTypeBot(config.Cfg.Telegram.Token),
|
||||||
&gotgproto.ClientOpts{
|
&gotgproto.ClientOpts{
|
||||||
@@ -79,17 +70,22 @@ func Init(ctx context.Context) {
|
|||||||
client.API().BotsSetBotCommands(ctx, &tg.BotsSetBotCommandsRequest{
|
client.API().BotsSetBotCommands(ctx, &tg.BotsSetBotCommandsRequest{
|
||||||
Scope: &tg.BotCommandScopeDefault{},
|
Scope: &tg.BotCommandScopeDefault{},
|
||||||
})
|
})
|
||||||
|
commands := []tg.BotCommand{
|
||||||
|
{Command: "start", Description: "开始使用"},
|
||||||
|
{Command: "help", Description: "显示帮助"},
|
||||||
|
{Command: "silent", Description: "开启/关闭静默模式"},
|
||||||
|
{Command: "storage", Description: "设置默认存储端"},
|
||||||
|
{Command: "save", Description: "保存文件"},
|
||||||
|
{Command: "dir", Description: "管理存储文件夹"},
|
||||||
|
{Command: "rule", Description: "管理规则"},
|
||||||
|
}
|
||||||
|
if config.Cfg.Telegram.Userbot.Enable {
|
||||||
|
commands = append(commands, tg.BotCommand{Command: "watch", Description: "监听聊天"})
|
||||||
|
commands = append(commands, tg.BotCommand{Command: "unwatch", Description: "取消监听聊天"})
|
||||||
|
}
|
||||||
_, err = client.API().BotsSetBotCommands(ctx, &tg.BotsSetBotCommandsRequest{
|
_, err = client.API().BotsSetBotCommands(ctx, &tg.BotsSetBotCommandsRequest{
|
||||||
Scope: &tg.BotCommandScopeDefault{},
|
Scope: &tg.BotCommandScopeDefault{},
|
||||||
Commands: []tg.BotCommand{
|
Commands: commands,
|
||||||
{Command: "start", Description: "开始使用"},
|
|
||||||
{Command: "help", Description: "显示帮助"},
|
|
||||||
{Command: "silent", Description: "开启/关闭静默模式"},
|
|
||||||
{Command: "storage", Description: "设置默认存储端"},
|
|
||||||
{Command: "save", Description: "保存所回复的文件"},
|
|
||||||
{Command: "dir", Description: "管理存储文件夹"},
|
|
||||||
{Command: "rule", Description: "管理规则"},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
resultChan <- struct {
|
resultChan <- struct {
|
||||||
client *gotgproto.Client
|
client *gotgproto.Client
|
||||||
@@ -104,8 +100,7 @@ func Init(ctx context.Context) {
|
|||||||
if result.err != nil {
|
if result.err != nil {
|
||||||
log.FromContext(ctx).Fatalf("初始化 Bot 失败: %s", result.err)
|
log.FromContext(ctx).Fatalf("初始化 Bot 失败: %s", result.err)
|
||||||
}
|
}
|
||||||
Client = result.client
|
handlers.Register(result.client.Dispatcher)
|
||||||
handlers.Register(Client.Dispatcher)
|
|
||||||
log.FromContext(ctx).Info("Bot 初始化完成")
|
log.FromContext(ctx).Info("Bot 初始化完成")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,19 +12,22 @@ func handleHelpCmd(ctx *ext.Context, update *ext.Update) error {
|
|||||||
const helpText string = `
|
const helpText string = `
|
||||||
Save Any Bot - 转存你的 Telegram 文件
|
Save Any Bot - 转存你的 Telegram 文件
|
||||||
版本: %s , 提交: %s
|
版本: %s , 提交: %s
|
||||||
|
|
||||||
命令:
|
命令:
|
||||||
/start - 开始使用
|
/start - 开始使用
|
||||||
/help - 显示帮助
|
/help - 显示帮助
|
||||||
/silent - 开关静默模式
|
/silent - 开关静默模式
|
||||||
/storage - 设置默认存储位置
|
/storage - 设置默认存储位置
|
||||||
/save [自定义文件名] - 保存文件
|
/save [自定义文件名] - 保存文件
|
||||||
|
/dir - 管理存储目录
|
||||||
|
/rule - 管理规则
|
||||||
|
|
||||||
静默模式: 开启后 Bot 直接保存到收到的文件到默认位置, 不再询问
|
使用帮助: https://sabot.unv.app/usage/
|
||||||
|
|
||||||
默认存储位置: 在静默模式下保存到的位置
|
|
||||||
|
|
||||||
向 Bot 发送(转发)文件, 或发送一个公开频道的消息链接以保存文件
|
|
||||||
`
|
`
|
||||||
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
|
return dispatcher.EndGroups
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,8 +38,7 @@ func handleMessageLink(ctx *ext.Context, update *ext.Update) error {
|
|||||||
editReplied("构建存储选择键盘失败: "+err.Error(), nil)
|
editReplied("构建存储选择键盘失败: "+err.Error(), nil)
|
||||||
return dispatcher.EndGroups
|
return dispatcher.EndGroups
|
||||||
}
|
}
|
||||||
editReplied(fmt.Sprintf("找到 %d 个文件, 请选择存储位置", len(files)),
|
editReplied(fmt.Sprintf("找到 %d 个文件, 请选择存储位置", len(files)), markup)
|
||||||
markup)
|
|
||||||
return dispatcher.EndGroups
|
return dispatcher.EndGroups
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,32 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/celestix/gotgproto/dispatcher"
|
"github.com/celestix/gotgproto/dispatcher"
|
||||||
"github.com/celestix/gotgproto/ext"
|
"github.com/celestix/gotgproto/ext"
|
||||||
"github.com/charmbracelet/log"
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/gotd/td/tg"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/mediautil"
|
||||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/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/pkg/tcbdata"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||||
"github.com/krau/SaveAny-Bot/storage"
|
"github.com/krau/SaveAny-Bot/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
func handleMediaMessage(ctx *ext.Context, update *ext.Update) error {
|
func handleMediaMessage(ctx *ext.Context, update *ext.Update) error {
|
||||||
logger := log.FromContext(ctx)
|
logger := log.FromContext(ctx)
|
||||||
message := update.EffectiveMessage.Message
|
message := update.EffectiveMessage.Message
|
||||||
|
groupID, isGroup := message.GetGroupedID()
|
||||||
|
if isGroup && groupID != 0 {
|
||||||
|
return handleGroupMediaMessage(ctx, update, message, groupID)
|
||||||
|
}
|
||||||
logger.Debugf("Got media: %s", message.Media.TypeName())
|
logger.Debugf("Got media: %s", message.Media.TypeName())
|
||||||
|
|
||||||
msg, file, err := shortcut.GetFileFromMessageWithReply(ctx, update, message)
|
msg, file, err := shortcut.GetFileFromMessageWithReply(ctx, update, message)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -38,6 +52,10 @@ func handleSilentSaveMedia(ctx *ext.Context, update *ext.Update) error {
|
|||||||
return dispatcher.EndGroups
|
return dispatcher.EndGroups
|
||||||
}
|
}
|
||||||
message := update.EffectiveMessage.Message
|
message := update.EffectiveMessage.Message
|
||||||
|
groupID, isGroup := message.GetGroupedID()
|
||||||
|
if isGroup && groupID != 0 {
|
||||||
|
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()
|
userID := update.GetUserChat().GetID()
|
||||||
msg, file, err := shortcut.GetFileFromMessageWithReply(ctx, update, message)
|
msg, file, err := shortcut.GetFileFromMessageWithReply(ctx, update, message)
|
||||||
@@ -46,3 +64,96 @@ func handleSilentSaveMedia(ctx *ext.Context, update *ext.Update) error {
|
|||||||
}
|
}
|
||||||
return shortcut.CreateAndAddTGFileTaskWithEdit(ctx, userID, stor, "", file, msg.ID)
|
return shortcut.CreateAndAddTGFileTaskWithEdit(ctx, userID, stor, "", file, msg.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MediaGroupHandler struct {
|
||||||
|
groups map[int64][]tfile.TGFileMessage
|
||||||
|
timers map[int64]*time.Timer
|
||||||
|
mu sync.Mutex
|
||||||
|
timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
var mediaGroupHandler = &MediaGroupHandler{
|
||||||
|
groups: make(map[int64][]tfile.TGFileMessage),
|
||||||
|
timers: make(map[int64]*time.Timer),
|
||||||
|
timeout: 1 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleGroupMediaMessage(ctx *ext.Context, update *ext.Update, message *tg.Message, groupID int64) error {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
media := message.Media
|
||||||
|
supported := mediautil.IsSupported(media)
|
||||||
|
if !supported {
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
file, err := tfile.FromMediaMessage(media, ctx.Raw, message, tfile.WithNameIfEmpty(
|
||||||
|
tgutil.GenFileNameFromMessage(*message),
|
||||||
|
))
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to get file from media: %s", err)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
mediaGroupHandler.mu.Lock()
|
||||||
|
defer mediaGroupHandler.mu.Unlock()
|
||||||
|
if mediaGroupHandler.groups[groupID] == nil {
|
||||||
|
mediaGroupHandler.groups[groupID] = make([]tfile.TGFileMessage, 0)
|
||||||
|
}
|
||||||
|
mediaGroupHandler.groups[groupID] = append(mediaGroupHandler.groups[groupID], file)
|
||||||
|
|
||||||
|
if timer, exists := mediaGroupHandler.timers[groupID]; exists {
|
||||||
|
timer.Stop()
|
||||||
|
}
|
||||||
|
mediaGroupHandler.timers[groupID] = time.AfterFunc(mediaGroupHandler.timeout, func() {
|
||||||
|
processMediaGroup(ctx, update, groupID)
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
func processMediaGroup(ctx *ext.Context, update *ext.Update, groupID int64) {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
mediaGroupHandler.mu.Lock()
|
||||||
|
items := mediaGroupHandler.groups[groupID]
|
||||||
|
delete(mediaGroupHandler.groups, groupID)
|
||||||
|
delete(mediaGroupHandler.timers, groupID)
|
||||||
|
mediaGroupHandler.mu.Unlock()
|
||||||
|
if len(items) == 0 {
|
||||||
|
logger.Warn("No media items to process for group", "groupID", groupID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.Debugf("Processing media group %d with %d items", groupID, len(items))
|
||||||
|
|
||||||
|
userId := update.GetUserChat().GetID()
|
||||||
|
msg, err := ctx.Reply(update, ext.ReplyTextString("正在保存文件..."), nil)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to reply: %s", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stor := storage.FromContext(ctx)
|
||||||
|
if stor != nil {
|
||||||
|
// In silent mode
|
||||||
|
if len(items) == 1 {
|
||||||
|
shortcut.CreateAndAddTGFileTaskWithEdit(ctx, userId, stor, "", items[0], msg.ID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
shortcut.CreateAndAddBatchTGFileTaskWithEdit(ctx, userId, stor, "", items, msg.ID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stors := storage.GetUserStorages(ctx, userId)
|
||||||
|
markup, err := msgelem.BuildAddSelectStorageKeyboard(stors, tcbdata.Add{
|
||||||
|
Files: items,
|
||||||
|
AsBatch: len(items) > 1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("构建存储选择键盘失败: %s", err)
|
||||||
|
ctx.EditMessage(userId, &tg.MessagesEditMessageRequest{
|
||||||
|
ID: msg.ID,
|
||||||
|
Message: "构建存储选择键盘失败: " + err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.EditMessage(userId, &tg.MessagesEditMessageRequest{
|
||||||
|
ID: msg.ID,
|
||||||
|
Message: fmt.Sprintf("共 %d 个文件, 请选择存储位置", len(items)),
|
||||||
|
ReplyMarkup: markup,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,26 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"path"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/celestix/gotgproto/dispatcher"
|
"github.com/celestix/gotgproto/dispatcher"
|
||||||
"github.com/celestix/gotgproto/dispatcher/handlers"
|
"github.com/celestix/gotgproto/dispatcher/handlers"
|
||||||
"github.com/celestix/gotgproto/dispatcher/handlers/filters"
|
"github.com/celestix/gotgproto/dispatcher/handlers/filters"
|
||||||
"github.com/celestix/gotgproto/ext"
|
"github.com/celestix/gotgproto/ext"
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/re"
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/re"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/ruleutil"
|
||||||
|
userclient "github.com/krau/SaveAny-Bot/client/user"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/config"
|
||||||
|
"github.com/krau/SaveAny-Bot/core"
|
||||||
|
"github.com/krau/SaveAny-Bot/core/tftask"
|
||||||
|
"github.com/krau/SaveAny-Bot/database"
|
||||||
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
||||||
|
"github.com/krau/SaveAny-Bot/storage"
|
||||||
|
"github.com/rs/xid"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Register(disp dispatcher.Dispatcher) {
|
func Register(disp dispatcher.Dispatcher) {
|
||||||
@@ -23,6 +37,8 @@ func Register(disp dispatcher.Dispatcher) {
|
|||||||
disp.AddHandler(handlers.NewCommand("storage", handleStorageCmd))
|
disp.AddHandler(handlers.NewCommand("storage", handleStorageCmd))
|
||||||
disp.AddHandler(handlers.NewCommand("dir", handleDirCmd))
|
disp.AddHandler(handlers.NewCommand("dir", handleDirCmd))
|
||||||
disp.AddHandler(handlers.NewCommand("rule", handleRuleCmd))
|
disp.AddHandler(handlers.NewCommand("rule", handleRuleCmd))
|
||||||
|
disp.AddHandler(handlers.NewCommand("watch", handleWatchCmd))
|
||||||
|
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.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))
|
||||||
@@ -38,4 +54,84 @@ func Register(disp dispatcher.Dispatcher) {
|
|||||||
}
|
}
|
||||||
disp.AddHandler(handlers.NewMessage(telegraphUrlRegexFilter, handleSilentMode(handleTelegraphUrlMessage, handleSilentSaveTelegraph)))
|
disp.AddHandler(handlers.NewMessage(telegraphUrlRegexFilter, handleSilentMode(handleTelegraphUrlMessage, handleSilentSaveTelegraph)))
|
||||||
disp.AddHandler(handlers.NewMessage(filters.Message.Media, handleSilentMode(handleMediaMessage, handleSilentSaveMedia)))
|
disp.AddHandler(handlers.NewMessage(filters.Message.Media, handleSilentMode(handleMediaMessage, handleSilentSaveMedia)))
|
||||||
|
|
||||||
|
if config.Cfg.Telegram.Userbot.Enable {
|
||||||
|
go listenMediaMessageEvent(userclient.GetMediaMessageCh())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func listenMediaMessageEvent(ch chan userclient.MediaMessageEvent) {
|
||||||
|
logger := log.FromContext(userclient.GetCtx())
|
||||||
|
for event := range ch {
|
||||||
|
logger.Debug("Received media message event", "chat_id", event.ChatID, "file_name", event.File.Name())
|
||||||
|
ctx := event.Ctx
|
||||||
|
file := event.File
|
||||||
|
chats, err := database.GetWatchChatsByChatID(ctx, event.ChatID)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to get watch chats for chat ID %d: %v", event.ChatID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
msgText := event.File.Message().GetMessage()
|
||||||
|
for _, chat := range chats {
|
||||||
|
if chat.Filter != "" {
|
||||||
|
filter := strings.Split(chat.Filter, ":")
|
||||||
|
if len(filter) != 2 {
|
||||||
|
logger.Warnf("Invalid filter format in chat %d, skipping", chat.ChatID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filterType := filter[0]
|
||||||
|
filterData := filter[1]
|
||||||
|
switch filterType {
|
||||||
|
case "msgre": // [TODO] enums for filter types
|
||||||
|
if ok, err := regexp.MatchString(filterData, msgText); err != nil {
|
||||||
|
continue
|
||||||
|
} else if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
logger.Warnf("Unsupported filter type %s in chat %d, skipping", filterType, chat.ChatID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
user, err := database.GetUserByID(ctx, chat.UserID)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to get user by ID %d: %v", chat.UserID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if user.DefaultStorage == "" {
|
||||||
|
logger.Warnf("User %d has no default storage set, skipping media message handling", chat.UserID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stor, err := storage.GetStorageByUserIDAndName(ctx, user.ChatID, user.DefaultStorage)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to get storage by user ID %d and name %s: %v", user.ChatID, user.DefaultStorage, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var dirPath string
|
||||||
|
if user.ApplyRule && user.Rules != nil {
|
||||||
|
matchedStorageName, matchedDirPath := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file))
|
||||||
|
dirPath = matchedDirPath.String()
|
||||||
|
if matchedStorageName.IsUsable() {
|
||||||
|
stor, err = storage.GetStorageByUserIDAndName(ctx, user.ChatID, matchedStorageName.String())
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to get storage by user ID and name: %s", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
storagePath := stor.JoinStoragePath(path.Join(dirPath, file.Name()))
|
||||||
|
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
|
||||||
|
taskid := xid.New().String()
|
||||||
|
task, err := tftask.NewTGFileTask(taskid, injectCtx, file, stor, storagePath, nil)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("create task failed: %s", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := core.AddTask(injectCtx, task); err != nil {
|
||||||
|
logger.Errorf("add task failed: %s", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
logger.Infof("Added media message task for user %d in chat %d: %s", chat.UserID, event.ChatID, file.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/celestix/gotgproto/dispatcher"
|
"github.com/celestix/gotgproto/dispatcher"
|
||||||
@@ -23,7 +24,7 @@ func handleSaveCmd(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(string(update.EffectiveMessage.Text), " ")
|
||||||
if len(args) >= 3 {
|
if len(args) >= 3 {
|
||||||
return handleBatchSave(ctx, update, args[1], args[2])
|
return handleBatchSave(ctx, update, args[1:])
|
||||||
}
|
}
|
||||||
replyTo := update.EffectiveMessage.ReplyToMessage
|
replyTo := update.EffectiveMessage.ReplyToMessage
|
||||||
if replyTo == nil || replyTo.Message == nil {
|
if replyTo == nil || replyTo.Message == nil {
|
||||||
@@ -60,7 +61,7 @@ func handleSaveCmd(ctx *ext.Context, update *ext.Update) error {
|
|||||||
func handleSilentSaveReplied(ctx *ext.Context, update *ext.Update) error {
|
func handleSilentSaveReplied(ctx *ext.Context, update *ext.Update) error {
|
||||||
args := strings.Split(string(update.EffectiveMessage.Text), " ")
|
args := strings.Split(string(update.EffectiveMessage.Text), " ")
|
||||||
if len(args) >= 3 {
|
if len(args) >= 3 {
|
||||||
return handleBatchSave(ctx, update, args[1], args[2])
|
return handleBatchSave(ctx, update, args[1:])
|
||||||
}
|
}
|
||||||
logger := log.FromContext(ctx)
|
logger := log.FromContext(ctx)
|
||||||
stor := storage.FromContext(ctx)
|
stor := storage.FromContext(ctx)
|
||||||
@@ -92,7 +93,20 @@ func handleSilentSaveReplied(ctx *ext.Context, update *ext.Update) error {
|
|||||||
return shortcut.CreateAndAddTGFileTaskWithEdit(ctx, update.GetUserChat().GetID(), stor, "", file, msg.GetID())
|
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, "-")
|
startID, endID, err := strutil.ParseIntStrRange(msgIdRangeArg, "-")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.Reply(update, ext.ReplyTextString("无效的消息ID范围: "+err.Error()), nil)
|
ctx.Reply(update, ext.ReplyTextString("无效的消息ID范围: "+err.Error()), nil)
|
||||||
@@ -121,7 +135,11 @@ func handleBatchSave(ctx *ext.Context, update *ext.Update, chatArg string, msgId
|
|||||||
return dispatcher.EndGroups
|
return dispatcher.EndGroups
|
||||||
}
|
}
|
||||||
files := make([]tfile.TGFileMessage, 0, len(msgs))
|
files := make([]tfile.TGFileMessage, 0, len(msgs))
|
||||||
|
sb := strings.Builder{}
|
||||||
for _, msg := range msgs {
|
for _, msg := range msgs {
|
||||||
|
if msg == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
media, ok := msg.GetMedia()
|
media, ok := msg.GetMedia()
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
@@ -130,11 +148,21 @@ func handleBatchSave(ctx *ext.Context, update *ext.Update, chatArg string, msgId
|
|||||||
if !supported {
|
if !supported {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
file, err := tfile.FromMediaMessage(media, msg, tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*msg)))
|
file, err := tfile.FromMediaMessage(media, ctx.Raw, msg, tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*msg)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.FromContext(ctx).Errorf("获取文件失败: %s", err)
|
log.FromContext(ctx).Errorf("获取文件失败: %s", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if filter != nil {
|
||||||
|
sb.Reset()
|
||||||
|
sb.WriteString(msg.GetMessage())
|
||||||
|
sb.WriteString(" ")
|
||||||
|
fn, _ := tgutil.GetMediaFileName(media)
|
||||||
|
sb.WriteString(fn)
|
||||||
|
if !filter.MatchString(sb.String()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
files = append(files, file)
|
files = append(files, file)
|
||||||
}
|
}
|
||||||
if len(files) == 0 {
|
if len(files) == 0 {
|
||||||
@@ -164,5 +192,4 @@ func handleBatchSave(ctx *ext.Context, update *ext.Update, chatArg string, msgId
|
|||||||
return dispatcher.EndGroups
|
return dispatcher.EndGroups
|
||||||
}
|
}
|
||||||
return shortcut.CreateAndAddBatchTGFileTaskWithEdit(ctx, update.GetUserChat().GetID(), stor, "", files, replied.ID)
|
return shortcut.CreateAndAddBatchTGFileTaskWithEdit(ctx, update.GetUserChat().GetID(), stor, "", files, replied.ID)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,6 @@ const (
|
|||||||
|
|
||||||
2. 设置默认存储后, 发送 /save <频道ID/用户名> <消息ID范围> 来批量保存文件. 遵从存储规则, 若未匹配到任何规则则使用默认存储.
|
2. 设置默认存储后, 发送 /save <频道ID/用户名> <消息ID范围> 来批量保存文件. 遵从存储规则, 若未匹配到任何规则则使用默认存储.
|
||||||
示例:
|
示例:
|
||||||
/save @moreacg 114-514
|
/save @acherkrau 114-514
|
||||||
`
|
`
|
||||||
)
|
)
|
||||||
|
|||||||
19
client/bot/handlers/utils/msgelem/watch.go
Normal file
19
client/bot/handlers/utils/msgelem/watch.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package msgelem
|
||||||
|
|
||||||
|
const (
|
||||||
|
WatchHelpText = `
|
||||||
|
使用 /watch 命令监听一个聊天的消息, 并自动保存到默认存储中, 遵从存储规则.
|
||||||
|
|
||||||
|
命令语法:
|
||||||
|
/watch <chat_id> [filter]
|
||||||
|
|
||||||
|
参数:
|
||||||
|
- <chat_id>: 聊天的 ID 或用户名
|
||||||
|
- [filter]: 可选, 格式为 过滤器类型:表达式 , 所有支持类型的过滤器请查看文档
|
||||||
|
|
||||||
|
命令示例:
|
||||||
|
/watch 2229835658 msgre:.*plana.*
|
||||||
|
|
||||||
|
这将监听 ID 为 2229835658 的聊天, 并转存所有包含 "plana" 的媒体消息
|
||||||
|
`
|
||||||
|
)
|
||||||
@@ -3,6 +3,8 @@ package ruleutil
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
|
"github.com/duke-git/lancet/v2/convertor"
|
||||||
|
|
||||||
"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"
|
"github.com/krau/SaveAny-Bot/pkg/consts"
|
||||||
@@ -33,11 +35,22 @@ func (m matchedStorName) String() string {
|
|||||||
return string(m)
|
return string(m)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m matchedStorName) IsValid() bool {
|
// can we use this storage name directly?
|
||||||
|
func (m matchedStorName) IsUsable() bool {
|
||||||
return m != "" && m != consts.RuleStorNameChosen
|
return m != "" && m != consts.RuleStorNameChosen
|
||||||
}
|
}
|
||||||
|
|
||||||
func ApplyRule(ctx context.Context, rules []database.Rule, inputs *ruleInput) (matchedStorageName matchedStorName, dirPath string) {
|
type MatchedDirPath string
|
||||||
|
|
||||||
|
func (m MatchedDirPath) String() string {
|
||||||
|
return string(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m MatchedDirPath) NeedNewForAlbum() bool {
|
||||||
|
return m != "" && m == consts.RuleDirPathNewForAlbum
|
||||||
|
}
|
||||||
|
|
||||||
|
func ApplyRule(ctx context.Context, rules []database.Rule, inputs *ruleInput) (matchedStorageName matchedStorName, dirPath MatchedDirPath) {
|
||||||
if inputs == nil || len(rules) == 0 {
|
if inputs == nil || len(rules) == 0 {
|
||||||
return "", ""
|
return "", ""
|
||||||
}
|
}
|
||||||
@@ -56,7 +69,7 @@ func ApplyRule(ctx context.Context, rules []database.Rule, inputs *ruleInput) (m
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if ok {
|
if ok {
|
||||||
dirPath = ru.StoragePath()
|
dirPath = MatchedDirPath(ru.StoragePath())
|
||||||
matchedStorageName = matchedStorName(ru.StorageName())
|
matchedStorageName = matchedStorName(ru.StorageName())
|
||||||
}
|
}
|
||||||
case ruleenum.MessageRegex.String():
|
case ruleenum.MessageRegex.String():
|
||||||
@@ -71,7 +84,26 @@ func ApplyRule(ctx context.Context, rules []database.Rule, inputs *ruleInput) (m
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if ok {
|
if ok {
|
||||||
dirPath = ru.StoragePath()
|
dirPath = MatchedDirPath(ru.StoragePath())
|
||||||
|
matchedStorageName = matchedStorName(ru.StorageName())
|
||||||
|
}
|
||||||
|
case ruleenum.IsAlbum.String():
|
||||||
|
matchAlbum, err := convertor.ToBool(ur.Data)
|
||||||
|
if err != nil {
|
||||||
|
matchAlbum = false
|
||||||
|
}
|
||||||
|
ru, err := rule.NewRuleMediaType(ur.StorageName, ur.DirPath, matchAlbum)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to create rule: %s", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ok, err := ru.Match(inputs.File.Message().GroupedID != 0)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to match rule: %s", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
dirPath = MatchedDirPath(ru.StoragePath())
|
||||||
matchedStorageName = matchedStorName(ru.StorageName())
|
matchedStorageName = matchedStorName(ru.StorageName())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,13 +10,16 @@ import (
|
|||||||
"github.com/celestix/gotgproto/ext"
|
"github.com/celestix/gotgproto/ext"
|
||||||
"github.com/celestix/gotgproto/types"
|
"github.com/celestix/gotgproto/types"
|
||||||
"github.com/charmbracelet/log"
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/gotd/td/telegram/downloader"
|
||||||
"github.com/gotd/td/tg"
|
"github.com/gotd/td/tg"
|
||||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/mediautil"
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/mediautil"
|
||||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
||||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/re"
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/re"
|
||||||
|
uc "github.com/krau/SaveAny-Bot/client/user"
|
||||||
"github.com/krau/SaveAny-Bot/common/cache"
|
"github.com/krau/SaveAny-Bot/common/cache"
|
||||||
"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/pkg/telegraph"
|
"github.com/krau/SaveAny-Bot/pkg/telegraph"
|
||||||
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||||
)
|
)
|
||||||
@@ -46,7 +49,7 @@ func GetFileFromMessageWithReply(ctx *ext.Context, update *ext.Update, message *
|
|||||||
} else {
|
} else {
|
||||||
options = append(options, tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*message)))
|
options = append(options, tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*message)))
|
||||||
}
|
}
|
||||||
file, err = tfile.FromMediaMessage(media, message, options...)
|
file, err = tfile.FromMediaMessage(media, ctx.Raw, message, options...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("Failed to get file from media: %s", err)
|
logger.Errorf("Failed to get file from media: %s", err)
|
||||||
ctx.Reply(update, ext.ReplyTextString("获取文件失败: "+err.Error()), nil)
|
ctx.Reply(update, ext.ReplyTextString("获取文件失败: "+err.Error()), nil)
|
||||||
@@ -81,29 +84,59 @@ func GetFilesFromUpdateLinkMessageWithReplyEdit(ctx *ext.Context, update *ext.Up
|
|||||||
}
|
}
|
||||||
|
|
||||||
files = make([]tfile.TGFileMessage, 0, len(msgLinks))
|
files = make([]tfile.TGFileMessage, 0, len(msgLinks))
|
||||||
for _, link := range msgLinks {
|
addFile := func(client downloader.Client, msg *tg.Message) {
|
||||||
chatId, msgId, err := tgutil.ParseMessageLink(ctx, link)
|
if msg == nil || msg.Media == nil {
|
||||||
if err != nil {
|
logger.Warn("message is nil, skipping")
|
||||||
logger.Errorf("failed to parse message link %s: %s", link, err)
|
return
|
||||||
continue
|
|
||||||
}
|
|
||||||
msg, err := tgutil.GetMessageByID(ctx, chatId, msgId)
|
|
||||||
if err != nil {
|
|
||||||
logger.Errorf("failed to get message by ID: %s", err)
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
media, ok := msg.GetMedia()
|
media, ok := msg.GetMedia()
|
||||||
if !ok {
|
if !ok {
|
||||||
logger.Debugf("message %d has no media", msg.GetID())
|
logger.Debugf("message %d has no media", msg.GetID())
|
||||||
continue
|
return
|
||||||
}
|
}
|
||||||
file, err := tfile.FromMediaMessage(media, msg, tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*msg)))
|
file, err := tfile.FromMediaMessage(media, client, msg, tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*msg)))
|
||||||
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)
|
||||||
continue
|
return
|
||||||
}
|
}
|
||||||
files = append(files, file)
|
files = append(files, file)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tctx := ctx
|
||||||
|
if config.Cfg.Telegram.Userbot.Enable {
|
||||||
|
tctx = uc.GetCtx()
|
||||||
|
}
|
||||||
|
|
||||||
|
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(tctx, link)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("failed to parse message link %s: %s", link, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
msg, err := tgutil.GetMessageByID(tctx, chatId, msgId)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("failed to get message by ID: %s", err)
|
||||||
|
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(tctx.Raw, gmsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
addFile(tctx.Raw, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
if len(files) == 0 {
|
if len(files) == 0 {
|
||||||
editReplied("没有找到可保存的文件", nil)
|
editReplied("没有找到可保存的文件", nil)
|
||||||
return nil, nil, nil, dispatcher.EndGroups
|
return nil, nil, nil, dispatcher.EndGroups
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package shortcut
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"path"
|
"path"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/celestix/gotgproto/dispatcher"
|
"github.com/celestix/gotgproto/dispatcher"
|
||||||
"github.com/celestix/gotgproto/ext"
|
"github.com/celestix/gotgproto/ext"
|
||||||
@@ -34,8 +35,8 @@ func CreateAndAddTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor storage
|
|||||||
}
|
}
|
||||||
if user.ApplyRule && user.Rules != nil {
|
if user.ApplyRule && user.Rules != nil {
|
||||||
matchedStorageName, matchedDirPath := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file))
|
matchedStorageName, matchedDirPath := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file))
|
||||||
dirPath = matchedDirPath
|
dirPath = matchedDirPath.String()
|
||||||
if matchedStorageName.IsValid() {
|
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 {
|
||||||
logger.Errorf("Failed to get storage by user ID and name: %s", err)
|
logger.Errorf("Failed to get storage by user ID and name: %s", err)
|
||||||
@@ -51,7 +52,7 @@ func CreateAndAddTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor storage
|
|||||||
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()
|
||||||
task, err := tftask.NewTGFileTask(taskid, injectCtx, file, ctx.Raw, stor, storagePath,
|
task, err := tftask.NewTGFileTask(taskid, injectCtx, file, stor, storagePath,
|
||||||
tftask.NewProgressTrack(
|
tftask.NewProgressTrack(
|
||||||
trackMsgID,
|
trackMsgID,
|
||||||
userID))
|
userID))
|
||||||
@@ -93,19 +94,28 @@ func CreateAndAddBatchTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor st
|
|||||||
})
|
})
|
||||||
return dispatcher.EndGroups
|
return dispatcher.EndGroups
|
||||||
}
|
}
|
||||||
|
|
||||||
useRule := user.ApplyRule && user.Rules != nil
|
useRule := user.ApplyRule && user.Rules != nil
|
||||||
applyRule := func(file tfile.TGFileMessage) (string, string) {
|
|
||||||
|
applyRule := func(file tfile.TGFileMessage) (string, ruleutil.MatchedDirPath) {
|
||||||
if !useRule {
|
if !useRule {
|
||||||
return stor.Name(), dirPath
|
return stor.Name(), ruleutil.MatchedDirPath(dirPath)
|
||||||
}
|
}
|
||||||
storName, dirP := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file))
|
storName, dirP := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file))
|
||||||
if !storName.IsValid() {
|
|
||||||
return stor.Name(), dirP
|
storname := storName.String()
|
||||||
|
if !storName.IsUsable() {
|
||||||
|
storname = stor.Name()
|
||||||
}
|
}
|
||||||
return storName.String(), dirP
|
return storname, dirP
|
||||||
}
|
}
|
||||||
|
|
||||||
elems := make([]batchtftask.TaskElement, 0, len(files))
|
elems := make([]batchtftask.TaskElement, 0, len(files))
|
||||||
|
type albumFile struct {
|
||||||
|
file tfile.TGFileMessage
|
||||||
|
storage storage.Storage
|
||||||
|
}
|
||||||
|
albumFiles := make(map[int64][]albumFile, 0)
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
storName, dirPath := applyRule(file)
|
storName, dirPath := applyRule(file)
|
||||||
fileStor := stor
|
fileStor := stor
|
||||||
@@ -120,21 +130,59 @@ func CreateAndAddBatchTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor st
|
|||||||
return dispatcher.EndGroups
|
return dispatcher.EndGroups
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
storPath := fileStor.JoinStoragePath(path.Join(dirPath, file.Name()))
|
if !dirPath.NeedNewForAlbum() {
|
||||||
elem, err := batchtftask.NewTaskElement(fileStor, storPath, file)
|
storPath := fileStor.JoinStoragePath(path.Join(dirPath.String(), file.Name()))
|
||||||
if err != nil {
|
elem, err := batchtftask.NewTaskElement(fileStor, storPath, file)
|
||||||
logger.Errorf("Failed to create task element: %s", err)
|
if err != nil {
|
||||||
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
logger.Errorf("Failed to create task element: %s", err)
|
||||||
ID: trackMsgID,
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
Message: "任务创建失败: " + err.Error(),
|
ID: trackMsgID,
|
||||||
|
Message: "任务创建失败: " + err.Error(),
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
elems = append(elems, *elem)
|
||||||
|
} else {
|
||||||
|
groupId, isGroup := file.Message().GetGroupedID()
|
||||||
|
if !isGroup || groupId == 0 {
|
||||||
|
logger.Warnf("File %s is not in a group, skipping album handling", file.Name())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := albumFiles[groupId]; !ok {
|
||||||
|
albumFiles[groupId] = make([]albumFile, 0)
|
||||||
|
}
|
||||||
|
albumFiles[groupId] = append(albumFiles[groupId], albumFile{
|
||||||
|
file: file,
|
||||||
|
storage: fileStor,
|
||||||
})
|
})
|
||||||
return dispatcher.EndGroups
|
|
||||||
}
|
}
|
||||||
elems = append(elems, *elem)
|
|
||||||
}
|
}
|
||||||
|
for _, afiles := range albumFiles {
|
||||||
|
if len(afiles) <= 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 对于需要新建目录的文件, 将第一个文件的文件名(去除扩展名)作为目录名
|
||||||
|
// 存储以第一个文件的存储为准
|
||||||
|
albumDir := strings.TrimSuffix(path.Base(afiles[0].file.Name()), path.Ext(afiles[0].file.Name()))
|
||||||
|
albumStor := afiles[0].storage
|
||||||
|
for _, af := range afiles {
|
||||||
|
afstorPath := af.storage.JoinStoragePath(path.Join(dirPath, albumDir, af.file.Name()))
|
||||||
|
elem, err := batchtftask.NewTaskElement(albumStor, afstorPath, af.file)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to create task element for album file: %s", err)
|
||||||
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
|
ID: trackMsgID,
|
||||||
|
Message: "任务创建失败: " + err.Error(),
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
elems = append(elems, *elem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
|
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
|
||||||
taskid := xid.New().String()
|
taskid := xid.New().String()
|
||||||
task := batchtftask.NewBatchTGFileTask(taskid, injectCtx, elems, ctx.Raw, batchtftask.NewProgressTracker(trackMsgID, userID), true)
|
task := batchtftask.NewBatchTGFileTask(taskid, injectCtx, elems, batchtftask.NewProgressTracker(trackMsgID, userID), true)
|
||||||
if err := core.AddTask(injectCtx, task); err != nil {
|
if err := core.AddTask(injectCtx, task); err != nil {
|
||||||
logger.Errorf("Failed to add batch task: %s", err)
|
logger.Errorf("Failed to add batch task: %s", err)
|
||||||
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||||
|
|||||||
110
client/bot/handlers/watch.go
Normal file
110
client/bot/handlers/watch.go
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/celestix/gotgproto/dispatcher"
|
||||||
|
"github.com/celestix/gotgproto/ext"
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleWatchCmd(ctx *ext.Context, update *ext.Update) error {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
args := strings.Split(string(update.EffectiveMessage.Text), " ")
|
||||||
|
if len(args) < 2 {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString(msgelem.WatchHelpText), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
userChatID := update.GetUserChat().GetID()
|
||||||
|
user, err := database.GetUserByChatID(ctx, userChatID)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("获取用户失败: %s", err)
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("获取用户失败"), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
if user.DefaultStorage == "" {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("请先设置默认存储, 使用 /storage 命令"), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
chatArg := args[1]
|
||||||
|
chatID, err := tgutil.ParseChatID(ctx, chatArg)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("无效的ID或用户名: "+err.Error()), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
watching, err := user.WatchingChat(ctx, chatID)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to check if user is watching chat %d: %s", chatID, err)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
if watching {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("已经在监听此聊天"), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
filter := ""
|
||||||
|
if len(args) > 2 {
|
||||||
|
filterArg := strings.Join(args[2:], " ")
|
||||||
|
filterType := strings.Split(filterArg, ":")[0]
|
||||||
|
filterData := strings.Split(filterArg, ":")[1]
|
||||||
|
if filterType == "" || filterData == "" {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("过滤器格式错误, 请使用 <过滤器类型>:<表达式>"), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
switch filterType {
|
||||||
|
case "msgre":
|
||||||
|
_, err := regexp.Compile(filterData)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("正则表达式格式错误: "+err.Error()), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
filter = filterType + ":" + filterData
|
||||||
|
default:
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("不支持的过滤器类型, 请参阅文档"), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := user.WatchChat(ctx, database.WatchChat{
|
||||||
|
UserID: user.ID,
|
||||||
|
ChatID: chatID,
|
||||||
|
Filter: filter,
|
||||||
|
}); err != nil {
|
||||||
|
logger.Errorf("Failed to watch chat %d: %s", chatID, err)
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("监听聊天失败: "+err.Error()), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("已开始监听聊天: "+chatArg), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleUnwatchCmd(ctx *ext.Context, update *ext.Update) error {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
args := strings.Split(string(update.EffectiveMessage.Text), " ")
|
||||||
|
if len(args) < 2 {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("请提供要取消监听的聊天ID或用户名"), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
userChatID := update.GetUserChat().GetID()
|
||||||
|
user, err := database.GetUserByChatID(ctx, userChatID)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("获取用户失败: %s", err)
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("获取用户失败"), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
chatArg := args[1]
|
||||||
|
chatID, err := tgutil.ParseChatID(ctx, chatArg)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("无效的ID或用户名: "+err.Error()), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
if err := user.UnwatchChat(ctx, chatID); err != nil {
|
||||||
|
logger.Errorf("Failed to unwatch chat %d: %s", chatID, err)
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("取消监听聊天失败: "+err.Error()), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
ctx.Reply(update, ext.ReplyTextString("已取消监听聊天: "+chatArg), nil)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
@@ -9,13 +9,14 @@ import (
|
|||||||
"github.com/gotd/td/telegram"
|
"github.com/gotd/td/telegram"
|
||||||
"github.com/krau/SaveAny-Bot/client/middleware/recovery"
|
"github.com/krau/SaveAny-Bot/client/middleware/recovery"
|
||||||
"github.com/krau/SaveAny-Bot/client/middleware/retry"
|
"github.com/krau/SaveAny-Bot/client/middleware/retry"
|
||||||
|
"github.com/krau/SaveAny-Bot/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// https://github.com/iyear/tdl/blob/master/core/tclient/tclient.go
|
// https://github.com/iyear/tdl/blob/master/core/tclient/tclient.go
|
||||||
func NewDefaultMiddlewares(ctx context.Context, timeout time.Duration) []telegram.Middleware {
|
func NewDefaultMiddlewares(ctx context.Context, timeout time.Duration) []telegram.Middleware {
|
||||||
return []telegram.Middleware{
|
return []telegram.Middleware{
|
||||||
recovery.New(ctx, newBackoff(timeout)),
|
recovery.New(ctx, newBackoff(timeout)),
|
||||||
retry.New(5),
|
retry.New(config.Cfg.Telegram.RpcRetry),
|
||||||
floodwait.NewSimpleWaiter(),
|
floodwait.NewSimpleWaiter(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/charmbracelet/log"
|
"github.com/charmbracelet/log"
|
||||||
"github.com/go-faster/errors"
|
|
||||||
"github.com/gotd/td/bin"
|
"github.com/gotd/td/bin"
|
||||||
"github.com/gotd/td/telegram"
|
"github.com/gotd/td/telegram"
|
||||||
"github.com/gotd/td/tg"
|
"github.com/gotd/td/tg"
|
||||||
@@ -37,7 +36,8 @@ func (r retry) Handle(next tg.Invoker) telegram.InvokeFunc {
|
|||||||
retries++
|
retries++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return errors.Wrap(err, "retry middleware skip")
|
// retry middleware skip
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import (
|
|||||||
"github.com/fatih/color"
|
"github.com/fatih/color"
|
||||||
)
|
)
|
||||||
|
|
||||||
type termialAuthConversator struct{}
|
type terminalAuthConversator struct{}
|
||||||
|
|
||||||
func (t *termialAuthConversator) AskPhoneNumber() (string, error) {
|
func (t *terminalAuthConversator) AskPhoneNumber() (string, error) {
|
||||||
phone := ""
|
phone := ""
|
||||||
err := huh.NewInput().Title("Your Phone Number").
|
err := huh.NewInput().Title("Your Phone Number").
|
||||||
Placeholder("+44 123456").
|
Placeholder("+44 123456").
|
||||||
@@ -29,7 +29,7 @@ func (t *termialAuthConversator) AskPhoneNumber() (string, error) {
|
|||||||
return strings.TrimSpace(phone), nil
|
return strings.TrimSpace(phone), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *termialAuthConversator) AskCode() (string, error) {
|
func (t *terminalAuthConversator) AskCode() (string, error) {
|
||||||
code := ""
|
code := ""
|
||||||
err := huh.NewInput().Title("Your Code").
|
err := huh.NewInput().Title("Your Code").
|
||||||
Placeholder("123456").
|
Placeholder("123456").
|
||||||
@@ -45,7 +45,7 @@ func (t *termialAuthConversator) AskCode() (string, error) {
|
|||||||
return strings.TrimSpace(code), nil
|
return strings.TrimSpace(code), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *termialAuthConversator) AskPassword() (string, error) {
|
func (t *terminalAuthConversator) AskPassword() (string, error) {
|
||||||
pwd := ""
|
pwd := ""
|
||||||
|
|
||||||
err := huh.NewInput().Title("Your 2FA Password").
|
err := huh.NewInput().Title("Your 2FA Password").
|
||||||
@@ -61,7 +61,7 @@ func (t *termialAuthConversator) AskPassword() (string, error) {
|
|||||||
return strings.TrimSpace(pwd), nil
|
return strings.TrimSpace(pwd), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *termialAuthConversator) AuthStatus(authStatus gotgproto.AuthStatus) {
|
func (t *terminalAuthConversator) AuthStatus(authStatus gotgproto.AuthStatus) {
|
||||||
switch authStatus.Event {
|
switch authStatus.Event {
|
||||||
case gotgproto.AuthStatusPhoneRetrial:
|
case gotgproto.AuthStatusPhoneRetrial:
|
||||||
color.Red("The phone number you just entered seems to be incorrect,")
|
color.Red("The phone number you just entered seems to be incorrect,")
|
||||||
@@ -5,46 +5,87 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/celestix/gotgproto"
|
"github.com/celestix/gotgproto"
|
||||||
|
"github.com/celestix/gotgproto/dispatcher"
|
||||||
|
"github.com/celestix/gotgproto/dispatcher/handlers"
|
||||||
|
"github.com/celestix/gotgproto/dispatcher/handlers/filters"
|
||||||
"github.com/celestix/gotgproto/ext"
|
"github.com/celestix/gotgproto/ext"
|
||||||
"github.com/celestix/gotgproto/sessionMaker"
|
"github.com/celestix/gotgproto/sessionMaker"
|
||||||
|
|
||||||
"github.com/charmbracelet/log"
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/gotd/td/telegram/dcs"
|
||||||
|
"github.com/gotd/td/tg"
|
||||||
"github.com/krau/SaveAny-Bot/client/middleware"
|
"github.com/krau/SaveAny-Bot/client/middleware"
|
||||||
|
"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/database"
|
||||||
"github.com/ncruces/go-sqlite3/gormlite"
|
"github.com/ncruces/go-sqlite3/gormlite"
|
||||||
|
"golang.org/x/net/proxy"
|
||||||
)
|
)
|
||||||
|
|
||||||
var UC *gotgproto.Client
|
var uc *gotgproto.Client
|
||||||
var ectx *ext.Context
|
var ectx *ext.Context
|
||||||
|
|
||||||
func GetCtx() *ext.Context {
|
func GetCtx() *ext.Context {
|
||||||
|
if uc == nil {
|
||||||
|
panic("User client is not initialized, please call Login first")
|
||||||
|
}
|
||||||
if ectx != nil {
|
if ectx != nil {
|
||||||
// UC.RefreshContext(ectx)
|
|
||||||
return ectx
|
return ectx
|
||||||
}
|
}
|
||||||
ectx = UC.CreateContext()
|
ectx = uc.CreateContext()
|
||||||
return ectx
|
return ectx
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetClient() *gotgproto.Client {
|
||||||
|
if uc == nil {
|
||||||
|
panic("User client is not initialized, please call Login first")
|
||||||
|
}
|
||||||
|
return uc
|
||||||
|
}
|
||||||
|
|
||||||
func Login(ctx context.Context) (*gotgproto.Client, error) {
|
func Login(ctx context.Context) (*gotgproto.Client, error) {
|
||||||
log.FromContext(ctx).Debug("Logging in as user client")
|
log.FromContext(ctx).Debug("Logging in user client")
|
||||||
if UC != nil {
|
if uc != nil {
|
||||||
return UC, nil
|
return uc, nil
|
||||||
}
|
}
|
||||||
res := make(chan struct {
|
res := make(chan struct {
|
||||||
client *gotgproto.Client
|
client *gotgproto.Client
|
||||||
err error
|
err error
|
||||||
})
|
})
|
||||||
go func() {
|
go func() {
|
||||||
|
var resolver dcs.Resolver
|
||||||
|
if config.Cfg.Telegram.Proxy.Enable && config.Cfg.Telegram.Proxy.URL != "" {
|
||||||
|
dialer, err := netutil.NewProxyDialer(config.Cfg.Telegram.Proxy.URL)
|
||||||
|
if err != nil {
|
||||||
|
res <- struct {
|
||||||
|
client *gotgproto.Client
|
||||||
|
err error
|
||||||
|
}{nil, err}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resolver = dcs.Plain(dcs.PlainOptions{
|
||||||
|
Dial: dialer.(proxy.ContextDialer).DialContext,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
resolver = dcs.DefaultResolver()
|
||||||
|
}
|
||||||
tclient, err := gotgproto.NewClient(
|
tclient, err := gotgproto.NewClient(
|
||||||
config.Cfg.Telegram.AppID,
|
config.Cfg.Telegram.AppID,
|
||||||
config.Cfg.Telegram.AppHash,
|
config.Cfg.Telegram.AppHash,
|
||||||
gotgproto.ClientTypePhone(""),
|
gotgproto.ClientTypePhone(""),
|
||||||
&gotgproto.ClientOpts{
|
&gotgproto.ClientOpts{
|
||||||
Session: sessionMaker.SqlSession(gormlite.Open(config.Cfg.Telegram.Userbot.Session)),
|
Session: sessionMaker.SqlSession(gormlite.Open(config.Cfg.Telegram.Userbot.Session)),
|
||||||
AuthConversator: &termialAuthConversator{},
|
AuthConversator: &terminalAuthConversator{},
|
||||||
Context: ctx,
|
Context: ctx,
|
||||||
DisableCopyright: true,
|
DisableCopyright: true,
|
||||||
|
Resolver: resolver,
|
||||||
|
MaxRetries: config.Cfg.Telegram.RpcRetry,
|
||||||
|
AutoFetchReply: true,
|
||||||
Middlewares: middleware.NewDefaultMiddlewares(ctx, 5*time.Minute),
|
Middlewares: middleware.NewDefaultMiddlewares(ctx, 5*time.Minute),
|
||||||
|
ErrorHandler: func(ctx *ext.Context, u *ext.Update, s string) error {
|
||||||
|
log.FromContext(ctx).Errorf("Unhandled error: %s", s)
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -69,7 +110,21 @@ func Login(ctx context.Context) (*gotgproto.Client, error) {
|
|||||||
if r.err != nil {
|
if r.err != nil {
|
||||||
return nil, r.err
|
return nil, r.err
|
||||||
}
|
}
|
||||||
UC = r.client
|
uc = r.client
|
||||||
return UC, nil
|
uc.Dispatcher.AddHandler(handlers.NewMessage(filters.Message.Media, func(ctx *ext.Context, u *ext.Update) error {
|
||||||
|
switch u.UpdateClass.(type) {
|
||||||
|
case *tg.UpdateEditChannelMessage, *tg.UpdateEditMessage, *tg.UpdateDeleteChannelMessages, *tg.UpdateDeleteMessages:
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
chatId := u.EffectiveChat().GetID()
|
||||||
|
watchChats, err := database.GetWatchChatsByChatID(ctx, chatId)
|
||||||
|
if err != nil || len(watchChats) == 0 {
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
return dispatcher.ContinueGroups
|
||||||
|
}))
|
||||||
|
uc.Dispatcher.AddHandler(handlers.NewMessage(filters.Message.Media, handleMediaMessage))
|
||||||
|
log.FromContext(ctx).Infof("User client logged in successfully: %s", uc.Self.FirstName+" "+uc.Self.LastName)
|
||||||
|
return uc, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
100
client/user/watch.go
Normal file
100
client/user/watch.go
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/celestix/gotgproto/dispatcher"
|
||||||
|
"github.com/celestix/gotgproto/ext"
|
||||||
|
"github.com/gotd/td/tg"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MediaMessageEvent struct {
|
||||||
|
Ctx *ext.Context
|
||||||
|
ChatID int64 // from witch the media message was sent
|
||||||
|
MessageID int
|
||||||
|
File tfile.TGFileMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
type messageKey struct {
|
||||||
|
ChatID int64
|
||||||
|
MessageID int
|
||||||
|
}
|
||||||
|
|
||||||
|
type MediaMessageHandler struct {
|
||||||
|
events map[messageKey]MediaMessageEvent
|
||||||
|
timers map[messageKey]*time.Timer
|
||||||
|
mu sync.Mutex
|
||||||
|
debounce time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
mediaMessageCh = make(chan MediaMessageEvent, 100)
|
||||||
|
mediaMessageHandler = &MediaMessageHandler{
|
||||||
|
events: make(map[messageKey]MediaMessageEvent),
|
||||||
|
timers: make(map[messageKey]*time.Timer),
|
||||||
|
debounce: 5 * time.Second,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetMediaMessageCh() chan MediaMessageEvent {
|
||||||
|
return mediaMessageCh
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendMediaMessageEvent(event MediaMessageEvent) {
|
||||||
|
key := messageKey{ChatID: event.ChatID, MessageID: event.MessageID}
|
||||||
|
|
||||||
|
mediaMessageHandler.mu.Lock()
|
||||||
|
defer mediaMessageHandler.mu.Unlock()
|
||||||
|
|
||||||
|
if timer, exists := mediaMessageHandler.timers[key]; exists {
|
||||||
|
timer.Stop()
|
||||||
|
} else {
|
||||||
|
mediaMessageHandler.events[key] = event
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaMessageHandler.timers[key] = time.AfterFunc(mediaMessageHandler.debounce, func() {
|
||||||
|
mediaMessageHandler.mu.Lock()
|
||||||
|
event := mediaMessageHandler.events[key]
|
||||||
|
delete(mediaMessageHandler.events, key)
|
||||||
|
delete(mediaMessageHandler.timers, key)
|
||||||
|
mediaMessageHandler.mu.Unlock()
|
||||||
|
|
||||||
|
mediaMessageCh <- event
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleMediaMessage(ctx *ext.Context, update *ext.Update) error {
|
||||||
|
message := update.EffectiveMessage
|
||||||
|
media, ok := message.GetMedia()
|
||||||
|
if !ok || media == nil {
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
support := func() bool {
|
||||||
|
switch media.(type) {
|
||||||
|
case *tg.MessageMediaDocument, *tg.MessageMediaPhoto:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if !support {
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
|
file, err := tfile.FromMediaMessage(media, ctx.Raw, message.Message, tfile.WithNameIfEmpty(
|
||||||
|
tgutil.GenFileNameFromMessage(*message.Message),
|
||||||
|
))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
chatId := update.EffectiveChat().GetID()
|
||||||
|
sendMediaMessageEvent(MediaMessageEvent{
|
||||||
|
Ctx: ctx,
|
||||||
|
ChatID: chatId,
|
||||||
|
MessageID: message.ID,
|
||||||
|
File: file,
|
||||||
|
})
|
||||||
|
return dispatcher.EndGroups
|
||||||
|
}
|
||||||
10
cmd/run.go
10
cmd/run.go
@@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/charmbracelet/log"
|
"github.com/charmbracelet/log"
|
||||||
"github.com/krau/SaveAny-Bot/client/bot"
|
"github.com/krau/SaveAny-Bot/client/bot"
|
||||||
userclient "github.com/krau/SaveAny-Bot/client/user"
|
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"
|
||||||
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||||
"github.com/krau/SaveAny-Bot/common/utils/fsutil"
|
"github.com/krau/SaveAny-Bot/common/utils/fsutil"
|
||||||
@@ -46,19 +47,18 @@ func initAll(ctx context.Context) {
|
|||||||
fmt.Println("Failed to load config:", err)
|
fmt.Println("Failed to load config:", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
cache.Init()
|
||||||
logger := log.FromContext(ctx)
|
logger := log.FromContext(ctx)
|
||||||
i18n.Init(config.Cfg.Lang)
|
i18n.Init(config.Cfg.Lang)
|
||||||
logger.Info(i18n.T(i18nk.Initing))
|
logger.Info(i18n.T(i18nk.Initing))
|
||||||
|
database.Init(ctx)
|
||||||
|
storage.LoadStorages(ctx)
|
||||||
if config.Cfg.Telegram.Userbot.Enable {
|
if config.Cfg.Telegram.Userbot.Enable {
|
||||||
uc, err := userclient.Login(ctx)
|
_, err := userclient.Login(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Fatalf("User client login failed: %s", err)
|
logger.Fatalf("User client login failed: %s", err)
|
||||||
}
|
}
|
||||||
logger.Infof("User client logged in as %s", uc.Self.FirstName)
|
|
||||||
}
|
}
|
||||||
database.Init(ctx)
|
|
||||||
storage.LoadStorages(ctx)
|
|
||||||
|
|
||||||
bot.Init(ctx)
|
bot.Init(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
15
common/cache/ristretto.go
vendored
15
common/cache/ristretto.go
vendored
@@ -2,19 +2,22 @@ package cache
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/charmbracelet/log"
|
"github.com/charmbracelet/log"
|
||||||
"github.com/dgraph-io/ristretto/v2"
|
"github.com/dgraph-io/ristretto/v2"
|
||||||
|
"github.com/krau/SaveAny-Bot/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
var cache *ristretto.Cache[string, any]
|
var cache *ristretto.Cache[string, any]
|
||||||
|
|
||||||
|
func Init() {
|
||||||
// TODO: maybe we should use simple ttl cache instead of ristretto...
|
if cache != nil {
|
||||||
func init() {
|
panic("cache already initialized")
|
||||||
|
}
|
||||||
c, err := ristretto.NewCache(&ristretto.Config[string, any]{
|
c, err := ristretto.NewCache(&ristretto.Config[string, any]{
|
||||||
NumCounters: 1e5,
|
NumCounters: config.Cfg.Cache.NumCounters,
|
||||||
MaxCost: 1e6, // 1000000 / 112 ≈ 8928
|
MaxCost: config.Cfg.Cache.MaxCost,
|
||||||
BufferItems: 64,
|
BufferItems: 64,
|
||||||
OnReject: func(item *ristretto.Item[any]) {
|
OnReject: func(item *ristretto.Item[any]) {
|
||||||
log.Warnf("Cache item rejected: key=%d, value=%v", item.Key, item.Value)
|
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 {
|
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 {
|
if !ok {
|
||||||
return fmt.Errorf("failed to set value in cache")
|
return fmt.Errorf("failed to set value in cache")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
package tdler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/gotd/td/telegram/downloader"
|
|
||||||
"github.com/krau/SaveAny-Bot/common/utils/dlutil"
|
|
||||||
"github.com/krau/SaveAny-Bot/config"
|
|
||||||
"github.com/krau/SaveAny-Bot/pkg/consts/tglimit"
|
|
||||||
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Client interface {
|
|
||||||
downloader.Client
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewDownloader(client Client, file tfile.TGFile) *downloader.Builder {
|
|
||||||
return downloader.NewDownloader().WithPartSize(tglimit.MaxPartSize).
|
|
||||||
Download(client, file.Location()).WithThreads(dlutil.BestThreads(file.Size(), config.Cfg.Threads))
|
|
||||||
}
|
|
||||||
15
common/utils/netutil/proxy.go
Normal file
15
common/utils/netutil/proxy.go
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
package netutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"golang.org/x/net/proxy"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewProxyDialer(proxyUrl string) (proxy.Dialer, error) {
|
||||||
|
url, err := url.Parse(proxyUrl)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return proxy.FromURL(url, proxy.Direct)
|
||||||
|
}
|
||||||
40
common/utils/tgutil/media.go
Normal file
40
common/utils/tgutil/media.go
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
package tgutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/gabriel-vasile/mimetype"
|
||||||
|
"github.com/gotd/td/tg"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetMediaFileName(media tg.MessageMediaClass) (string, error) {
|
||||||
|
switch v := media.(type) {
|
||||||
|
case *tg.MessageMediaPhoto:
|
||||||
|
f, ok := v.Photo.AsNotEmpty()
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("unknown type media: %T", media)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d.png", f.ID), nil
|
||||||
|
case *tg.MessageMediaDocument:
|
||||||
|
f, ok := v.Document.AsNotEmpty()
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("unknown type media: %T", media)
|
||||||
|
}
|
||||||
|
fileName := ""
|
||||||
|
for _, attribute := range f.Attributes {
|
||||||
|
if name, ok := attribute.(*tg.DocumentAttributeFilename); ok {
|
||||||
|
fileName = name.GetFileName()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if fileName == "" {
|
||||||
|
mmt := mimetype.Lookup(f.GetMimeType())
|
||||||
|
if mmt != nil {
|
||||||
|
fileName = fmt.Sprintf("%d.%s", f.GetID(), mmt.Extension())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fileName, nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unsupported type media: %T", media)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/celestix/gotgproto/ext"
|
"github.com/celestix/gotgproto/ext"
|
||||||
"github.com/duke-git/lancet/v2/maputil"
|
"github.com/duke-git/lancet/v2/maputil"
|
||||||
|
|
||||||
"github.com/duke-git/lancet/v2/mathutil"
|
"github.com/duke-git/lancet/v2/mathutil"
|
||||||
"github.com/duke-git/lancet/v2/slice"
|
"github.com/duke-git/lancet/v2/slice"
|
||||||
lcstrutil "github.com/duke-git/lancet/v2/strutil"
|
lcstrutil "github.com/duke-git/lancet/v2/strutil"
|
||||||
@@ -18,6 +19,9 @@ import (
|
|||||||
"github.com/rs/xid"
|
"github.com/rs/xid"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// generate a file name from the message content and media type
|
||||||
|
//
|
||||||
|
// it will never return an empty string
|
||||||
func GenFileNameFromMessage(message tg.Message) string {
|
func GenFileNameFromMessage(message tg.Message) string {
|
||||||
ext := func(media tg.MessageMediaClass) string {
|
ext := func(media tg.MessageMediaClass) string {
|
||||||
switch media := media.(type) {
|
switch media := media.(type) {
|
||||||
@@ -26,11 +30,11 @@ func GenFileNameFromMessage(message tg.Message) string {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
ext := mimetype.Lookup(doc.MimeType).Extension()
|
mmt := mimetype.Lookup(doc.MimeType)
|
||||||
if ext == "" {
|
if mmt == nil || mmt.Extension() == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return ext
|
return mmt.Extension()
|
||||||
case *tg.MessageMediaPhoto:
|
case *tg.MessageMediaPhoto:
|
||||||
return ".jpg"
|
return ".jpg"
|
||||||
}
|
}
|
||||||
@@ -81,7 +85,13 @@ func GenFileNameFromMessage(message tg.Message) string {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
if filename == "" {
|
if filename == "" {
|
||||||
filename = fmt.Sprintf("%d_%s", message.GetID(), xid.New().String())
|
mname, err := GetMediaFileName(message.Media)
|
||||||
|
if err != nil {
|
||||||
|
filename = fmt.Sprintf("%d_%s", message.GetID(), xid.New().String())
|
||||||
|
} else {
|
||||||
|
filename = mname
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
return filename + ext
|
return filename + ext
|
||||||
}
|
}
|
||||||
@@ -159,6 +169,96 @@ func GetMessagesRange(ctx *ext.Context, chatID int64, minId, maxId int) ([]*tg.M
|
|||||||
return result, nil
|
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) {
|
func GetMessageByID(ctx *ext.Context, chatID int64, msgID int) (*tg.Message, error) {
|
||||||
key := fmt.Sprintf("tgmsg:%d:%d:%d", ctx.Self.ID, chatID, msgID)
|
key := fmt.Sprintf("tgmsg:%d:%d:%d", ctx.Self.ID, chatID, msgID)
|
||||||
if msg, ok := cache.Get[*tg.Message](key); ok {
|
if msg, ok := cache.Get[*tg.Message](key); ok {
|
||||||
@@ -181,3 +281,31 @@ func GetMessageByID(ctx *ext.Context, chatID int64, msgID int) (*tg.Message, err
|
|||||||
cache.Set(key, tgm)
|
cache.Set(key, tgm)
|
||||||
return tgm, nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,70 +1,34 @@
|
|||||||
#创建文件时,若需要保留中文注释,请务必确保本文件编码为 UTF-8 ,否则会无法读取。
|
# 创建文件时,若需要保留中文注释,请务必确保本文件编码为 UTF-8 ,否则会无法读取。
|
||||||
workers = 4 # 同时下载文件数
|
# 更详细的配置请在 https://sabot.unv.app/deployment/configuration 查看
|
||||||
retry = 3 # 下载失败重试次数
|
workers = 4 # 同时下载文件数
|
||||||
threads = 4 # 单个任务下载最大线程数
|
retry = 3 # 下载失败重试次数
|
||||||
stream = false # 使用stream模式, 详情请查看文档
|
threads = 4 # 单个任务下载使用的最大线程数
|
||||||
|
stream = false # 使用流式传输模式, 建议仅在硬盘空间十分有限时使用.
|
||||||
|
|
||||||
[telegram]
|
[telegram]
|
||||||
# Bot Token
|
# Bot Token
|
||||||
# 更换 Bot Token 后请删除数据库文件和 session.db
|
# 更换 Bot Token 后请删除会话数据库文件 (默认路径为 data/session.db )
|
||||||
token = ""
|
token = ""
|
||||||
# Telegram API 配置, 若不配置也可运行, 将使用默认的 API ID 和 API HASH
|
# Telegram API 配置, 若不配置也可运行, 将使用默认的 API ID 和 API HASH
|
||||||
# 推荐使用自己的 API ID 和 API HASH (https://my.telegram.org)
|
# 推荐使用自己的 API ID 和 API HASH (https://my.telegram.org)
|
||||||
# app_id = 1025907
|
# app_id = 1025907
|
||||||
# app_hash = "452b0359b988148995f22ff0f4229750"
|
# app_hash = "452b0359b988148995f22ff0f4229750"
|
||||||
|
|
||||||
# 初始化超时时间, 单位: 秒
|
|
||||||
timeout = 60
|
|
||||||
|
|
||||||
# flood_retry = 5
|
|
||||||
# rpc_retry = 5
|
|
||||||
|
|
||||||
[telegram.proxy]
|
[telegram.proxy]
|
||||||
# 启用代理连接 telegram, 只支持 socks5
|
# 启用代理连接 telegram, 只支持 socks5
|
||||||
enable = false
|
enable = false
|
||||||
url = "socks5://127.0.0.1:7890"
|
url = "socks5://127.0.0.1:7890"
|
||||||
|
|
||||||
# 用户列表
|
|
||||||
[[users]]
|
|
||||||
# telegram user id
|
|
||||||
id = 114514
|
|
||||||
# 使用黑名单模式,开启后下方留空以使用所有存储,反之则为白名单,白名单请在下方输入允许的存储名
|
|
||||||
blacklist = true
|
|
||||||
# 将列表留空并开启黑名单模式以允许使用所有存储,此处示例为黑名单模式,用户 114514 可使用所有存储
|
|
||||||
storages = []
|
|
||||||
|
|
||||||
[[users]]
|
|
||||||
id = 123456
|
|
||||||
blacklist = false # 使用白名单模式,此时,用户123456 仅可使用下方列表中的存储
|
|
||||||
# 此时该用户只能使用名为 本机1 的存储
|
|
||||||
storages = ["本机1"]
|
|
||||||
|
|
||||||
# 存储列表
|
# 存储列表
|
||||||
[[storages]]
|
[[storages]]
|
||||||
# 标识名, 需要唯一
|
# 标识名, 需要唯一
|
||||||
name = "本机1"
|
name = "本机1"
|
||||||
# 存储类型, 目前可用: local, alist, webdav, minio
|
# 存储类型, 目前可用: local, alist, webdav, minio, telegram
|
||||||
type = "local"
|
type = "local"
|
||||||
# 启用存储
|
# 启用存储
|
||||||
enable = true
|
enable = true
|
||||||
# 文件保存根路径
|
# 文件保存根路径
|
||||||
base_path = "./downloads"
|
base_path = "./downloads"
|
||||||
|
|
||||||
[[storages]]
|
|
||||||
name = "MyAlist"
|
|
||||||
type = "alist"
|
|
||||||
enable = false #记得启用
|
|
||||||
base_path = '/'
|
|
||||||
url = 'https://alist.com'
|
|
||||||
username = 'admin'
|
|
||||||
password = 'password'
|
|
||||||
# alist token 刷新时间
|
|
||||||
# 86400--1天 604800--7天 1296000--15天 2592000--30天 15552000--180天
|
|
||||||
token_exp = 86400
|
|
||||||
# alist 可直接使用 token 登录, 此时 username, password, token_exp 将被忽略
|
|
||||||
# 请自行在 alist 侧配置合理的 token 过期时间
|
|
||||||
# token = ""
|
|
||||||
|
|
||||||
[[storages]]
|
[[storages]]
|
||||||
name = "MyWebdav"
|
name = "MyWebdav"
|
||||||
type = "webdav"
|
type = "webdav"
|
||||||
@@ -74,31 +38,17 @@ url = 'https://example.com/dav'
|
|||||||
username = 'username'
|
username = 'username'
|
||||||
password = 'password'
|
password = 'password'
|
||||||
|
|
||||||
[[storages]]
|
# 用户列表
|
||||||
name = "MyMinio"
|
[[users]]
|
||||||
type = "minio"
|
# telegram user id
|
||||||
enable = true
|
id = 114514
|
||||||
endpoint = 'play.min.io'
|
# 存储过滤列表, 元素为存储标识名.
|
||||||
use_ssl = true
|
# 将该列表留空并开启黑名单过滤模式以允许使用所有存储,此处示例为黑名单模式,用户 114514 可使用所有存储
|
||||||
access_key_id = 'Q3AM3UQ867SPQQA43P2F'
|
storages = []
|
||||||
secret_access_key = 'zuf+tfteSlswRu7BJ86wekitnifILbZam1KYY3TG'
|
# 使用列表过滤黑名单模式,反之则为白名单,白名单请在列表中指定可用的存储.
|
||||||
bucket_name = 'saveanybot'
|
blacklist = true
|
||||||
base_path = '/path/telegram'
|
|
||||||
|
|
||||||
|
|
||||||
# 其他配置
|
|
||||||
|
|
||||||
# [log]
|
|
||||||
# # 日志等级
|
|
||||||
# level = "DEBUG"
|
|
||||||
|
|
||||||
# [temp]
|
|
||||||
# # 下载文件临时目录, 请不要在此目录下存放任何其他文件
|
|
||||||
# base_path = "cache/"
|
|
||||||
# # 临时文件保存时间, 单位: 秒
|
|
||||||
# cache_ttl = 30
|
|
||||||
|
|
||||||
# [db]
|
|
||||||
# path = "data/data.db" # 数据库文件路径
|
|
||||||
# session = "data/session.db"
|
|
||||||
|
|
||||||
|
[[users]]
|
||||||
|
id = 123456
|
||||||
|
storages = ["本机1"]
|
||||||
|
blacklist = false # 使用白名单模式,此时,用户 123456 仅可使用标识名为 '本地1' 的存储
|
||||||
7
config/cache.go
Normal file
7
config/cache.go
Normal 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
6
config/db.go
Normal 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
22
config/hook.go
Normal 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
5
config/temp.go
Normal 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
20
config/tg.go
Normal 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"`
|
||||||
|
}
|
||||||
@@ -22,53 +22,16 @@ type Config struct {
|
|||||||
Threads int `toml:"threads" mapstructure:"threads" json:"threads"`
|
Threads int `toml:"threads" mapstructure:"threads" json:"threads"`
|
||||||
Stream bool `toml:"stream" mapstructure:"stream" json:"stream"`
|
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"`
|
Temp tempConfig `toml:"temp" mapstructure:"temp"`
|
||||||
Log logConfig `toml:"log" mapstructure:"log"`
|
|
||||||
DB dbConfig `toml:"db" mapstructure:"db"`
|
DB dbConfig `toml:"db" mapstructure:"db"`
|
||||||
Telegram telegramConfig `toml:"telegram" mapstructure:"telegram"`
|
Telegram telegramConfig `toml:"telegram" mapstructure:"telegram"`
|
||||||
Storages []storage.StorageConfig `toml:"-" mapstructure:"-" json:"storages"`
|
Storages []storage.StorageConfig `toml:"-" mapstructure:"-" json:"storages"`
|
||||||
|
Hook hookConfig `toml:"hook" mapstructure:"hook" json:"hook"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type tempConfig struct {
|
var Cfg *Config = &Config{}
|
||||||
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
|
|
||||||
|
|
||||||
func (c Config) GetStorageByName(name string) storage.StorageConfig {
|
func (c Config) GetStorageByName(name string) storage.StorageConfig {
|
||||||
for _, storage := range c.Storages {
|
for _, storage := range c.Storages {
|
||||||
@@ -89,28 +52,36 @@ func Init(ctx context.Context) error {
|
|||||||
replacer := strings.NewReplacer(".", "_")
|
replacer := strings.NewReplacer(".", "_")
|
||||||
viper.SetEnvKeyReplacer(replacer)
|
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)
|
"cache.ttl": 86400,
|
||||||
viper.SetDefault("threads", 4)
|
"cache.num_counters": 1e5,
|
||||||
|
"cache.max_cost": 1e6,
|
||||||
|
|
||||||
viper.SetDefault("telegram.app_id", 1025907)
|
// Telegram
|
||||||
viper.SetDefault("telegram.app_hash", "452b0359b988148995f22ff0f4229750")
|
"telegram.app_id": 1025907,
|
||||||
viper.SetDefault("telegram.timeout", 60)
|
"telegram.app_hash": "452b0359b988148995f22ff0f4229750",
|
||||||
viper.SetDefault("telegram.flood_retry", 5)
|
"telegram.rpc_retry": 5,
|
||||||
viper.SetDefault("telegram.rpc_retry", 5)
|
"telegram.userbot.enable": false,
|
||||||
viper.SetDefault("telegram.userbot.enable", false)
|
"telegram.userbot.session": "data/usersession.db",
|
||||||
viper.SetDefault("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")
|
for key, value := range defaultConfigs {
|
||||||
viper.SetDefault("db.session", "data/session.db")
|
viper.SetDefault(key, value)
|
||||||
viper.SetDefault("db.expire", 86400*5)
|
}
|
||||||
|
|
||||||
if err := viper.SafeWriteConfigAs("config.toml"); err != nil {
|
if err := viper.SafeWriteConfigAs("config.toml"); err != nil {
|
||||||
if _, ok := err.(viper.ConfigFileAlreadyExistsError); !ok {
|
if _, ok := err.(viper.ConfigFileAlreadyExistsError); !ok {
|
||||||
@@ -123,8 +94,6 @@ func Init(ctx context.Context) error {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
Cfg = &Config{}
|
|
||||||
|
|
||||||
if err := viper.Unmarshal(Cfg); err != nil {
|
if err := viper.Unmarshal(Cfg); err != nil {
|
||||||
fmt.Println("Error unmarshalling config file, ", err)
|
fmt.Println("Error unmarshalling config file, ", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -171,7 +140,6 @@ func Init(ctx context.Context) error {
|
|||||||
userStorages[user.ID] = user.Storages
|
userStorages[user.ID] = user.Storages
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ import (
|
|||||||
|
|
||||||
"github.com/charmbracelet/log"
|
"github.com/charmbracelet/log"
|
||||||
"github.com/duke-git/lancet/v2/retry"
|
"github.com/duke-git/lancet/v2/retry"
|
||||||
"github.com/krau/SaveAny-Bot/common/tdler"
|
|
||||||
"github.com/krau/SaveAny-Bot/common/utils/fsutil"
|
"github.com/krau/SaveAny-Bot/common/utils/fsutil"
|
||||||
"github.com/krau/SaveAny-Bot/common/utils/ioutil"
|
"github.com/krau/SaveAny-Bot/common/utils/ioutil"
|
||||||
"github.com/krau/SaveAny-Bot/config"
|
"github.com/krau/SaveAny-Bot/config"
|
||||||
"github.com/krau/SaveAny-Bot/pkg/enums/key"
|
"github.com/krau/SaveAny-Bot/pkg/enums/ctxkey"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||||
"golang.org/x/sync/errgroup"
|
"golang.org/x/sync/errgroup"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -61,10 +61,12 @@ func (t *Task) processElement(ctx context.Context, elem TaskElement) error {
|
|||||||
t.Progress.OnProgress(ctx, t)
|
t.Progress.OnProgress(ctx, t)
|
||||||
})
|
})
|
||||||
errg.Go(func() error {
|
errg.Go(func() error {
|
||||||
|
defer pw.Close()
|
||||||
logger.Info("Starting file download in stream mode")
|
logger.Info("Starting file download in stream mode")
|
||||||
_, err := tdler.NewDownloader(t.client, elem.File).Stream(uploadCtx, wr)
|
_, err := tfile.NewDownloader(elem.File).Stream(uploadCtx, wr)
|
||||||
if closeErr := pw.CloseWithError(err); closeErr != nil {
|
if err != nil {
|
||||||
logger.Errorf("Failed to close pipe writer: %v", closeErr)
|
logger.Errorf("Failed to download file: %v", err)
|
||||||
|
pw.CloseWithError(err)
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
})
|
})
|
||||||
@@ -88,7 +90,7 @@ func (t *Task) processElement(ctx context.Context, elem TaskElement) error {
|
|||||||
t.downloaded.Add(int64(n))
|
t.downloaded.Add(int64(n))
|
||||||
t.Progress.OnProgress(ctx, t)
|
t.Progress.OnProgress(ctx, t)
|
||||||
})
|
})
|
||||||
_, err = tdler.NewDownloader(t.client, elem.File).Parallel(ctx, wrAt)
|
_, err = tfile.NewDownloader(elem.File).Parallel(ctx, wrAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to download file: %w", err)
|
return fmt.Errorf("failed to download file: %w", err)
|
||||||
}
|
}
|
||||||
@@ -104,7 +106,7 @@ func (t *Task) processElement(ctx context.Context, elem TaskElement) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get file stat: %w", err)
|
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 {
|
err = retry.Retry(func() error {
|
||||||
var file *os.File
|
var file *os.File
|
||||||
file, err = os.Open(elem.localPath)
|
file, err = os.Open(elem.localPath)
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
||||||
"github.com/krau/SaveAny-Bot/common/tdler"
|
|
||||||
"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/tfile"
|
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||||
"github.com/krau/SaveAny-Bot/storage"
|
"github.com/krau/SaveAny-Bot/storage"
|
||||||
"github.com/rs/xid"
|
"github.com/rs/xid"
|
||||||
@@ -29,12 +29,15 @@ type Task struct {
|
|||||||
Progress ProgressTracker
|
Progress ProgressTracker
|
||||||
IgnoreErrors bool // if true, errors during processing will be ignored
|
IgnoreErrors bool // if true, errors during processing will be ignored
|
||||||
downloaded atomic.Int64
|
downloaded atomic.Int64
|
||||||
client tdler.Client
|
|
||||||
totalSize int64
|
totalSize int64
|
||||||
processing map[string]TaskElementInfo
|
processing map[string]TaskElementInfo
|
||||||
failed map[string]error // errors for each element
|
failed map[string]error // errors for each element
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *Task) Type() tasktype.TaskType {
|
||||||
|
return tasktype.TaskTypeTgfiles
|
||||||
|
}
|
||||||
|
|
||||||
func NewTaskElement(
|
func NewTaskElement(
|
||||||
stor storage.Storage,
|
stor storage.Storage,
|
||||||
path string,
|
path string,
|
||||||
@@ -68,14 +71,12 @@ func NewBatchTGFileTask(
|
|||||||
id string,
|
id string,
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
files []TaskElement,
|
files []TaskElement,
|
||||||
client tdler.Client,
|
|
||||||
progress ProgressTracker,
|
progress ProgressTracker,
|
||||||
ignoreErrors bool,
|
ignoreErrors bool,
|
||||||
) *Task {
|
) *Task {
|
||||||
task := &Task{
|
task := &Task{
|
||||||
ID: id,
|
ID: id,
|
||||||
Ctx: ctx,
|
Ctx: ctx,
|
||||||
client: client,
|
|
||||||
Elems: files,
|
Elems: files,
|
||||||
Progress: progress,
|
Progress: progress,
|
||||||
downloaded: atomic.Int64{},
|
downloaded: atomic.Int64{},
|
||||||
|
|||||||
28
core/core.go
28
core/core.go
@@ -2,32 +2,54 @@ package core
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
"github.com/charmbracelet/log"
|
"github.com/charmbracelet/log"
|
||||||
"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/queue"
|
"github.com/krau/SaveAny-Bot/pkg/queue"
|
||||||
)
|
)
|
||||||
|
|
||||||
var queueInstance *queue.TaskQueue[Exectable]
|
var queueInstance *queue.TaskQueue[Exectable]
|
||||||
|
|
||||||
type Exectable interface {
|
type Exectable interface {
|
||||||
|
Type() tasktype.TaskType
|
||||||
TaskID() string
|
TaskID() string
|
||||||
Execute(ctx context.Context) error
|
Execute(ctx context.Context) error
|
||||||
}
|
}
|
||||||
|
|
||||||
func worker(ctx context.Context, qe *queue.TaskQueue[Exectable], semaphore chan struct{}) {
|
func worker(ctx context.Context, qe *queue.TaskQueue[Exectable], semaphore chan struct{}) {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
execHooks := config.Cfg.Hook.Exec
|
||||||
for {
|
for {
|
||||||
semaphore <- struct{}{}
|
semaphore <- struct{}{}
|
||||||
qtask, err := qe.Get()
|
qtask, err := qe.Get()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
logger.Error("Failed to get task from queue:", err)
|
||||||
break // queue closed and empty
|
break // queue closed and empty
|
||||||
}
|
}
|
||||||
log.FromContext(ctx).Infof("Processing task: %s", qtask.ID)
|
|
||||||
task := qtask.Data
|
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 {
|
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 {
|
} 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)
|
qe.Done(qtask.ID)
|
||||||
<-semaphore
|
<-semaphore
|
||||||
|
|||||||
23
core/hookutil.go
Normal file
23
core/hookutil.go
Normal 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()
|
||||||
|
}
|
||||||
@@ -8,15 +8,17 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/charmbracelet/log"
|
"github.com/charmbracelet/log"
|
||||||
"github.com/krau/SaveAny-Bot/common/tdler"
|
|
||||||
"github.com/krau/SaveAny-Bot/common/utils/fsutil"
|
"github.com/krau/SaveAny-Bot/common/utils/fsutil"
|
||||||
"github.com/krau/SaveAny-Bot/config"
|
"github.com/krau/SaveAny-Bot/config"
|
||||||
"github.com/krau/SaveAny-Bot/pkg/enums/key"
|
"github.com/krau/SaveAny-Bot/pkg/enums/ctxkey"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||||
)
|
)
|
||||||
|
|
||||||
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()))
|
logger := log.FromContext(ctx).WithPrefix(fmt.Sprintf("file[%s]", t.File.Name()))
|
||||||
t.Progress.OnStart(ctx, t)
|
if t.Progress != nil {
|
||||||
|
t.Progress.OnStart(ctx, t)
|
||||||
|
}
|
||||||
if t.stream {
|
if t.stream {
|
||||||
return executeStream(ctx, t)
|
return executeStream(ctx, t)
|
||||||
}
|
}
|
||||||
@@ -34,9 +36,11 @@ func (t *TGFileTask) Execute(ctx context.Context) error {
|
|||||||
wrAt := newWriterAt(ctx, localFile, t.Progress, t)
|
wrAt := newWriterAt(ctx, localFile, t.Progress, t)
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
t.Progress.OnDone(ctx, t, err)
|
if t.Progress != nil {
|
||||||
|
t.Progress.OnDone(ctx, t, err)
|
||||||
|
}
|
||||||
}()
|
}()
|
||||||
_, err = tdler.NewDownloader(t.client, t.File).Parallel(ctx, wrAt)
|
_, err = tfile.NewDownloader(t.File).Parallel(ctx, wrAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to download file: %w", err)
|
return fmt.Errorf("failed to download file: %w", err)
|
||||||
}
|
}
|
||||||
@@ -52,7 +56,7 @@ func (t *TGFileTask) Execute(ctx context.Context) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get file stat: %w", err)
|
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 {
|
for i := range config.Cfg.Retry + 1 {
|
||||||
if err = vctx.Err(); err != nil {
|
if err = vctx.Err(); err != nil {
|
||||||
return fmt.Errorf("context canceled while saving file: %w", err)
|
return fmt.Errorf("context canceled while saving file: %w", err)
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
|
|
||||||
"github.com/charmbracelet/log"
|
"github.com/charmbracelet/log"
|
||||||
"github.com/krau/SaveAny-Bot/common/tdler"
|
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||||
"golang.org/x/sync/errgroup"
|
"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()))
|
logger := log.FromContext(ctx).WithPrefix(fmt.Sprintf("file[%s]", task.File.Name()))
|
||||||
|
|
||||||
pr, pw := io.Pipe()
|
pr, pw := io.Pipe()
|
||||||
@@ -21,16 +21,20 @@ func executeStream(ctx context.Context, task *TGFileTask) error {
|
|||||||
})
|
})
|
||||||
wr := newWriter(ctx, pw, task.Progress, task)
|
wr := newWriter(ctx, pw, task.Progress, task)
|
||||||
errg.Go(func() error {
|
errg.Go(func() error {
|
||||||
|
defer pw.Close()
|
||||||
logger.Info("Starting file download in stream mode")
|
logger.Info("Starting file download in stream mode")
|
||||||
_, err := tdler.NewDownloader(task.client, task.File).Stream(uploadCtx, wr)
|
_, err := tfile.NewDownloader(task.File).Stream(uploadCtx, wr)
|
||||||
if closeErr := pw.CloseWithError(err); closeErr != nil {
|
if err != nil {
|
||||||
logger.Errorf("Failed to close pipe writer: %v", closeErr)
|
logger.Errorf("Failed to download file: %v", err)
|
||||||
|
pw.CloseWithError(err)
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
})
|
})
|
||||||
var err error
|
var err error
|
||||||
defer func() {
|
defer func() {
|
||||||
task.Progress.OnDone(ctx, task, err)
|
if task.Progress != nil {
|
||||||
|
task.Progress.OnDone(ctx, task, err)
|
||||||
|
}
|
||||||
}()
|
}()
|
||||||
if err = errg.Wait(); err != nil {
|
if err = errg.Wait(); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -8,22 +8,22 @@ type TaskInfo interface {
|
|||||||
StorageName() string
|
StorageName() string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *TGFileTask) TaskID() string {
|
func (t *Task) TaskID() string {
|
||||||
return t.ID
|
return t.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *TGFileTask) FileName() string {
|
func (t *Task) FileName() string {
|
||||||
return t.File.Name()
|
return t.File.Name()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *TGFileTask) FileSize() int64 {
|
func (t *Task) FileSize() int64 {
|
||||||
return t.File.Size()
|
return t.File.Size()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *TGFileTask) StoragePath() string {
|
func (t *Task) StoragePath() string {
|
||||||
return t.Path
|
return t.Path
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *TGFileTask) StorageName() string {
|
func (t *Task) StorageName() string {
|
||||||
return t.Storage.Name()
|
return t.Storage.Name()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,43 +5,44 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/krau/SaveAny-Bot/common/tdler"
|
|
||||||
"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/tfile"
|
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||||
"github.com/krau/SaveAny-Bot/storage"
|
"github.com/krau/SaveAny-Bot/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
type TGFileTask struct {
|
type Task struct {
|
||||||
ID string
|
ID string
|
||||||
Ctx context.Context
|
Ctx context.Context
|
||||||
File tfile.TGFile
|
File tfile.TGFile
|
||||||
Storage storage.Storage
|
Storage storage.Storage
|
||||||
Path string
|
Path string
|
||||||
Progress ProgressTracker
|
Progress ProgressTracker
|
||||||
client tdler.Client
|
|
||||||
stream bool // true if the file should be downloaded in stream mode
|
stream bool // true if the file should be downloaded in stream mode
|
||||||
localPath string
|
localPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *Task) Type() tasktype.TaskType {
|
||||||
|
return tasktype.TaskTypeTgfiles
|
||||||
|
}
|
||||||
|
|
||||||
func NewTGFileTask(
|
func NewTGFileTask(
|
||||||
id string,
|
id string,
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
file tfile.TGFile,
|
file tfile.TGFile,
|
||||||
client tdler.Client,
|
|
||||||
stor storage.Storage,
|
stor storage.Storage,
|
||||||
path string,
|
path string,
|
||||||
progress ProgressTracker,
|
progress ProgressTracker,
|
||||||
) (*TGFileTask, error) {
|
) (*Task, error) {
|
||||||
_, ok := stor.(storage.StorageCannotStream)
|
_, ok := stor.(storage.StorageCannotStream)
|
||||||
if !config.Cfg.Stream || ok {
|
if !config.Cfg.Stream || ok {
|
||||||
cachePath, err := filepath.Abs(filepath.Join(config.Cfg.Temp.BasePath, fmt.Sprintf("%s_%s", id, file.Name())))
|
cachePath, err := filepath.Abs(filepath.Join(config.Cfg.Temp.BasePath, fmt.Sprintf("%s_%s", id, file.Name())))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get absolute path for cache: %w", err)
|
return nil, fmt.Errorf("failed to get absolute path for cache: %w", err)
|
||||||
}
|
}
|
||||||
tftask := &TGFileTask{
|
tftask := &Task{
|
||||||
ID: id,
|
ID: id,
|
||||||
Ctx: ctx,
|
Ctx: ctx,
|
||||||
client: client,
|
|
||||||
File: file,
|
File: file,
|
||||||
Storage: stor,
|
Storage: stor,
|
||||||
Path: path,
|
Path: path,
|
||||||
@@ -50,10 +51,9 @@ func NewTGFileTask(
|
|||||||
}
|
}
|
||||||
return tftask, nil
|
return tftask, nil
|
||||||
}
|
}
|
||||||
tfileTask := &TGFileTask{
|
tfileTask := &Task{
|
||||||
ID: id,
|
ID: id,
|
||||||
Ctx: ctx,
|
Ctx: ctx,
|
||||||
client: client,
|
|
||||||
File: file,
|
File: file,
|
||||||
Storage: stor,
|
Storage: stor,
|
||||||
Path: path,
|
Path: path,
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ func (w *ProgressWriterAt) WriteAt(p []byte, off int64) (int, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
w.progress.OnProgress(w.ctx, w.info, w.downloaded.Add(int64(at)), w.total)
|
if w.progress != nil {
|
||||||
|
w.progress.OnProgress(w.ctx, w.info, w.downloaded.Add(int64(at)), w.total)
|
||||||
|
}
|
||||||
return at, nil
|
return at, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +56,9 @@ func (w *ProgressWriter) Write(p []byte) (int, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
w.progress.OnProgress(w.ctx, w.info, w.downloaded.Add(int64(at)), w.total)
|
if w.progress != nil {
|
||||||
|
w.progress.OnProgress(w.ctx, w.info, w.downloaded.Add(int64(at)), w.total)
|
||||||
|
}
|
||||||
return at, nil
|
return at, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
|
||||||
"github.com/krau/SaveAny-Bot/pkg/telegraph"
|
"github.com/krau/SaveAny-Bot/pkg/telegraph"
|
||||||
"github.com/krau/SaveAny-Bot/storage"
|
"github.com/krau/SaveAny-Bot/storage"
|
||||||
)
|
)
|
||||||
@@ -23,6 +24,10 @@ type Task struct {
|
|||||||
downloaded atomic.Int64
|
downloaded atomic.Int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *Task) Type() tasktype.TaskType {
|
||||||
|
return tasktype.TaskTypeTphpics
|
||||||
|
}
|
||||||
|
|
||||||
func NewTask(
|
func NewTask(
|
||||||
id string,
|
id string,
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
|
|||||||
39
database/chat.go
Normal file
39
database/chat.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
func (user *User) WatchChat(ctx context.Context, chat WatchChat) error {
|
||||||
|
if len(user.WatchChats) == 0 {
|
||||||
|
user.WatchChats = make([]WatchChat, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
user.WatchChats = append(user.WatchChats, chat)
|
||||||
|
return db.WithContext(ctx).Save(user.WatchChats).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (user *User) UnwatchChat(ctx context.Context, chatID int64) error {
|
||||||
|
var watchChat WatchChat
|
||||||
|
err := db.WithContext(ctx).Where("chat_id = ? AND user_id = ?", chatID, user.ID).First(&watchChat).Error
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return db.WithContext(ctx).Unscoped().Delete(&watchChat).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (user *User) WatchingChat(ctx context.Context, chatID int64) (bool, error) {
|
||||||
|
var count int64
|
||||||
|
err := db.WithContext(ctx).Model(&WatchChat{}).Where("chat_id = ? AND user_id = ?", chatID, user.ID).Count(&count).Error
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return count > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetWatchChatsByChatID(ctx context.Context, chatID int64) ([]*WatchChat, error) {
|
||||||
|
var watchChats []*WatchChat
|
||||||
|
err := db.WithContext(ctx).Where("chat_id = ?", chatID).Find(&watchChats).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return watchChats, nil
|
||||||
|
}
|
||||||
@@ -37,7 +37,7 @@ func Init(ctx context.Context) {
|
|||||||
logger.Fatal("Failed to open database: ", err)
|
logger.Fatal("Failed to open database: ", err)
|
||||||
}
|
}
|
||||||
logger.Debug("Database connected")
|
logger.Debug("Database connected")
|
||||||
if err := db.AutoMigrate(&User{}, &Dir{}, &Rule{}); err != nil {
|
if err := db.AutoMigrate(&User{}, &Dir{}, &Rule{}, &WatchChat{}); err != nil {
|
||||||
logger.Fatal("迁移数据库失败, 如果您从旧版本升级, 建议手动删除数据库文件后重试: ", err)
|
logger.Fatal("迁移数据库失败, 如果您从旧版本升级, 建议手动删除数据库文件后重试: ", err)
|
||||||
}
|
}
|
||||||
if err := syncUsers(ctx); err != nil {
|
if err := syncUsers(ctx); err != nil {
|
||||||
|
|||||||
@@ -12,6 +12,14 @@ type User struct {
|
|||||||
Dirs []Dir
|
Dirs []Dir
|
||||||
ApplyRule bool
|
ApplyRule bool
|
||||||
Rules []Rule
|
Rules []Rule
|
||||||
|
WatchChats []WatchChat
|
||||||
|
}
|
||||||
|
|
||||||
|
type WatchChat struct {
|
||||||
|
gorm.Model
|
||||||
|
UserID uint // User's database ID (not chat ID)
|
||||||
|
ChatID int64
|
||||||
|
Filter string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Dir struct {
|
type Dir struct {
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package database
|
package database
|
||||||
|
|
||||||
import "context"
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
func CreateUser(ctx context.Context, chatID int64) error {
|
func CreateUser(ctx context.Context, chatID int64) error {
|
||||||
if _, err := GetUserByChatID(ctx, chatID); err == nil {
|
if _, err := GetUserByChatID(ctx, chatID); err == nil {
|
||||||
@@ -11,19 +15,16 @@ func CreateUser(ctx context.Context, chatID int64) error {
|
|||||||
|
|
||||||
func GetAllUsers(ctx context.Context) ([]User, error) {
|
func GetAllUsers(ctx context.Context) ([]User, error) {
|
||||||
var users []User
|
var users []User
|
||||||
err := db.Preload("Dirs").
|
err := db.WithContext(ctx).
|
||||||
WithContext(ctx).
|
Preload(clause.Associations).
|
||||||
Preload("Rules").
|
|
||||||
Find(&users).Error
|
Find(&users).Error
|
||||||
return users, err
|
return users, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetUserByChatID(ctx context.Context, chatID int64) (*User, error) {
|
func GetUserByChatID(ctx context.Context, chatID int64) (*User, error) {
|
||||||
var user User
|
var user User
|
||||||
err := db.
|
err := db.WithContext(ctx).
|
||||||
Preload("Dirs").
|
Preload(clause.Associations).
|
||||||
WithContext(ctx).
|
|
||||||
Preload("Rules").
|
|
||||||
Where("chat_id = ?", chatID).First(&user).Error
|
Where("chat_id = ?", chatID).First(&user).Error
|
||||||
return &user, err
|
return &user, err
|
||||||
}
|
}
|
||||||
@@ -36,5 +37,16 @@ func UpdateUser(ctx context.Context, user *User) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func DeleteUser(ctx context.Context, user *User) error {
|
func DeleteUser(ctx context.Context, user *User) error {
|
||||||
return db.WithContext(ctx).Unscoped().Select("Dirs", "Rules").Delete(user).Error
|
return db.WithContext(ctx).
|
||||||
|
Unscoped().
|
||||||
|
Select(clause.Associations).
|
||||||
|
Delete(user).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetUserByID(ctx context.Context, id uint) (*User, error) {
|
||||||
|
var user User
|
||||||
|
err := db.WithContext(ctx).
|
||||||
|
Preload(clause.Associations).
|
||||||
|
Where("id = ?", id).First(&user).Error
|
||||||
|
return &user, err
|
||||||
}
|
}
|
||||||
1
docs/.gitignore
vendored
Normal file
1
docs/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
public/
|
||||||
5
docs/archetypes/default.md
Normal file
5
docs/archetypes/default.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
+++
|
||||||
|
date = '{{ .Date }}'
|
||||||
|
draft = true
|
||||||
|
title = '{{ replace .File.ContentBaseName "-" " " | title }}'
|
||||||
|
+++
|
||||||
1
docs/assets/_variables.scss
Normal file
1
docs/assets/_variables.scss
Normal file
@@ -0,0 +1 @@
|
|||||||
|
$font-size-base: 18px;
|
||||||
31
docs/content/en/_index.md
Normal file
31
docs/content/en/_index.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
title: Introduction
|
||||||
|
---
|
||||||
|
|
||||||
|
# Save Any Bot
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|

|
||||||
14
docs/content/en/contribute/_index.md
Normal file
14
docs/content/en/contribute/_index.md
Normal 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.
|
||||||
4
docs/content/en/deployment/_index.md
Normal file
4
docs/content/en/deployment/_index.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
title: "Deployment Guide"
|
||||||
|
weight: 5
|
||||||
|
---
|
||||||
141
docs/content/en/deployment/configuration/_index.md
Normal file
141
docs/content/en/deployment/configuration/_index.md
Normal 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"
|
||||||
|
```
|
||||||
65
docs/content/en/deployment/configuration/storages.md
Normal file
65
docs/content/en/deployment/configuration/storages.md
Normal 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
|
||||||
|
```
|
||||||
145
docs/content/en/deployment/installation.md
Normal file
145
docs/content/en/deployment/installation.md
Normal 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
|
||||||
|
```
|
||||||
18
docs/content/en/help/_index.md
Normal file
18
docs/content/en/help/_index.md
Normal 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.
|
||||||
65
docs/content/en/usage/_index.md
Normal file
65
docs/content/en/usage/_index.md
Normal 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
31
docs/content/zh/_index.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
title: 介绍
|
||||||
|
---
|
||||||
|
|
||||||
|
# Save Any Bot
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
把 Telegram 上的文件转存到多种存储端.
|
||||||
|
|
||||||
|
## 特性
|
||||||
|
|
||||||
|
- 支持文档/视频/图片/贴纸... 甚至还有 Telegraph
|
||||||
|
- 破解禁止保存的文件
|
||||||
|
- 批量下载
|
||||||
|
- 流式传输
|
||||||
|
- 多用户
|
||||||
|
- 基于存储规则的自动整理
|
||||||
|
- 支持多种存储端:
|
||||||
|
- Alist
|
||||||
|
- Minio (S3 兼容)
|
||||||
|
- WebDAV
|
||||||
|
- Telegram (重传回指定聊天)
|
||||||
|
- 本地磁盘
|
||||||
|
|
||||||
|
## [贡献者](https://github.com/krau/SaveAny-Bot/graphs/contributors)
|
||||||
|
|
||||||
|

|
||||||
14
docs/content/zh/contribute/_index.md
Normal file
14
docs/content/zh/contribute/_index.md
Normal 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. 更新文档, 添加配置说明
|
||||||
4
docs/content/zh/deployment/_index.md
Normal file
4
docs/content/zh/deployment/_index.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
title: "部署指南"
|
||||||
|
weight: 5
|
||||||
|
---
|
||||||
174
docs/content/zh/deployment/configuration/_index.md
Normal file
174
docs/content/zh/deployment/configuration/_index.md
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
---
|
||||||
|
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://`
|
||||||
|
- `userbot`: userbot 配置, 可选.
|
||||||
|
- `enable`: 启用 userbot 集成, 需要登录用户账号, 此时请务必使用自己的 api id & hash.
|
||||||
|
- `session`: userbot 会话文件路径, 默认为 `data/usersession.db`.
|
||||||
|
|
||||||
|
{{< hint warning >}}
|
||||||
|
启用 userbot 集成后, bot 可以下载私密频道和群组的文件, 但具有无法避免的账号被封禁的风险.
|
||||||
|
<br />
|
||||||
|
开启 userbot 集成后第一次启动 bot 时需要通过终端交互输入手机号, 2FA 和验证码, 如果你使用 docker 部署, 请进入容器内执行相关操作.
|
||||||
|
{{< /hint >}}
|
||||||
|
|
||||||
|
```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"
|
||||||
|
[telegram.userbot]
|
||||||
|
enable = false
|
||||||
|
session = "data/usersession.db"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 存储端列表
|
||||||
|
|
||||||
|
存储端列表用于定义 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"
|
||||||
|
```
|
||||||
65
docs/content/zh/deployment/configuration/storages.md
Normal file
65
docs/content/zh/deployment/configuration/storages.md
Normal 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 将把文件发送到这个聊天
|
||||||
|
```
|
||||||
145
docs/content/zh/deployment/installation.md
Normal file
145
docs/content/zh/deployment/installation.md
Normal 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
|
||||||
|
```
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
title: "常见问题"
|
||||||
|
weight: 15
|
||||||
|
---
|
||||||
|
|
||||||
# 常见问题
|
# 常见问题
|
||||||
|
|
||||||
## 上传 alist 失败也会显示成功
|
## 上传 alist 失败也会显示成功
|
||||||
@@ -6,11 +11,8 @@
|
|||||||
|
|
||||||
## Bot 提示下载成功但是 alist 未显示
|
## Bot 提示下载成功但是 alist 未显示
|
||||||
|
|
||||||
alist 缓存了目录结构, 参考文档可以调整缓存时间
|
alist 缓存了目录结构, 参考 <a href="https://alist.nn.ci/zh/guide/drivers/common.html#缓存过期" target="_blank">文档</a> 可以调整缓存时间
|
||||||
|
|
||||||
https://alist.nn.ci/zh/guide/drivers/common.html#缓存过期
|
|
||||||
|
|
||||||
## docker部署配置了代理后仍无法连接 telegram (初始化客户端超时)
|
## docker部署配置了代理后仍无法连接 telegram (初始化客户端超时)
|
||||||
|
|
||||||
docker 不能直接访问宿主机网络, 如果你不熟悉其用法, 请将容器设为 host 模式:
|
docker 不能直接访问宿主机网络, 如果你不熟悉其用法, 请将容器设为 host 模式.
|
||||||
|
|
||||||
114
docs/content/zh/usage/_index.md
Normal file
114
docs/content/zh/usage/_index.md
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
|
3. IS-ALBUM
|
||||||
|
|
||||||
|
添加规则的基本语法:
|
||||||
|
|
||||||
|
"规则类型 规则内容 存储名 路径"
|
||||||
|
|
||||||
|
注意空格的使用, 语法正确 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
|
||||||
|
|
||||||
|
同上, 但是是根据消息本身的文本内容正则匹配
|
||||||
|
|
||||||
|
### IS-ALBUM
|
||||||
|
|
||||||
|
匹配相册消息 (media group), 规则内容只能为 `true` 或 `false`.
|
||||||
|
|
||||||
|
规则中的路径若使用 "NEW-FOR-ALBUM" , 则表示为该组消息新建一个文件夹来存储它们. 见: https://github.com/krau/SaveAny-Bot/issues/87
|
||||||
|
|
||||||
|
例如:
|
||||||
|
|
||||||
|
```
|
||||||
|
IS-ALBUM true MyWebdav NEW-FOR-ALBUM
|
||||||
|
```
|
||||||
|
|
||||||
|
这将会把以 media group 形式发送的消息保存到名为 MyWebdav 的存储下, 并为每个相册新建一个文件夹(由第一个文件生成)来存储它们.
|
||||||
|
|
||||||
|
|
||||||
|
## 监听聊天
|
||||||
|
|
||||||
|
{{< hint warning >}}
|
||||||
|
该功能需开启 UserBot 集成.
|
||||||
|
{{< /hint >}}
|
||||||
|
|
||||||
|
监听指定聊天的消息, 并自动保存到默认存储中, 遵从存储规则, 并且可以设置过滤器来只保存匹配的消息.
|
||||||
|
|
||||||
|
监听聊天:
|
||||||
|
|
||||||
|
```
|
||||||
|
/watch <chat_id/username> [filter]
|
||||||
|
```
|
||||||
|
|
||||||
|
取消监听:
|
||||||
|
|
||||||
|
```
|
||||||
|
/unwatch <chat_id/username>
|
||||||
|
```
|
||||||
|
|
||||||
|
过滤器类型:
|
||||||
|
|
||||||
|
### msgre
|
||||||
|
|
||||||
|
正则匹配消息文本, 例如:
|
||||||
|
|
||||||
|
```
|
||||||
|
/watch 12345678 msgre:.*hello.*
|
||||||
|
```
|
||||||
|
|
||||||
|
这将会监听 ID 为 12345678 的聊天, 并且只保存消息文本中包含 "hello" 的消息.
|
||||||
@@ -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` 文件, 添加新的示例配置
|
|
||||||
|
|
||||||
*可能确实有点麻烦了 = =*
|
|
||||||
@@ -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
|
|
||||||
```
|
|
||||||
@@ -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
|
|
||||||
|
|
||||||
同上, 根据消息文本内容正则匹配
|
|
||||||
|
|
||||||
## 复制并发送媒体消息
|
|
||||||
|
|
||||||
将接收到的文件(媒体)消息, 或链接对应的消息原样发送到当前聊天, 点击选择存储按钮中的 "发送到当前聊天" 即可.
|
|
||||||
@@ -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
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
# SaveAnyBot 文档
|
|
||||||
|
|
||||||
SaveAnyBot 是一个可以保存 Telegram 上的文件到云存储的机器人, 就像 PikPak Bot 一样.
|
|
||||||
|
|
||||||
不同的是, SaveAnyBot 提供更灵活的存储端选择, 并实现一些更强大的功能.
|
|
||||||
|
|
||||||
本项目以 AGPL-3.0 协议开源, 请遵守协议使用.
|
|
||||||
5
docs/go.mod
Normal file
5
docs/go.mod
Normal 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
2
docs/go.sum
Normal 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
47
docs/hugo.toml
Normal 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
|
||||||
3
docs/layouts/shortcodes/codeblock.html
Normal file
3
docs/layouts/shortcodes/codeblock.html
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{{- $lang := .Get "lang" | default "text" -}}
|
||||||
|
{{- $content := .Inner | strings.TrimSpace | htmlEscape -}}
|
||||||
|
<pre><code class="language-{{ $lang }}">{{ $content }}</code></pre>
|
||||||
@@ -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
@@ -0,0 +1 @@
|
|||||||
|
{"Target":"book.min.a22f4c7d8c2bdc5e3d6e34ba11cb59ab50ea5772594e71305bfd5a595dc78b7e.css","MediaType":"text/css","Data":{"Integrity":"sha256-oi9MfYwr3F49bjS6EctZq1DqV3JZTnEwW/1aWV3Hi34="}}
|
||||||
0
docs/logo.jpg → docs/static/logo.png
vendored
0
docs/logo.jpg → docs/static/logo.png
vendored
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.1 KiB |
18
entrypoint.sh
Normal file
18
entrypoint.sh
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
if [ -n "$CONFIG_URL" ]; then
|
||||||
|
echo "[INFO] Downloading config from $CONFIG_URL"
|
||||||
|
if curl -sSLo /app/config.toml "$CONFIG_URL"; then
|
||||||
|
echo "[INFO] Configuration downloaded successfully"
|
||||||
|
else
|
||||||
|
echo "[ERROR] Failed to download config from $CONFIG_URL"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f /app/config.toml ]; then
|
||||||
|
echo "[ERROR] Missing config.toml: 请通过挂载或 CONFIG_URL 提供配置文件"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec /app/saveany-bot
|
||||||
62
go.mod
62
go.mod
@@ -12,29 +12,29 @@ require (
|
|||||||
github.com/gabriel-vasile/mimetype v1.4.9
|
github.com/gabriel-vasile/mimetype v1.4.9
|
||||||
github.com/go-faster/errors v0.7.1
|
github.com/go-faster/errors v0.7.1
|
||||||
github.com/gotd/contrib v0.21.0
|
github.com/gotd/contrib v0.21.0
|
||||||
github.com/gotd/td v0.125.0
|
github.com/gotd/td v0.129.0
|
||||||
github.com/minio/minio-go/v7 v7.0.92
|
github.com/minio/minio-go/v7 v7.0.95
|
||||||
github.com/rhysd/go-github-selfupdate v1.2.3
|
github.com/rhysd/go-github-selfupdate v1.2.3
|
||||||
github.com/rs/xid v1.6.0
|
github.com/rs/xid v1.6.0
|
||||||
github.com/spf13/cobra v1.9.1
|
github.com/spf13/cobra v1.9.1
|
||||||
github.com/spf13/viper v1.20.1
|
github.com/spf13/viper v1.20.1
|
||||||
golang.org/x/net v0.41.0
|
golang.org/x/net v0.42.0
|
||||||
golang.org/x/time v0.12.0
|
golang.org/x/time v0.12.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/AnimeKaizoku/cacher v1.0.2 // indirect
|
github.com/AnimeKaizoku/cacher v1.0.3 // indirect
|
||||||
github.com/atotto/clipboard v0.1.4 // indirect
|
github.com/atotto/clipboard v0.1.4 // indirect
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
github.com/catppuccin/go v0.3.0 // indirect
|
github.com/catppuccin/go v0.3.0 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/charmbracelet/bubbles v0.21.0 // indirect
|
github.com/charmbracelet/bubbles v0.21.0 // indirect
|
||||||
github.com/charmbracelet/bubbletea v1.3.4 // indirect
|
github.com/charmbracelet/bubbletea v1.3.6 // indirect
|
||||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
github.com/charmbracelet/colorprofile v0.3.1 // indirect
|
||||||
github.com/charmbracelet/lipgloss v1.1.0 // indirect
|
github.com/charmbracelet/lipgloss v1.1.0 // indirect
|
||||||
github.com/charmbracelet/x/ansi v0.8.0 // indirect
|
github.com/charmbracelet/x/ansi v0.9.3 // indirect
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
|
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
|
||||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
|
github.com/charmbracelet/x/exp/strings v0.0.0-20250725211024-d60e1b0112b2 // indirect
|
||||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||||
github.com/coder/websocket v1.8.13 // indirect
|
github.com/coder/websocket v1.8.13 // indirect
|
||||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||||
@@ -47,7 +47,7 @@ require (
|
|||||||
github.com/go-faster/yaml v0.4.6 // indirect
|
github.com/go-faster/yaml v0.4.6 // indirect
|
||||||
github.com/go-ini/ini v1.67.0 // indirect
|
github.com/go-ini/ini v1.67.0 // indirect
|
||||||
github.com/go-logfmt/logfmt v0.6.0 // indirect
|
github.com/go-logfmt/logfmt v0.6.0 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
|
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.5 // indirect
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
github.com/google/go-github/v30 v30.1.0 // indirect
|
github.com/google/go-github/v30 v30.1.0 // indirect
|
||||||
github.com/google/go-querystring v1.1.0 // indirect
|
github.com/google/go-querystring v1.1.0 // indirect
|
||||||
@@ -57,13 +57,13 @@ require (
|
|||||||
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf // indirect
|
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||||
github.com/minio/crc64nvme v1.0.2 // indirect
|
github.com/minio/crc64nvme v1.1.0 // indirect
|
||||||
github.com/minio/md5-simd v1.1.2 // indirect
|
github.com/minio/md5-simd v1.1.2 // indirect
|
||||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||||
@@ -73,7 +73,7 @@ require (
|
|||||||
github.com/ncruces/julianday v1.0.0 // indirect
|
github.com/ncruces/julianday v1.0.0 // indirect
|
||||||
github.com/ogen-go/ogen v1.14.0 // indirect
|
github.com/ogen-go/ogen v1.14.0 // indirect
|
||||||
github.com/onsi/gomega v1.36.2 // indirect
|
github.com/onsi/gomega v1.36.2 // indirect
|
||||||
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect
|
github.com/philhofer/fwd v1.2.0 // indirect
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
@@ -82,48 +82,50 @@ require (
|
|||||||
github.com/tetratelabs/wazero v1.9.0 // indirect
|
github.com/tetratelabs/wazero v1.9.0 // indirect
|
||||||
github.com/tinylib/msgp v1.3.0 // indirect
|
github.com/tinylib/msgp v1.3.0 // indirect
|
||||||
github.com/ulikunitz/xz v0.5.12 // indirect
|
github.com/ulikunitz/xz v0.5.12 // indirect
|
||||||
go.opentelemetry.io/otel v1.36.0 // indirect
|
go.opentelemetry.io/otel v1.37.0 // indirect
|
||||||
go.opentelemetry.io/otel/metric v1.36.0 // indirect
|
go.opentelemetry.io/otel/metric v1.37.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.36.0 // indirect
|
go.opentelemetry.io/otel/trace v1.37.0 // indirect
|
||||||
go.uber.org/atomic v1.11.0 // indirect
|
go.uber.org/atomic v1.11.0 // indirect
|
||||||
go.uber.org/zap v1.27.0 // indirect
|
go.uber.org/zap v1.27.0 // indirect
|
||||||
golang.org/x/crypto v0.39.0 // indirect
|
golang.org/x/crypto v0.40.0 // indirect
|
||||||
golang.org/x/mod v0.25.0 // indirect
|
golang.org/x/mod v0.26.0 // indirect
|
||||||
golang.org/x/oauth2 v0.30.0 // indirect
|
golang.org/x/oauth2 v0.30.0 // indirect
|
||||||
golang.org/x/tools v0.34.0 // indirect
|
golang.org/x/tools v0.35.0 // indirect
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
modernc.org/libc v1.65.10 // indirect
|
modernc.org/libc v1.66.6 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
modernc.org/sqlite v1.38.0 // indirect
|
modernc.org/sqlite v1.38.2 // indirect
|
||||||
rsc.io/qr v0.2.0 // indirect
|
rsc.io/qr v0.2.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/dgraph-io/ristretto/v2 v2.2.0
|
github.com/dgraph-io/ristretto/v2 v2.2.0
|
||||||
github.com/duke-git/lancet/v2 v2.3.6
|
github.com/duke-git/lancet/v2 v2.3.7
|
||||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
github.com/glebarez/sqlite v1.11.0 // indirect
|
github.com/glebarez/sqlite v1.11.0 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/klauspost/compress v1.18.0 // indirect
|
github.com/klauspost/compress v1.18.0 // indirect
|
||||||
github.com/mitchellh/mapstructure v1.5.0
|
github.com/mitchellh/mapstructure v1.5.0
|
||||||
github.com/ncruces/go-sqlite3 v0.26.1
|
github.com/ncruces/go-sqlite3 v0.27.1
|
||||||
github.com/ncruces/go-sqlite3/gormlite v0.24.0
|
github.com/ncruces/go-sqlite3/gormlite v0.24.0
|
||||||
github.com/nicksnyder/go-i18n/v2 v2.6.0
|
github.com/nicksnyder/go-i18n/v2 v2.6.0
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4
|
github.com/pelletier/go-toml/v2 v2.2.4
|
||||||
github.com/sagikazarmark/locafero v0.9.0 // indirect
|
github.com/sagikazarmark/locafero v0.10.0 // indirect
|
||||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||||
github.com/spf13/afero v1.14.0 // indirect
|
github.com/spf13/afero v1.14.0 // indirect
|
||||||
github.com/spf13/cast v1.9.2 // indirect
|
github.com/spf13/cast v1.9.2 // indirect
|
||||||
github.com/spf13/pflag v1.0.6 // indirect
|
github.com/spf13/pflag v1.0.7 // indirect
|
||||||
github.com/subosito/gotenv v1.6.0 // indirect
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
go.uber.org/multierr v1.11.0
|
go.uber.org/multierr v1.11.0
|
||||||
golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476 // indirect
|
golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 // indirect
|
||||||
golang.org/x/sync v0.15.0
|
golang.org/x/sync v0.16.0
|
||||||
golang.org/x/sys v0.33.0 // indirect
|
golang.org/x/sys v0.34.0 // indirect
|
||||||
golang.org/x/text v0.26.0
|
golang.org/x/text v0.27.0
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
gorm.io/gorm v1.30.0
|
gorm.io/gorm v1.30.1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
replace github.com/celestix/gotgproto v1.0.0-beta21 => github.com/krau/gotgproto v0.0.0-20250730080659-caaadb4b1f35
|
||||||
|
|||||||
137
go.sum
137
go.sum
@@ -1,5 +1,5 @@
|
|||||||
github.com/AnimeKaizoku/cacher v1.0.2 h1:7Bf5qRylWb7q2Evib0OXlhG37/t7BP2HK/7IyPvSmGQ=
|
github.com/AnimeKaizoku/cacher v1.0.3 h1:foNAmLfY/DXfA4yEy4uP6WK2Ni7JC+s3QhZv72Dn6zs=
|
||||||
github.com/AnimeKaizoku/cacher v1.0.2/go.mod h1:jw0de/b0K6W7Y3T9rHCMGVKUf6oG7hENNcssxYcZTCc=
|
github.com/AnimeKaizoku/cacher v1.0.3/go.mod h1:jw0de/b0K6W7Y3T9rHCMGVKUf6oG7hENNcssxYcZTCc=
|
||||||
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
||||||
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||||
@@ -14,26 +14,24 @@ github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdn
|
|||||||
github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
|
github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
|
||||||
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
|
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
|
||||||
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
||||||
github.com/celestix/gotgproto v1.0.0-beta21 h1:VUuAC/Kj5Sdu/WZan3ZUb0GFNAavFxMYxmHAhCBX0J8=
|
|
||||||
github.com/celestix/gotgproto v1.0.0-beta21/go.mod h1:viDkHe9rBegJoEE/jNuFfbBM0XZ3pSx/ugjaNaVnbvU=
|
|
||||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
|
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
|
||||||
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
|
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
|
||||||
github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI=
|
github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU=
|
||||||
github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo=
|
github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc=
|
||||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
|
||||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0=
|
||||||
github.com/charmbracelet/huh v0.7.0 h1:W8S1uyGETgj9Tuda3/JdVkc3x7DBLZYPZc4c+/rnRdc=
|
github.com/charmbracelet/huh v0.7.0 h1:W8S1uyGETgj9Tuda3/JdVkc3x7DBLZYPZc4c+/rnRdc=
|
||||||
github.com/charmbracelet/huh v0.7.0/go.mod h1:UGC3DZHlgOKHvHC07a5vHag41zzhpPFj34U92sOmyuk=
|
github.com/charmbracelet/huh v0.7.0/go.mod h1:UGC3DZHlgOKHvHC07a5vHag41zzhpPFj34U92sOmyuk=
|
||||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||||
github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig=
|
github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig=
|
||||||
github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw=
|
github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw=
|
||||||
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
|
github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
|
||||||
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
|
github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
|
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||||
github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
|
github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
|
||||||
@@ -42,8 +40,8 @@ github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9
|
|||||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
|
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
|
||||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
||||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
|
github.com/charmbracelet/x/exp/strings v0.0.0-20250725211024-d60e1b0112b2 h1:mI6RFtm+NvDgzRhAL1GEFeOqaJkG+9gBvEnk55uJHKc=
|
||||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
|
github.com/charmbracelet/x/exp/strings v0.0.0-20250725211024-d60e1b0112b2/go.mod h1:Rgw3/F+xlcUc5XygUtimVSxAqCOsqyvJjqF5UHRvc5k=
|
||||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||||
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||||
@@ -63,8 +61,8 @@ github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa5
|
|||||||
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
|
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
|
||||||
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
github.com/duke-git/lancet/v2 v2.3.6 h1:NKxSSh+dlgp37funvxLCf3xLBeUYa7VW1thYQP6j3Y8=
|
github.com/duke-git/lancet/v2 v2.3.7 h1:nnNBA9KyoqwbPm4nFmEFVIbXeAmpqf6IDCH45+HHHNs=
|
||||||
github.com/duke-git/lancet/v2 v2.3.6/go.mod h1:zGa2R4xswg6EG9I6WnyubDbFO/+A/RROxIbXcwryTsc=
|
github.com/duke-git/lancet/v2 v2.3.7/go.mod h1:zGa2R4xswg6EG9I6WnyubDbFO/+A/RROxIbXcwryTsc=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||||
@@ -97,12 +95,12 @@ github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
|||||||
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||||
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
|
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
|
||||||
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
|
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
|
||||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
|
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||||
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
@@ -125,8 +123,8 @@ github.com/gotd/ige v0.2.2 h1:XQ9dJZwBfDnOGSTxKXBGP4gMud3Qku2ekScRjDWWfEk=
|
|||||||
github.com/gotd/ige v0.2.2/go.mod h1:tuCRb+Y5Y3eNTo3ypIfNpQ4MFjrnONiL2jN2AKZXmb0=
|
github.com/gotd/ige v0.2.2/go.mod h1:tuCRb+Y5Y3eNTo3ypIfNpQ4MFjrnONiL2jN2AKZXmb0=
|
||||||
github.com/gotd/neo v0.1.5 h1:oj0iQfMbGClP8xI59x7fE/uHoTJD7NZH9oV1WNuPukQ=
|
github.com/gotd/neo v0.1.5 h1:oj0iQfMbGClP8xI59x7fE/uHoTJD7NZH9oV1WNuPukQ=
|
||||||
github.com/gotd/neo v0.1.5/go.mod h1:9A2a4bn9zL6FADufBdt7tZt+WMhvZoc5gWXihOPoiBQ=
|
github.com/gotd/neo v0.1.5/go.mod h1:9A2a4bn9zL6FADufBdt7tZt+WMhvZoc5gWXihOPoiBQ=
|
||||||
github.com/gotd/td v0.125.0 h1:XGygdCC37887z4MK39tq6lgq82XvkDtdF7WaLptSS3g=
|
github.com/gotd/td v0.129.0 h1:8arlrzBK6qXjMCz1ltBVMCN/Nrc0negTq9mmIQnHyxA=
|
||||||
github.com/gotd/td v0.125.0/go.mod h1:7BKKxCD1m3XXsryHXt5OzoufvhK5gRW5cgehYyUw8o0=
|
github.com/gotd/td v0.129.0/go.mod h1:t9A85Tp/ujnYZwAgBM+hCoVAEagciAZxLBhoDsP7Yno=
|
||||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||||
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf h1:WfD7VjIE6z8dIvMsI4/s+1qr5EL+zoIGev1BQj1eoJ8=
|
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf h1:WfD7VjIE6z8dIvMsI4/s+1qr5EL+zoIGev1BQj1eoJ8=
|
||||||
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf/go.mod h1:hyb9oH7vZsitZCiBt0ZvifOrB+qc8PS5IiilCIb87rg=
|
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf/go.mod h1:hyb9oH7vZsitZCiBt0ZvifOrB+qc8PS5IiilCIb87rg=
|
||||||
@@ -139,8 +137,8 @@ github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/
|
|||||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
@@ -149,6 +147,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
|||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/krau/gotgproto v0.0.0-20250730080659-caaadb4b1f35 h1:CVzeQNKRgkXm8DQX5bszxI73RcOVGTOFbMynhgjA2lQ=
|
||||||
|
github.com/krau/gotgproto v0.0.0-20250730080659-caaadb4b1f35/go.mod h1:xjZlGA8ABRKkfGMmkHKyz520hK6pMfyE8yxpSTqohME=
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
@@ -159,12 +159,12 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J
|
|||||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
github.com/minio/crc64nvme v1.0.2 h1:6uO1UxGAD+kwqWWp7mBFsi5gAse66C4NXO8cmcVculg=
|
github.com/minio/crc64nvme v1.1.0 h1:e/tAguZ+4cw32D+IO/8GSf5UVr9y+3eJcxZI2WOO/7Q=
|
||||||
github.com/minio/crc64nvme v1.0.2/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
|
github.com/minio/crc64nvme v1.1.0/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
|
||||||
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||||
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||||
github.com/minio/minio-go/v7 v7.0.92 h1:jpBFWyRS3p8P/9tsRc+NuvqoFi7qAmTCFPoRFmobbVw=
|
github.com/minio/minio-go/v7 v7.0.95 h1:ywOUPg+PebTMTzn9VDsoFJy32ZuARN9zhB+K3IYEvYU=
|
||||||
github.com/minio/minio-go/v7 v7.0.92/go.mod h1:vTIc8DNcnAZIhyFsk8EB90AbPjj3j68aWIEQCiPj7d0=
|
github.com/minio/minio-go/v7 v7.0.95/go.mod h1:wOOX3uxS334vImCNRVyIDdXX9OsXDm89ToynKgqUKlo=
|
||||||
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
||||||
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
|
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
|
||||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||||
@@ -175,8 +175,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
|
|||||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||||
github.com/ncruces/go-sqlite3 v0.26.1 h1:lBXmbmucH1Bsj57NUQR6T84UoMN7jnNImhF+ibEITJU=
|
github.com/ncruces/go-sqlite3 v0.27.1 h1:suqlM7xhSyDVMV9RgX99MCPqt9mB6YOCzHZuiI36K34=
|
||||||
github.com/ncruces/go-sqlite3 v0.26.1/go.mod h1:XFTPtFIo1DmGCh+XVP8KGn9b/o2f+z0WZuT09x2N6eo=
|
github.com/ncruces/go-sqlite3 v0.27.1/go.mod h1:gpF5s+92aw2MbDmZK0ZOnCdFlpe11BH20CTspVqri0c=
|
||||||
github.com/ncruces/go-sqlite3/gormlite v0.24.0 h1:81sHeq3CCdhjoqAB650n5wEdRlLO9VBvosArskcN3+c=
|
github.com/ncruces/go-sqlite3/gormlite v0.24.0 h1:81sHeq3CCdhjoqAB650n5wEdRlLO9VBvosArskcN3+c=
|
||||||
github.com/ncruces/go-sqlite3/gormlite v0.24.0/go.mod h1:vXfVWdBfg7qOgqQqHpzUWl9LLswD0h+8mK4oouaV2oc=
|
github.com/ncruces/go-sqlite3/gormlite v0.24.0/go.mod h1:vXfVWdBfg7qOgqQqHpzUWl9LLswD0h+8mK4oouaV2oc=
|
||||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
@@ -193,8 +193,8 @@ github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8=
|
|||||||
github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY=
|
github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY=
|
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||||
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
@@ -211,20 +211,21 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7
|
|||||||
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/sagikazarmark/locafero v0.9.0 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFTqyMTC9k=
|
github.com/sagikazarmark/locafero v0.10.0 h1:FM8Cv6j2KqIhM2ZK7HZjm4mpj9NBktLgowT1aN9q5Cc=
|
||||||
github.com/sagikazarmark/locafero v0.9.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk=
|
github.com/sagikazarmark/locafero v0.10.0/go.mod h1:Ieo3EUsjifvQu4NZwV5sPd4dwvu0OCgEQV7vjc9yDjw=
|
||||||
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
|
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
|
||||||
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||||
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
||||||
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
||||||
github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
|
github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
|
||||||
github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
|
github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
|
||||||
github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
|
github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
|
||||||
github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||||
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
||||||
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
||||||
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
|
||||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M=
|
||||||
|
github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
|
github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
|
||||||
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
|
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
@@ -244,12 +245,12 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
|
|||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||||
go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
|
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||||
go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E=
|
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||||
go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE=
|
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||||
go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs=
|
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||||
go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=
|
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||||
go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
|
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
@@ -260,43 +261,43 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
|||||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
||||||
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
||||||
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
||||||
golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476 h1:bsqhLWFR6G6xiQcb+JoGqdKdRU6WzPWmK8E0jxTjzo4=
|
golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 h1:R9PFI6EUdfVKgwKjZef7QIwGcBKu86OEFpJ9nUEP2l4=
|
||||||
golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
golang.org/x/exp v0.0.0-20250718183923-645b1fa84792/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc=
|
||||||
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg=
|
||||||
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=
|
||||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
||||||
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
||||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||||
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
||||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
||||||
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
||||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0=
|
||||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||||
google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
@@ -311,18 +312,20 @@ gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
|||||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=
|
gorm.io/gorm v1.30.1 h1:lSHg33jJTBxs2mgJRfRZeLDG+WZaHYCk3Wtfl6Ngzo4=
|
||||||
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
|
gorm.io/gorm v1.30.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
|
||||||
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
|
modernc.org/cc/v4 v4.26.3 h1:yEN8dzrkRFnn4PUUKXLYIqVf2PJYAEjMTFjO3BDGc3I=
|
||||||
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
modernc.org/cc/v4 v4.26.3/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
||||||
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
|
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
|
||||||
modernc.org/fileutil v1.3.3 h1:3qaU+7f7xxTUmvU1pJTZiDLAIoJVdUSSauJNHg9yXoA=
|
modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM=
|
||||||
modernc.org/fileutil v1.3.3/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
modernc.org/libc v1.65.10 h1:ZwEk8+jhW7qBjHIT+wd0d9VjitRyQef9BnzlzGwMODc=
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
modernc.org/libc v1.65.10/go.mod h1:StFvYpx7i/mXtBAfVOjaU0PWZOvIRoZSgXhrwXzr8Po=
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
modernc.org/libc v1.66.6 h1:RyQpwAhM/19nXD8y3iejM/AjmKwY2TjxZTlUWTsWw2U=
|
||||||
|
modernc.org/libc v1.66.6/go.mod h1:j8z0EYAuumoMQ3+cWXtmw6m+LYn3qm8dcZDFtFTSq+M=
|
||||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
@@ -331,8 +334,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
|||||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
modernc.org/sqlite v1.38.0 h1:+4OrfPQ8pxHKuWG4md1JpR/EYAh3Md7TdejuuzE7EUI=
|
modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
|
||||||
modernc.org/sqlite v1.38.0/go.mod h1:1Bj+yES4SVvBZ4cBOpVZ6QgesMCKpJZDq0nxYzOpmNE=
|
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
|
||||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package consts
|
package consts
|
||||||
|
|
||||||
const (
|
const (
|
||||||
RuleStorNameChosen = "CHOSEN"
|
RuleStorNameChosen = "CHOSEN"
|
||||||
|
RuleDirPathNewForAlbum = "NEW-FOR-ALBUM" // create a new directory for album files
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package tglimit
|
package tglimit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gotd/td/telegram/uploader"
|
||||||
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
MaxPartSize = 1024 * 1024
|
MaxPartSize = 1024 * 1024
|
||||||
MaxUploadPartSize = 512 * 1024
|
MaxUploadPartSize = uploader.MaximumPartSize
|
||||||
)
|
)
|
||||||
|
|||||||
5
pkg/enums/ctxkey/context_key.go
Normal file
5
pkg/enums/ctxkey/context_key.go
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
package ctxkey
|
||||||
|
|
||||||
|
//go:generate go-enum --values --names --flag --nocase --noprefix
|
||||||
|
// ENUM(content-length)
|
||||||
|
type ContextKey string
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
// Build Date: 2025-03-18T23:42:14Z
|
// Build Date: 2025-03-18T23:42:14Z
|
||||||
// Built By: goreleaser
|
// Built By: goreleaser
|
||||||
|
|
||||||
package key
|
package ctxkey
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -12,14 +12,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// ContextKeyContentLength is a ContextKey of type content-length.
|
// ContentLength is a ContextKey of type content-length.
|
||||||
ContextKeyContentLength ContextKey = "content-length"
|
ContentLength ContextKey = "content-length"
|
||||||
)
|
)
|
||||||
|
|
||||||
var ErrInvalidContextKey = fmt.Errorf("not a valid ContextKey, try [%s]", strings.Join(_ContextKeyNames, ", "))
|
var ErrInvalidContextKey = fmt.Errorf("not a valid ContextKey, try [%s]", strings.Join(_ContextKeyNames, ", "))
|
||||||
|
|
||||||
var _ContextKeyNames = []string{
|
var _ContextKeyNames = []string{
|
||||||
string(ContextKeyContentLength),
|
string(ContentLength),
|
||||||
}
|
}
|
||||||
|
|
||||||
// ContextKeyNames returns a list of possible string values of ContextKey.
|
// 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
|
// ContextKeyValues returns a list of the values for ContextKey
|
||||||
func ContextKeyValues() []ContextKey {
|
func ContextKeyValues() []ContextKey {
|
||||||
return []ContextKey{
|
return []ContextKey{
|
||||||
ContextKeyContentLength,
|
ContentLength,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ func (x ContextKey) IsValid() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var _ContextKeyValue = map[string]ContextKey{
|
var _ContextKeyValue = map[string]ContextKey{
|
||||||
"content-length": ContextKeyContentLength,
|
"content-length": ContentLength,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseContextKey attempts to convert a string to a ContextKey.
|
// ParseContextKey attempts to convert a string to a ContextKey.
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
package key
|
|
||||||
|
|
||||||
//go:generate go-enum --values --names --flag --nocase
|
|
||||||
// ENUM(content-length)
|
|
||||||
type ContextKey string
|
|
||||||
@@ -5,6 +5,7 @@ type RuleType string
|
|||||||
const (
|
const (
|
||||||
FileNameRegex RuleType = "FILENAME-REGEX"
|
FileNameRegex RuleType = "FILENAME-REGEX"
|
||||||
MessageRegex RuleType = "MESSAGE-REGEX"
|
MessageRegex RuleType = "MESSAGE-REGEX"
|
||||||
|
IsAlbum RuleType = "IS-ALBUM"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (r RuleType) String() string {
|
func (r RuleType) String() string {
|
||||||
@@ -12,5 +13,5 @@ func (r RuleType) String() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Values() []RuleType {
|
func Values() []RuleType {
|
||||||
return []RuleType{FileNameRegex, MessageRegex}
|
return []RuleType{FileNameRegex, MessageRegex, IsAlbum}
|
||||||
}
|
}
|
||||||
|
|||||||
38
pkg/rule/is_album.go
Normal file
38
pkg/rule/is_album.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package rule
|
||||||
|
|
||||||
|
import (
|
||||||
|
ruleenum "github.com/krau/SaveAny-Bot/pkg/enums/rule"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ RuleClass[bool] = (*RuleMediaType)(nil)
|
||||||
|
|
||||||
|
type RuleMediaType struct {
|
||||||
|
storInfo
|
||||||
|
matchAlbum bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r RuleMediaType) Type() ruleenum.RuleType {
|
||||||
|
return ruleenum.IsAlbum
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r RuleMediaType) Match(input bool) (bool, error) {
|
||||||
|
return r.matchAlbum == input, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r RuleMediaType) StorageName() string {
|
||||||
|
return r.storName
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r RuleMediaType) StoragePath() string {
|
||||||
|
return r.storPath
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRuleMediaType(storName, storPath string, matchAlbum bool) (*RuleMediaType, error) {
|
||||||
|
return &RuleMediaType{
|
||||||
|
storInfo: storInfo{
|
||||||
|
storName: storName,
|
||||||
|
storPath: storPath,
|
||||||
|
},
|
||||||
|
matchAlbum: matchAlbum,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
13
pkg/tfile/dler.go
Normal file
13
pkg/tfile/dler.go
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
package tfile
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gotd/td/telegram/downloader"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/dlutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/config"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/consts/tglimit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewDownloader(file TGFile) *downloader.Builder {
|
||||||
|
return downloader.NewDownloader().WithPartSize(tglimit.MaxPartSize).
|
||||||
|
Download(file.Dler(), file.Location()).WithThreads(dlutil.BestThreads(file.Size(), config.Cfg.Threads))
|
||||||
|
}
|
||||||
@@ -3,13 +3,15 @@ package tfile
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
|
||||||
|
|
||||||
|
"github.com/celestix/gotgproto/functions"
|
||||||
|
"github.com/gotd/td/telegram/downloader"
|
||||||
"github.com/gotd/td/tg"
|
"github.com/gotd/td/tg"
|
||||||
)
|
)
|
||||||
|
|
||||||
type TGFile interface {
|
type TGFile interface {
|
||||||
Location() tg.InputFileLocationClass
|
Location() tg.InputFileLocationClass
|
||||||
|
Dler() downloader.Client // witch client to use for downloading
|
||||||
Size() int64
|
Size() int64
|
||||||
Name() string
|
Name() string
|
||||||
}
|
}
|
||||||
@@ -24,6 +26,7 @@ type tgFile struct {
|
|||||||
size int64
|
size int64
|
||||||
name string
|
name string
|
||||||
message *tg.Message
|
message *tg.Message
|
||||||
|
dler downloader.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *tgFile) Location() tg.InputFileLocationClass {
|
func (f *tgFile) Location() tg.InputFileLocationClass {
|
||||||
@@ -42,11 +45,20 @@ func (f *tgFile) Message() *tg.Message {
|
|||||||
return f.message
|
return f.message
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTGFile(location tg.InputFileLocationClass, size int64, name string,
|
func (f *tgFile) Dler() downloader.Client {
|
||||||
|
return f.dler
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTGFile(
|
||||||
|
location tg.InputFileLocationClass,
|
||||||
|
dler downloader.Client,
|
||||||
|
size int64,
|
||||||
|
name string,
|
||||||
opts ...TGFileOptions,
|
opts ...TGFileOptions,
|
||||||
) TGFile {
|
) TGFile {
|
||||||
f := &tgFile{
|
f := &tgFile{
|
||||||
location: location,
|
location: location,
|
||||||
|
dler: dler,
|
||||||
size: size,
|
size: size,
|
||||||
name: name,
|
name: name,
|
||||||
}
|
}
|
||||||
@@ -56,7 +68,7 @@ func NewTGFile(location tg.InputFileLocationClass, size int64, name string,
|
|||||||
return f
|
return f
|
||||||
}
|
}
|
||||||
|
|
||||||
func FromMedia(media tg.MessageMediaClass, opts ...TGFileOptions) (TGFile, error) {
|
func FromMedia(media tg.MessageMediaClass, client downloader.Client, opts ...TGFileOptions) (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()
|
||||||
@@ -70,14 +82,13 @@ func FromMedia(media tg.MessageMediaClass, opts ...TGFileOptions) (TGFile, error
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
file := &tgFile{
|
file := NewTGFile(
|
||||||
location: document.AsInputDocumentFileLocation(),
|
document.AsInputDocumentFileLocation(),
|
||||||
size: document.Size,
|
client,
|
||||||
name: fileName,
|
document.Size,
|
||||||
}
|
fileName,
|
||||||
for _, opt := range opts {
|
opts...,
|
||||||
opt(file)
|
)
|
||||||
}
|
|
||||||
return file, nil
|
return file, nil
|
||||||
case *tg.MessageMediaPhoto:
|
case *tg.MessageMediaPhoto:
|
||||||
photo, ok := m.Photo.AsNotEmpty()
|
photo, ok := m.Photo.AsNotEmpty()
|
||||||
@@ -98,27 +109,30 @@ func FromMedia(media tg.MessageMediaClass, opts ...TGFileOptions) (TGFile, error
|
|||||||
location.AccessHash = photo.GetAccessHash()
|
location.AccessHash = photo.GetAccessHash()
|
||||||
location.FileReference = photo.GetFileReference()
|
location.FileReference = photo.GetFileReference()
|
||||||
location.ThumbSize = size.GetType()
|
location.ThumbSize = size.GetType()
|
||||||
fileName := fmt.Sprintf("photo_%s_%d.jpg", time.Now().Format("2006-01-02_15-04-05"), photo.GetID())
|
fileName, err := functions.GetMediaFileName(m)
|
||||||
file := &tgFile{
|
if err != nil {
|
||||||
location: location,
|
fileName = fmt.Sprintf("photo_%d.png", photo.GetID())
|
||||||
size: 0,
|
|
||||||
name: fileName,
|
|
||||||
}
|
|
||||||
for _, opt := range opts {
|
|
||||||
opt(file)
|
|
||||||
}
|
}
|
||||||
|
file := NewTGFile(
|
||||||
|
location,
|
||||||
|
client,
|
||||||
|
0, // Photo size is not available in InputPhotoFileLocation
|
||||||
|
fileName,
|
||||||
|
opts...,
|
||||||
|
)
|
||||||
return file, nil
|
return file, nil
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("unsupported media type: %T", media)
|
return nil, fmt.Errorf("unsupported media type: %T", media)
|
||||||
}
|
}
|
||||||
|
|
||||||
func FromMediaMessage(media tg.MessageMediaClass, msg *tg.Message, opts ...TGFileOptions) (TGFileMessage, error) {
|
func FromMediaMessage(media tg.MessageMediaClass, client downloader.Client, msg *tg.Message, opts ...TGFileOptions) (TGFileMessage, error) {
|
||||||
file, err := FromMedia(media, opts...)
|
file, err := FromMedia(media, client, opts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &tgFile{
|
return &tgFile{
|
||||||
location: file.Location(),
|
location: file.Location(),
|
||||||
|
dler: file.Dler(),
|
||||||
size: file.Size(),
|
size: file.Size(),
|
||||||
name: file.Name(),
|
name: file.Name(),
|
||||||
message: msg,
|
message: msg,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
|
|
||||||
"github.com/charmbracelet/log"
|
"github.com/charmbracelet/log"
|
||||||
config "github.com/krau/SaveAny-Bot/config/storage"
|
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"
|
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("Authorization", a.token)
|
||||||
req.Header.Set("File-Path", url.PathEscape(candidate))
|
req.Header.Set("File-Path", url.PathEscape(candidate))
|
||||||
req.Header.Set("Content-Type", "application/octet-stream")
|
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)
|
length, ok := length.(int64)
|
||||||
if ok {
|
if ok {
|
||||||
req.ContentLength = length
|
req.ContentLength = length
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ import (
|
|||||||
|
|
||||||
"github.com/charmbracelet/log"
|
"github.com/charmbracelet/log"
|
||||||
config "github.com/krau/SaveAny-Bot/config/storage"
|
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"
|
storenum "github.com/krau/SaveAny-Bot/pkg/enums/storage"
|
||||||
"github.com/minio/minio-go/v7"
|
"github.com/minio/minio-go/v7"
|
||||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||||
|
"github.com/rs/xid"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Minio struct {
|
type Minio struct {
|
||||||
@@ -60,7 +62,7 @@ func (m *Minio) Name() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Minio) JoinStoragePath(p string) string {
|
func (m *Minio) JoinStoragePath(p string) string {
|
||||||
return path.Join(m.config.BasePath, p)
|
return strings.TrimPrefix(path.Join(m.config.BasePath, p), "/")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Minio) Save(ctx context.Context, r io.Reader, storagePath string) error {
|
func (m *Minio) Save(ctx context.Context, r io.Reader, storagePath string) error {
|
||||||
@@ -71,9 +73,20 @@ func (m *Minio) Save(ctx context.Context, r io.Reader, storagePath string) error
|
|||||||
candidate := storagePath
|
candidate := storagePath
|
||||||
for i := 1; m.Exists(ctx, candidate); i++ {
|
for i := 1; m.Exists(ctx, candidate); i++ {
|
||||||
candidate = fmt.Sprintf("%s_%d%s", base, i, ext)
|
candidate = fmt.Sprintf("%s_%d%s", base, i, ext)
|
||||||
|
if i > 1000 {
|
||||||
|
m.logger.Errorf("Too many attempts to find a unique filename for %s", storagePath)
|
||||||
|
candidate = fmt.Sprintf("%s_%s%s", base, xid.New().String(), ext)
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
size := int64(-1)
|
||||||
_, err := m.client.PutObject(ctx, m.config.BucketName, candidate, r, -1, minio.PutObjectOptions{})
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to upload file to minio: %w", err)
|
return fmt.Errorf("failed to upload file to minio: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user