Compare commits
93 Commits
v0.39.0
...
copilot/ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef1ecf960f | ||
|
|
e1b4087801 | ||
|
|
6896bdc852 | ||
|
|
3a6402a71b | ||
|
|
173a5e3733 | ||
|
|
20a5e317ae | ||
|
|
127901fd24 | ||
|
|
30c165033e | ||
|
|
9dcb5201e1 | ||
|
|
7b0142ef82 | ||
|
|
2f6b2470a4 | ||
|
|
ac10c32215 | ||
|
|
7def7f5b28 | ||
|
|
3ce00884a0 | ||
|
|
cd7cf4964d | ||
|
|
bc3c841d1d | ||
|
|
743c15f1a5 | ||
|
|
b05d86509c | ||
|
|
f17a380579 | ||
|
|
cabe1189e2 | ||
|
|
a988d05e24 | ||
|
|
1af2c1f7c7 | ||
|
|
7b36fb45f5 | ||
|
|
62cceee592 | ||
|
|
6d315f7af2 | ||
|
|
5352491c76 | ||
|
|
3f914f7a64 | ||
|
|
8972d8a169 | ||
|
|
1339c69dbf | ||
|
|
63aeabb39b | ||
|
|
e60e983229 | ||
|
|
75e5fd10ea | ||
|
|
c8d8a2e0eb | ||
|
|
044e732084 | ||
|
|
0e951f641c | ||
|
|
8dd6265d55 | ||
|
|
ad7e01f052 | ||
|
|
c069d1edc7 | ||
|
|
fd3b17a360 | ||
|
|
a3162eff89 | ||
|
|
cebadd39f7 | ||
|
|
95f7d5abb5 | ||
|
|
5b8249060d | ||
|
|
0b97d15b2a | ||
|
|
6db5633ca6 | ||
|
|
4ccbf8177e | ||
|
|
6e16dc6051 | ||
|
|
9ed2c425be | ||
|
|
bc892d9370 | ||
|
|
29d523bd4f | ||
|
|
3f9c3f2a73 | ||
|
|
b796b045a8 | ||
|
|
5f7b936270 | ||
|
|
df64ec3069 | ||
|
|
d3cc56c8e6 | ||
|
|
018ed47949 | ||
|
|
09f4dd4ce7 | ||
|
|
adc64ad510 | ||
|
|
da6cf42355 | ||
|
|
8c76516953 | ||
|
|
15c4fffb98 | ||
|
|
b5cdf1e880 | ||
|
|
264dd9f9ed | ||
|
|
b25df2e214 | ||
|
|
5999ddbe1d | ||
|
|
7424190ee5 | ||
|
|
87e8836c78 | ||
|
|
1a7747c2d2 | ||
|
|
ca0fd67fba | ||
|
|
4d736b925b | ||
|
|
ead2b20f4e | ||
|
|
080d474714 | ||
|
|
f453205fde | ||
|
|
407677f270 | ||
|
|
958bfd1dbe | ||
|
|
debe33d84d | ||
|
|
52eead3bf5 | ||
|
|
0af049a507 | ||
|
|
8752dd865c | ||
|
|
c0b4580e34 | ||
|
|
280fd6ead8 | ||
|
|
0ca3d97711 | ||
|
|
51198a1e3d | ||
|
|
651835c467 | ||
|
|
45c978980c | ||
|
|
c21ff7e499 | ||
|
|
32cc1e4b5a | ||
|
|
c974791dc0 | ||
|
|
91814a83c7 | ||
|
|
685047e463 | ||
|
|
37e9c79ceb | ||
|
|
494d1bf51c | ||
|
|
a6f194aedd |
22
.github/ISSUE_TEMPLATE/bug.yml
vendored
22
.github/ISSUE_TEMPLATE/bug.yml
vendored
@@ -1,35 +1,41 @@
|
||||
name: "👾 报告 bug"
|
||||
description: "报告 bug"
|
||||
name: "👾 Bug Report"
|
||||
description: "Report a bug or unexpected behavior"
|
||||
labels:
|
||||
- "bug"
|
||||
assignees:
|
||||
- krau
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
# Please Search Before Submitting / 提交前请搜索
|
||||
Please make sure to search existing issues before submitting a new bug report.
|
||||
提交新的 Bug 报告前请务必搜索已有的 issue,避免重复
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: "👾 问题描述"
|
||||
label: "👾 Description"
|
||||
description: "What happened?"
|
||||
placeholder: "When called ... happens ..."
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: "⚡️ 预期行为"
|
||||
label: "⚡️ Expected Behavior"
|
||||
description: "What was expected?"
|
||||
placeholder: "It should be ..."
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: "📄 配置文件"
|
||||
label: "📄 Configuration File"
|
||||
description: "Please provide your config file"
|
||||
placeholder: "请自行隐去密钥信息"
|
||||
placeholder: "Please remove sensitive information"
|
||||
render: toml
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: "🔍 日志"
|
||||
label: "🔍 Logs"
|
||||
description: "Please provide logs"
|
||||
placeholder: "可删除隐私信息"
|
||||
placeholder: "Please remove sensitive information"
|
||||
render: shell
|
||||
validations:
|
||||
required: true
|
||||
|
||||
8
.github/ISSUE_TEMPLATE/config.yml
vendored
8
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,8 +1,8 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: 💬 不知道如何正确使用?
|
||||
- name: 💬 Don't know how to use it correctly?
|
||||
url: https://github.com/krau/SaveAny-Bot/discussions
|
||||
about: "前往讨论区提问"
|
||||
- name: 📄 文档
|
||||
about: "Go to the discussion area to ask questions"
|
||||
- name: 📄 Documentation
|
||||
url: https://sabot.unv.app
|
||||
about: "查看文档"
|
||||
about: "View the documentation"
|
||||
|
||||
70
.github/ISSUE_TEMPLATE/feature.yml
vendored
70
.github/ISSUE_TEMPLATE/feature.yml
vendored
@@ -1,5 +1,5 @@
|
||||
name: "⭐️ 功能请求"
|
||||
description: "功能请求"
|
||||
name: "⭐️ Feature Request"
|
||||
description: "Feature request"
|
||||
labels:
|
||||
- "enhancement"
|
||||
assignees:
|
||||
@@ -8,7 +8,69 @@ body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
# 请详细描述你想要的功能
|
||||
Please describe the feature you want in detail.
|
||||
请详细描述你想要的功能。
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ IMPORTANT NOTICE / 说明
|
||||
|
||||
Save Any Bot supports multiple storage backends, **including Telegram**.
|
||||
However, **all backends are treated equally**, keep this in mind when submitting feature requests.
|
||||
|
||||
Save Any Bot 支持多种存储后端,**包括 Telegram**。
|
||||
但**所有后端在设计上是平等的**,请在提出功能请求前务必理解这一点。
|
||||
|
||||
### ❌ Out of scope requests / 不在项目范围内的请求
|
||||
The following requests are **out of scope** and will be closed without discussion:
|
||||
|
||||
以下请求**不属于本项目设计范围**,将被直接关闭,不再讨论:
|
||||
|
||||
- Adding **Telegram-specific behaviors or exceptions**
|
||||
添加 **仅针对 Telegram 的特殊行为或例外逻辑**
|
||||
- Treating Telegram as anything other than a **generic file storage backend**
|
||||
将 Telegram 视为非“通用文件存储后端”的特殊存在
|
||||
- Saving or syncing **non-file content** (text messages, chat history, etc.)
|
||||
保存或同步 **非文件内容**(文本消息、聊天记录等)
|
||||
- Preserving or reconstructing original messages (e.g. 1:1 forwarding)
|
||||
保留或还原原始消息形态(例如 1:1 转发)
|
||||
- Perform special reprocessing on files to adapt to specific storage backends
|
||||
(e.g. splitting, re-encoding, transforming, etc.)
|
||||
为适配特定存储后端而对文件进行特殊处理
|
||||
(如分割、转码、重编码、转换格式等)
|
||||
- Any request that requires different logic *only because the backend is Telegram*
|
||||
任何**仅因后端是 Telegram 而需要不同逻辑**的请求
|
||||
|
||||
### ❌ Abuse-leaning or high-risk requests / 滥用倾向的请求
|
||||
Requests that may **enable or encourage** the following will NOT be accepted:
|
||||
|
||||
可能**促成或鼓励**以下行为的请求将不会被接受:
|
||||
|
||||
- Violating Telegram Terms of Service
|
||||
违反 Telegram 服务条款
|
||||
- Building traffic, mirror, or profit-oriented channels using third-party content
|
||||
利用第三方内容构建引流、镜像或牟利用途的频道
|
||||
|
||||
### ⚖️ Design principle / 设计原则
|
||||
Save Any Bot follows a **backend-agnostic design**:
|
||||
|
||||
Save Any Bot 遵循 **后端无关(backend-agnostic)** 的设计原则:
|
||||
|
||||
- If a feature cannot be implemented **uniformly across all backends**, it will not be added.
|
||||
如果某个功能无法在 **所有后端** 中统一实现,则不会被添加。
|
||||
- No backend-specific hacks or special cases will be introduced.
|
||||
不会引入任何后端特有的 hack 或特殊处理逻辑。
|
||||
|
||||
---
|
||||
|
||||
If your request falls into any of the categories above, please do not open an issue.
|
||||
Such issues will be closed.
|
||||
|
||||
如果你的请求符合以上任一情况,请不要提交 issue,
|
||||
相关 issue 将被直接关闭。
|
||||
|
||||
Thank you for respecting the scope and design principles of this project.
|
||||
感谢你的理解与支持。
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: "⭐️ Feature description"
|
||||
@@ -30,4 +92,4 @@ body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## Thank you for contributing to the project :slightly_smiling_face:
|
||||
## Thank you for contributing to the project :slightly_smiling_face:
|
||||
|
||||
166
.github/workflows/build-docker.yml
vendored
166
.github/workflows/build-docker.yml
vendored
@@ -7,32 +7,53 @@ on:
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
IMAGE_NAME: krau/saveany-bot
|
||||
|
||||
concurrency:
|
||||
group: docker-build-${{ github.repository }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
prepare:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
version: ${{ steps.vars.outputs.version }}
|
||||
major_minor: ${{ steps.vars.outputs.major_minor }}
|
||||
short_sha: ${{ steps.vars.outputs.short_sha }}
|
||||
build_time: ${{ steps.vars.outputs.build_time }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Extract Version Components
|
||||
id: vars
|
||||
run: |
|
||||
VERSION=${GITHUB_REF#refs/tags/v}
|
||||
MAJOR_MINOR=$(echo "$VERSION" | cut -d. -f1,2)
|
||||
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "major_minor=$MAJOR_MINOR" >> "$GITHUB_OUTPUT"
|
||||
echo "short_sha=$SHORT_SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "build_time=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build:
|
||||
needs: prepare
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
matrix:
|
||||
arch: [amd64, arm64]
|
||||
type: [default, micro, pico]
|
||||
fail-fast: false
|
||||
runs-on: ${{ matrix.arch == 'amd64' && 'ubuntu-latest' || 'ubuntu-24.04-arm' }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Extract metadata for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=sha
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
@@ -44,26 +65,99 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract Dockerfile args
|
||||
id: args
|
||||
run: |
|
||||
echo "git_commit=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
|
||||
echo "build_time=$(git show -s --format=%cI)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
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
|
||||
file: ${{ matrix.type == 'default' && './Dockerfile' || format('./Dockerfile.{0}', matrix.type) }}
|
||||
platforms: ${{ matrix.arch == 'amd64' && 'linux/amd64' || 'linux/arm64' }}
|
||||
# 关键修改:不再使用 tags,而是通过 image output 按摘要推送
|
||||
outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||
build-args: |
|
||||
VERSION=${{ steps.meta.outputs.version }}
|
||||
GitCommit=${{ steps.args.outputs.git_commit }}
|
||||
BuildTime=${{ steps.args.outputs.build_time }}
|
||||
VERSION=${{ needs.prepare.outputs.version }}
|
||||
GitCommit=${{ needs.prepare.outputs.short_sha }}
|
||||
BuildTime=${{ needs.prepare.outputs.build_time }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name:
|
||||
Export digest
|
||||
# 将 digest 写入文件,供后续步骤读取
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
echo "$digest" > /tmp/digests/digest
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: digest-${{ matrix.type }}-${{ matrix.arch }}
|
||||
path: /tmp/digests/digest
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
create-manifest:
|
||||
needs: [prepare, build]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
strategy:
|
||||
matrix:
|
||||
type: [default, micro, pico]
|
||||
steps:
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digest-${{ matrix.type }}-*
|
||||
merge-multiple: false
|
||||
|
||||
- name: Create and push manifest lists
|
||||
run: |
|
||||
REPO="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
|
||||
VERSION="${{ needs.prepare.outputs.version }}"
|
||||
MAJOR_MINOR="${{ needs.prepare.outputs.major_minor }}"
|
||||
SHA="${{ needs.prepare.outputs.short_sha }}"
|
||||
TYPE="${{ matrix.type }}"
|
||||
|
||||
DIGEST_AMD64=$(cat /tmp/digests/digest-${TYPE}-amd64/digest)
|
||||
DIGEST_ARM64=$(cat /tmp/digests/digest-${TYPE}-arm64/digest)
|
||||
|
||||
echo "Found digests for $TYPE:"
|
||||
echo "AMD64: $DIGEST_AMD64"
|
||||
echo "ARM64: $DIGEST_ARM64"
|
||||
|
||||
TAGS=()
|
||||
|
||||
if [ "$TYPE" == "default" ]; then
|
||||
TAGS+=("$REPO:latest")
|
||||
TAGS+=("$REPO:$VERSION")
|
||||
TAGS+=("$REPO:$MAJOR_MINOR")
|
||||
TAGS+=("$REPO:sha-$SHA")
|
||||
else
|
||||
TAGS+=("$REPO:$TYPE")
|
||||
TAGS+=("$REPO:$TYPE-latest")
|
||||
TAGS+=("$REPO:$TYPE-$VERSION")
|
||||
fi
|
||||
|
||||
SRC_AMD64="${REPO}@${DIGEST_AMD64}"
|
||||
SRC_ARM64="${REPO}@${DIGEST_ARM64}"
|
||||
|
||||
echo "Creating manifest list with sources:"
|
||||
echo " $SRC_AMD64"
|
||||
echo " $SRC_ARM64"
|
||||
|
||||
for TAG in "${TAGS[@]}"; do
|
||||
echo "Pushing tag: $TAG"
|
||||
docker buildx imagetools create -t "$TAG" "$SRC_AMD64" "$SRC_ARM64"
|
||||
done
|
||||
|
||||
4
.github/workflows/build-release.yml
vendored
4
.github/workflows/build-release.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
uses: softprops/action-gh-release@v2
|
||||
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
|
||||
4
.github/workflows/docs.yml
vendored
4
.github/workflows/docs.yml
vendored
@@ -9,11 +9,11 @@ on:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: true # Fetch Hugo themes (true OR recursive)
|
||||
fetch-depth: 0 # Fetch all history for .GitInfo and .Lastmod
|
||||
|
||||
17
.github/workflows/update-contributors.yml
vendored
17
.github/workflows/update-contributors.yml
vendored
@@ -1,17 +0,0 @@
|
||||
name: Update Contributors
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
contrib-readme-job:
|
||||
runs-on: ubuntu-latest
|
||||
name: A job to automate contrib in readme
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Contribute List
|
||||
uses: akhilmhdh/contributors-readme-action@v2.3.10
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -9,4 +9,6 @@ cache.db
|
||||
temp/
|
||||
.hugo_build.lock
|
||||
playwright/
|
||||
testplugins/
|
||||
testplugins/
|
||||
*.exe
|
||||
tmp-*
|
||||
301
AGENTS.md
Normal file
301
AGENTS.md
Normal file
@@ -0,0 +1,301 @@
|
||||
# SaveAny-Bot Agent Guidelines
|
||||
|
||||
This document provides essential information for AI coding agents working on the SaveAny-Bot project.
|
||||
|
||||
## Project Overview
|
||||
|
||||
SaveAny-Bot is a Telegram bot written in Go that saves files/messages from Telegram and various websites to multiple storage backends (local, S3, MinIO, WebDAV, AList, Telegram). It features a plugin system for parsing web content and extensible storage backends.
|
||||
|
||||
**Tech Stack**: Go 1.24.2, gotd/td (Telegram MTProto), Cobra (CLI), Viper (config), GORM (ORM), SQLite, Goja (JS runtime), Playwright (browser automation)
|
||||
|
||||
## Build & Test Commands
|
||||
|
||||
### Build
|
||||
```bash
|
||||
# Standard build
|
||||
go build -o saveany-bot .
|
||||
|
||||
# Run directly
|
||||
go run ./cmd
|
||||
|
||||
# Docker build (multi-stage, Alpine-based)
|
||||
docker build -t saveany-bot .
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Test
|
||||
```bash
|
||||
# Run all tests
|
||||
go test ./...
|
||||
|
||||
# Run tests in specific package
|
||||
go test ./pkg/queue
|
||||
go test ./storage/telegram
|
||||
|
||||
# Run tests with verbose output
|
||||
go test -v ./...
|
||||
|
||||
# Run a single test
|
||||
go test -run TestQueueBasic ./pkg/queue
|
||||
|
||||
# Run with coverage
|
||||
go test -cover ./...
|
||||
```
|
||||
|
||||
### Lint & Format
|
||||
```bash
|
||||
# Format code (standard Go formatting)
|
||||
go fmt ./...
|
||||
|
||||
# Vet code for common issues
|
||||
go vet ./...
|
||||
|
||||
# Generate code (i18n keys)
|
||||
go generate ./...
|
||||
```
|
||||
|
||||
### Other Commands
|
||||
```bash
|
||||
# Update dependencies
|
||||
go mod tidy
|
||||
|
||||
# View documentation
|
||||
cd docs && hugo server -D
|
||||
```
|
||||
|
||||
## Code Style Guidelines
|
||||
|
||||
### Imports
|
||||
- Standard library first, then third-party, then project-internal
|
||||
- Group imports with blank lines between groups
|
||||
- Use explicit import aliases for clarity when needed (e.g., `storconfig`, `storenum`)
|
||||
|
||||
```go
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"github.com/krau/SaveAny-Bot/pkg/enums/storage"
|
||||
)
|
||||
```
|
||||
|
||||
### Formatting
|
||||
- Line length: reasonable (no hard limit, but be sensible)
|
||||
- Organize code with blank lines between logical sections
|
||||
- Follow standard Go conventions for braces, spacing, etc.
|
||||
|
||||
### Types & Interfaces
|
||||
- Use clear, descriptive type names (PascalCase for exported, camelCase for unexported)
|
||||
- Define interfaces where abstraction is needed (e.g., `Executable`, `StorageConfig`)
|
||||
- Embed context in method signatures, not structs: `func (s *Service) Do(ctx context.Context) error`
|
||||
- Prefer composition over inheritance
|
||||
|
||||
```go
|
||||
// Interfaces define behavior
|
||||
type Executable interface {
|
||||
Type() tasktype.TaskType
|
||||
Title() string
|
||||
TaskID() string
|
||||
Execute(ctx context.Context) error
|
||||
}
|
||||
|
||||
// Structs compose behavior
|
||||
type Local struct {
|
||||
config config.LocalStorageConfig
|
||||
logger *log.Logger
|
||||
}
|
||||
```
|
||||
|
||||
### Naming Conventions
|
||||
- **Packages**: lowercase, single word when possible (avoid underscores)
|
||||
- **Files**: lowercase with underscores for multiword (e.g., `auth_terminal.go`, `progress_reader.go`)
|
||||
- **Variables**: camelCase for unexported, PascalCase for exported
|
||||
- **Constants**: PascalCase for exported, camelCase for unexported (not ALL_CAPS)
|
||||
- **Functions/Methods**: PascalCase for exported, camelCase for unexported
|
||||
- **Test files**: `*_test.go` pattern
|
||||
|
||||
### Error Handling
|
||||
- Always handle errors explicitly; never ignore them
|
||||
- Wrap errors with context using `fmt.Errorf("context: %w", err)`
|
||||
- Use `errors.Is()` and `errors.As()` for error checking
|
||||
- Log errors with appropriate level (Error, Warn, Info)
|
||||
- Return errors from functions rather than panicking (except for truly unrecoverable situations)
|
||||
|
||||
```go
|
||||
// Good error handling
|
||||
if err := db.Save(user).Error; err != nil {
|
||||
return fmt.Errorf("failed to save user %d: %w", user.ChatID, err)
|
||||
}
|
||||
|
||||
// Check specific errors
|
||||
if errors.Is(err, context.Canceled) {
|
||||
logger.Info("Operation was canceled")
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
### Logging
|
||||
- Use `github.com/charmbracelet/log` package
|
||||
- Get logger from context: `log.FromContext(ctx)`
|
||||
- Create prefixed loggers for components: `logger.WithPrefix("component")`
|
||||
- Use appropriate levels: Debug, Info, Warn, Error
|
||||
- Include context in log messages (e.g., task IDs, file names)
|
||||
|
||||
```go
|
||||
logger := log.FromContext(ctx)
|
||||
logger.Infof("Processing task: %s", task.ID)
|
||||
logger.Errorf("Failed to save file %s: %v", filename, err)
|
||||
```
|
||||
|
||||
### Concurrency
|
||||
- Use channels for communication between goroutines
|
||||
- Protect shared state with `sync.Mutex` or `sync.RWMutex`
|
||||
- Use `sync.WaitGroup` for coordinating goroutine completion
|
||||
- Always pass `context.Context` for cancellation support
|
||||
- Use `context.WithCancel/WithTimeout` for managing goroutine lifetimes
|
||||
|
||||
```go
|
||||
// Example from queue implementation
|
||||
func (tq *TaskQueue[T]) Add(task *Task[T]) error {
|
||||
tq.mu.Lock()
|
||||
defer tq.mu.Unlock()
|
||||
// ... critical section
|
||||
tq.cond.Signal()
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
### Comments
|
||||
- Document exported types, functions, and packages with doc comments
|
||||
- Start doc comments with the name being documented
|
||||
- Use `//` for single-line comments
|
||||
- Explain *why*, not *what* (code should be self-explanatory for "what")
|
||||
- Add `[NOTE]`, `[WARN]`, `[IMPORTANT]` tags for important clarifications
|
||||
|
||||
```go
|
||||
// GetUserByChatID retrieves a user by their Telegram chat ID.
|
||||
// Returns an error if the user is not found.
|
||||
func GetUserByChatID(ctx context.Context, chatID int64) (*User, error) {
|
||||
```
|
||||
|
||||
## Architecture & Conventions
|
||||
|
||||
### Application Structure
|
||||
- **Entry point**: `main.go` → `cmd.Execute(ctx)`
|
||||
- **CLI root**: `cmd/root.go` (Cobra), implementation in `cmd/run.go`
|
||||
- **Startup sequence**: Config → Cache → i18n → Database → Storage → Parsers → Userbot → Bot → Queue
|
||||
- Follow this order when adding new initialization steps in `cmd/run.go::initAll`
|
||||
|
||||
### Configuration (Viper)
|
||||
- Config defined in `config/viper.go::Config`
|
||||
- Read from `config.toml` (see `config.example.toml`)
|
||||
- Environment variables: `SAVEANY_*` prefix (e.g., `SAVEANY_TELEGRAM_TOKEN`)
|
||||
- Access via `config.C()` (returns a copy, don't modify the return value)
|
||||
- Storage configs validated via `config/storage/factory.go::LoadStorageConfigs`
|
||||
|
||||
### Telegram Client
|
||||
- **Bot client**: `client/bot/bot.go::Init` (uses gotgproto)
|
||||
- **Handlers**: Centralized in `client/bot/handlers/` directory
|
||||
- **Registration**: All handlers registered in `handlers.Register`
|
||||
- **Commands**: Add to `CommandHandlers` slice for automatic `/help` and bot command list updates
|
||||
- **Middleware**: Common middleware in `client/middleware/` (floodwait, retry, etc.)
|
||||
|
||||
### Tasks & Queue
|
||||
- **Task interface**: `core/core.go::Executable` (Type, Title, TaskID, Execute methods)
|
||||
- **Queue**: `pkg/queue.TaskQueue[Executable]` (generic, thread-safe)
|
||||
- **Workers**: Count from `config.C().Workers`
|
||||
- **Task types**: Implementations in `core/tasks/**` (tfile, parsed, telegraph, directlinks, batchtfile)
|
||||
- **Lifecycle hooks**: `TaskBeforeStart`, `TaskSuccess`, `TaskFail`, `TaskCancel` (defined in config)
|
||||
- **Adding tasks**: Use `core.AddTask(ctx, task)`
|
||||
|
||||
### Database (GORM + SQLite)
|
||||
- **Init**: `database.Init` using `config.C().DB.Path`
|
||||
- **Models**: User, Dir, Rule, WatchChat (in `database/*.go`)
|
||||
- **Migrations**: Automatic via `db.AutoMigrate`
|
||||
- **User sync**: `database.syncUsers` syncs DB with `config.C().Users` (don't manually create/delete users)
|
||||
- **Context**: Always use `db.WithContext(ctx)` for operations
|
||||
|
||||
### Storage Backends
|
||||
- **Interface**: Defined in `config/storage/types.go` and `storage/`
|
||||
- **Implementations**: local, alist, s3/minio, webdav, telegram (each in subdirectory)
|
||||
- **Adding new storage**:
|
||||
1. Add enum to `pkg/enums/storage`
|
||||
2. Create config struct in `config/storage/` with `Validate()` method
|
||||
3. Implement storage in `storage/<name>/`
|
||||
4. Register in `storageFactories` mapping
|
||||
5. Update `config.example.toml` with example
|
||||
|
||||
### Parser Plugins (JavaScript)
|
||||
- **Runtime**: Goja (JS runtime) + Playwright (browser automation)
|
||||
- **Plugin API**: `registerParser({ metadata, canHandle, parse })` in JS
|
||||
- **Integration**: Defined in `parsers/` directory
|
||||
- **Documentation**: See `plugins/README.md`
|
||||
- Plugin `parse` returns `Item`/`Resource` which becomes download/transfer task
|
||||
|
||||
### Internationalization (i18n)
|
||||
- **Usage**: `i18n.T(i18nk.SomeKey, map[string]any{"Name": value})`
|
||||
- **Locale files**: `common/i18n/locale/*.yaml`
|
||||
- **Key generation**: Run `go generate ./...` to generate `common/i18n/i18nk/keys.go`
|
||||
- **Adding new strings**: Add to YAML → run `go generate` → use in code
|
||||
- All user-facing strings should be internationalized
|
||||
|
||||
### Context Usage
|
||||
- Always pass `context.Context` as first parameter
|
||||
- Use `log.FromContext(ctx)` to get contextual logger
|
||||
- Respect context cancellation in long-running operations
|
||||
- Store request-scoped data in context (e.g., `ctxkey.ContentLength`)
|
||||
|
||||
## Special Rules from .github/copilot-instructions.md
|
||||
|
||||
1. **Never modify `config.C()` return values** - it returns a copy. Modify config in `config.Init` or via Viper.
|
||||
2. **Handlers must update `CommandHandlers` slice** - ensures `/help` and bot commands stay in sync.
|
||||
3. **Task execution must preserve hooks** - don't remove `TaskBeforeStart`, `TaskSuccess`, `TaskFail`, `TaskCancel` hook calls.
|
||||
4. **User sync is automatic** - don't manually create/delete users in DB; use config-based sync.
|
||||
5. **Prefer context logger** - use `log.FromContext(ctx)` over global logger when context is available.
|
||||
6. **Storage factory pattern** - new storage types must register in `storageFactories` mapping.
|
||||
7. **Plugin API compatibility** - changes to `Item`/`Resource` structures require updating `plugins/README.md`.
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Adding a New Command
|
||||
1. Create handler function in `client/bot/handlers/<name>.go`
|
||||
2. Add to `CommandHandlers` slice in `register.go`
|
||||
3. Add i18n key to `common/i18n/locale/*.yaml`
|
||||
4. Run `go generate ./...`
|
||||
5. Test with Telegram bot
|
||||
|
||||
### Adding a New Task Type
|
||||
1. Create struct implementing `core.Executable` in `core/tasks/<type>/`
|
||||
2. Implement `Type()`, `Title()`, `TaskID()`, `Execute(ctx)` methods
|
||||
3. Add task type enum to `pkg/enums/tasktype`
|
||||
4. Use `core.AddTask(ctx, task)` to enqueue
|
||||
|
||||
### Adding a New Storage Backend
|
||||
1. Define config struct in `config/storage/<name>.go` with `Validate()` method
|
||||
2. Implement storage interface in `storage/<name>/<name>.go`
|
||||
3. Add storage type enum to `pkg/enums/storage`
|
||||
4. Register factory in `config/storage/factory.go::storageFactories`
|
||||
5. Update `config.example.toml` with configuration example
|
||||
|
||||
## File References
|
||||
|
||||
When referencing code locations, use `path/to/file.go:line` format (e.g., `core/core.go:23` for the worker function).
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
- Write tests for new functionality (place in `*_test.go` files)
|
||||
- Test files should be in same package as code being tested
|
||||
- Use table-driven tests for multiple test cases
|
||||
- Mock external dependencies (databases, network calls)
|
||||
- Aim for meaningful tests, not just coverage numbers
|
||||
|
||||
## Notes
|
||||
|
||||
- Binary size matters: use `CGO_ENABLED=0` for static binaries
|
||||
- FFmpeg is included in Docker images for media processing
|
||||
- Build process supports cross-compilation (amd64/arm64, Linux/macOS/Windows)
|
||||
- Documentation site uses Hugo; edit files in `docs/` directory
|
||||
- Session data stored in SQLite; delete `data/session.db` if changing bot token
|
||||
@@ -20,6 +20,7 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
-X 'github.com/krau/SaveAny-Bot/config.Version=${VERSION}' \
|
||||
-X 'github.com/krau/SaveAny-Bot/config.GitCommit=${GitCommit}' \
|
||||
-X 'github.com/krau/SaveAny-Bot/config.BuildTime=${BuildTime}' \
|
||||
-X 'github.com/krau/SaveAny-Bot/config.Docker=true' \
|
||||
" \
|
||||
-o saveany-bot .
|
||||
|
||||
|
||||
41
Dockerfile.micro
Normal file
41
Dockerfile.micro
Normal file
@@ -0,0 +1,41 @@
|
||||
FROM golang:alpine AS builder
|
||||
|
||||
ARG VERSION="dev"
|
||||
ARG GitCommit="Unknown"
|
||||
ARG BuildTime="Unknown"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
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 \
|
||||
--mount=type=cache,target=/go/pkg \
|
||||
CGO_ENABLED=0 \
|
||||
go build -trimpath \
|
||||
-tags=no_jsparser,no_minio,no_bubbletea \
|
||||
-ldflags=" \
|
||||
-s -w \
|
||||
-X 'github.com/krau/SaveAny-Bot/config.Version=${VERSION}' \
|
||||
-X 'github.com/krau/SaveAny-Bot/config.GitCommit=${GitCommit}' \
|
||||
-X 'github.com/krau/SaveAny-Bot/config.BuildTime=${BuildTime}' \
|
||||
-X 'github.com/krau/SaveAny-Bot/config.Docker=true' \
|
||||
" \
|
||||
-o saveany-bot .
|
||||
|
||||
FROM alpine:latest
|
||||
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/saveany-bot .
|
||||
COPY entrypoint.sh .
|
||||
|
||||
RUN chmod +x /app/saveany-bot && \
|
||||
chmod +x /app/entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
|
||||
35
Dockerfile.pico
Normal file
35
Dockerfile.pico
Normal file
@@ -0,0 +1,35 @@
|
||||
# pico is the minimum build of SaveAnyBot, which disables all the optional features like JS parsing and MinIO support.
|
||||
FROM golang:alpine AS builder
|
||||
|
||||
ARG VERSION="dev"
|
||||
ARG GitCommit="Unknown"
|
||||
ARG BuildTime="Unknown"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
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 \
|
||||
--mount=type=cache,target=/go/pkg \
|
||||
CGO_ENABLED=0 \
|
||||
go build -trimpath \
|
||||
-tags=no_jsparser,no_minio,sqlite_glebarez,no_bubbletea \
|
||||
-ldflags=" \
|
||||
-s -w \
|
||||
-X 'github.com/krau/SaveAny-Bot/config.Version=${VERSION}' \
|
||||
-X 'github.com/krau/SaveAny-Bot/config.GitCommit=${GitCommit}' \
|
||||
-X 'github.com/krau/SaveAny-Bot/config.BuildTime=${BuildTime}' \
|
||||
-X 'github.com/krau/SaveAny-Bot/config.Docker=true' \
|
||||
" \
|
||||
-o saveany-bot . && chmod +x saveany-bot
|
||||
|
||||
FROM scratch
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/saveany-bot .
|
||||
|
||||
ENTRYPOINT ["/app/saveany-bot"]
|
||||
99
README.md
99
README.md
@@ -2,9 +2,9 @@
|
||||
|
||||
# <img src="docs/static/logo.png" width="45" align="center"> Save Any Bot
|
||||
|
||||
**简体中文** | [English](https://sabot.unv.app/en/)
|
||||
**English** | [简体中文](./README_zh.md)
|
||||
|
||||
> **把 Telegram 上的文件转存到多种存储端.**
|
||||
> **Save Any Telegram File to Anywhere 📂. Support restrict saving content and beyond telegram.**
|
||||
|
||||
[](https://github.com/krau/saveany-bot/releases)
|
||||
[](https://github.com/krau/saveany-bot/releases)
|
||||
@@ -19,46 +19,47 @@
|
||||
|
||||
## 🎯 Features
|
||||
|
||||
- 支持文档/视频/图片/贴纸…甚至还有 [Telegraph](https://telegra.ph/)
|
||||
- 破解禁止保存的文件
|
||||
- 批量下载
|
||||
- 流式传输
|
||||
- 多用户使用
|
||||
- 基于存储规则的自动整理
|
||||
- 监听并自动转存指定聊天的消息, 支持过滤
|
||||
- 使用 js 编写解析器插件以转存任意网站的文件
|
||||
- 存储端支持:
|
||||
- Support documents / videos / photos / stickers… and even [Telegraph](https://telegra.ph/)
|
||||
- Bypass "restrict saving content" media
|
||||
- Batch download
|
||||
- Streaming transfer
|
||||
- Multi-user support
|
||||
- Auto organize files based on storage rules
|
||||
- Watch specified chats and auto-save messages, with filters
|
||||
- Write JS parser plugins to save files from almost any website
|
||||
- Storage backends:
|
||||
- Alist
|
||||
- S3 (MinioSDK)
|
||||
- S3
|
||||
- WebDAV
|
||||
- 本地磁盘
|
||||
- Telegram (重传回指定聊天)
|
||||
- Local filesystem
|
||||
- Telegram (re-upload to specified chats)
|
||||
|
||||
## 📦 Quick Start
|
||||
|
||||
创建文件 `config.toml` 并填入以下内容:
|
||||
Create a `config.toml` file with the following content:
|
||||
|
||||
```toml
|
||||
lang = "en" # Language setting, "en" for English
|
||||
[telegram]
|
||||
token = "" # 你的 Bot Token, 在 @BotFather 获取
|
||||
token = "" # Your bot token, obtained from @BotFather
|
||||
[telegram.proxy]
|
||||
# 启用代理连接 telegram, 当前只支持 socks5
|
||||
# Enable proxy for Telegram
|
||||
enable = false
|
||||
url = "socks5://127.0.0.1:7890"
|
||||
|
||||
[[storages]]
|
||||
name = "本地磁盘"
|
||||
name = "Local Disk"
|
||||
type = "local"
|
||||
enable = true
|
||||
base_path = "./downloads"
|
||||
|
||||
[[users]]
|
||||
id = 114514 # 你的 Telegram 账号 id
|
||||
id = 114514 # Your Telegram account id
|
||||
storages = []
|
||||
blacklist = true
|
||||
```
|
||||
|
||||
使用 Docker 运行 Save Any Bot:
|
||||
Run Save Any Bot with Docker:
|
||||
|
||||
```bash
|
||||
docker run -d --name saveany-bot \
|
||||
@@ -67,69 +68,23 @@ docker run -d --name saveany-bot \
|
||||
ghcr.io/krau/saveany-bot:latest
|
||||
```
|
||||
|
||||
请 [**查看文档**](https://sabot.unv.app/) 以获取更多配置选项和使用方法.
|
||||
Please [**read the docs**](https://sabot.unv.app/en/) for more configuration options and usage.
|
||||
|
||||
## Sponsors
|
||||
|
||||
本项目受到 [YxVM](https://yxvm.com/) 与 [NodeSupport](https://github.com/NodeSeekDev/NodeSupport) 的支持.
|
||||
This project is supported by [YxVM](https://yxvm.com/) and [NodeSupport](https://github.com/NodeSeekDev/NodeSupport).
|
||||
|
||||
如果这个项目对你有帮助, 你可以考虑通过以下方式赞助我:
|
||||
If this project is helpful to you, consider sponsoring me via:
|
||||
|
||||
- [爱发电](https://afdian.com/a/unvapp)
|
||||
- [Afdian](https://afdian.com/a/unvapp)
|
||||
|
||||
## Contributors
|
||||
|
||||
<!-- readme: contributors -start -->
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="https://github.com/krau">
|
||||
<img src="https://avatars.githubusercontent.com/u/71133316?v=4" width="100;" alt="krau"/>
|
||||
<br />
|
||||
<sub><b>Krau</b></sub>
|
||||
</a>
|
||||
</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">
|
||||
<a href="https://github.com/TG-Twilight">
|
||||
<img src="https://avatars.githubusercontent.com/u/121682528?v=4" width="100;" alt="TG-Twilight"/>
|
||||
<br />
|
||||
<sub><b>Simon Twilight</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/ysicing">
|
||||
<img src="https://avatars.githubusercontent.com/u/8605565?v=4" width="100;" alt="ysicing"/>
|
||||
<br />
|
||||
<sub><b>缘生</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/AHCorn">
|
||||
<img src="https://avatars.githubusercontent.com/u/42889600?v=4" width="100;" alt="AHCorn"/>
|
||||
<br />
|
||||
<sub><b>安和</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tbody>
|
||||
</table>
|
||||
<!-- readme: contributors -end -->
|
||||
|
||||
## Thanks
|
||||
## Thanks To
|
||||
|
||||
- [gotd](https://github.com/gotd/td)
|
||||
- [TG-FileStreamBot](https://github.com/EverythingSuckz/TG-FileStreamBot)
|
||||
- [gotgproto](https://github.com/celestix/gotgproto)
|
||||
- [tdl](https://github.com/iyear/tdl)
|
||||
- All the dependencies
|
||||
- All the dependencies, contributors, sponsors and users.
|
||||
|
||||
## Contact
|
||||
|
||||
|
||||
90
README_zh.md
Normal file
90
README_zh.md
Normal file
@@ -0,0 +1,90 @@
|
||||
<div align="center">
|
||||
|
||||
# <img src="docs/static/logo.png" width="45" align="center"> Save Any Bot
|
||||
|
||||
> **把 Telegram 上的文件转存到多种存储端**
|
||||
|
||||
[](https://github.com/krau/saveany-bot/releases)
|
||||
[](https://github.com/krau/saveany-bot/releases)
|
||||
[](https://github.com/krau/saveany-bot/actions/workflows/build-release.yml)
|
||||
[](https://github.com/krau/saveany-bot/stargazers)
|
||||
[](https://github.com/krau/saveany-bot/releases)
|
||||
[](https://github.com/krau/saveany-bot/issues)
|
||||
[](https://github.com/krau/saveany-bot/pulls)
|
||||
[](./LICENSE)
|
||||
|
||||
</div>
|
||||
|
||||
## 🎯 特性
|
||||
|
||||
- 支持文档/视频/图片/贴纸…甚至还有 [Telegraph](https://telegra.ph/)
|
||||
- 破解禁止保存的文件
|
||||
- 批量下载
|
||||
- 流式传输
|
||||
- 多用户使用
|
||||
- 基于存储规则的自动整理
|
||||
- 监听并自动转存指定聊天的消息, 支持过滤
|
||||
- 使用 js 编写解析器插件以转存任意网站的文件
|
||||
- 存储端支持:
|
||||
- Alist
|
||||
- S3
|
||||
- WebDAV
|
||||
- 本地磁盘
|
||||
- Telegram (重传回指定聊天)
|
||||
|
||||
## 快速开始
|
||||
|
||||
创建文件 `config.toml` 并填入以下内容:
|
||||
|
||||
```toml
|
||||
[telegram]
|
||||
token = "" # 你的 Bot Token, 在 @BotFather 获取
|
||||
[telegram.proxy]
|
||||
# 启用代理连接 telegram
|
||||
enable = false
|
||||
url = "socks5://127.0.0.1:7890"
|
||||
|
||||
[[storages]]
|
||||
name = "本地磁盘"
|
||||
type = "local"
|
||||
enable = true
|
||||
base_path = "./downloads"
|
||||
|
||||
[[users]]
|
||||
id = 114514 # 你的 Telegram 账号 id
|
||||
storages = []
|
||||
blacklist = true
|
||||
```
|
||||
|
||||
使用 Docker 运行 Save Any Bot:
|
||||
|
||||
```bash
|
||||
docker run -d --name saveany-bot \
|
||||
-v ./config.toml:/app/config.toml \
|
||||
-v ./downloads:/app/downloads \
|
||||
ghcr.io/krau/saveany-bot:latest
|
||||
```
|
||||
|
||||
请 [**查看文档**](https://sabot.unv.app/) 以获取更多配置选项和使用方法.
|
||||
|
||||
## 赞助
|
||||
|
||||
本项目受到 [YxVM](https://yxvm.com/) 与 [NodeSupport](https://github.com/NodeSeekDev/NodeSupport) 的支持.
|
||||
|
||||
如果这个项目对你有帮助, 你可以考虑通过以下方式赞助我:
|
||||
|
||||
- [爱发电](https://afdian.com/a/unvapp)
|
||||
|
||||
## 鸣谢
|
||||
|
||||
- [gotd](https://github.com/gotd/td)
|
||||
- [TG-FileStreamBot](https://github.com/EverythingSuckz/TG-FileStreamBot)
|
||||
- [gotgproto](https://github.com/celestix/gotgproto)
|
||||
- [tdl](https://github.com/iyear/tdl)
|
||||
- All the dependencies, contributors, sponsors and users.
|
||||
|
||||
## 社区和关于作者
|
||||
|
||||
- [](https://t.me/ProjectSaveAny)
|
||||
- [](https://github.com/krau/saveany-bot/discussions)
|
||||
- [](https://t.me/acherkrau)
|
||||
629
api/handlers.go
Normal file
629
api/handlers.go
Normal file
@@ -0,0 +1,629 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/krau/SaveAny-Bot/client/bot"
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/ruleutil"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"github.com/krau/SaveAny-Bot/core"
|
||||
tftask "github.com/krau/SaveAny-Bot/core/tasks/tfile"
|
||||
"github.com/krau/SaveAny-Bot/database"
|
||||
"github.com/krau/SaveAny-Bot/pkg/queue"
|
||||
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
"github.com/rs/xid"
|
||||
)
|
||||
|
||||
// Request/Response types
|
||||
type CreateTaskRequest struct {
|
||||
TelegramURL string `json:"telegram_url"`
|
||||
StorageName string `json:"storage_name,omitempty"`
|
||||
DirPath string `json:"dir_path,omitempty"`
|
||||
UserID int64 `json:"user_id"`
|
||||
}
|
||||
|
||||
type CreateTaskResponse struct {
|
||||
TaskID string `json:"task_id"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type TaskStatusResponse struct {
|
||||
TaskID string `json:"task_id"`
|
||||
Status string `json:"status"` // queued, running, completed, failed, canceled
|
||||
Title string `json:"title"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Downloaded int64 `json:"downloaded,omitempty"` // Bytes downloaded
|
||||
Total int64 `json:"total,omitempty"` // Total bytes
|
||||
ProgressPct float64 `json:"progress_pct,omitempty"` // Progress percentage (0-100)
|
||||
}
|
||||
|
||||
type ListTasksResponse struct {
|
||||
Queued []TaskInfo `json:"queued"`
|
||||
Running []TaskInfo `json:"running"`
|
||||
}
|
||||
|
||||
type TaskInfo struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
type ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// Task tracking
|
||||
var (
|
||||
taskStatuses = make(map[string]*taskStatus)
|
||||
taskStatusesMu sync.RWMutex
|
||||
)
|
||||
|
||||
type taskStatus struct {
|
||||
ID string
|
||||
Status string
|
||||
Title string
|
||||
CreatedAt time.Time
|
||||
Error string
|
||||
Downloaded atomic.Int64 // Use atomic for lock-free updates
|
||||
Total atomic.Int64 // Use atomic for lock-free updates
|
||||
ProgressPct uint64 // Store as uint64 bits of float64 for atomic access
|
||||
}
|
||||
|
||||
func handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func handleCreateTask(w http.ResponseWriter, r *http.Request) {
|
||||
var req CreateTaskRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate request
|
||||
if req.TelegramURL == "" {
|
||||
respondError(w, "telegram_url is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.UserID <= 0 {
|
||||
respondError(w, "user_id is required and must be positive", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
logger := log.FromContext(r.Context()).WithPrefix("api")
|
||||
|
||||
// Get user from database
|
||||
userDB, err := database.GetUserByChatID(r.Context(), req.UserID)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to get user: %v", err)
|
||||
respondError(w, "user not found", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Get storage
|
||||
var stor storage.Storage
|
||||
if req.StorageName != "" {
|
||||
stor, err = storage.GetStorageByUserIDAndName(r.Context(), req.UserID, req.StorageName)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to get storage: %v", err)
|
||||
respondError(w, "storage not found", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Use first available storage for the user
|
||||
storages := storage.GetUserStorages(r.Context(), req.UserID)
|
||||
if len(storages) == 0 {
|
||||
respondError(w, "no storage available for user", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
stor = storages[0]
|
||||
}
|
||||
|
||||
// Parse Telegram URL
|
||||
botCtx := bot.ExtContext()
|
||||
if botCtx == nil {
|
||||
respondError(w, "bot not initialized", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
linkUrl, err := url.Parse(req.TelegramURL)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to parse URL: %v", err)
|
||||
respondError(w, "invalid telegram URL format", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
chatID, msgID, err := tgutil.ParseMessageLink(botCtx, req.TelegramURL)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to parse Telegram URL: %v", err)
|
||||
respondError(w, "invalid telegram URL format", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Get message from Telegram
|
||||
msg, err := tgutil.GetMessageByID(botCtx, chatID, msgID)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to get message: %v", err)
|
||||
respondError(w, "failed to retrieve message", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if message has media
|
||||
media, ok := msg.GetMedia()
|
||||
if !ok {
|
||||
respondError(w, "message has no media", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Collect files - handle both single and grouped messages
|
||||
files := make([]tfile.TGFileMessage, 0)
|
||||
|
||||
// Check for grouped messages (media group)
|
||||
groupID, isGroup := msg.GetGroupedID()
|
||||
if isGroup && groupID != 0 && !linkUrl.Query().Has("single") {
|
||||
// Handle media group
|
||||
gmsgs, err := tgutil.GetGroupedMessages(botCtx, chatID, msg)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to get grouped messages: %v", err)
|
||||
// Fall back to single message
|
||||
file, err := createTGFileWithMedia(botCtx, msg, media, userDB)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to create TGFile: %v", err)
|
||||
respondError(w, "invalid message format", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
files = append(files, file)
|
||||
} else {
|
||||
// Process all messages in the group
|
||||
for _, gmsg := range gmsgs {
|
||||
if gmsg.Media == nil {
|
||||
continue
|
||||
}
|
||||
gMedia, ok := gmsg.GetMedia()
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
file, err := createTGFileWithMedia(botCtx, gmsg, gMedia, userDB)
|
||||
if err != nil {
|
||||
logger.Warnf("Failed to create TGFile for grouped message: %v", err)
|
||||
continue
|
||||
}
|
||||
files = append(files, file)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Single message
|
||||
file, err := createTGFileWithMedia(botCtx, msg, media, userDB)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to create TGFile: %v", err)
|
||||
respondError(w, "invalid message format", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
files = append(files, file)
|
||||
}
|
||||
|
||||
if len(files) == 0 {
|
||||
respondError(w, "no savable files found", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Create tasks for all files with proper album handling
|
||||
taskIDs := make([]string, 0, len(files))
|
||||
baseDirPath := req.DirPath
|
||||
if baseDirPath == "" {
|
||||
baseDirPath = "/"
|
||||
}
|
||||
|
||||
// Create context with bot extension
|
||||
injectCtx := tgutil.ExtWithContext(r.Context(), botCtx)
|
||||
|
||||
// Apply storage rules if enabled for the user
|
||||
useRule := userDB.ApplyRule && userDB.Rules != nil
|
||||
|
||||
// Helper to apply rules to a file
|
||||
applyRule := func(file tfile.TGFileMessage) (storage.Storage, ruleutil.MatchedDirPath) {
|
||||
fileStor := stor
|
||||
dirPath := ruleutil.MatchedDirPath(baseDirPath)
|
||||
|
||||
if useRule {
|
||||
matched, matchedStorName, matchedDirPath := ruleutil.ApplyRule(injectCtx, userDB.Rules, ruleutil.NewInput(file))
|
||||
if matched {
|
||||
// Rule matched, apply overrides
|
||||
if matchedDirPath != "" {
|
||||
dirPath = matchedDirPath
|
||||
}
|
||||
if matchedStorName.Usable() {
|
||||
var err error
|
||||
fileStor, err = storage.GetStorageByUserIDAndName(injectCtx, userDB.ChatID, matchedStorName.String())
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to get storage from rule: %v", err)
|
||||
// Fall back to original storage
|
||||
fileStor = stor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return fileStor, dirPath
|
||||
}
|
||||
|
||||
// Separate files into regular and album files
|
||||
type albumFile struct {
|
||||
file tfile.TGFileMessage
|
||||
storage storage.Storage
|
||||
dirPath ruleutil.MatchedDirPath
|
||||
}
|
||||
albumFiles := make(map[int64][]albumFile)
|
||||
|
||||
for _, tgFile := range files {
|
||||
fileStor, dirPath := applyRule(tgFile)
|
||||
|
||||
// Check if this needs album handling (NEW-FOR-ALBUM rule)
|
||||
if dirPath.NeedNewForAlbum() {
|
||||
groupId, isGroup := tgFile.Message().GetGroupedID()
|
||||
if !isGroup || groupId == 0 {
|
||||
logger.Warnf("File %s has NEW-FOR-ALBUM rule but is not in a group, treating as regular file", tgFile.Name())
|
||||
// Treat as regular file with base dir path
|
||||
storagePath := fileStor.JoinStoragePath(path.Join(baseDirPath, tgFile.Name()))
|
||||
taskID := xid.New().String()
|
||||
|
||||
task, err := tftask.NewTGFileTask(taskID, injectCtx, tgFile, fileStor, storagePath, &apiProgressTracker{
|
||||
taskID: taskID,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to create task: %v", err)
|
||||
respondError(w, "failed to create task", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
trackTask(taskID, task.Title(), "queued")
|
||||
if err := core.AddTask(injectCtx, task); err != nil {
|
||||
logger.Errorf("Failed to add task: %v", err)
|
||||
updateTaskStatus(taskID, "failed", err.Error())
|
||||
respondError(w, "failed to add task to queue", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
taskIDs = append(taskIDs, taskID)
|
||||
continue
|
||||
}
|
||||
|
||||
// Group by album ID
|
||||
if _, ok := albumFiles[groupId]; !ok {
|
||||
albumFiles[groupId] = make([]albumFile, 0)
|
||||
}
|
||||
albumFiles[groupId] = append(albumFiles[groupId], albumFile{
|
||||
file: tgFile,
|
||||
storage: fileStor,
|
||||
dirPath: dirPath,
|
||||
})
|
||||
} else {
|
||||
// Regular file - create task immediately
|
||||
storagePath := fileStor.JoinStoragePath(path.Join(dirPath.String(), tgFile.Name()))
|
||||
taskID := xid.New().String()
|
||||
|
||||
task, err := tftask.NewTGFileTask(taskID, injectCtx, tgFile, fileStor, storagePath, &apiProgressTracker{
|
||||
taskID: taskID,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to create task: %v", err)
|
||||
respondError(w, "failed to create task", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
trackTask(taskID, task.Title(), "queued")
|
||||
if err := core.AddTask(injectCtx, task); err != nil {
|
||||
logger.Errorf("Failed to add task: %v", err)
|
||||
updateTaskStatus(taskID, "failed", err.Error())
|
||||
respondError(w, "failed to add task to queue", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
taskIDs = append(taskIDs, taskID)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle album files - group them into subdirectories
|
||||
for _, afiles := range albumFiles {
|
||||
if len(afiles) <= 1 {
|
||||
// Single file in album, treat as regular
|
||||
for _, af := range afiles {
|
||||
storagePath := af.storage.JoinStoragePath(path.Join(baseDirPath, af.file.Name()))
|
||||
taskID := xid.New().String()
|
||||
|
||||
task, err := tftask.NewTGFileTask(taskID, injectCtx, af.file, af.storage, storagePath, &apiProgressTracker{
|
||||
taskID: taskID,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to create task: %v", err)
|
||||
respondError(w, "failed to create task", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
trackTask(taskID, task.Title(), "queued")
|
||||
if err := core.AddTask(injectCtx, task); err != nil {
|
||||
logger.Errorf("Failed to add task: %v", err)
|
||||
updateTaskStatus(taskID, "failed", err.Error())
|
||||
respondError(w, "failed to add task to queue", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
taskIDs = append(taskIDs, taskID)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Multiple files in album - create subdirectory named after first file
|
||||
// Remove extension from first file's name to use as directory name
|
||||
albumDir := strings.TrimSuffix(path.Base(afiles[0].file.Name()), path.Ext(afiles[0].file.Name()))
|
||||
albumStor := afiles[0].storage
|
||||
|
||||
for _, af := range afiles {
|
||||
// All files go into the album subdirectory
|
||||
storagePath := albumStor.JoinStoragePath(path.Join(baseDirPath, albumDir, af.file.Name()))
|
||||
taskID := xid.New().String()
|
||||
|
||||
task, err := tftask.NewTGFileTask(taskID, injectCtx, af.file, albumStor, storagePath, &apiProgressTracker{
|
||||
taskID: taskID,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to create task for album file: %v", err)
|
||||
respondError(w, "failed to create task", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
trackTask(taskID, task.Title(), "queued")
|
||||
if err := core.AddTask(injectCtx, task); err != nil {
|
||||
logger.Errorf("Failed to add task: %v", err)
|
||||
updateTaskStatus(taskID, "failed", err.Error())
|
||||
respondError(w, "failed to add task to queue", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
taskIDs = append(taskIDs, taskID)
|
||||
}
|
||||
}
|
||||
|
||||
// Send success response
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
|
||||
// Return first task ID for single file, or all task IDs for media group
|
||||
if len(taskIDs) == 1 {
|
||||
json.NewEncoder(w).Encode(CreateTaskResponse{
|
||||
TaskID: taskIDs[0],
|
||||
Message: "task created successfully",
|
||||
})
|
||||
} else {
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"task_ids": taskIDs,
|
||||
"message": fmt.Sprintf("%d tasks created successfully", len(taskIDs)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// createTGFileWithMedia creates a TGFile with proper filename handling using user's strategy
|
||||
func createTGFileWithMedia(botCtx *ext.Context, msg *tg.Message, media tg.MessageMediaClass, userDB *database.User) (tfile.TGFileMessage, error) {
|
||||
// Use the same filename generation logic as bot handlers
|
||||
opts := []tfile.TGFileOption{tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*msg))}
|
||||
return tfile.FromMediaMessage(media, botCtx.Raw, msg, opts...)
|
||||
}
|
||||
|
||||
func handleGetTask(w http.ResponseWriter, r *http.Request) {
|
||||
taskID := r.PathValue("id")
|
||||
if taskID == "" {
|
||||
respondError(w, "task_id is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
taskStatusesMu.RLock()
|
||||
status, exists := taskStatuses[taskID]
|
||||
taskStatusesMu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
respondError(w, "task not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(TaskStatusResponse{
|
||||
TaskID: status.ID,
|
||||
Status: status.Status,
|
||||
Title: status.Title,
|
||||
CreatedAt: status.CreatedAt,
|
||||
Error: status.Error,
|
||||
Downloaded: status.Downloaded.Load(),
|
||||
Total: status.Total.Load(),
|
||||
ProgressPct: math.Float64frombits(atomic.LoadUint64((*uint64)(&status.ProgressPct))),
|
||||
})
|
||||
}
|
||||
|
||||
func handleListTasks(w http.ResponseWriter, r *http.Request) {
|
||||
queued := core.GetQueuedTasks(r.Context())
|
||||
running := core.GetRunningTasks(r.Context())
|
||||
|
||||
response := ListTasksResponse{
|
||||
Queued: convertTaskInfos(queued),
|
||||
Running: convertTaskInfos(running),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
func handleCancelTask(w http.ResponseWriter, r *http.Request) {
|
||||
taskID := r.PathValue("id")
|
||||
if taskID == "" {
|
||||
respondError(w, "task_id is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := core.CancelTask(r.Context(), taskID); err != nil {
|
||||
log.FromContext(r.Context()).Errorf("Failed to cancel task %s: %v", taskID, err)
|
||||
respondError(w, "failed to cancel task", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
updateTaskStatus(taskID, "canceled", "")
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"message": "task canceled"})
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func respondError(w http.ResponseWriter, message string, statusCode int) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(statusCode)
|
||||
json.NewEncoder(w).Encode(ErrorResponse{Error: message})
|
||||
}
|
||||
|
||||
func trackTask(taskID, title, status string) {
|
||||
taskStatusesMu.Lock()
|
||||
defer taskStatusesMu.Unlock()
|
||||
taskStatuses[taskID] = &taskStatus{
|
||||
ID: taskID,
|
||||
Status: status,
|
||||
Title: title,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func updateTaskStatus(taskID, status, errorMsg string) {
|
||||
taskStatusesMu.Lock()
|
||||
defer taskStatusesMu.Unlock()
|
||||
if ts, exists := taskStatuses[taskID]; exists {
|
||||
ts.Status = status
|
||||
ts.Error = errorMsg
|
||||
}
|
||||
}
|
||||
|
||||
func convertTaskInfos(tasks []queue.TaskInfo) []TaskInfo {
|
||||
result := make([]TaskInfo, len(tasks))
|
||||
for i, t := range tasks {
|
||||
result[i] = TaskInfo{
|
||||
ID: t.ID,
|
||||
Title: t.Title,
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// apiProgressTracker implements tftask.ProgressTracker for API tasks
|
||||
type apiProgressTracker struct {
|
||||
taskID string
|
||||
}
|
||||
|
||||
func (a *apiProgressTracker) OnStart(ctx context.Context, info tftask.TaskInfo) {
|
||||
updateTaskStatus(a.taskID, "running", "")
|
||||
}
|
||||
|
||||
func (a *apiProgressTracker) OnProgress(ctx context.Context, info tftask.TaskInfo, downloaded int64, total int64) {
|
||||
// Use atomic operations to avoid mutex locks for better performance
|
||||
// OnProgress is called very frequently during downloads
|
||||
taskStatusesMu.RLock()
|
||||
ts, exists := taskStatuses[a.taskID]
|
||||
taskStatusesMu.RUnlock()
|
||||
|
||||
if exists {
|
||||
ts.Downloaded.Store(downloaded)
|
||||
ts.Total.Store(total)
|
||||
if total > 0 {
|
||||
progressPct := float64(downloaded) / float64(total) * 100.0
|
||||
atomic.StoreUint64((*uint64)(&ts.ProgressPct), math.Float64bits(progressPct))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *apiProgressTracker) OnDone(ctx context.Context, info tftask.TaskInfo, err error) {
|
||||
if err != nil {
|
||||
updateTaskStatus(a.taskID, "failed", err.Error())
|
||||
sendWebhook(a.taskID, "failed", err.Error())
|
||||
} else {
|
||||
updateTaskStatus(a.taskID, "completed", "")
|
||||
sendWebhook(a.taskID, "completed", "")
|
||||
}
|
||||
}
|
||||
|
||||
// sendWebhook sends a callback to the configured webhook URL
|
||||
func sendWebhook(taskID, status, errorMsg string) {
|
||||
cfg := config.C()
|
||||
if cfg.API.WebhookURL == "" {
|
||||
return
|
||||
}
|
||||
|
||||
taskStatusesMu.RLock()
|
||||
ts, exists := taskStatuses[taskID]
|
||||
taskStatusesMu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
|
||||
logger := log.WithPrefix("webhook")
|
||||
|
||||
payload := TaskStatusResponse{
|
||||
TaskID: ts.ID,
|
||||
Status: status,
|
||||
Title: ts.Title,
|
||||
CreatedAt: ts.CreatedAt,
|
||||
Error: errorMsg,
|
||||
Downloaded: ts.Downloaded.Load(),
|
||||
Total: ts.Total.Load(),
|
||||
ProgressPct: math.Float64frombits(atomic.LoadUint64((*uint64)(&ts.ProgressPct))),
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to marshal webhook payload: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", cfg.API.WebhookURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to create webhook request: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if cfg.API.Token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+cfg.API.Token)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to send webhook: %v", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
logger.Errorf("Webhook returned error status %d, failed to read response body: %v", resp.StatusCode, err)
|
||||
} else {
|
||||
logger.Errorf("Webhook returned error status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
}
|
||||
}
|
||||
66
api/middleware.go
Normal file
66
api/middleware.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
)
|
||||
|
||||
// authMiddleware validates API token
|
||||
func authMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Skip auth for health check
|
||||
if r.URL.Path == "/health" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
cfg := config.C()
|
||||
|
||||
// Check token if configured
|
||||
if cfg.API.Token != "" {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
http.Error(w, `{"error":"unauthorized: missing token"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
token := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
if token != cfg.API.Token {
|
||||
http.Error(w, `{"error":"unauthorized: invalid token"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// loggingMiddleware logs HTTP requests
|
||||
func loggingMiddleware(logger *log.Logger) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
|
||||
// Wrap response writer to capture status code
|
||||
wrapper := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
|
||||
|
||||
next.ServeHTTP(wrapper, r)
|
||||
|
||||
logger.Infof("%s %s %d %s", r.Method, r.URL.Path, wrapper.statusCode, time.Since(start))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type responseWriter struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
}
|
||||
|
||||
func (rw *responseWriter) WriteHeader(code int) {
|
||||
rw.statusCode = code
|
||||
rw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
47
api/middleware_test.go
Normal file
47
api/middleware_test.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAuthMiddleware_NoAuth(t *testing.T) {
|
||||
// Create a test handler
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("OK"))
|
||||
})
|
||||
|
||||
// Create request
|
||||
req := httptest.NewRequest("GET", "/api/v1/tasks", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
// Apply middleware
|
||||
authMiddleware(handler).ServeHTTP(rec, req)
|
||||
|
||||
// When no token is configured, request should succeed or be unauthorized
|
||||
if rec.Code != http.StatusOK && rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("Expected status 200 or 401, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMiddleware_HealthCheck(t *testing.T) {
|
||||
// Create a test handler
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("OK"))
|
||||
})
|
||||
|
||||
// Create request to health endpoint
|
||||
req := httptest.NewRequest("GET", "/health", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
// Apply middleware
|
||||
authMiddleware(handler).ServeHTTP(rec, req)
|
||||
|
||||
// Health check should always work without auth
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200 for health check, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
77
api/server.go
Normal file
77
api/server.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
)
|
||||
|
||||
var server *http.Server
|
||||
|
||||
// Init initializes and starts the HTTP API server
|
||||
func Init(ctx context.Context) error {
|
||||
cfg := config.C()
|
||||
if !cfg.API.Enable {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate that token is configured when API is enabled
|
||||
if cfg.API.Token == "" {
|
||||
return fmt.Errorf("API is enabled but token is not configured. Please set 'api.token' in your configuration file for security")
|
||||
}
|
||||
|
||||
logger := log.FromContext(ctx).WithPrefix("api")
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Register API routes
|
||||
registerRoutes(mux)
|
||||
|
||||
// Wrap with middleware
|
||||
handler := loggingMiddleware(logger)(authMiddleware(mux))
|
||||
|
||||
server = &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", cfg.API.Port),
|
||||
Handler: handler,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 15 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
logger.Infof("Starting API server on port %d", cfg.API.Port)
|
||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
logger.Errorf("API server error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Graceful shutdown on context cancellation
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := server.Shutdown(shutdownCtx); err != nil {
|
||||
logger.Errorf("Failed to shutdown API server: %v", err)
|
||||
} else {
|
||||
logger.Info("API server stopped")
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func registerRoutes(mux *http.ServeMux) {
|
||||
// Health check endpoint (no auth required)
|
||||
mux.HandleFunc("/health", handleHealth)
|
||||
|
||||
// API v1 endpoints
|
||||
mux.HandleFunc("POST /api/v1/tasks", handleCreateTask)
|
||||
mux.HandleFunc("GET /api/v1/tasks/{id}", handleGetTask)
|
||||
mux.HandleFunc("GET /api/v1/tasks", handleListTasks)
|
||||
mux.HandleFunc("DELETE /api/v1/tasks/{id}", handleCancelTask)
|
||||
}
|
||||
@@ -9,46 +9,44 @@ import (
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/celestix/gotgproto/sessionMaker"
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/gotd/td/telegram/dcs"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers"
|
||||
"github.com/krau/SaveAny-Bot/client/middleware"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/netutil"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"github.com/ncruces/go-sqlite3/gormlite"
|
||||
"golang.org/x/net/proxy"
|
||||
"github.com/krau/SaveAny-Bot/database"
|
||||
)
|
||||
|
||||
var ectx *ext.Context
|
||||
|
||||
func ExtContext() *ext.Context {
|
||||
return ectx
|
||||
}
|
||||
|
||||
func Init(ctx context.Context) <-chan struct{} {
|
||||
log.FromContext(ctx).Info("初始化 Bot...")
|
||||
log.FromContext(ctx).Info("Initializing Bot...")
|
||||
resultChan := make(chan struct {
|
||||
client *gotgproto.Client
|
||||
err error
|
||||
})
|
||||
shouldRestart := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
var resolver dcs.Resolver
|
||||
if config.C().Telegram.Proxy.Enable && config.C().Telegram.Proxy.URL != "" {
|
||||
dialer, err := netutil.NewProxyDialer(config.C().Telegram.Proxy.URL)
|
||||
if err != nil {
|
||||
resultChan <- struct {
|
||||
client *gotgproto.Client
|
||||
err error
|
||||
}{nil, err}
|
||||
return
|
||||
}
|
||||
resolver = dcs.Plain(dcs.PlainOptions{
|
||||
Dial: dialer.(proxy.ContextDialer).DialContext,
|
||||
})
|
||||
} else {
|
||||
resolver = dcs.DefaultResolver()
|
||||
resolver, err := tgutil.NewConfigProxyResolver()
|
||||
if err != nil {
|
||||
resultChan <- struct {
|
||||
client *gotgproto.Client
|
||||
err error
|
||||
}{nil, err}
|
||||
return
|
||||
}
|
||||
client, err := gotgproto.NewClient(
|
||||
config.C().Telegram.AppID,
|
||||
config.C().Telegram.AppHash,
|
||||
gotgproto.ClientTypeBot(config.C().Telegram.Token),
|
||||
&gotgproto.ClientOpts{
|
||||
Session: sessionMaker.SqlSession(gormlite.Open(config.C().DB.Session)),
|
||||
Session: sessionMaker.SqlSession(database.GetDialect(config.C().DB.Session)),
|
||||
DisableCopyright: true,
|
||||
Middlewares: middleware.NewDefaultMiddlewares(ctx, 5*time.Minute),
|
||||
Resolver: resolver,
|
||||
@@ -77,7 +75,7 @@ func Init(ctx context.Context) <-chan struct{} {
|
||||
})
|
||||
commands := make([]tg.BotCommand, 0, len(handlers.CommandHandlers))
|
||||
for _, info := range handlers.CommandHandlers {
|
||||
commands = append(commands, tg.BotCommand{Command: info.Cmd, Description: info.Desc})
|
||||
commands = append(commands, tg.BotCommand{Command: info.Cmd, Description: i18n.T(info.Desc)})
|
||||
}
|
||||
_, err = client.API().BotsSetBotCommands(ctx, &tg.BotsSetBotCommandsRequest{
|
||||
Scope: &tg.BotCommandScopeDefault{},
|
||||
@@ -91,13 +89,14 @@ func Init(ctx context.Context) <-chan struct{} {
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.FromContext(ctx).Errorf("已取消 Bot 初始化: %s", ctx.Err())
|
||||
log.FromContext(ctx).Errorf("Bot initialization cancelled: %s", ctx.Err())
|
||||
case result := <-resultChan:
|
||||
if result.err != nil {
|
||||
log.FromContext(ctx).Fatalf("初始化 Bot 失败: %s", result.err)
|
||||
log.FromContext(ctx).Fatalf("Failed to initialize Bot: %s", result.err)
|
||||
}
|
||||
handlers.Register(result.client.Dispatcher)
|
||||
log.FromContext(ctx).Info("Bot 初始化完成")
|
||||
ectx = result.client.CreateContext()
|
||||
log.FromContext(ctx).Info("Bot initialization completed.")
|
||||
}
|
||||
return shouldRestart
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/shortcut"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/fsutil"
|
||||
"github.com/krau/SaveAny-Bot/database"
|
||||
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
|
||||
@@ -33,12 +35,14 @@ func handleAddCallback(ctx *ext.Context, update *ext.Update) error {
|
||||
selectedStorage, err := storage.GetStorageByUserIDAndName(ctx, userID, data.SelectedStorName)
|
||||
if err != nil {
|
||||
log.FromContext(ctx).Errorf("Failed to get storage: %s", err)
|
||||
ctx.AnswerCallback(msgelem.AlertCallbackAnswer(queryID, "存储获取失败: "+err.Error()))
|
||||
ctx.AnswerCallback(msgelem.AlertCallbackAnswer(queryID, i18n.T(i18nk.BotMsgCommonErrorGetStorageFailed, map[string]any{
|
||||
"Error": err.Error(),
|
||||
})))
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
dirs, err := database.GetDirsByUserChatIDAndStorageName(ctx, userID, data.SelectedStorName)
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return fmt.Errorf("获取用户目录失败: %w", err)
|
||||
return fmt.Errorf("failed to get user directories: %w", err)
|
||||
}
|
||||
|
||||
if !data.SettedDir && len(dirs) != 0 {
|
||||
@@ -46,12 +50,14 @@ func handleAddCallback(ctx *ext.Context, update *ext.Update) error {
|
||||
markup, err := msgelem.BuildSetDirMarkupForAdd(dirs, dataid)
|
||||
if err != nil {
|
||||
log.FromContext(ctx).Errorf("Failed to build directory keyboard: %s", err)
|
||||
ctx.AnswerCallback(msgelem.AlertCallbackAnswer(queryID, "目录键盘构建失败: "+err.Error()))
|
||||
ctx.AnswerCallback(msgelem.AlertCallbackAnswer(queryID, i18n.T(i18nk.BotMsgCommonErrorBuildStorageSelectKeyboardFailed, map[string]any{
|
||||
"Error": err.Error(),
|
||||
})))
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||
ID: update.CallbackQuery.GetMsgID(),
|
||||
Message: "请选择要存储到的目录",
|
||||
Message: i18n.T(i18nk.BotMsgCommonPromptSelectDir, nil),
|
||||
ReplyMarkup: markup,
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
@@ -61,7 +67,9 @@ func handleAddCallback(ctx *ext.Context, update *ext.Update) error {
|
||||
if data.DirID != 0 {
|
||||
dir, err := database.GetDirByID(ctx, data.DirID)
|
||||
if err != nil {
|
||||
ctx.AnswerCallback(msgelem.AlertCallbackAnswer(queryID, "获取目录失败: "+err.Error()))
|
||||
ctx.AnswerCallback(msgelem.AlertCallbackAnswer(queryID, i18n.T(i18nk.BotMsgCommonErrorGetDirFailed, map[string]any{
|
||||
"Error": err.Error(),
|
||||
})))
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
dirPath = dir.Path
|
||||
@@ -80,8 +88,21 @@ func handleAddCallback(ctx *ext.Context, update *ext.Update) error {
|
||||
dirPath = path.Join(dirPath, fsutil.NormalizePathname(data.ParsedItem.Title))
|
||||
}
|
||||
shortcut.CreateAndAddParsedTaskWithEdit(ctx, selectedStorage, dirPath, data.ParsedItem, msgID, userID)
|
||||
case tasktype.TaskTypeDirectlinks:
|
||||
shortcut.CreateAndAddDirectTaskWithEdit(ctx, selectedStorage, dirPath, data.DirectLinks, msgID, userID)
|
||||
case tasktype.TaskTypeAria2:
|
||||
client := GetAria2Client()
|
||||
if client == nil {
|
||||
ctx.AnswerCallback(msgelem.AlertCallbackAnswer(queryID, i18n.T(i18nk.BotMsgAria2ErrorAria2ClientInitFailed, map[string]any{
|
||||
"Error": "aria2 client not initialized",
|
||||
})))
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
shortcut.CreateAndAddAria2TaskWithEdit(ctx, selectedStorage, dirPath, data.Aria2URIs, client, msgID, userID)
|
||||
case tasktype.TaskTypeYtdlp:
|
||||
shortcut.CreateAndAddYtdlpTaskWithEdit(ctx, selectedStorage, dirPath, data.YtdlpURLs, msgID, userID)
|
||||
default:
|
||||
log.FromContext(ctx).Errorf("Unsupported task type: %s", data.TaskType)
|
||||
return fmt.Errorf("unexcept task type: %s", data.TaskType)
|
||||
}
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
@@ -8,21 +8,46 @@ import (
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||
"github.com/krau/SaveAny-Bot/core"
|
||||
)
|
||||
|
||||
func handleCancelCallback(ctx *ext.Context, update *ext.Update) error {
|
||||
taskid := strings.Split(string(update.CallbackQuery.Data), " ")[1]
|
||||
if err := core.CancelTask(ctx, taskid); err != nil {
|
||||
log.FromContext(ctx).Errorf("error cancelling task %s: %v", taskid, err)
|
||||
ctx.AnswerCallback(msgelem.AlertCallbackAnswer(update.CallbackQuery.GetQueryID(), "取消任务失败: "+err.Error()))
|
||||
log.FromContext(ctx).Errorf("Failed to cancel task %s: %v", taskid, err)
|
||||
ctx.AnswerCallback(msgelem.AlertCallbackAnswer(update.CallbackQuery.GetQueryID(), i18n.T(i18nk.BotMsgCancelErrorCancelFailed, map[string]any{
|
||||
"Error": err.Error(),
|
||||
})))
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
ctx.EditMessage(update.CallbackQuery.GetUserID(), &tg.MessagesEditMessageRequest{
|
||||
ID: update.CallbackQuery.GetMsgID(),
|
||||
Message: "正在取消任务...",
|
||||
Message: i18n.T(i18nk.BotMsgCancelInfoCancellingTask, nil),
|
||||
})
|
||||
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
func handleCancelCmd(ctx *ext.Context, update *ext.Update) error {
|
||||
logger := log.FromContext(ctx)
|
||||
args := strings.Fields(update.EffectiveMessage.Text)
|
||||
if len(args) < 2 {
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCancelUsage, nil)), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
taskID := args[1]
|
||||
if err := core.CancelTask(ctx, taskID); err != nil {
|
||||
logger.Errorf("failed to cancel task %s: %v", taskID, err)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCancelErrorCancelFailed, map[string]any{
|
||||
"Error": err.Error(),
|
||||
})), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCancelInfoCancelRequested, map[string]any{
|
||||
"TaskID": taskID,
|
||||
})), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
@@ -8,19 +8,22 @@ import (
|
||||
"github.com/celestix/gotgproto/dispatcher"
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"github.com/krau/SaveAny-Bot/database"
|
||||
"github.com/krau/SaveAny-Bot/pkg/enums/fnamest"
|
||||
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
||||
)
|
||||
|
||||
func handleConfigCmd(ctx *ext.Context, update *ext.Update) error {
|
||||
ctx.Reply(update, ext.ReplyTextString("请选择要配置的选项"), &ext.ReplyOpts{
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgConfigPromptSelectOption)), &ext.ReplyOpts{
|
||||
Markup: &tg.ReplyInlineMarkup{
|
||||
Rows: []tg.KeyboardButtonRow{
|
||||
{
|
||||
Buttons: []tg.KeyboardButtonClass{
|
||||
&tg.KeyboardButtonCallback{
|
||||
Text: "文件名策略",
|
||||
Text: i18n.T(i18nk.BotMsgConfigButtonFilenameStrategy),
|
||||
Data: fmt.Appendf(nil, "%s %s", tcbdata.TypeConfig, "fnamest"),
|
||||
},
|
||||
},
|
||||
@@ -37,7 +40,7 @@ func handleConfigCallback(ctx *ext.Context, update *ext.Update) error {
|
||||
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
|
||||
QueryID: update.CallbackQuery.GetQueryID(),
|
||||
Alert: true,
|
||||
Message: "无效的回调数据",
|
||||
Message: i18n.T(i18nk.BotMsgConfigErrorInvalidCallbackData),
|
||||
CacheTime: 5,
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
@@ -71,8 +74,10 @@ func handleConfigFnameSTCallback(ctx *ext.Context, update *ext.Update) error {
|
||||
return err
|
||||
}
|
||||
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||
ID: update.CallbackQuery.GetMsgID(),
|
||||
Message: fmt.Sprintf("已将文件名策略设置为: %s", fnamest.FnameSTDisplay[st]),
|
||||
ID: update.CallbackQuery.GetMsgID(),
|
||||
Message: i18n.T(i18nk.BotMsgConfigInfoFilenameStrategySet, map[string]any{
|
||||
"Strategy": fnamest.GetDisplay(st, config.C().Lang),
|
||||
}),
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
@@ -80,7 +85,7 @@ func handleConfigFnameSTCallback(ctx *ext.Context, update *ext.Update) error {
|
||||
buttons := make([]tg.KeyboardButtonClass, 0, len(opts))
|
||||
for _, opt := range opts {
|
||||
buttons = append(buttons, &tg.KeyboardButtonCallback{
|
||||
Text: fnamest.FnameSTDisplay[opt],
|
||||
Text: fnamest.GetDisplay(opt, config.C().Lang),
|
||||
Data: fmt.Appendf(nil, "%s %s %s", tcbdata.TypeConfig, "fnamest", opt),
|
||||
})
|
||||
}
|
||||
@@ -96,8 +101,10 @@ func handleConfigFnameSTCallback(ctx *ext.Context, update *ext.Update) error {
|
||||
currentSt = fnamest.Default
|
||||
}
|
||||
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||
ID: update.CallbackQuery.GetMsgID(),
|
||||
Message: fmt.Sprintf("请选择文件名策略, 当前策略: %s", fnamest.FnameSTDisplay[currentSt]),
|
||||
ID: update.CallbackQuery.GetMsgID(),
|
||||
Message: i18n.T(i18nk.BotMsgConfigPromptSelectFilenameStrategy, map[string]any{
|
||||
"Strategy": fnamest.GetDisplay(currentSt, config.C().Lang),
|
||||
}),
|
||||
ReplyMarkup: markup,
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
@@ -111,34 +118,27 @@ func handleConfigFnameTmpl(ctx *ext.Context, update *ext.Update) error {
|
||||
}
|
||||
args := strings.Fields(string(update.EffectiveMessage.Text))
|
||||
if len(args) <= 1 {
|
||||
text := `使用该命令设置文件名模板, 示例:
|
||||
/fnametmpl 图片_{{.msgid}}_{{.msgdate}}.jpg
|
||||
|
||||
可用变量:
|
||||
- {{.msgid}}: 消息ID
|
||||
- {{.msgtags}}: 消息中的标签, 将以下划线分隔输出
|
||||
- {{.msggen}}: 根据消息生成的文件名
|
||||
- {{.msgdate}}: 消息日期, 格式 YYYY-MM-DD_HH-MM-SS
|
||||
- {{.origname}}: 媒体的原始文件名 (如果有)
|
||||
- {{.chatid}}: 消息的聊天ID
|
||||
`
|
||||
text := i18n.T(i18nk.BotMsgConfigFnametmplHelp, nil)
|
||||
if user.FilenameTemplate != "" {
|
||||
text += fmt.Sprintf("\n\n当前模板: %s", user.FilenameTemplate)
|
||||
text += "\n\n" + i18n.T(i18nk.BotMsgConfigInfoCurrentTemplatePrefix, map[string]any{
|
||||
"Template": user.FilenameTemplate,
|
||||
})
|
||||
}
|
||||
text += "\n\n模板仅在文件名策略设置为 '自定义模板' 时生效, 且模板解析错误时会回退到默认文件名"
|
||||
ctx.Reply(update, ext.ReplyTextString(text), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
newTmpl := strings.Join(args[1:], " ")
|
||||
_, err = template.New("filename").Parse(newTmpl)
|
||||
if err != nil {
|
||||
ctx.Reply(update, ext.ReplyTextString("无效的模板, 请检查语法\n"+err.Error()), nil)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgConfigErrorInvalidTemplate, map[string]any{
|
||||
"Error": err.Error(),
|
||||
})), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
user.FilenameTemplate = newTmpl
|
||||
if err := database.UpdateUser(ctx, user); err != nil {
|
||||
return err
|
||||
}
|
||||
ctx.Reply(update, ext.ReplyTextString("已更新文件名模板"), nil)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgConfigInfoTemplateUpdated, nil)), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"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/i18n"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||
"github.com/krau/SaveAny-Bot/database"
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
)
|
||||
@@ -18,8 +20,8 @@ func handleDirCmd(ctx *ext.Context, update *ext.Update) error {
|
||||
userChatID := update.GetUserChat().GetID()
|
||||
dirs, err := database.GetUserDirsByChatID(ctx, userChatID)
|
||||
if err != nil {
|
||||
logger.Errorf("获取用户文件夹失败: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("获取用户文件夹失败"), nil)
|
||||
logger.Errorf("Failed to get user directories: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgDirErrorGetUserDirsFailed)), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
if len(args) < 2 {
|
||||
@@ -28,8 +30,8 @@ func handleDirCmd(ctx *ext.Context, update *ext.Update) error {
|
||||
}
|
||||
user, err := database.GetUserByChatID(ctx, update.GetUserChat().GetID())
|
||||
if err != nil {
|
||||
logger.Errorf("获取用户失败: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("获取用户失败"), nil)
|
||||
logger.Errorf("Failed to get user: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgDirErrorGetUserFailed)), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
switch args[1] {
|
||||
@@ -45,11 +47,11 @@ func handleDirCmd(ctx *ext.Context, update *ext.Update) error {
|
||||
}
|
||||
|
||||
if err := database.CreateDirForUser(ctx, user.ID, args[2], args[3]); err != nil {
|
||||
logger.Errorf("创建文件夹失败: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("创建文件夹失败"), nil)
|
||||
logger.Errorf("Failed to create directory: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgDirErrorCreateDirFailed)), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
ctx.Reply(update, ext.ReplyTextString("文件夹添加成功"), nil)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgDirInfoCreateDirSuccess)), nil)
|
||||
case "del":
|
||||
// /dir del 3
|
||||
if len(args) < 3 {
|
||||
@@ -58,17 +60,17 @@ func handleDirCmd(ctx *ext.Context, update *ext.Update) error {
|
||||
}
|
||||
dirID, err := strconv.Atoi(args[2])
|
||||
if err != nil {
|
||||
ctx.Reply(update, ext.ReplyTextString("文件夹ID无效"), nil)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgDirErrorInvalidDirId)), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
if err := database.DeleteDirByID(ctx, uint(dirID)); err != nil {
|
||||
logger.Errorf("删除文件夹失败: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("删除文件夹失败"), nil)
|
||||
logger.Errorf("Failed to delete directory: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgDirErrorDeleteDirFailed)), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
ctx.Reply(update, ext.ReplyTextString("文件夹删除成功"), nil)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgDirInfoDeleteDirSuccess)), nil)
|
||||
default:
|
||||
ctx.Reply(update, ext.ReplyTextString("未知操作"), nil)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgDirErrorUnknownOperation)), nil)
|
||||
}
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
113
client/bot/handlers/dl.go
Normal file
113
client/bot/handlers/dl.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/duke-git/lancet/v2/slice"
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"github.com/krau/SaveAny-Bot/pkg/aria2"
|
||||
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
|
||||
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
)
|
||||
|
||||
func handleDlCmd(ctx *ext.Context, update *ext.Update) error {
|
||||
logger := log.FromContext(ctx)
|
||||
args := strings.Split(update.EffectiveMessage.Text, " ")
|
||||
if len(args) < 2 {
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgDlUsage)), nil)
|
||||
return nil
|
||||
}
|
||||
links := args[1:]
|
||||
for i, link := range links {
|
||||
links[i] = strings.TrimSpace(link)
|
||||
u, err := url.Parse(link)
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
logger.Warn("invaild link", link)
|
||||
links[i] = ""
|
||||
}
|
||||
}
|
||||
links = slice.Compact(links)
|
||||
if len(links) == 0 {
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgDlErrorNoValidLinks)), nil)
|
||||
return nil
|
||||
}
|
||||
markup, err := msgelem.BuildAddSelectStorageKeyboard(storage.GetUserStorages(ctx, update.GetUserChat().GetID()), tcbdata.Add{
|
||||
TaskType: tasktype.TaskTypeDirectlinks,
|
||||
DirectLinks: links,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgDlInfoFilesSelectStorage, map[string]any{
|
||||
"Count": len(links),
|
||||
})), &ext.ReplyOpts{
|
||||
Markup: markup,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
var aria2ClientInitOnce sync.Once
|
||||
var aria2ClientInitErr error
|
||||
var aria2Client *aria2.Client
|
||||
|
||||
// GetAria2Client returns the shared aria2 client instance
|
||||
func GetAria2Client() *aria2.Client {
|
||||
return aria2Client
|
||||
}
|
||||
|
||||
func handleAria2DlCmd(ctx *ext.Context, update *ext.Update) error {
|
||||
if !config.C().Aria2.Enable {
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgAria2ErrorAria2NotEnabled)), nil)
|
||||
return nil
|
||||
}
|
||||
logger := log.FromContext(ctx)
|
||||
args := strings.Split(update.EffectiveMessage.Text, " ")
|
||||
if len(args) < 2 {
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgDlUsage)), nil)
|
||||
return nil
|
||||
}
|
||||
links := args[1:]
|
||||
for i, link := range links {
|
||||
links[i] = strings.TrimSpace(link)
|
||||
}
|
||||
links = slice.Compact(links)
|
||||
if len(links) == 0 {
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgDlErrorNoValidLinks)), nil)
|
||||
return nil
|
||||
}
|
||||
logger.Debug("Preparing aria2 download", "links", links)
|
||||
|
||||
// Initialize aria2 client to check connection
|
||||
aria2ClientInitOnce.Do(func() {
|
||||
aria2Client, aria2ClientInitErr = aria2.NewClient(config.C().Aria2.Url, config.C().Aria2.Secret)
|
||||
})
|
||||
if aria2ClientInitErr != nil {
|
||||
logger.Error("Failed to initialize aria2 client", "error", aria2ClientInitErr)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgAria2ErrorAria2ClientInitFailed, map[string]any{
|
||||
"Error": aria2ClientInitErr.Error(),
|
||||
})), nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build storage selection keyboard (don't add to aria2 yet)
|
||||
markup, err := msgelem.BuildAddSelectStorageKeyboard(storage.GetUserStorages(ctx, update.GetUserChat().GetID()), tcbdata.Add{
|
||||
TaskType: tasktype.TaskTypeAria2,
|
||||
Aria2URIs: links,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgAria2InfoSelectStorage)), &ext.ReplyOpts{
|
||||
Markup: markup,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/celestix/gotgproto/dispatcher"
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/dirutil"
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/shortcut"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
)
|
||||
@@ -24,8 +24,10 @@ func handleMessageLink(ctx *ext.Context, update *ext.Update) error {
|
||||
if len(files) == 1 {
|
||||
req, err := msgelem.BuildAddOneSelectStorageMessage(ctx, stors, files[0], replied.ID)
|
||||
if err != nil {
|
||||
logger.Errorf("构建存储选择消息失败: %s", err)
|
||||
editReplied("构建存储选择消息失败: "+err.Error(), nil)
|
||||
logger.Errorf("Failed to build storage selection message: %s", err)
|
||||
editReplied(i18n.T(i18nk.BotMsgCommonErrorBuildStorageSelectMessageFailed, map[string]any{
|
||||
"Error": err.Error(),
|
||||
}), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
ctx.EditMessage(update.EffectiveChat().GetID(), req)
|
||||
@@ -35,22 +37,20 @@ func handleMessageLink(ctx *ext.Context, update *ext.Update) error {
|
||||
Files: files,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Errorf("构建存储选择键盘失败: %s", err)
|
||||
editReplied("构建存储选择键盘失败: "+err.Error(), nil)
|
||||
logger.Errorf("Failed to build storage selection keyboard: %s", err)
|
||||
editReplied(i18n.T(i18nk.BotMsgCommonErrorBuildStorageSelectKeyboardFailed, map[string]any{
|
||||
"Error": err.Error(),
|
||||
}), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
editReplied(fmt.Sprintf("找到 %d 个文件, 请选择存储位置", len(files)), markup)
|
||||
editReplied(i18n.T(i18nk.BotMsgCommonInfoFoundFilesSelectStorage, map[string]any{
|
||||
"Count": len(files),
|
||||
}), markup)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
func handleSilentSaveLink(ctx *ext.Context, update *ext.Update) error {
|
||||
logger := log.FromContext(ctx)
|
||||
stor := storage.FromContext(ctx)
|
||||
if stor == nil {
|
||||
logger.Warn("Context storage is nil")
|
||||
ctx.Reply(update, ext.ReplyTextString("未找到存储"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
replied, files, _, err := shortcut.GetFilesFromUpdateLinkMessageWithReplyEdit(ctx, update)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/mediautil"
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/shortcut"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||
"github.com/krau/SaveAny-Bot/database"
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
)
|
||||
@@ -34,8 +36,10 @@ func handleMediaMessage(ctx *ext.Context, update *ext.Update) error {
|
||||
stors := storage.GetUserStorages(ctx, userId)
|
||||
req, err := msgelem.BuildAddOneSelectStorageMessage(ctx, stors, file, msg.ID)
|
||||
if err != nil {
|
||||
logger.Errorf("构建存储选择消息失败: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("构建存储选择消息失败: "+err.Error()), nil)
|
||||
logger.Errorf("Failed to build storage selection message: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorBuildStorageSelectMessageFailed, map[string]any{
|
||||
"Error": err.Error(),
|
||||
})), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
ctx.EditMessage(update.EffectiveChat().GetID(), req)
|
||||
@@ -45,11 +49,6 @@ func handleMediaMessage(ctx *ext.Context, update *ext.Update) error {
|
||||
func handleSilentSaveMedia(ctx *ext.Context, update *ext.Update) error {
|
||||
logger := log.FromContext(ctx)
|
||||
stor := storage.FromContext(ctx)
|
||||
if stor == nil {
|
||||
logger.Warn("Context storage is nil")
|
||||
ctx.Reply(update, ext.ReplyTextString("未找到存储"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
message := update.EffectiveMessage.Message
|
||||
groupID, isGroup := message.GetGroupedID()
|
||||
if isGroup && groupID != 0 {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -12,6 +11,8 @@ import (
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/mediautil"
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/shortcut"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
||||
@@ -89,7 +90,7 @@ func processMediaGroup(ctx *ext.Context, update *ext.Update, groupID int64) {
|
||||
logger.Debugf("Processing media group %d with %d items", groupID, len(items))
|
||||
|
||||
userId := update.GetUserChat().GetID()
|
||||
msg, err := ctx.Reply(update, ext.ReplyTextString("正在保存文件..."), nil)
|
||||
msg, err := ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgMediaGroupInfoSavingFiles, nil)), nil)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to reply: %s", err)
|
||||
return
|
||||
@@ -111,16 +112,20 @@ func processMediaGroup(ctx *ext.Context, update *ext.Update, groupID int64) {
|
||||
AsBatch: len(items) > 1,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Errorf("构建存储选择键盘失败: %s", err)
|
||||
logger.Errorf("Failed to build storage selection keyboard: %s", err)
|
||||
ctx.EditMessage(userId, &tg.MessagesEditMessageRequest{
|
||||
ID: msg.ID,
|
||||
Message: "构建存储选择键盘失败: " + err.Error(),
|
||||
Message: i18n.T(i18nk.BotMsgMediaGroupErrorBuildStorageSelectKeyboardFailed, map[string]any{
|
||||
"Error": err.Error(),
|
||||
}),
|
||||
})
|
||||
return
|
||||
}
|
||||
ctx.EditMessage(userId, &tg.MessagesEditMessageRequest{
|
||||
ID: msg.ID,
|
||||
Message: fmt.Sprintf("共 %d 个文件, 请选择存储位置", len(items)),
|
||||
ID: msg.ID,
|
||||
Message: i18n.T(i18nk.BotMsgMediaGroupInfoGroupFoundFilesSelectStorage, map[string]any{
|
||||
"Count": len(items),
|
||||
}),
|
||||
ReplyMarkup: markup,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/duke-git/lancet/v2/slice"
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/dirutil"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"github.com/krau/SaveAny-Bot/database"
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
@@ -13,11 +15,7 @@ import (
|
||||
func checkPermission(ctx *ext.Context, update *ext.Update) error {
|
||||
userID := update.GetUserChat().GetID()
|
||||
if !slice.Contain(config.C().GetUsersID(), userID) {
|
||||
const noPermissionText string = `
|
||||
您不在白名单中, 无法使用此 Bot.
|
||||
您可以部署自己的实例: https://github.com/krau/SaveAny-Bot
|
||||
`
|
||||
ctx.Reply(update, ext.ReplyTextString(noPermissionText), nil)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorNoPermission, nil)), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
@@ -29,25 +27,31 @@ func handleSilentMode(next func(*ext.Context, *ext.Update) error, handler func(*
|
||||
userID := update.GetUserChat().GetID()
|
||||
user, err := database.GetUserByChatID(ctx, userID)
|
||||
if err != nil {
|
||||
ctx.Reply(update, ext.ReplyTextString("获取用户信息失败: "+err.Error()), nil)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorGetUserInfoFailed, map[string]any{
|
||||
"Error": err.Error(),
|
||||
})), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
if !user.Silent {
|
||||
return next(ctx, update)
|
||||
}
|
||||
if user.DefaultStorage == "" {
|
||||
ctx.Reply(update, ext.ReplyTextString("您已开启静默模式, 但未设置默认存储端, 请先使用 /storage 设置"), nil)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorDefaultStorageNotSet, nil)), nil)
|
||||
return next(ctx, update)
|
||||
}
|
||||
stor, err := storage.GetStorageByUserIDAndName(ctx, userID, user.DefaultStorage)
|
||||
if err != nil {
|
||||
ctx.Reply(update, ext.ReplyTextString("获取默认存储失败: "+err.Error()), nil)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorGetStorageFailed, map[string]any{
|
||||
"Error": err.Error(),
|
||||
})), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
if user.DefaultDir != 0 {
|
||||
dir, err := database.GetDirByID(ctx, user.DefaultDir)
|
||||
if err != nil {
|
||||
ctx.Reply(update, ext.ReplyTextString("获取默认文件夹失败: "+err.Error()), nil)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorGetDirFailed, map[string]any{
|
||||
"Error": err.Error(),
|
||||
})), nil)
|
||||
return next(ctx, update)
|
||||
}
|
||||
ctx.Context = dirutil.WithContext(ctx.Context, dir)
|
||||
|
||||
@@ -14,6 +14,8 @@ import (
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/dirutil"
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/shortcut"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/fsutil"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||
"github.com/krau/SaveAny-Bot/parsers"
|
||||
@@ -29,22 +31,40 @@ func handleTextMessage(ctx *ext.Context, u *ext.Update) error {
|
||||
if len(entityUrls) > 0 {
|
||||
text += "\n" + strings.Join(entityUrls, "\n")
|
||||
}
|
||||
ok, pser := parsers.CanHandle(text)
|
||||
// read lines and remove empty lines & duplicates
|
||||
lines := strings.Split(text, "\n")
|
||||
seen := make(map[string]struct{})
|
||||
var processedLines []string
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[line]; ok {
|
||||
continue
|
||||
}
|
||||
seen[line] = struct{}{}
|
||||
processedLines = append(processedLines, line)
|
||||
}
|
||||
source := strings.TrimSpace(strings.Join(processedLines, "\n"))
|
||||
ok, pser := parsers.CanHandle(source)
|
||||
if !ok {
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
msg, err := ctx.Reply(u, ext.ReplyTextString("正在解析..."), nil)
|
||||
msg, err := ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgParseInfoParsing, nil)), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
item, err := pser.Parse(ctx, text)
|
||||
item, err := pser.Parse(ctx, source)
|
||||
if errors.Is(err, parsers.ErrNoParserFound) {
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
if err != nil {
|
||||
logger.Error("Failed to parse text", "error", err)
|
||||
ctx.Reply(u, ext.ReplyTextString("Failed to parse text: "+err.Error()), nil)
|
||||
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgParseErrorParseTextFailed, map[string]any{
|
||||
"Error": err.Error(),
|
||||
})), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
logger.Debug("Parsed item from text message", "title", item.Title, "url", item.URL)
|
||||
@@ -55,13 +75,17 @@ func handleTextMessage(ctx *ext.Context, u *ext.Update) error {
|
||||
})
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to build storage selection keyboard: %s", err)
|
||||
ctx.Reply(u, ext.ReplyTextString("Failed to build storage selection keyboard: "+err.Error()), nil)
|
||||
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgParseErrorBuildStorageSelectKeyboardFailed, map[string]any{
|
||||
"Error": err.Error(),
|
||||
})), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
text, entities, err := msgelem.BuildParsedTextEntity(*item)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to build parsed text entity: %s", err)
|
||||
ctx.Reply(u, ext.ReplyTextString("Failed to build parsed text entity: "+err.Error()), nil)
|
||||
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgParseErrorBuildParsedTextEntityFailed, map[string]any{
|
||||
"Error": err.Error(),
|
||||
})), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||
@@ -77,11 +101,6 @@ func handleTextMessage(ctx *ext.Context, u *ext.Update) error {
|
||||
func handleSilentSaveText(ctx *ext.Context, u *ext.Update) error {
|
||||
logger := log.FromContext(ctx)
|
||||
stor := storage.FromContext(ctx)
|
||||
if stor == nil {
|
||||
logger.Warn("Context storage is nil")
|
||||
ctx.Reply(u, ext.ReplyTextString("未找到存储"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
text := u.EffectiveMessage.Text
|
||||
if text == "" {
|
||||
return dispatcher.EndGroups
|
||||
@@ -92,7 +111,9 @@ func handleSilentSaveText(ctx *ext.Context, u *ext.Update) error {
|
||||
}
|
||||
if err != nil {
|
||||
logger.Error("Failed to parse text", "error", err)
|
||||
ctx.Reply(u, ext.ReplyTextString("Failed to parse text: "+err.Error()), nil)
|
||||
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgParseErrorParseTextFailed, map[string]any{
|
||||
"Error": err.Error(),
|
||||
})), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
logger.Debug("Parsed item from text message", "title", item.Title, "url", item.URL)
|
||||
@@ -100,7 +121,9 @@ func handleSilentSaveText(ctx *ext.Context, u *ext.Update) error {
|
||||
text, entities, err := msgelem.BuildParsedTextEntity(*item)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to build parsed text entity: %s", err)
|
||||
ctx.Reply(u, ext.ReplyTextString("Failed to build parsed text entity: "+err.Error()), nil)
|
||||
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgParseErrorBuildParsedTextEntityFailed, map[string]any{
|
||||
"Error": err.Error(),
|
||||
})), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
msg, err := ctx.SendMessage(userID, &tg.MessagesSendMessageRequest{
|
||||
|
||||
@@ -7,17 +7,15 @@ import (
|
||||
"github.com/celestix/gotgproto/dispatcher"
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"github.com/krau/SaveAny-Bot/parsers"
|
||||
)
|
||||
|
||||
func handleParserCmd(ctx *ext.Context, u *ext.Update) error {
|
||||
args := strings.Split(u.EffectiveMessage.Text, " ")
|
||||
help := `
|
||||
用法:
|
||||
|
||||
/parser install <回复一个文件> - 安装解析器
|
||||
`
|
||||
help := i18n.T(i18nk.BotMsgParserHelpText, nil)
|
||||
if len(args) < 2 {
|
||||
ctx.Reply(u, ext.ReplyTextString(help), nil)
|
||||
return nil
|
||||
@@ -36,35 +34,35 @@ func handleParserCmd(ctx *ext.Context, u *ext.Update) error {
|
||||
|
||||
func handleParserInstallCmd(ctx *ext.Context, u *ext.Update) error {
|
||||
if !config.C().Parser.PluginEnable {
|
||||
ctx.Reply(u, ext.ReplyTextString("解析器插件功能未启用"), nil)
|
||||
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgParserPluginNotEnabled, nil)), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
if u.EffectiveMessage.ReplyToMessage == nil || u.EffectiveMessage.ReplyToMessage.Media == nil {
|
||||
ctx.Reply(u, ext.ReplyTextString("请回复一个包含解析器文件的消息"), nil)
|
||||
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgParserPromptReplyWithParserFile, nil)), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
media := u.EffectiveMessage.ReplyToMessage.Media
|
||||
document, ok := media.(*tg.MessageMediaDocument)
|
||||
if !ok {
|
||||
ctx.Reply(u, ext.ReplyTextString("回复的消息不包含有效的文件"), nil)
|
||||
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgParserErrorNoValidFileInReply, nil)), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
value, ok := document.GetDocument()
|
||||
if !ok {
|
||||
ctx.Reply(u, ext.ReplyTextString("回复的消息不包含有效的文件"), nil)
|
||||
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgParserErrorNoValidFileInReply, nil)), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
doc, ok := value.AsNotEmpty()
|
||||
if !ok {
|
||||
ctx.Reply(u, ext.ReplyTextString("回复的消息不包含有效的文件"), nil)
|
||||
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgParserErrorNoValidFileInReply, nil)), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
if !strings.HasPrefix(doc.MimeType, "text/") {
|
||||
ctx.Reply(u, ext.ReplyTextString("错误的文件类型"), nil)
|
||||
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgParserErrorWrongFileType, nil)), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
if doc.Size > 1024*1024*10 {
|
||||
ctx.Reply(u, ext.ReplyTextString("文件过大"), nil)
|
||||
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgParserErrorFileTooLarge, nil)), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
var fileName string
|
||||
@@ -75,23 +73,29 @@ func handleParserInstallCmd(ctx *ext.Context, u *ext.Update) error {
|
||||
}
|
||||
}
|
||||
if fileName == "" {
|
||||
ctx.Reply(u, ext.ReplyTextString("无法获取文件名"), nil)
|
||||
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgParserErrorGetFilenameFailed, nil)), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
if !strings.HasSuffix(fileName, ".js") {
|
||||
ctx.Reply(u, ext.ReplyTextString("仅支持 .js 文件作为解析器"), nil)
|
||||
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgParserErrorOnlyJsSupported, nil)), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
data := bytes.NewBuffer(nil)
|
||||
_, err := ctx.DownloadMedia(media, ext.DownloadOutputStream{Writer: data}, nil)
|
||||
if err != nil {
|
||||
ctx.Reply(u, ext.ReplyTextString("文件下载失败: "+err.Error()), nil)
|
||||
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgParserErrorDownloadFileFailed, map[string]any{
|
||||
"Error": err.Error(),
|
||||
})), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
if err := parsers.AddPlugin(ctx, data.String(), fileName); err != nil {
|
||||
ctx.Reply(u, ext.ReplyTextString("插件安装失败: "+err.Error()), nil)
|
||||
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgParserErrorInstallPluginFailed, map[string]any{
|
||||
"Error": err.Error(),
|
||||
})), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
ctx.Reply(u, ext.ReplyTextString("插件安装成功: "+fileName), nil)
|
||||
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgParserInfoInstallPluginSuccess, map[string]any{
|
||||
"Name": fileName,
|
||||
})), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
@@ -10,31 +10,38 @@ import (
|
||||
sabotfilters "github.com/krau/SaveAny-Bot/client/bot/handlers/utils/filters"
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/re"
|
||||
userclient "github.com/krau/SaveAny-Bot/client/user"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
||||
)
|
||||
|
||||
type DescCommandHandler struct {
|
||||
Cmd string
|
||||
Desc string
|
||||
Desc i18nk.Key
|
||||
handler func(ctx *ext.Context, u *ext.Update) error
|
||||
}
|
||||
|
||||
var CommandHandlers = []DescCommandHandler{
|
||||
{"start", "开始使用", handleHelpCmd},
|
||||
{"silent", "切换静默模式", handleSilentCmd},
|
||||
{"storage", "设置默认存储端", handleStorageCmd},
|
||||
{"dir", "管理存储文件夹", handleDirCmd},
|
||||
{"rule", "管理自动存储规则", handleRuleCmd},
|
||||
{"watch", "监听聊天(UserBot)", handleWatchCmd},
|
||||
{"unwatch", "取消监听聊天(UserBot)", handleUnwatchCmd},
|
||||
{"lswatch", "列出监听的聊天(UserBot)", handleLswatchCmd},
|
||||
{"save", "保存文件", handleSilentMode(handleSaveCmd, handleSilentSaveReplied)},
|
||||
{"config", "修改配置", handleConfigCmd},
|
||||
{"fnametmpl", "设置文件命名模板", handleConfigFnameTmpl},
|
||||
{"update", "检查更新", handleUpdateCmd},
|
||||
{"help", "显示帮助", handleHelpCmd},
|
||||
{"parser", "管理解析器", handleParserCmd},
|
||||
{"start", i18nk.BotMsgCmdStart, handleHelpCmd},
|
||||
{"silent", i18nk.BotMsgCmdSilent, handleSilentCmd},
|
||||
{"storage", i18nk.BotMsgCmdStorage, handleStorageCmd},
|
||||
{"dir", i18nk.BotMsgCmdDir, handleDirCmd},
|
||||
{"rule", i18nk.BotMsgCmdRule, handleRuleCmd},
|
||||
{"save", i18nk.BotMsgCmdSave, handleSilentMode(handleSaveCmd, handleSilentSaveReplied)},
|
||||
{"dl", i18nk.BotMsgCmdDl, handleDlCmd},
|
||||
{"aria2dl", i18nk.BotMsgCmdAria2dl, handleAria2DlCmd},
|
||||
{"ytdlp", i18nk.BotMsgCmdYtdlp, handleYtdlpCmd},
|
||||
{"task", i18nk.BotMsgCmdTask, handleTaskCmd},
|
||||
{"cancel", i18nk.BotMsgCmdCancel, handleCancelCmd},
|
||||
{"config", i18nk.BotMsgCmdConfig, handleConfigCmd},
|
||||
{"fnametmpl", i18nk.BotMsgCmdFnametmpl, handleConfigFnameTmpl},
|
||||
{"help", i18nk.BotMsgCmdHelp, handleHelpCmd},
|
||||
{"parser", i18nk.BotMsgCmdParser, handleParserCmd},
|
||||
{"watch", i18nk.BotMsgCmdWatch, handleWatchCmd},
|
||||
{"unwatch", i18nk.BotMsgCmdUnwatch, handleUnwatchCmd},
|
||||
{"lswatch", i18nk.BotMsgCmdLswatch, handleLswatchCmd},
|
||||
{"syncpeers", i18nk.BotMsgCmdSyncpeers, handleSyncpeersCmd},
|
||||
{"update", i18nk.BotMsgCmdUpdate, handleUpdateCmd},
|
||||
}
|
||||
|
||||
func Register(disp dispatcher.Dispatcher) {
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/duke-git/lancet/v2/slice"
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/strutil"
|
||||
"github.com/krau/SaveAny-Bot/database"
|
||||
"github.com/krau/SaveAny-Bot/pkg/rule"
|
||||
@@ -21,8 +23,8 @@ func handleRuleCmd(ctx *ext.Context, update *ext.Update) error {
|
||||
userChatID := update.GetUserChat().GetID()
|
||||
user, err := database.GetUserByChatID(ctx, userChatID)
|
||||
if err != nil {
|
||||
logger.Errorf("获取用户规则失败: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("获取用户规则失败"), nil)
|
||||
logger.Errorf("Failed to get user rules: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgRuleErrorGetUserRulesFailed, nil)), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
if len(args) < 2 {
|
||||
@@ -34,11 +36,14 @@ func handleRuleCmd(ctx *ext.Context, update *ext.Update) error {
|
||||
// /rule switch
|
||||
applyRule := !user.ApplyRule
|
||||
if err := database.UpdateUserApplyRule(ctx, user.ChatID, applyRule); err != nil {
|
||||
logger.Errorf("更新用户失败: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("更新用户失败"), nil)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgRuleErrorUpdateUserFailed, nil)), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
ctx.Reply(update, ext.ReplyTextString(fmt.Sprintf("已%s规则模式", map[bool]string{true: "启用", false: "禁用"}[applyRule])), nil)
|
||||
if applyRule {
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgRuleInfoRuleModeEnabled, nil)), nil)
|
||||
} else {
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgRuleInfoRuleModeDisabled, nil)), nil)
|
||||
}
|
||||
case "add":
|
||||
// /rule add <type> <data> <storage> <dirpath>
|
||||
if len(args) < 6 {
|
||||
@@ -52,10 +57,13 @@ func handleRuleCmd(ctx *ext.Context, update *ext.Update) error {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
return rule.RuleType(""), fmt.Errorf("无效的规则类型: %s\n可用: %v", ruleTypeArg, slice.Join(rule.Values(), ", "))
|
||||
return rule.RuleType(""), fmt.Errorf("invalid rule type: %s\navailable: %v", ruleTypeArg, slice.Join(rule.Values(), ", "))
|
||||
}()
|
||||
if err != nil {
|
||||
ctx.Reply(update, ext.ReplyTextString(err.Error()), nil)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgRuleErrorInvalidRuleType, map[string]any{
|
||||
"Type": ruleTypeArg,
|
||||
"Available": slice.Join(rule.Values(), ", "),
|
||||
})), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
@@ -71,29 +79,29 @@ func handleRuleCmd(ctx *ext.Context, update *ext.Update) error {
|
||||
UserID: user.ID,
|
||||
}
|
||||
if err := database.CreateRule(ctx, rd); err != nil {
|
||||
logger.Errorf("创建规则失败: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("创建规则失败"), nil)
|
||||
logger.Errorf("failed to create rule: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgRuleErrorCreateRuleFailed, nil)), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
ctx.Reply(update, ext.ReplyTextString("创建规则成功"), nil)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgRuleInfoCreateRuleSuccess, nil)), nil)
|
||||
case "del":
|
||||
// /rule del <id>
|
||||
if len(args) < 3 {
|
||||
ctx.Reply(update, ext.ReplyTextString("请提供规则ID"), nil)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgRulePromptProvideRuleId, nil)), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
ruleID := args[2]
|
||||
id, err := strconv.Atoi(ruleID)
|
||||
if err != nil {
|
||||
ctx.Reply(update, ext.ReplyTextString("无效的规则ID"), nil)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgRuleErrorInvalidRuleId, nil)), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
if err := database.DeleteRule(ctx, uint(id)); err != nil {
|
||||
logger.Errorf("删除规则失败: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("删除规则失败"), nil)
|
||||
logger.Errorf("failed to delete rule %d: %s", id, err)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgRuleErrorDeleteRuleFailed, nil)), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
ctx.Reply(update, ext.ReplyTextString("删除规则成功"), nil)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgRuleInfoDeleteRuleSuccess, nil)), nil)
|
||||
default:
|
||||
ctx.Reply(update, ext.ReplyTextStyledTextArray(msgelem.BuildRuleHelpStyling(user.ApplyRule, user.Rules)), nil)
|
||||
return dispatcher.EndGroups
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/celestix/gotgproto/dispatcher"
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/duke-git/lancet/v2/validator"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/dirutil"
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/mediautil"
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/shortcut"
|
||||
"github.com/krau/SaveAny-Bot/client/user"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/strutil"
|
||||
@@ -26,7 +27,7 @@ import (
|
||||
|
||||
func handleSaveCmd(ctx *ext.Context, update *ext.Update) error {
|
||||
logger := log.FromContext(ctx)
|
||||
args := strings.Split(string(update.EffectiveMessage.Text), " ")
|
||||
args := strings.Split(update.EffectiveMessage.Text, " ")
|
||||
if len(args) >= 3 {
|
||||
return handleBatchSave(ctx, update, args[1:])
|
||||
}
|
||||
@@ -35,17 +36,6 @@ func handleSaveCmd(ctx *ext.Context, update *ext.Update) error {
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgSaveHelpText)), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
// genFilename := func() string {
|
||||
// if len(args) > 1 {
|
||||
// return args[1]
|
||||
// }
|
||||
// filename := tgutil.GenFileNameFromMessage(*replyTo.Message)
|
||||
// return filename
|
||||
// }()
|
||||
// option := tfile.WithNameIfEmpty(genFilename)
|
||||
// if len(args) > 1 {
|
||||
// option = tfile.WithName(genFilename)
|
||||
// }
|
||||
userDB, err := database.GetUserByChatID(ctx, update.GetUserChat().GetID())
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -63,8 +53,8 @@ func handleSaveCmd(ctx *ext.Context, update *ext.Update) error {
|
||||
stors := storage.GetUserStorages(ctx, userId)
|
||||
req, err := msgelem.BuildAddOneSelectStorageMessage(ctx, stors, file, msg.ID)
|
||||
if err != nil {
|
||||
logger.Errorf("构建存储选择消息失败: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("构建存储选择消息失败: "+err.Error()), nil)
|
||||
logger.Errorf("Failed to build storage selection message: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorBuildStorageSelectMessageFailed, map[string]any{"Error": err.Error()})), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
ctx.EditMessage(update.EffectiveChat().GetID(), req)
|
||||
@@ -76,13 +66,7 @@ func handleSilentSaveReplied(ctx *ext.Context, update *ext.Update) error {
|
||||
if len(args) >= 3 {
|
||||
return handleBatchSave(ctx, update, args[1:])
|
||||
}
|
||||
logger := log.FromContext(ctx)
|
||||
stor := storage.FromContext(ctx)
|
||||
if stor == nil {
|
||||
logger.Warn("Context storage is nil")
|
||||
ctx.Reply(update, ext.ReplyTextString("未找到存储"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
replyTo := update.EffectiveMessage.ReplyToMessage
|
||||
if replyTo == nil || replyTo.Message == nil {
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgSaveHelpText)), nil)
|
||||
@@ -114,35 +98,40 @@ func handleBatchSave(ctx *ext.Context, update *ext.Update, args []string) error
|
||||
var err error
|
||||
filter, err = regexp.Compile(filterStr)
|
||||
if err != nil {
|
||||
ctx.Reply(update, ext.ReplyTextString("无效的正则表达式: "+err.Error()), nil)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorInvalidRegex, map[string]any{"Error": err.Error()})), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
}
|
||||
startID, endID, err := strutil.ParseIntStrRange(msgIdRangeArg, "-")
|
||||
if err != nil {
|
||||
ctx.Reply(update, ext.ReplyTextString("无效的消息ID范围: "+err.Error()), nil)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorInvalidMsgIdRange, map[string]any{"Error": err.Error()})), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
chatID, err := tgutil.ParseChatID(ctx, chatArg)
|
||||
tctx := ctx
|
||||
uctx := user.GetCtx()
|
||||
if uctx != nil && validator.IsIntStr(chatArg) {
|
||||
tctx = uctx
|
||||
}
|
||||
chatID, err := tgutil.ParseChatID(tctx, chatArg)
|
||||
if err != nil {
|
||||
ctx.Reply(update, ext.ReplyTextString("无效的ID或用户名: "+err.Error()), nil)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorInvalidIdOrUsername, map[string]any{"Error": err.Error()})), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
replied, err := ctx.Reply(update, ext.ReplyTextString("正在获取消息..."), nil)
|
||||
replied, err := ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonInfoFetchingMessages)), nil)
|
||||
if err != nil {
|
||||
log.FromContext(ctx).Errorf("回复失败: %s", err)
|
||||
log.FromContext(ctx).Errorf("Failed to reply: %s", err)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
// [TODO]: generator istead of get all messages
|
||||
msgs, err := tgutil.GetMessagesRange(ctx, chatID, int(startID), int(endID))
|
||||
msgs, err := tgutil.GetMessagesRange(tctx, chatID, int(startID), int(endID))
|
||||
if err != nil {
|
||||
ctx.Reply(update, ext.ReplyTextString("获取消息失败: "+err.Error()), nil)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorGetMessagesFailed, map[string]any{"Error": err.Error()})), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
if len(msgs) == 0 {
|
||||
ctx.Reply(update, ext.ReplyTextString("没有找到指定范围内的消息"), nil)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorNoMessagesInRange)), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
files := make([]tfile.TGFileMessage, 0, len(msgs))
|
||||
@@ -159,9 +148,9 @@ func handleBatchSave(ctx *ext.Context, update *ext.Update, args []string) error
|
||||
if !supported {
|
||||
continue
|
||||
}
|
||||
file, err := tfile.FromMediaMessage(media, ctx.Raw, msg, tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*msg)))
|
||||
file, err := tfile.FromMediaMessage(media, tctx.Raw, msg, tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*msg)))
|
||||
if err != nil {
|
||||
log.FromContext(ctx).Errorf("获取文件失败: %s", err)
|
||||
log.FromContext(ctx).Errorf("Failed to get file from message: %s", err)
|
||||
continue
|
||||
}
|
||||
if filter != nil {
|
||||
@@ -177,7 +166,7 @@ func handleBatchSave(ctx *ext.Context, update *ext.Update, args []string) error
|
||||
files = append(files, file)
|
||||
}
|
||||
if len(files) == 0 {
|
||||
ctx.Reply(update, ext.ReplyTextString("没有找到指定范围内的可保存消息"), nil)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorNoSavableMessagesInRange)), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
stor := storage.FromContext(ctx)
|
||||
@@ -188,16 +177,16 @@ func handleBatchSave(ctx *ext.Context, update *ext.Update, args []string) error
|
||||
Files: files,
|
||||
})
|
||||
if err != nil {
|
||||
log.FromContext(ctx).Errorf("构建存储选择键盘失败: %s", err)
|
||||
log.FromContext(ctx).Errorf("Failed to build storage selection keyboard: %s", err)
|
||||
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
|
||||
ID: replied.ID,
|
||||
Message: "构建存储选择键盘失败: " + err.Error(),
|
||||
Message: i18n.T(i18nk.BotMsgCommonErrorBuildStorageSelectKeyboardFailed, map[string]any{"Error": err.Error()}),
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
|
||||
ID: replied.ID,
|
||||
Message: fmt.Sprintf("找到 %d 个文件, 请选择存储位置", len(files)),
|
||||
Message: i18n.T(i18nk.BotMsgCommonInfoFoundFilesSelectStorage, map[string]any{"Count": len(files)}),
|
||||
ReplyMarkup: markup,
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/celestix/gotgproto/dispatcher"
|
||||
@@ -9,6 +8,8 @@ import (
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
||||
"github.com/krau/SaveAny-Bot/common/cache"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||
"github.com/krau/SaveAny-Bot/database"
|
||||
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
@@ -17,20 +18,27 @@ import (
|
||||
func handleSilentCmd(ctx *ext.Context, update *ext.Update) error {
|
||||
user, err := database.GetUserByChatID(ctx, update.GetUserChat().GetID())
|
||||
if err != nil {
|
||||
ctx.Reply(update, ext.ReplyTextString("获取用户信息失败: "+err.Error()), nil)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorGetUserInfoFailed, map[string]any{
|
||||
"Error": err.Error(),
|
||||
})), nil)
|
||||
return nil
|
||||
}
|
||||
if !user.Silent && user.DefaultStorage == "" {
|
||||
ctx.Reply(update, ext.ReplyTextString("请先使用 /storage 设置默认存储位置"), nil)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorDefaultStorageNotSet, nil)), nil)
|
||||
return nil
|
||||
}
|
||||
user.Silent = !user.Silent
|
||||
if err := database.UpdateUser(ctx, user); err != nil {
|
||||
ctx.Reply(update, ext.ReplyTextString("更新用户信息失败: "+err.Error()), nil)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorUpdateUserInfoFailed, map[string]any{
|
||||
"Error": err.Error(),
|
||||
})), nil)
|
||||
return nil
|
||||
}
|
||||
responseText := "已" + map[bool]string{true: "开启", false: "关闭"}[user.Silent] + "静默模式"
|
||||
ctx.Reply(update, ext.ReplyTextString(responseText), nil)
|
||||
if user.Silent {
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonInfoSilentModeOn, nil)), nil)
|
||||
} else {
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonInfoSilentModeOff, nil)), nil)
|
||||
}
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
@@ -49,18 +57,22 @@ func handleSetDefaultCallback(ctx *ext.Context, update *ext.Update) error {
|
||||
}
|
||||
|
||||
if !ok {
|
||||
return failedAnswer("数据已过期")
|
||||
return failedAnswer(i18n.T(i18nk.BotMsgCommonErrorDataExpired, nil))
|
||||
}
|
||||
userID := update.CallbackQuery.GetUserID()
|
||||
|
||||
storageName := data.StorageName
|
||||
selectedStorage, err := storage.GetStorageByUserIDAndName(ctx, userID, storageName)
|
||||
if err != nil {
|
||||
return failedAnswer("存储获取失败: " + err.Error())
|
||||
return failedAnswer(i18n.T(i18nk.BotMsgCommonErrorGetStorageFailed, map[string]any{
|
||||
"Error": err.Error(),
|
||||
}))
|
||||
}
|
||||
user, err := database.GetUserByChatID(ctx, userID)
|
||||
if err != nil {
|
||||
return failedAnswer("获取用户信息失败: " + err.Error())
|
||||
return failedAnswer(i18n.T(i18nk.BotMsgCommonErrorGetUserInfoFailed, map[string]any{
|
||||
"Error": err.Error(),
|
||||
}))
|
||||
}
|
||||
var dir *database.Dir
|
||||
if data.DirID != 0 {
|
||||
@@ -68,24 +80,28 @@ func handleSetDefaultCallback(ctx *ext.Context, update *ext.Update) error {
|
||||
var err error
|
||||
dir, err = database.GetDirByID(ctx, data.DirID)
|
||||
if err != nil {
|
||||
return failedAnswer("获取文件夹信息失败: " + err.Error())
|
||||
return failedAnswer(i18n.T(i18nk.BotMsgDirErrorGetUserDirsFailed, nil))
|
||||
}
|
||||
user.DefaultDir = dir.ID
|
||||
} else {
|
||||
// 检查是否有可用的文件夹
|
||||
dirs, err := database.GetDirsByUserIDAndStorageName(ctx, user.ID, storageName)
|
||||
if err != nil {
|
||||
return failedAnswer("获取目录失败: " + err.Error())
|
||||
return failedAnswer(i18n.T(i18nk.BotMsgCommonErrorGetDirFailed, map[string]any{
|
||||
"Error": err.Error(),
|
||||
}))
|
||||
}
|
||||
if len(dirs) > 0 {
|
||||
// 要求选择文件夹
|
||||
markup, err := msgelem.BuildSetDefaultDirMarkup(ctx, storageName, dirs)
|
||||
if err != nil {
|
||||
return failedAnswer("构建目录选择失败: " + err.Error())
|
||||
return failedAnswer(i18n.T(i18nk.BotMsgCommonErrorBuildDirSelectKeyboardFailed, map[string]any{
|
||||
"Error": err.Error(),
|
||||
}))
|
||||
}
|
||||
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||
ID: update.CallbackQuery.GetMsgID(),
|
||||
Message: "请选择要保存到的默认文件夹",
|
||||
Message: i18n.T(i18nk.BotMsgCommonPromptSelectDefaultDir, nil),
|
||||
ReplyMarkup: markup,
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
@@ -93,11 +109,18 @@ func handleSetDefaultCallback(ctx *ext.Context, update *ext.Update) error {
|
||||
}
|
||||
user.DefaultStorage = selectedStorage.Name()
|
||||
if err := database.UpdateUser(ctx, user); err != nil {
|
||||
return failedAnswer("更新用户信息失败: " + err.Error())
|
||||
return failedAnswer(i18n.T(i18nk.BotMsgCommonErrorUpdateUserInfoFailed, map[string]any{
|
||||
"Error": err.Error(),
|
||||
}))
|
||||
}
|
||||
msg := fmt.Sprintf("已将默认存储位置设为: %s", selectedStorage.Name())
|
||||
msg := i18n.T(i18nk.BotMsgCommonInfoDefaultStorageSet, map[string]any{
|
||||
"Name": selectedStorage.Name(),
|
||||
})
|
||||
if dir != nil {
|
||||
msg += fmt.Sprintf(":/%s", strings.TrimPrefix(dir.Path, "/"))
|
||||
msg = i18n.T(i18nk.BotMsgCommonInfoDefaultStorageWithDirSet, map[string]any{
|
||||
"Name": selectedStorage.Name(),
|
||||
"Dir": strings.TrimPrefix(dir.Path, "/"),
|
||||
})
|
||||
}
|
||||
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||
ID: update.CallbackQuery.GetMsgID(),
|
||||
@@ -110,15 +133,17 @@ func handleStorageCmd(ctx *ext.Context, update *ext.Update) error {
|
||||
userID := update.GetUserChat().GetID()
|
||||
storages := storage.GetUserStorages(ctx, userID)
|
||||
if len(storages) == 0 {
|
||||
ctx.Reply(update, ext.ReplyTextString("无可用的存储"), nil)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorNoAvailableStorage, nil)), nil)
|
||||
return nil
|
||||
}
|
||||
markup, err := msgelem.BuildSetDefaultStorageMarkup(ctx, storages)
|
||||
if err != nil {
|
||||
ctx.Reply(update, ext.ReplyTextString("获取存储失败: "+err.Error()), nil)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorGetStorageFailed, map[string]any{
|
||||
"Error": err.Error(),
|
||||
})), nil)
|
||||
return nil
|
||||
}
|
||||
ctx.Reply(update, ext.ReplyTextString("请选择要设为默认的存储位置"), &ext.ReplyOpts{
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonPromptSelectDefaultStorage, nil)), &ext.ReplyOpts{
|
||||
Markup: markup,
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
|
||||
62
client/bot/handlers/sync_peers.go
Normal file
62
client/bot/handlers/sync_peers.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/celestix/gotgproto/dispatcher"
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/celestix/gotgproto/storage"
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/gotd/td/telegram/query/dialogs"
|
||||
"github.com/krau/SaveAny-Bot/client/user"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
)
|
||||
|
||||
var syncpeerMu sync.Mutex
|
||||
|
||||
func handleSyncpeersCmd(ctx *ext.Context, u *ext.Update) error {
|
||||
if !config.C().Telegram.Userbot.Enable {
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
syncpeerMu.Lock()
|
||||
defer syncpeerMu.Unlock()
|
||||
uctx := user.GetCtx()
|
||||
if uctx == nil {
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgSyncpeersStart)), nil)
|
||||
tapi := uctx.Raw
|
||||
peerStorage := uctx.PeerStorage
|
||||
log.FromContext(ctx).Info("Starting to sync peers...")
|
||||
count := 0
|
||||
err := dialogs.NewQueryBuilder(tapi).GetDialogs().BatchSize(50).ForEach(ctx, func(ctx context.Context, e dialogs.Elem) error {
|
||||
for cid, channel := range e.Entities.Channels() {
|
||||
peerStorage.AddPeer(cid, channel.AccessHash, storage.TypeChannel, channel.Username)
|
||||
count++
|
||||
}
|
||||
for uid, user := range e.Entities.Users() {
|
||||
peerStorage.AddPeer(uid, user.AccessHash, storage.TypeUser, user.Username)
|
||||
count++
|
||||
}
|
||||
for gid := range e.Entities.Chats() {
|
||||
peerStorage.AddPeer(gid, storage.DefaultAccessHash, storage.TypeChat, storage.DefaultUsername)
|
||||
count++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
log.FromContext(ctx).Error("Failed to sync peers", "error", err)
|
||||
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgSyncpeersFailed, map[string]any{
|
||||
"Error": err.Error(),
|
||||
})), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
log.FromContext(ctx).Info("Finished syncing peers")
|
||||
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgSyncpeersSuccess, map[string]any{
|
||||
"Count": count,
|
||||
})), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
114
client/bot/handlers/tasks.go
Normal file
114
client/bot/handlers/tasks.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/celestix/gotgproto/dispatcher"
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/gotd/td/telegram/message/styling"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||
"github.com/krau/SaveAny-Bot/core"
|
||||
)
|
||||
|
||||
func handleTaskCmd(ctx *ext.Context, update *ext.Update) error {
|
||||
logger := log.FromContext(ctx)
|
||||
args := strings.Fields(update.EffectiveMessage.Text)
|
||||
if len(args) == 1 {
|
||||
showRunningTasks(ctx, update)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
switch args[1] {
|
||||
case "running", "run", "r":
|
||||
showRunningTasks(ctx, update)
|
||||
case "queued", "queue", "q", "waiting":
|
||||
showQueuedTasks(ctx, update)
|
||||
case "cancel", "c":
|
||||
if len(args) < 3 {
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgTasksUsageCancel)), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
taskID := args[2]
|
||||
if err := core.CancelTask(ctx, taskID); err != nil {
|
||||
logger.Errorf("Failed to cancel task %s: %v", taskID, err)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgTasksCancelFailed, map[string]any{"Error": err.Error()})), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
ctx.Reply(update, ext.ReplyTextStyledTextArray([]styling.StyledTextOption{
|
||||
styling.Plain(i18n.T(i18nk.BotMsgTasksCancelRequestedPrefix)),
|
||||
styling.Code(taskID),
|
||||
}), nil)
|
||||
default:
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgTasksUsage)), nil)
|
||||
}
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
func showRunningTasks(ctx *ext.Context, update *ext.Update) {
|
||||
tasks := core.GetRunningTasks(ctx)
|
||||
if len(tasks) == 0 {
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgTasksRunningEmpty)), nil)
|
||||
return
|
||||
}
|
||||
opts := make([]styling.StyledTextOption, 0, 2+len(tasks)*4)
|
||||
opts = append(opts,
|
||||
styling.Bold(i18n.T(i18nk.BotMsgTasksRunningTitle)),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgTasksTotalPrefix, map[string]any{"Count": len(tasks)})),
|
||||
)
|
||||
for _, t := range tasks {
|
||||
created := t.Created.In(time.Local).Format("2006-01-02 15:04:05")
|
||||
status := i18n.T(i18nk.BotMsgTasksStatusRunning)
|
||||
if t.Cancelled {
|
||||
status = i18n.T(i18nk.BotMsgTasksStatusCancelRequested)
|
||||
}
|
||||
opts = append(opts,
|
||||
styling.Plain("\n"+i18n.T(i18nk.BotMsgTasksFieldId)),
|
||||
styling.Code(t.ID),
|
||||
styling.Plain("\n"+i18n.T(i18nk.BotMsgTasksFieldTitle)),
|
||||
styling.Code(t.Title),
|
||||
styling.Plain("\n"+i18n.T(i18nk.BotMsgTasksFieldCreated)),
|
||||
styling.Code(created),
|
||||
styling.Plain("\n"+i18n.T(i18nk.BotMsgTasksFieldStatus)),
|
||||
styling.Code(status),
|
||||
)
|
||||
}
|
||||
ctx.Reply(update, ext.ReplyTextStyledTextArray(opts), nil)
|
||||
}
|
||||
|
||||
func showQueuedTasks(ctx *ext.Context, update *ext.Update) {
|
||||
tasks := core.GetQueuedTasks(ctx)
|
||||
if len(tasks) == 0 {
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgTasksQueuedEmpty)), nil)
|
||||
return
|
||||
}
|
||||
opts := make([]styling.StyledTextOption, 0, 2+len(tasks)*3)
|
||||
opts = append(opts,
|
||||
styling.Bold(i18n.T(i18nk.BotMsgTasksQueuedTitle)),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgTasksTotalPrefix, map[string]any{"Count": len(tasks)})),
|
||||
)
|
||||
for _, t := range tasks {
|
||||
created := t.Created.In(time.Local).Format("2006-01-02 15:04:05")
|
||||
status := i18n.T(i18nk.BotMsgTasksStatusQueued)
|
||||
if t.Cancelled {
|
||||
status = i18n.T(i18nk.BotMsgTasksStatusCancelRequested)
|
||||
}
|
||||
opts = append(opts,
|
||||
styling.Plain("\n"+i18n.T(i18nk.BotMsgTasksFieldId)),
|
||||
styling.Code(t.ID),
|
||||
styling.Plain("\n"+i18n.T(i18nk.BotMsgTasksFieldTitle)),
|
||||
styling.Code(t.Title),
|
||||
styling.Plain("\n"+i18n.T(i18nk.BotMsgTasksFieldCreated)),
|
||||
styling.Code(created),
|
||||
styling.Plain("\n"+i18n.T(i18nk.BotMsgTasksFieldStatus)),
|
||||
styling.Code(status),
|
||||
)
|
||||
if len(tasks) > 10 {
|
||||
opts = append(opts, styling.Plain("\n"+i18n.T(i18nk.BotMsgTasksTruncatedNote, map[string]any{"Count": len(tasks)})))
|
||||
break
|
||||
}
|
||||
}
|
||||
ctx.Reply(update, ext.ReplyTextStyledTextArray(opts), nil)
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/dirutil"
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/shortcut"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
|
||||
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
@@ -34,18 +36,20 @@ func handleTelegraphUrlMessage(ctx *ext.Context, update *ext.Update) error {
|
||||
TphPics: result.Pics,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Errorf("构建存储选择键盘失败: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("构建存储选择键盘失败: "+err.Error()), nil)
|
||||
logger.Errorf("Failed to build storage selection keyboard: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgTelegraphErrorBuildStorageSelectKeyboardFailed, map[string]any{
|
||||
"Error": err.Error(),
|
||||
})), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
eb := entity.Builder{}
|
||||
if err := styling.Perform(&eb,
|
||||
styling.Plain("标题: "),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgTelegraphInfoTitlePrefix, nil)),
|
||||
styling.Code(result.Page.Title),
|
||||
styling.Plain("\n图片数量: "),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgTelegraphInfoPicCountPrefix, nil)),
|
||||
styling.Code(fmt.Sprintf("%d", len(result.Pics))),
|
||||
styling.Plain("\n请选择存储位置"),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgTelegraphInfoPromptSelectStorage, nil)),
|
||||
); err != nil {
|
||||
log.FromContext(ctx).Errorf("Failed to build entity: %s", err)
|
||||
return dispatcher.EndGroups
|
||||
@@ -61,13 +65,7 @@ func handleTelegraphUrlMessage(ctx *ext.Context, update *ext.Update) error {
|
||||
}
|
||||
|
||||
func handleSilentSaveTelegraph(ctx *ext.Context, update *ext.Update) error {
|
||||
logger := log.FromContext(ctx)
|
||||
stor := storage.FromContext(ctx)
|
||||
if stor == nil {
|
||||
logger.Warn("Context storage is nil")
|
||||
ctx.Reply(update, ext.ReplyTextString("未找到存储"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
msg, result, err := shortcut.GetTphPicsFromMessageWithReply(ctx, update)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -2,7 +2,6 @@ package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
@@ -11,29 +10,45 @@ import (
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/gotd/td/telegram/message/html"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"github.com/rhysd/go-github-selfupdate/selfupdate"
|
||||
"github.com/unvgo/ghselfupdate"
|
||||
)
|
||||
|
||||
func handleUpdateCmd(ctx *ext.Context, u *ext.Update) error {
|
||||
currentV, err := semver.Parse(config.Version)
|
||||
if err != nil {
|
||||
ctx.Reply(u, ext.ReplyTextString(fmt.Sprintf("You are in dev or the version var failed to inject: %v", err)), nil)
|
||||
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgUpdateErrorVersionVarInvalid, map[string]any{
|
||||
"Error": err.Error(),
|
||||
})), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
latest, ok, err := selfupdate.DetectLatest(config.GitRepo)
|
||||
latest, ok, err := ghselfupdate.DetectLatest(config.GitRepo)
|
||||
if err != nil {
|
||||
ctx.Reply(u, ext.ReplyTextString(fmt.Sprintf("检测最新版本失败: %v", err)), nil)
|
||||
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgUpdateErrorCheckLatestFailed, map[string]any{
|
||||
"Error": err.Error(),
|
||||
})), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
if !ok {
|
||||
ctx.Reply(u, ext.ReplyTextString("没有找到版本信息"), nil)
|
||||
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgUpdateErrorNoReleaseFound, nil)), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
if latest.Version.Major != currentV.Major {
|
||||
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgUpdateInfoMajorUpgradeRequired, map[string]any{
|
||||
"Current": currentV.String(),
|
||||
"Latest": latest.Version.String(),
|
||||
})), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
if latest.Version.LT(currentV) || latest.Version.Equals(currentV) {
|
||||
ctx.Reply(u, ext.ReplyTextString(fmt.Sprintf("当前已经是最新版本: %s", config.Version)), nil)
|
||||
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgUpdateInfoAlreadyLatest, map[string]any{
|
||||
"Version": config.Version,
|
||||
})), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
indocker := config.Docker == "true"
|
||||
ctx.Sender.To(u.GetUserChat().AsInputPeer()).StyledText(ctx, html.String(nil, func() string {
|
||||
md := latest.ReleaseNotes
|
||||
md = regexp.MustCompile(`(?m)^###\s+ (.+)$`).ReplaceAllString(md, "<b>$1</b>")
|
||||
@@ -49,24 +64,29 @@ func handleUpdateCmd(ctx *ext.Context, u *ext.Update) error {
|
||||
|
||||
return `<blockquote expandable>` + md + `</blockquote>`
|
||||
}()))
|
||||
text := fmt.Sprintf(`发现新版本: %s
|
||||
当前版本: %s
|
||||
|
||||
文件大小: %.2f MB
|
||||
下载链接: %s
|
||||
发布时间: %s
|
||||
|
||||
升级将重启 Bot , 是否升级?`, latest.Version, config.Version,
|
||||
float64(latest.AssetByteSize)/(1024*1024), latest.AssetURL,
|
||||
latest.PublishedAt.Format("2006-01-02 15:04:05"),
|
||||
)
|
||||
if indocker {
|
||||
text := i18n.T(i18nk.BotMsgUpdateInfoNewVersionInDocker, map[string]any{
|
||||
"Latest": latest.Version.String(),
|
||||
"Current": config.Version,
|
||||
"PublishedAt": latest.PublishedAt.Format("2006-01-02 15:04:05"),
|
||||
})
|
||||
ctx.Reply(u, ext.ReplyTextString(text), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
text := i18n.T(i18nk.BotMsgUpdateInfoNewVersionPromptUpgrade, map[string]any{
|
||||
"Latest": latest.Version.String(),
|
||||
"Current": config.Version,
|
||||
"SizeMB": float64(latest.AssetByteSize) / (1024 * 1024),
|
||||
"URL": latest.AssetURL,
|
||||
"PublishedAt": latest.PublishedAt.Format("2006-01-02 15:04:05"),
|
||||
})
|
||||
ctx.Reply(u, ext.ReplyTextString(text), &ext.ReplyOpts{
|
||||
Markup: &tg.ReplyInlineMarkup{
|
||||
Rows: []tg.KeyboardButtonRow{
|
||||
{
|
||||
Buttons: []tg.KeyboardButtonClass{
|
||||
&tg.KeyboardButtonCallback{
|
||||
Text: "升级",
|
||||
Text: i18n.T(i18nk.BotMsgUpdateButtonUpgrade, nil),
|
||||
Data: []byte("update"),
|
||||
},
|
||||
},
|
||||
@@ -84,19 +104,25 @@ func handleUpdateCallback(ctx *ext.Context, u *ext.Update) error {
|
||||
}
|
||||
ctx.EditMessage(u.GetUserChat().GetID(), &tg.MessagesEditMessageRequest{
|
||||
ID: u.CallbackQuery.GetMsgID(),
|
||||
Message: fmt.Sprintf("正在升级中, 当前版本: %s", config.Version),
|
||||
Message: i18n.T(i18nk.BotMsgUpdateInfoUpgradingWithVersion, map[string]any{
|
||||
"Current": config.Version,
|
||||
}),
|
||||
})
|
||||
latest, err := selfupdate.UpdateSelf(currentV, config.GitRepo)
|
||||
latest, err := ghselfupdate.UpdateSelf(currentV, config.GitRepo)
|
||||
if err != nil {
|
||||
ctx.EditMessage(u.GetUserChat().GetID(), &tg.MessagesEditMessageRequest{
|
||||
ID: u.CallbackQuery.GetMsgID(),
|
||||
Message: fmt.Sprintf("升级失败: %v", err),
|
||||
Message: i18n.T(i18nk.BotMsgUpdateErrorUpgradeFailed, map[string]any{
|
||||
"Error": err.Error(),
|
||||
}),
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
ctx.EditMessage(u.GetUserChat().GetID(), &tg.MessagesEditMessageRequest{
|
||||
ID: u.CallbackQuery.GetMsgID(),
|
||||
Message: fmt.Sprintf("已升级至版本 %s\n若 Bot 未自动重启请手动启动", latest.Version),
|
||||
Message: i18n.T(i18nk.BotMsgUpdateInfoUpgradeSuccess, map[string]any{
|
||||
"Version": latest.Version.String(),
|
||||
}),
|
||||
})
|
||||
return errors.New("SAVEANTBOT-RESTART")
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ type FilenameTemplateData struct {
|
||||
MsgTags string `json:"msgtags,omitempty"`
|
||||
MsgGen string `json:"msggen,omitempty"`
|
||||
MsgDate string `json:"msgdate,omitempty"`
|
||||
MsgRaw string `json:"msgraw,omitempty"`
|
||||
OrigName string `json:"origname,omitempty"`
|
||||
ChatID string `json:"chatid,omitempty"`
|
||||
}
|
||||
@@ -39,6 +40,7 @@ func (f FilenameTemplateData) ToMap() map[string]string {
|
||||
"msgid": f.MsgID,
|
||||
"msgtags": f.MsgTags,
|
||||
"msggen": f.MsgGen,
|
||||
"msgraw": f.MsgRaw,
|
||||
"msgdate": f.MsgDate,
|
||||
"origname": f.OrigName,
|
||||
"chatid": f.ChatID,
|
||||
@@ -108,8 +110,10 @@ func BuildFilenameTemplateData(message *tg.Message) map[string]string {
|
||||
t := time.Unix(int64(date), 0)
|
||||
return t.Format("2006-01-02_15-04-05")
|
||||
}(),
|
||||
MsgRaw: message.GetMessage(),
|
||||
ChatID: func() string {
|
||||
// 如果消息是频道的(从消息链接中fetch的) 直接使用其chat id, 无论它是否是从其他来源转发的
|
||||
// 如果消息是频道的(从消息链接中fetch的) 直接使用其chat id,
|
||||
// 无论它是否是从其他来源转发的
|
||||
if message.GetPost() {
|
||||
peer := message.GetPeerID()
|
||||
switch p := peer.(type) {
|
||||
|
||||
@@ -5,26 +5,28 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/gotd/td/telegram/message/styling"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||
"github.com/krau/SaveAny-Bot/database"
|
||||
)
|
||||
|
||||
func BuildDirHelpStyling(dirs []database.Dir) []styling.StyledTextOption {
|
||||
return []styling.StyledTextOption{
|
||||
styling.Bold("使用方法: /dir <操作> <参数...>"),
|
||||
styling.Plain("\n\n可用操作:\n"),
|
||||
styling.Bold(i18n.T(i18nk.BotMsgDirHelpUsage, nil)),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgDirHelpAvailableOps, nil)),
|
||||
styling.Code("add"),
|
||||
styling.Plain(" <存储名> <路径> - 添加路径\n"),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgDirHelpAddSuffix, nil)),
|
||||
styling.Code("del"),
|
||||
styling.Plain(" <路径ID> - 删除路径\n"),
|
||||
styling.Plain("\n添加路径示例:\n"),
|
||||
styling.Code("/dir add local1 path/to/dir"),
|
||||
styling.Plain("\n\n删除路径示例:\n"),
|
||||
styling.Code("/dir del 3"),
|
||||
styling.Plain("\n\n当前已添加的路径:\n"),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgDirHelpDelSuffix, nil)),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgDirHelpAddExamplePrefix, nil)),
|
||||
styling.Code(i18n.T(i18nk.BotMsgDirHelpAddExampleCmd, nil)),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgDirHelpDelExamplePrefix, nil)),
|
||||
styling.Code(i18n.T(i18nk.BotMsgDirHelpDelExampleCmd, nil)),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgDirHelpExistingDirsPrefix, nil)),
|
||||
styling.Blockquote(func() string {
|
||||
var sb strings.Builder
|
||||
for _, dir := range dirs {
|
||||
sb.WriteString(fmt.Sprintf("%d: ", dir.ID))
|
||||
fmt.Fprintf(&sb, "%d: ", dir.ID)
|
||||
sb.WriteString(dir.StorageName)
|
||||
sb.WriteString(" - ")
|
||||
sb.WriteString(dir.Path)
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"github.com/gotd/td/telegram/message/entity"
|
||||
"github.com/gotd/td/telegram/message/styling"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||
"github.com/krau/SaveAny-Bot/pkg/parser"
|
||||
)
|
||||
|
||||
@@ -14,15 +16,15 @@ func BuildParsedTextEntity(item parser.Item) (string, []tg.MessageEntityClass, e
|
||||
eb := entity.Builder{}
|
||||
if err := styling.Perform(&eb,
|
||||
styling.Bold(fmt.Sprintf("[%s]%s", item.Site, item.Title)),
|
||||
styling.Plain("\n链接: "),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgParseInfoLinkPrefix, nil)),
|
||||
styling.Code(item.URL),
|
||||
styling.Plain("\n作者: "),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgParseInfoAuthorPrefix, nil)),
|
||||
styling.Code(item.Author),
|
||||
styling.Plain("\n描述: "),
|
||||
styling.Code(strutil.Ellipsis(item.Description, 233)),
|
||||
styling.Plain("\n文件数量: "),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgParseInfoDescriptionPrefix, nil)),
|
||||
styling.Blockquote(strutil.Ellipsis(item.Description, 233), true),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgParseInfoFileCountPrefix, nil)),
|
||||
styling.Code(fmt.Sprintf("%d", len(item.Resources))),
|
||||
styling.Plain("\n预计总大小: "),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgParseInfoTotalSizePrefix, nil)),
|
||||
styling.Code(fmt.Sprintf("%.2f MB", func() float64 {
|
||||
var totalSize int64
|
||||
for _, res := range item.Resources {
|
||||
@@ -30,9 +32,9 @@ func BuildParsedTextEntity(item parser.Item) (string, []tg.MessageEntityClass, e
|
||||
}
|
||||
return float64(totalSize) / 1024 / 1024
|
||||
}())),
|
||||
styling.Plain("\n请选择存储位置"),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgParseInfoPromptSelectStorage, nil)),
|
||||
); err != nil {
|
||||
return "", nil, fmt.Errorf("构建消息失败: %w", err)
|
||||
return "", nil, fmt.Errorf("failed to build parsed text entity: %w", err)
|
||||
}
|
||||
text, entities := eb.Complete()
|
||||
return text, entities, nil
|
||||
|
||||
@@ -5,21 +5,28 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/gotd/td/telegram/message/styling"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||
"github.com/krau/SaveAny-Bot/database"
|
||||
)
|
||||
|
||||
func BuildRuleHelpStyling(enabled bool, rules []database.Rule) []styling.StyledTextOption {
|
||||
return []styling.StyledTextOption{
|
||||
styling.Bold("使用方法: /rule <操作> <参数...>"),
|
||||
styling.Bold(fmt.Sprintf("\n当前已%s规则模式", map[bool]string{true: "启用", false: "禁用"}[enabled])),
|
||||
styling.Plain("\n\n可用操作:\n"),
|
||||
styling.Bold(i18n.T(i18nk.BotMsgRuleHelpUsage, nil)),
|
||||
styling.Bold(func() string {
|
||||
if enabled {
|
||||
return i18n.T(i18nk.BotMsgRuleHelpCurrentModeEnabled, nil)
|
||||
}
|
||||
return i18n.T(i18nk.BotMsgRuleHelpCurrentModeDisabled, nil)
|
||||
}()),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgRuleHelpAvailableOps, nil)),
|
||||
styling.Code("switch"),
|
||||
styling.Plain(" - 开关规则模式\n"),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgRuleHelpSwitchSuffix, nil)),
|
||||
styling.Code("add"),
|
||||
styling.Plain(" <类型> <数据> <存储名> <路径> - 添加规则\n"),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgRuleHelpAddSuffix, nil)),
|
||||
styling.Code("del"),
|
||||
styling.Plain(" <规则ID> - 删除规则\n"),
|
||||
styling.Plain("\n当前已添加的规则:\n"),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgRuleHelpDelSuffix, nil)),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgRuleHelpExistingRulesPrefix, nil)),
|
||||
styling.Blockquote(func() string {
|
||||
var sb strings.Builder
|
||||
for _, rule := range rules {
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"github.com/gotd/td/telegram/message/styling"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/krau/SaveAny-Bot/common/cache"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||
"github.com/krau/SaveAny-Bot/database"
|
||||
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
|
||||
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
||||
@@ -45,6 +47,11 @@ func BuildAddSelectStorageKeyboard(stors []storage.Storage, adddata tcbdata.Add)
|
||||
TphDirPath: adddata.TphDirPath,
|
||||
|
||||
ParsedItem: adddata.ParsedItem,
|
||||
|
||||
DirectLinks: adddata.DirectLinks,
|
||||
|
||||
Aria2URIs: adddata.Aria2URIs,
|
||||
YtdlpURLs: adddata.YtdlpURLs,
|
||||
}
|
||||
dataid := xid.New().String()
|
||||
err := cache.Set(dataid, data)
|
||||
@@ -68,11 +75,14 @@ func BuildAddSelectStorageKeyboard(stors []storage.Storage, adddata tcbdata.Add)
|
||||
func BuildAddOneSelectStorageMessage(ctx context.Context, stors []storage.Storage, file tfile.TGFileMessage, msgId int) (*tg.MessagesEditMessageRequest, error) {
|
||||
eb := entity.Builder{}
|
||||
var entities []tg.MessageEntityClass
|
||||
text := fmt.Sprintf("文件名: %s\n请选择存储位置", file.Name())
|
||||
text := i18n.T(i18nk.BotMsgTasksInfoAddedToQueueFull, map[string]any{
|
||||
"Filename": file.Name(),
|
||||
"QueueLength": 0,
|
||||
})
|
||||
if err := styling.Perform(&eb,
|
||||
styling.Plain("文件名: "),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgStorageInfoFilenamePrefix, nil)),
|
||||
styling.Code(file.Name()),
|
||||
styling.Plain("\n请选择存储位置"),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgStorageInfoPromptSelectStorage, nil)),
|
||||
); err != nil {
|
||||
log.FromContext(ctx).Errorf("Failed to build entity: %s", err)
|
||||
} else {
|
||||
@@ -183,7 +193,7 @@ func BuildSetDirMarkupForAdd(dirs []database.Dir, dataid string) (*tg.ReplyInlin
|
||||
return nil, fmt.Errorf("failed to set default directory data in cache: %w", err)
|
||||
}
|
||||
buttons = append(buttons, &tg.KeyboardButtonCallback{
|
||||
Text: "默认",
|
||||
Text: i18n.T(i18nk.BotMsgDirButtonDefault, nil),
|
||||
Data: fmt.Appendf(nil, "%s %s", tcbdata.TypeAdd, dirDefaultDataId),
|
||||
})
|
||||
markup := &tg.ReplyInlineMarkup{}
|
||||
|
||||
@@ -2,13 +2,14 @@ package msgelem
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/gotd/td/telegram/message/entity"
|
||||
"github.com/gotd/td/telegram/message/styling"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||
)
|
||||
|
||||
func BuildTaskAddedEntities(
|
||||
@@ -18,11 +19,15 @@ func BuildTaskAddedEntities(
|
||||
) (string, []tg.MessageEntityClass) {
|
||||
entityBuilder := entity.Builder{}
|
||||
var entities []tg.MessageEntityClass
|
||||
text := fmt.Sprintf("已添加到任务队列\n文件名: %s\n当前排队任务数: %d", filename, queueLength)
|
||||
text := i18n.T(i18nk.BotMsgTasksInfoAddedToQueueFull, map[string]any{
|
||||
"Filename": filename,
|
||||
"QueueLength": queueLength,
|
||||
})
|
||||
if err := styling.Perform(&entityBuilder,
|
||||
styling.Plain("已添加到任务队列\n文件名: "),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgTasksInfoAddedToQueuePrefix, nil)),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgTasksInfoFilenamePrefix, nil)),
|
||||
styling.Code(filename),
|
||||
styling.Plain("\n当前排队任务数: "),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgTasksInfoQueueLengthPrefix, nil)),
|
||||
styling.Bold(strconv.Itoa(queueLength)),
|
||||
); err != nil {
|
||||
log.FromContext(ctx).Errorf("Failed to build entity: %s", err)
|
||||
|
||||
@@ -34,7 +34,7 @@ func (m matchedStorName) String() string {
|
||||
}
|
||||
|
||||
// can we use this storage name directly?
|
||||
func (m matchedStorName) IsUsable() bool {
|
||||
func (m matchedStorName) Usable() bool {
|
||||
return m != "" && m != rule.RuleStorNameChosen
|
||||
}
|
||||
|
||||
|
||||
65
client/bot/handlers/utils/shortcut/aria2.go
Normal file
65
client/bot/handlers/utils/shortcut/aria2.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package shortcut
|
||||
|
||||
import (
|
||||
"github.com/celestix/gotgproto/dispatcher"
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||
"github.com/krau/SaveAny-Bot/core"
|
||||
"github.com/krau/SaveAny-Bot/core/tasks/aria2dl"
|
||||
"github.com/krau/SaveAny-Bot/pkg/aria2"
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
"github.com/rs/xid"
|
||||
)
|
||||
|
||||
func CreateAndAddAria2TaskWithEdit(ctx *ext.Context, stor storage.Storage, dirPath string, uris []string, aria2Client *aria2.Client, msgID int, userID int64) error {
|
||||
logger := log.FromContext(ctx)
|
||||
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
|
||||
|
||||
// Now add to aria2 after user selected storage
|
||||
logger.Infof("Adding download to aria2, uris type: %T, value: %+v", uris, uris)
|
||||
|
||||
// Ensure uris is valid
|
||||
if len(uris) == 0 {
|
||||
logger.Error("URIs list is empty")
|
||||
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||
ID: msgID,
|
||||
Message: i18n.T(i18nk.BotMsgDlErrorNoValidLinks, nil),
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
gid, err := aria2Client.AddURI(ctx, uris, nil)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to add aria2 download: %s", err)
|
||||
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||
ID: msgID,
|
||||
Message: i18n.T(i18nk.BotMsgAria2ErrorAddingAria2Download, map[string]any{
|
||||
"Error": err.Error(),
|
||||
}),
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
logger.Infof("Aria2 download added with GID: %s", gid)
|
||||
|
||||
// Create task with the GID
|
||||
task := aria2dl.NewTask(xid.New().String(), injectCtx, gid, uris, aria2Client, stor, stor.JoinStoragePath(dirPath), aria2dl.NewProgress(msgID, userID))
|
||||
if err := core.AddTask(injectCtx, task); err != nil {
|
||||
logger.Errorf("Failed to add task: %s", err)
|
||||
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||
ID: msgID,
|
||||
Message: i18n.T(i18nk.BotMsgCommonErrorTaskAddFailed, map[string]any{
|
||||
"Error": err.Error(),
|
||||
}),
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||
ID: msgID,
|
||||
Message: i18n.T(i18nk.BotMsgCommonInfoTaskAdded, nil),
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
34
client/bot/handlers/utils/shortcut/directlinks.go
Normal file
34
client/bot/handlers/utils/shortcut/directlinks.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package shortcut
|
||||
|
||||
import (
|
||||
"github.com/celestix/gotgproto/dispatcher"
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||
"github.com/krau/SaveAny-Bot/core"
|
||||
"github.com/krau/SaveAny-Bot/core/tasks/directlinks"
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
"github.com/rs/xid"
|
||||
)
|
||||
|
||||
func CreateAndAddDirectTaskWithEdit(ctx *ext.Context, stor storage.Storage, dirPath string, links []string, msgID int, userID int64) error {
|
||||
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
|
||||
task := directlinks.NewTask(xid.New().String(), injectCtx, links, stor, stor.JoinStoragePath(dirPath), directlinks.NewProgress(msgID, userID))
|
||||
if err := core.AddTask(injectCtx, task); err != nil {
|
||||
log.FromContext(ctx).Errorf("Failed to add task: %s", err)
|
||||
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||
ID: msgID,
|
||||
Message: i18n.T(i18nk.BotMsgCommonErrorTaskAddFailed, map[string]any{
|
||||
"Error": err.Error(),
|
||||
}),
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||
Message: i18n.T(i18nk.BotMsgCommonInfoTaskAdded, nil),
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
@@ -17,6 +17,8 @@ import (
|
||||
"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/i18n"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/tphutil"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
@@ -36,7 +38,7 @@ func GetFileFromMessageWithReply(ctx *ext.Context, update *ext.Update, message *
|
||||
return nil, nil, dispatcher.ContinueGroups
|
||||
}
|
||||
|
||||
replied, err = ctx.Reply(update, ext.ReplyTextString("正在获取文件信息..."), nil)
|
||||
replied, err = ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonInfoFetchingFileInfo, nil)), nil)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to reply: %s", err)
|
||||
return nil, nil, dispatcher.EndGroups
|
||||
@@ -52,7 +54,9 @@ func GetFileFromMessageWithReply(ctx *ext.Context, update *ext.Update, message *
|
||||
file, err = tfile.FromMediaMessage(media, ctx.Raw, message, tfileopts...)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to get file from media: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("获取文件失败: "+err.Error()), nil)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorGetFileFailed, map[string]any{
|
||||
"Error": err.Error(),
|
||||
})), nil)
|
||||
return nil, nil, dispatcher.EndGroups
|
||||
}
|
||||
return replied, file, nil
|
||||
@@ -68,7 +72,7 @@ func GetFilesFromUpdateLinkMessageWithReplyEdit(ctx *ext.Context, update *ext.Up
|
||||
logger.Warn("no matched message links but called handleMessageLink")
|
||||
return nil, nil, nil, dispatcher.EndGroups
|
||||
}
|
||||
replied, err = ctx.Reply(update, ext.ReplyTextString("正在获取消息..."), nil)
|
||||
replied, err = ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonInfoFetchingMessages, nil)), nil)
|
||||
if err != nil {
|
||||
logger.Errorf("failed to reply: %s", err)
|
||||
return nil, nil, nil, dispatcher.EndGroups
|
||||
@@ -85,7 +89,9 @@ func GetFilesFromUpdateLinkMessageWithReplyEdit(ctx *ext.Context, update *ext.Up
|
||||
user, err := database.GetUserByChatID(ctx, update.GetUserChat().GetID())
|
||||
if err != nil {
|
||||
logger.Errorf("failed to get user from db: %s", err)
|
||||
editReplied("获取用户信息失败: "+err.Error(), nil)
|
||||
editReplied(i18n.T(i18nk.BotMsgCommonErrorGetUserInfoFailed, map[string]any{
|
||||
"Error": err.Error(),
|
||||
}), nil)
|
||||
return nil, nil, nil, dispatcher.EndGroups
|
||||
}
|
||||
files = make([]tfile.TGFileMessage, 0, len(msgLinks))
|
||||
@@ -110,7 +116,9 @@ func GetFilesFromUpdateLinkMessageWithReplyEdit(ctx *ext.Context, update *ext.Up
|
||||
|
||||
tctx := ctx
|
||||
if config.C().Telegram.Userbot.Enable {
|
||||
tctx = uc.GetCtx()
|
||||
if uc.GetCtx() != nil {
|
||||
tctx = uc.GetCtx()
|
||||
}
|
||||
}
|
||||
|
||||
for _, link := range msgLinks {
|
||||
@@ -126,7 +134,7 @@ func GetFilesFromUpdateLinkMessageWithReplyEdit(ctx *ext.Context, update *ext.Up
|
||||
}
|
||||
msg, err := tgutil.GetMessageByID(tctx, chatId, msgId)
|
||||
if err != nil {
|
||||
logger.Errorf("failed to get message by ID: %s", err)
|
||||
logger.Error(err)
|
||||
continue
|
||||
}
|
||||
groupID, isGroup := msg.GetGroupedID()
|
||||
@@ -144,7 +152,7 @@ func GetFilesFromUpdateLinkMessageWithReplyEdit(ctx *ext.Context, update *ext.Up
|
||||
}
|
||||
}
|
||||
if len(files) == 0 {
|
||||
editReplied("没有找到可保存的文件", nil)
|
||||
editReplied(i18n.T(i18nk.BotMsgCommonErrorNoSavableFilesFound, nil), nil)
|
||||
return nil, nil, nil, dispatcher.EndGroups
|
||||
}
|
||||
return replied, files, editReplied, nil
|
||||
@@ -155,7 +163,7 @@ func GetCallbackDataWithAnswer[DataType any](ctx *ext.Context, update *ext.Updat
|
||||
if !ok {
|
||||
log.FromContext(ctx).Warnf("Invalid data ID: %s", dataid)
|
||||
queryID := update.CallbackQuery.GetQueryID()
|
||||
ctx.AnswerCallback(msgelem.AlertCallbackAnswer(queryID, "数据已过期或无效"))
|
||||
ctx.AnswerCallback(msgelem.AlertCallbackAnswer(queryID, i18n.T(i18nk.BotMsgCommonErrorDataExpired, nil)))
|
||||
var zero DataType
|
||||
return zero, dispatcher.EndGroups
|
||||
}
|
||||
@@ -180,11 +188,13 @@ func GetTphPicsFromMessageWithReply(ctx *ext.Context, update *ext.Update) (*type
|
||||
tphdir, err := url.PathUnescape(pagepath)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to unescape telegraph path: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("解析 telegraph 路径失败: "+err.Error()), nil)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorParseTelegraphPathFailed, map[string]any{
|
||||
"Error": err.Error(),
|
||||
})), nil)
|
||||
return nil, nil, dispatcher.EndGroups
|
||||
}
|
||||
tphdir = strings.TrimSpace(tphdir)
|
||||
msg, err := ctx.Reply(update, ext.ReplyTextString("正在获取 telegraph 页面..."), nil)
|
||||
msg, err := ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonInfoFetchingTelegraphPage, nil)), nil)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to reply to update: %s", err)
|
||||
return nil, nil, dispatcher.EndGroups
|
||||
@@ -193,7 +203,9 @@ func GetTphPicsFromMessageWithReply(ctx *ext.Context, update *ext.Update) (*type
|
||||
page, err := tphutil.DefaultClient().GetPage(ctx, pagepath)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to get telegraph page: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("获取 telegraph 页面失败: "+err.Error()), nil)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorGetTelegraphPageFailed, map[string]any{
|
||||
"Error": err.Error(),
|
||||
})), nil)
|
||||
return nil, nil, dispatcher.EndGroups
|
||||
}
|
||||
imgs := make([]string, 0)
|
||||
@@ -227,7 +239,7 @@ func GetTphPicsFromMessageWithReply(ctx *ext.Context, update *ext.Update) (*type
|
||||
}
|
||||
if len(imgs) == 0 {
|
||||
logger.Warn("No images found in telegraph page")
|
||||
ctx.Reply(update, ext.ReplyTextString("在 telegraph 页面中未找到图片"), nil)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorNoImagesInTelegraphPage, nil)), nil)
|
||||
return nil, nil, dispatcher.EndGroups
|
||||
}
|
||||
return msg, &TelegraphResult{
|
||||
|
||||
@@ -6,9 +6,11 @@ import (
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||
"github.com/krau/SaveAny-Bot/core"
|
||||
"github.com/krau/SaveAny-Bot/core/tasks/parsed"
|
||||
parsed "github.com/krau/SaveAny-Bot/core/tasks/parsed"
|
||||
"github.com/krau/SaveAny-Bot/pkg/parser"
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
"github.com/rs/xid"
|
||||
@@ -21,7 +23,9 @@ func CreateAndAddParsedTaskWithEdit(ctx *ext.Context, stor storage.Storage, dirP
|
||||
log.FromContext(ctx).Errorf("Failed to add task: %s", err)
|
||||
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||
ID: msgID,
|
||||
Message: "任务添加失败: " + err.Error(),
|
||||
Message: i18n.T(i18nk.BotMsgCommonErrorTaskAddFailed, map[string]any{
|
||||
"Error": err.Error(),
|
||||
}),
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package shortcut
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
@@ -11,6 +10,8 @@ import (
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/ruleutil"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||
"github.com/krau/SaveAny-Bot/core"
|
||||
"github.com/krau/SaveAny-Bot/core/tasks/batchtfile"
|
||||
@@ -29,7 +30,9 @@ func CreateAndAddTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor storage
|
||||
logger.Errorf("Failed to get user by chat ID: %s", err)
|
||||
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||
ID: trackMsgID,
|
||||
Message: "获取用户失败: " + err.Error(),
|
||||
Message: i18n.T(i18nk.BotMsgCommonErrorGetUserWithErrFailed, map[string]any{
|
||||
"Error": err.Error(),
|
||||
}),
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
@@ -41,13 +44,15 @@ func CreateAndAddTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor storage
|
||||
if matchedDirPath != "" {
|
||||
dirPath = matchedDirPath.String()
|
||||
}
|
||||
if matchedStorageName.IsUsable() {
|
||||
if matchedStorageName.Usable() {
|
||||
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)
|
||||
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||
ID: trackMsgID,
|
||||
Message: "获取存储失败: " + err.Error(),
|
||||
Message: i18n.T(i18nk.BotMsgCommonErrorGetStorageFailed, map[string]any{
|
||||
"Error": err.Error(),
|
||||
}),
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
@@ -65,7 +70,9 @@ startCreateTask:
|
||||
logger.Errorf("create task failed: %s", err)
|
||||
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||
ID: trackMsgID,
|
||||
Message: "创建任务失败: " + err.Error(),
|
||||
Message: i18n.T(i18nk.BotMsgCommonErrorTaskCreateFailed, map[string]any{
|
||||
"Error": err.Error(),
|
||||
}),
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
@@ -73,7 +80,9 @@ startCreateTask:
|
||||
logger.Errorf("add task failed: %s", err)
|
||||
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||
ID: trackMsgID,
|
||||
Message: "添加任务失败: " + err.Error(),
|
||||
Message: i18n.T(i18nk.BotMsgCommonErrorTaskAddFailed, map[string]any{
|
||||
"Error": err.Error(),
|
||||
}),
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
@@ -95,7 +104,9 @@ func CreateAndAddBatchTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor st
|
||||
logger.Errorf("Failed to get user by chat ID: %s", err)
|
||||
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||
ID: trackMsgID,
|
||||
Message: "获取用户失败: " + err.Error(),
|
||||
Message: i18n.T(i18nk.BotMsgCommonErrorGetUserWithErrFailed, map[string]any{
|
||||
"Error": err.Error(),
|
||||
}),
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
@@ -111,7 +122,7 @@ func CreateAndAddBatchTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor st
|
||||
return stor.Name(), ruleutil.MatchedDirPath(dirPath)
|
||||
}
|
||||
storname := storName.String()
|
||||
if !storName.IsUsable() {
|
||||
if !storName.Usable() {
|
||||
storname = stor.Name()
|
||||
}
|
||||
return storname, dirP
|
||||
@@ -132,7 +143,9 @@ func CreateAndAddBatchTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor st
|
||||
logger.Errorf("Failed to get storage by user ID and name: %s", err)
|
||||
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||
ID: trackMsgID,
|
||||
Message: "获取存储失败: " + err.Error(),
|
||||
Message: i18n.T(i18nk.BotMsgCommonErrorGetStorageFailed, map[string]any{
|
||||
"Error": err.Error(),
|
||||
}),
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
@@ -144,7 +157,9 @@ func CreateAndAddBatchTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor st
|
||||
logger.Errorf("Failed to create task element: %s", err)
|
||||
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||
ID: trackMsgID,
|
||||
Message: "任务创建失败: " + err.Error(),
|
||||
Message: i18n.T(i18nk.BotMsgCommonErrorTaskCreateFailed, map[string]any{
|
||||
"Error": err.Error(),
|
||||
}),
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
@@ -179,7 +194,9 @@ func CreateAndAddBatchTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor st
|
||||
logger.Errorf("Failed to create task element for album file: %s", err)
|
||||
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||
ID: trackMsgID,
|
||||
Message: "任务创建失败: " + err.Error(),
|
||||
Message: i18n.T(i18nk.BotMsgCommonErrorTaskCreateFailed, map[string]any{
|
||||
"Error": err.Error(),
|
||||
}),
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
@@ -194,13 +211,17 @@ func CreateAndAddBatchTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor st
|
||||
logger.Errorf("Failed to add batch task: %s", err)
|
||||
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||
ID: trackMsgID,
|
||||
Message: "批量任务添加失败: " + err.Error(),
|
||||
Message: i18n.T(i18nk.BotMsgCommonErrorTaskAddFailed, map[string]any{
|
||||
"Error": err.Error(),
|
||||
}),
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||
ID: trackMsgID,
|
||||
Message: fmt.Sprintf("已添加批量任务, 共 %d 个文件", len(files)),
|
||||
Message: i18n.T(i18nk.BotMsgCommonInfoBatchTasksAdded, map[string]any{
|
||||
"Count": len(files),
|
||||
}),
|
||||
ReplyMarkup: nil,
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/tphutil"
|
||||
"github.com/krau/SaveAny-Bot/core"
|
||||
@@ -38,7 +40,9 @@ func CreateAndAddtelegraphWithEdit(
|
||||
log.FromContext(ctx).Errorf("Failed to add task: %s", err)
|
||||
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||
ID: trackMsgID,
|
||||
Message: "任务添加失败: " + err.Error(),
|
||||
Message: i18n.T(i18nk.BotMsgCommonErrorTaskAddFailed, map[string]any{
|
||||
"Error": err.Error(),
|
||||
}),
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
62
client/bot/handlers/utils/shortcut/ytdlp.go
Normal file
62
client/bot/handlers/utils/shortcut/ytdlp.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package shortcut
|
||||
|
||||
import (
|
||||
"github.com/celestix/gotgproto/dispatcher"
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/rs/xid"
|
||||
|
||||
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||
"github.com/krau/SaveAny-Bot/core"
|
||||
"github.com/krau/SaveAny-Bot/core/tasks/ytdlp"
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
)
|
||||
|
||||
func CreateAndAddYtdlpTaskWithEdit(ctx *ext.Context, stor storage.Storage, dirPath string, urls []string, msgID int, userID int64) error {
|
||||
logger := log.FromContext(ctx)
|
||||
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
|
||||
|
||||
// Validate URLs
|
||||
if len(urls) == 0 {
|
||||
logger.Error("URLs list is empty")
|
||||
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||
ID: msgID,
|
||||
Message: i18n.T(i18nk.BotMsgYtdlpErrorNoValidUrls, nil),
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
logger.Infof("Creating yt-dlp task for %d URL(s)", len(urls))
|
||||
|
||||
// Create yt-dlp task
|
||||
task := ytdlp.NewTask(
|
||||
xid.New().String(),
|
||||
injectCtx,
|
||||
urls,
|
||||
stor,
|
||||
stor.JoinStoragePath(dirPath),
|
||||
ytdlp.NewProgress(msgID, userID),
|
||||
)
|
||||
|
||||
// Add task to queue
|
||||
if err := core.AddTask(injectCtx, task); err != nil {
|
||||
logger.Errorf("Failed to add yt-dlp task: %s", err)
|
||||
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||
ID: msgID,
|
||||
Message: i18n.T(i18nk.BotMsgCommonErrorTaskAddFailed, map[string]any{
|
||||
"Error": err.Error(),
|
||||
}),
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
|
||||
ID: msgID,
|
||||
Message: i18n.T(i18nk.BotMsgCommonInfoTaskAdded, nil),
|
||||
})
|
||||
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
@@ -5,7 +5,9 @@ import (
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/celestix/gotgproto/dispatcher"
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
@@ -16,10 +18,12 @@ import (
|
||||
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||
"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/tasks/tfile"
|
||||
coretfile "github.com/krau/SaveAny-Bot/core/tasks/tfile"
|
||||
"github.com/krau/SaveAny-Bot/database"
|
||||
"github.com/krau/SaveAny-Bot/pkg/enums/fnamest"
|
||||
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
"github.com/rs/xid"
|
||||
)
|
||||
@@ -34,18 +38,18 @@ func handleWatchCmd(ctx *ext.Context, update *ext.Update) error {
|
||||
userChatID := update.GetUserChat().GetID()
|
||||
user, err := database.GetUserByChatID(ctx, userChatID)
|
||||
if err != nil {
|
||||
logger.Errorf("获取用户失败: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("获取用户失败"), nil)
|
||||
logger.Errorf("Failed to get user: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorGetUserFailed)), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
if user.DefaultStorage == "" {
|
||||
ctx.Reply(update, ext.ReplyTextString("请先设置默认存储, 使用 /storage 命令"), nil)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorDefaultStorageNotSet)), 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)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorInvalidIdOrUsername, map[string]any{"Error": err.Error()})), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
watching, err := user.WatchingChat(ctx, chatID)
|
||||
@@ -54,7 +58,7 @@ func handleWatchCmd(ctx *ext.Context, update *ext.Update) error {
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
if watching {
|
||||
ctx.Reply(update, ext.ReplyTextString("已经在监听此聊天"), nil)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgWatchInfoAlreadyWatchingChat)), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
filter := ""
|
||||
@@ -63,19 +67,19 @@ func handleWatchCmd(ctx *ext.Context, update *ext.Update) error {
|
||||
filterType := strings.Split(filterArg, ":")[0]
|
||||
filterData := strings.Split(filterArg, ":")[1]
|
||||
if filterType == "" || filterData == "" {
|
||||
ctx.Reply(update, ext.ReplyTextString("过滤器格式错误, 请使用 <过滤器类型>:<表达式>"), nil)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgWatchErrorFilterFormatInvalid)), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
switch filterType {
|
||||
case "msgre":
|
||||
_, err := regexp.Compile(filterData)
|
||||
if err != nil {
|
||||
ctx.Reply(update, ext.ReplyTextString("正则表达式格式错误: "+err.Error()), nil)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorInvalidRegex, map[string]any{"Error": err.Error()})), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
filter = filterType + ":" + filterData
|
||||
default:
|
||||
ctx.Reply(update, ext.ReplyTextString("不支持的过滤器类型, 请参阅文档"), nil)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgWatchErrorFilterTypeUnsupported)), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
}
|
||||
@@ -85,10 +89,10 @@ func handleWatchCmd(ctx *ext.Context, update *ext.Update) error {
|
||||
Filter: filter,
|
||||
}); err != nil {
|
||||
logger.Errorf("Failed to watch chat %d: %s", chatID, err)
|
||||
ctx.Reply(update, ext.ReplyTextString("监听聊天失败: "+err.Error()), nil)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgWatchErrorWatchChatFailed, map[string]any{"Error": err.Error()})), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
ctx.Reply(update, ext.ReplyTextString("已开始监听聊天: "+chatArg), nil)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgWatchInfoWatchChatStarted, map[string]any{"Chat": chatArg})), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
@@ -97,22 +101,22 @@ func handleLswatchCmd(ctx *ext.Context, update *ext.Update) error {
|
||||
userChatID := update.GetUserChat().GetID()
|
||||
user, err := database.GetUserByChatID(ctx, userChatID)
|
||||
if err != nil {
|
||||
logger.Errorf("获取用户失败: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("获取用户失败"), nil)
|
||||
logger.Errorf("Failed to get user: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorGetUserFailed)), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
chats := user.WatchChats
|
||||
if len(chats) == 0 {
|
||||
ctx.Reply(update, ext.ReplyTextString("当前没有监听任何聊天"), nil)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgWatchInfoWatchListEmpty)), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.WriteString("当前监听的聊天:\n")
|
||||
sb.WriteString(i18n.T(i18nk.BotMsgWatchInfoWatchListHeader))
|
||||
for _, chat := range chats {
|
||||
sb.WriteString("- ")
|
||||
sb.WriteString(fmt.Sprintf("%d", chat.ChatID))
|
||||
if chat.Filter != "" {
|
||||
sb.WriteString(" (过滤器: ")
|
||||
sb.WriteString(i18n.T(i18nk.BotMsgWatchInfoWatchListFilterPrefix))
|
||||
sb.WriteString(chat.Filter)
|
||||
sb.WriteString(")")
|
||||
}
|
||||
@@ -126,32 +130,82 @@ func handleUnwatchCmd(ctx *ext.Context, update *ext.Update) error {
|
||||
logger := log.FromContext(ctx)
|
||||
args := strings.Split(update.EffectiveMessage.Text, " ")
|
||||
if len(args) < 2 {
|
||||
ctx.Reply(update, ext.ReplyTextString("请提供要取消监听的聊天ID或用户名"), nil)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgWatchErrorUnwatchNoChatProvided)), 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)
|
||||
logger.Errorf("Failed to get user: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorGetUserFailed)), 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)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorInvalidIdOrUsername, map[string]any{"Error": 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)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgWatchErrorUnwatchChatFailed, map[string]any{"Error": err.Error()})), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
ctx.Reply(update, ext.ReplyTextString("已取消监听聊天: "+chatArg), nil)
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgWatchInfoWatchChatStopped, map[string]any{"Chat": chatArg})), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
type watchMediaGroupHandler struct {
|
||||
groups map[int64]map[uint][]tfile.TGFileMessage // chatID -> userID -> files
|
||||
timers map[int64]map[uint]*time.Timer
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
var watchMediaGroupMgr = &watchMediaGroupHandler{
|
||||
groups: make(map[int64]map[uint][]tfile.TGFileMessage),
|
||||
timers: make(map[int64]map[uint]*time.Timer),
|
||||
}
|
||||
|
||||
func (w *watchMediaGroupHandler) addFile(chatID int64, userID uint, file tfile.TGFileMessage, timeout time.Duration, callback func([]tfile.TGFileMessage)) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
if w.groups[chatID] == nil {
|
||||
w.groups[chatID] = make(map[uint][]tfile.TGFileMessage)
|
||||
}
|
||||
if w.timers[chatID] == nil {
|
||||
w.timers[chatID] = make(map[uint]*time.Timer)
|
||||
}
|
||||
|
||||
if timer, exists := w.timers[chatID][userID]; exists {
|
||||
timer.Stop()
|
||||
}
|
||||
|
||||
w.groups[chatID][userID] = append(w.groups[chatID][userID], file)
|
||||
|
||||
w.timers[chatID][userID] = time.AfterFunc(timeout, func() {
|
||||
w.mu.Lock()
|
||||
files := w.groups[chatID][userID]
|
||||
delete(w.groups[chatID], userID)
|
||||
delete(w.timers[chatID], userID)
|
||||
if len(w.groups[chatID]) == 0 {
|
||||
delete(w.groups, chatID)
|
||||
}
|
||||
if len(w.timers[chatID]) == 0 {
|
||||
delete(w.timers, chatID)
|
||||
}
|
||||
w.mu.Unlock()
|
||||
|
||||
if len(files) > 0 {
|
||||
callback(files)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func listenMediaMessageEvent(ch chan userclient.MediaMessageEvent) {
|
||||
if userclient.GetCtx() == nil {
|
||||
return
|
||||
}
|
||||
logger := log.FromContext(userclient.GetCtx())
|
||||
for event := range ch {
|
||||
logger.Debug("Received media message event", "chat_id", event.ChatID, "file_name", event.File.Name())
|
||||
@@ -221,6 +275,24 @@ func listenMediaMessageEvent(ch chan userclient.MediaMessageEvent) {
|
||||
}
|
||||
file.SetName(sb.String())
|
||||
}
|
||||
|
||||
// Check if this is a media group and if rules specify NEW-FOR-ALBUM
|
||||
groupID, isGroup := file.Message().GetGroupedID()
|
||||
needAlbumHandling := false
|
||||
if isGroup && groupID != 0 && user.ApplyRule && user.Rules != nil {
|
||||
_, _, matchedDirPath := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file))
|
||||
needAlbumHandling = matchedDirPath.NeedNewForAlbum()
|
||||
}
|
||||
|
||||
if needAlbumHandling {
|
||||
// For media groups with NEW-FOR-ALBUM rule, collect all files of the same group
|
||||
watchMediaGroupMgr.addFile(event.ChatID, user.ID, file, time.Duration(config.C().Telegram.MediaGroupTimeout)*time.Second, func(files []tfile.TGFileMessage) {
|
||||
processWatchMediaGroup(ctx, user, stor, "", files)
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Process single file or media group without album folder creation
|
||||
var dirPath string
|
||||
if user.ApplyRule && user.Rules != nil {
|
||||
matched, matchedStorageName, matchedDirPath := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file))
|
||||
@@ -228,7 +300,7 @@ func listenMediaMessageEvent(ch chan userclient.MediaMessageEvent) {
|
||||
goto startCreateTask
|
||||
}
|
||||
dirPath = matchedDirPath.String()
|
||||
if matchedStorageName.IsUsable() {
|
||||
if matchedStorageName.Usable() {
|
||||
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)
|
||||
@@ -240,7 +312,7 @@ func listenMediaMessageEvent(ch chan userclient.MediaMessageEvent) {
|
||||
storagePath := stor.JoinStoragePath(path.Join(dirPath, file.Name()))
|
||||
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
|
||||
taskid := xid.New().String()
|
||||
task, err := tfile.NewTGFileTask(taskid, injectCtx, file, stor, storagePath, nil)
|
||||
task, err := coretfile.NewTGFileTask(taskid, injectCtx, file, stor, storagePath, nil)
|
||||
if err != nil {
|
||||
logger.Errorf("create task failed: %s", err)
|
||||
continue
|
||||
@@ -253,3 +325,97 @@ func listenMediaMessageEvent(ch chan userclient.MediaMessageEvent) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func processWatchMediaGroup(ctx *ext.Context, user *database.User, stor storage.Storage, dirPath string, files []tfile.TGFileMessage) {
|
||||
logger := log.FromContext(ctx)
|
||||
if len(files) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
useRule := user.ApplyRule && user.Rules != nil
|
||||
|
||||
applyRule := func(file tfile.TGFileMessage) (string, ruleutil.MatchedDirPath) {
|
||||
if !useRule {
|
||||
return stor.Name(), ruleutil.MatchedDirPath(dirPath)
|
||||
}
|
||||
matched, storName, dirP := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file))
|
||||
if !matched {
|
||||
return stor.Name(), ruleutil.MatchedDirPath(dirPath)
|
||||
}
|
||||
storname := storName.String()
|
||||
if !storName.Usable() {
|
||||
storname = stor.Name()
|
||||
}
|
||||
return storname, dirP
|
||||
}
|
||||
|
||||
type albumFile struct {
|
||||
file tfile.TGFileMessage
|
||||
storage storage.Storage
|
||||
}
|
||||
albumFiles := make(map[int64][]albumFile)
|
||||
|
||||
// Collect files by group ID
|
||||
for _, file := range files {
|
||||
storName, ruleDirPath := applyRule(file)
|
||||
fileStor := stor
|
||||
if storName != stor.Name() && storName != "" {
|
||||
var err error
|
||||
fileStor, err = storage.GetStorageByUserIDAndName(ctx, user.ChatID, storName)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to get storage by user ID and name: %s", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
groupId, isGroup := file.Message().GetGroupedID()
|
||||
if !isGroup || groupId == 0 {
|
||||
logger.Warnf("File %s is not in a group, skipping", file.Name())
|
||||
continue
|
||||
}
|
||||
|
||||
if !ruleDirPath.NeedNewForAlbum() {
|
||||
logger.Warnf("File %s does not need album folder, skipping", file.Name())
|
||||
continue
|
||||
}
|
||||
|
||||
if _, ok := albumFiles[groupId]; !ok {
|
||||
albumFiles[groupId] = make([]albumFile, 0)
|
||||
}
|
||||
albumFiles[groupId] = append(albumFiles[groupId], albumFile{
|
||||
file: file,
|
||||
storage: fileStor,
|
||||
})
|
||||
}
|
||||
|
||||
// Process album files with folder creation
|
||||
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
|
||||
totalTasks := 0
|
||||
for groupID, afiles := range albumFiles {
|
||||
if len(afiles) <= 1 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Use first file's name (without extension) as album folder name
|
||||
albumDir := strings.TrimSuffix(path.Base(afiles[0].file.Name()), path.Ext(afiles[0].file.Name()))
|
||||
albumStor := afiles[0].storage
|
||||
|
||||
logger.Infof("Creating album folder for group %d: %s with %d files", groupID, albumDir, len(afiles))
|
||||
|
||||
for _, af := range afiles {
|
||||
afstorPath := af.storage.JoinStoragePath(path.Join(dirPath, albumDir, af.file.Name()))
|
||||
taskid := xid.New().String()
|
||||
task, err := coretfile.NewTGFileTask(taskid, injectCtx, af.file, albumStor, afstorPath, nil)
|
||||
if err != nil {
|
||||
logger.Errorf("create task failed for album file: %s", err)
|
||||
continue
|
||||
}
|
||||
if err := core.AddTask(injectCtx, task); err != nil {
|
||||
logger.Errorf("add task failed: %s", err)
|
||||
continue
|
||||
}
|
||||
totalTasks++
|
||||
}
|
||||
}
|
||||
logger.Infof("Added %d watch media tasks for user %d", totalTasks, user.ChatID)
|
||||
}
|
||||
|
||||
63
client/bot/handlers/ytdlp.go
Normal file
63
client/bot/handlers/ytdlp.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/celestix/gotgproto/dispatcher"
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/duke-git/lancet/v2/slice"
|
||||
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
|
||||
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
)
|
||||
|
||||
func handleYtdlpCmd(ctx *ext.Context, update *ext.Update) error {
|
||||
logger := log.FromContext(ctx)
|
||||
args := strings.Split(update.EffectiveMessage.Text, " ")
|
||||
if len(args) < 2 {
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgYtdlpUsage)), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
urls := args[1:]
|
||||
// Validate and clean URLs
|
||||
for i, link := range urls {
|
||||
urls[i] = strings.TrimSpace(link)
|
||||
u, err := url.Parse(link)
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
logger.Warnf("Invalid URL: %s", link)
|
||||
urls[i] = ""
|
||||
}
|
||||
}
|
||||
urls = slice.Compact(urls)
|
||||
|
||||
if len(urls) == 0 {
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgYtdlpErrorNoValidUrls)), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
logger.Debugf("Preparing yt-dlp download for %d URL(s)", len(urls))
|
||||
|
||||
// Build storage selection keyboard
|
||||
markup, err := msgelem.BuildAddSelectStorageKeyboard(storage.GetUserStorages(ctx, update.GetUserChat().GetID()), tcbdata.Add{
|
||||
TaskType: tasktype.TaskTypeYtdlp,
|
||||
YtdlpURLs: urls,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgYtdlpInfoUrlsSelectStorage, map[string]any{
|
||||
"Count": len(urls),
|
||||
})), &ext.ReplyOpts{
|
||||
Markup: markup,
|
||||
})
|
||||
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
@@ -1,80 +1,57 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/celestix/gotgproto"
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/fatih/color"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
type terminalAuthConversator struct{}
|
||||
|
||||
func (t *terminalAuthConversator) AskPhoneNumber() (string, error) {
|
||||
phone := ""
|
||||
err := huh.NewInput().Title("Your Phone Number").
|
||||
Placeholder("+44 123456").
|
||||
Prompt("> ").
|
||||
Value(&phone).
|
||||
WithTheme(huh.ThemeCatppuccin()).
|
||||
Run()
|
||||
|
||||
func readLine(prompt string) (string, error) {
|
||||
fmt.Print(prompt)
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
text, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(text), nil
|
||||
}
|
||||
|
||||
log.Info("Sending code to your phone number...")
|
||||
|
||||
return strings.TrimSpace(phone), nil
|
||||
func (t *terminalAuthConversator) AskPhoneNumber() (string, error) {
|
||||
fmt.Println("Your Phone Number (e.g. +44 123456):")
|
||||
return readLine("> ")
|
||||
}
|
||||
|
||||
func (t *terminalAuthConversator) AskCode() (string, error) {
|
||||
code := ""
|
||||
err := huh.NewInput().Title("Your Code").
|
||||
Placeholder("123456").
|
||||
Value(&code).
|
||||
Prompt("> ").
|
||||
WithTheme(huh.ThemeCatppuccin()).
|
||||
Run()
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return strings.TrimSpace(code), nil
|
||||
fmt.Println("Your Code (e.g. 123456):")
|
||||
return readLine("> ")
|
||||
}
|
||||
|
||||
func (t *terminalAuthConversator) AskPassword() (string, error) {
|
||||
pwd := ""
|
||||
|
||||
err := huh.NewInput().Title("Your 2FA Password").
|
||||
EchoMode(huh.EchoModePassword).
|
||||
Value(&pwd).
|
||||
Prompt("> ").
|
||||
WithTheme(huh.ThemeCatppuccin()).
|
||||
Run()
|
||||
fmt.Println("Your 2FA Password:")
|
||||
fmt.Print("> ")
|
||||
bytePwd, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||
fmt.Println()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return strings.TrimSpace(pwd), nil
|
||||
return strings.TrimSpace(string(bytePwd)), nil
|
||||
}
|
||||
|
||||
func (t *terminalAuthConversator) AuthStatus(authStatus gotgproto.AuthStatus) {
|
||||
switch authStatus.Event {
|
||||
case gotgproto.AuthStatusPhoneRetrial:
|
||||
color.Red("The phone number you just entered seems to be incorrect,")
|
||||
color.Red("Attempts Left: %d", authStatus.AttemptsLeft)
|
||||
color.Red("Please try again....")
|
||||
fmt.Printf("The phone number is incorrect. Attempts left: %d\n", authStatus.AttemptsLeft)
|
||||
case gotgproto.AuthStatusPasswordRetrial:
|
||||
color.Red("The 2FA password you just entered seems to be incorrect,")
|
||||
color.Red("Attempts Left: %d", authStatus.AttemptsLeft)
|
||||
color.Red("Please try again....")
|
||||
fmt.Printf("The 2FA password is incorrect. Attempts left: %d\n", authStatus.AttemptsLeft)
|
||||
case gotgproto.AuthStatusPhoneCodeRetrial:
|
||||
color.Red("The OTP you just entered seems to be incorrect,")
|
||||
color.Red("Attempts Left: %d", authStatus.AttemptsLeft)
|
||||
color.Red("Please try again....")
|
||||
fmt.Printf("The OTP code is incorrect. Attempts left: %d\n", authStatus.AttemptsLeft)
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,37 +12,27 @@ import (
|
||||
"github.com/celestix/gotgproto/sessionMaker"
|
||||
|
||||
"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/common/utils/netutil"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"github.com/krau/SaveAny-Bot/database"
|
||||
"github.com/ncruces/go-sqlite3/gormlite"
|
||||
"golang.org/x/net/proxy"
|
||||
)
|
||||
|
||||
var uc *gotgproto.Client
|
||||
var ectx *ext.Context
|
||||
|
||||
func GetCtx() *ext.Context {
|
||||
if uc == nil {
|
||||
panic("User client is not initialized, please call Login first")
|
||||
}
|
||||
if ectx != nil {
|
||||
return ectx
|
||||
}
|
||||
if uc == nil {
|
||||
return nil
|
||||
}
|
||||
ectx = uc.CreateContext()
|
||||
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) {
|
||||
log.FromContext(ctx).Debug("Logging in user client")
|
||||
if uc != nil {
|
||||
@@ -53,28 +43,20 @@ func Login(ctx context.Context) (*gotgproto.Client, error) {
|
||||
err error
|
||||
})
|
||||
go func() {
|
||||
var resolver dcs.Resolver
|
||||
if config.C().Telegram.Proxy.Enable && config.C().Telegram.Proxy.URL != "" {
|
||||
dialer, err := netutil.NewProxyDialer(config.C().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()
|
||||
resolver, err := tgutil.NewConfigProxyResolver()
|
||||
if err != nil {
|
||||
res <- struct {
|
||||
client *gotgproto.Client
|
||||
err error
|
||||
}{nil, err}
|
||||
return
|
||||
}
|
||||
tclient, err := gotgproto.NewClient(
|
||||
config.C().Telegram.AppID,
|
||||
config.C().Telegram.AppHash,
|
||||
gotgproto.ClientTypePhone(""),
|
||||
&gotgproto.ClientOpts{
|
||||
Session: sessionMaker.SqlSession(gormlite.Open(config.C().Telegram.Userbot.Session)),
|
||||
Session: sessionMaker.SqlSession(database.GetDialect(config.C().Telegram.Userbot.Session)),
|
||||
AuthConversator: &terminalAuthConversator{},
|
||||
Context: ctx,
|
||||
DisableCopyright: true,
|
||||
|
||||
@@ -3,8 +3,10 @@ package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"flag"
|
||||
"fmt"
|
||||
"go/format"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -35,7 +37,7 @@ func main() {
|
||||
return err
|
||||
}
|
||||
|
||||
var content map[string]interface{}
|
||||
var content map[string]any
|
||||
if err := yaml.Unmarshal(data, &content); err != nil {
|
||||
return fmt.Errorf("failed to parse yaml %s: %w", path, err)
|
||||
}
|
||||
@@ -54,14 +56,9 @@ func main() {
|
||||
}
|
||||
sort.Strings(list)
|
||||
|
||||
f, err := os.Create(*out)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error creating output file: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
w := bufio.NewWriter(f)
|
||||
// Generate code to buffer
|
||||
var buf bytes.Buffer
|
||||
w := bufio.NewWriter(&buf)
|
||||
fmt.Fprintf(w, "// Code generated by cmd/geni18n. DO NOT EDIT.\n")
|
||||
fmt.Fprintf(w, "package %s\n\n", *pkg)
|
||||
fmt.Fprintf(w, "type Key string\n\n")
|
||||
@@ -72,16 +69,29 @@ func main() {
|
||||
}
|
||||
fmt.Fprintf(w, ")\n")
|
||||
w.Flush()
|
||||
|
||||
// Format the generated code
|
||||
formatted, err := format.Source(buf.Bytes())
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to format generated code: %v\n", err)
|
||||
formatted = buf.Bytes()
|
||||
}
|
||||
|
||||
// Write to output file
|
||||
if err := os.WriteFile(*out, formatted, 0644); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error writing output file: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func collectKeys(node map[string]interface{}, prefix string, keys map[string]struct{}) {
|
||||
func collectKeys(node map[string]any, prefix string, keys map[string]struct{}) {
|
||||
for k, v := range node {
|
||||
fullKey := k
|
||||
if prefix != "" {
|
||||
fullKey = prefix + "." + k
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case map[string]interface{}:
|
||||
case map[string]any:
|
||||
collectKeys(val, fullKey, keys)
|
||||
default:
|
||||
keys[fullKey] = struct{}{}
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/krau/SaveAny-Bot/cmd/upload"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -13,6 +15,11 @@ var rootCmd = &cobra.Command{
|
||||
Run: Run,
|
||||
}
|
||||
|
||||
func init() {
|
||||
config.RegisterFlags(rootCmd)
|
||||
upload.Register(rootCmd)
|
||||
}
|
||||
|
||||
func Execute(ctx context.Context) {
|
||||
if err := rootCmd.ExecuteContext(ctx); err != nil {
|
||||
fmt.Println(err)
|
||||
|
||||
49
cmd/run.go
49
cmd/run.go
@@ -10,11 +10,11 @@ import (
|
||||
"slices"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/krau/SaveAny-Bot/api"
|
||||
"github.com/krau/SaveAny-Bot/client/bot"
|
||||
userclient "github.com/krau/SaveAny-Bot/client/user"
|
||||
"github.com/krau/SaveAny-Bot/common/cache"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/fsutil"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"github.com/krau/SaveAny-Bot/core"
|
||||
@@ -34,7 +34,7 @@ func Run(cmd *cobra.Command, _ []string) {
|
||||
})
|
||||
ctx = log.WithContext(ctx, logger)
|
||||
|
||||
exitChan, err := initAll(ctx)
|
||||
exitChan, err := initAll(ctx, cmd)
|
||||
if err != nil {
|
||||
logger.Fatal("Init failed", "error", err)
|
||||
}
|
||||
@@ -46,39 +46,42 @@ func Run(cmd *cobra.Command, _ []string) {
|
||||
core.Run(ctx)
|
||||
|
||||
<-ctx.Done()
|
||||
logger.Info(i18n.T(i18nk.LifetimeExiting))
|
||||
defer logger.Info(i18n.T(i18nk.LifetimeBye))
|
||||
logger.Info("Exiting...")
|
||||
defer logger.Info("Exit complete")
|
||||
cleanCache()
|
||||
}
|
||||
|
||||
func initAll(ctx context.Context) (<-chan struct{}, error) {
|
||||
if err := config.Init(ctx); err != nil {
|
||||
func initAll(ctx context.Context, cmd *cobra.Command) (<-chan struct{}, error) {
|
||||
configFile := config.GetConfigFile(cmd)
|
||||
if err := config.Init(ctx, configFile); err != nil {
|
||||
return nil, fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
cache.Init()
|
||||
logger := log.FromContext(ctx)
|
||||
i18n.Init(config.C().Lang)
|
||||
logger.Info(i18n.T(i18nk.LifetimeIniting))
|
||||
logger.Info("Initializing...")
|
||||
database.Init(ctx)
|
||||
storage.LoadStorages(ctx)
|
||||
if config.C().Parser.PluginEnable {
|
||||
for _, dir := range config.C().Parser.PluginDirs {
|
||||
if err := parsers.LoadPlugins(ctx, dir); err != nil {
|
||||
logger.Error(i18n.T(i18nk.ParserPluginLoadFailed), "dir", dir, "error", err)
|
||||
logger.Error("Failed to load parser plugins", "dir", dir, "error", err)
|
||||
} else {
|
||||
logger.Debug(i18n.T(i18nk.ParserPluginLoadedDir), "dir", dir)
|
||||
logger.Debug("Loaded parser plugins from directory", "dir", dir)
|
||||
}
|
||||
}
|
||||
}
|
||||
if config.C().Telegram.Userbot.Enable {
|
||||
_, err := userclient.Login(ctx)
|
||||
if err != nil {
|
||||
logger.Fatal(i18n.T(i18nk.LifetimeUserLoginFailed, map[string]any{
|
||||
"Error": err,
|
||||
}))
|
||||
logger.Fatal("User login failed", "error", err)
|
||||
}
|
||||
}
|
||||
return bot.Init(ctx), nil
|
||||
exitChan := bot.Init(ctx)
|
||||
if err := api.Init(ctx); err != nil {
|
||||
return nil, fmt.Errorf("failed to init API server: %w", err)
|
||||
}
|
||||
return exitChan, nil
|
||||
}
|
||||
|
||||
func cleanCache() {
|
||||
@@ -87,33 +90,23 @@ func cleanCache() {
|
||||
}
|
||||
if config.C().Temp.BasePath != "" && !config.C().Stream {
|
||||
if slices.Contains([]string{"/", ".", "\\", ".."}, filepath.Clean(config.C().Temp.BasePath)) {
|
||||
log.Error(i18n.T(i18nk.ConfigErrInvalidCacheDir, map[string]any{
|
||||
"Path": config.C().Temp.BasePath,
|
||||
}))
|
||||
log.Error("Invalid cache directory", "path", config.C().Temp.BasePath)
|
||||
return
|
||||
}
|
||||
currentDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
log.Error(i18n.T(i18nk.ErrGetWorkdirFailed, map[string]any{
|
||||
"Error": err,
|
||||
}))
|
||||
log.Error("Failed to get working directory", "error", err)
|
||||
return
|
||||
}
|
||||
cachePath := filepath.Join(currentDir, config.C().Temp.BasePath)
|
||||
cachePath, err = filepath.Abs(cachePath)
|
||||
if err != nil {
|
||||
log.Error(i18n.T(i18nk.ErrGetCacheAbsPathFailed, map[string]any{
|
||||
"Error": err,
|
||||
}))
|
||||
log.Error("Failed to get absolute cache path", "error", err)
|
||||
return
|
||||
}
|
||||
log.Info(i18n.T(i18nk.LifetimeCleaningCache, map[string]any{
|
||||
"Path": cachePath,
|
||||
}))
|
||||
log.Info("Cleaning cache directory", "path", cachePath)
|
||||
if err := fsutil.RemoveAllInDir(cachePath); err != nil {
|
||||
log.Error(i18n.T(i18nk.ErrCleanCacheFailed, map[string]any{
|
||||
"Error": err,
|
||||
}))
|
||||
log.Error("Failed to clean cache directory", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
130
cmd/upload/cmd.go
Normal file
130
cmd/upload/cmd.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package upload
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/krau/SaveAny-Bot/client/bot"
|
||||
"github.com/krau/SaveAny-Bot/common/cache"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/ioutil"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"github.com/krau/SaveAny-Bot/database"
|
||||
"github.com/krau/SaveAny-Bot/pkg/enums/ctxkey"
|
||||
stortype "github.com/krau/SaveAny-Bot/pkg/enums/storage"
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var uploadCmd = &cobra.Command{
|
||||
Use: "upload",
|
||||
Short: "upload local files to storage",
|
||||
RunE: Upload,
|
||||
}
|
||||
|
||||
func Register(root *cobra.Command) {
|
||||
uploadCmd.Flags().StringP("file", "f", "", "file path to upload")
|
||||
uploadCmd.MarkFlagRequired("file")
|
||||
uploadCmd.Flags().StringP("storage", "s", "", "storage name to upload to")
|
||||
uploadCmd.MarkFlagRequired("storage")
|
||||
uploadCmd.Flags().StringP("dir", "d", "", "storage dir to upload to, default is the base_path of the storage")
|
||||
uploadCmd.Flags().Bool("no-progress", false, "disable progress bar")
|
||||
root.AddCommand(uploadCmd)
|
||||
}
|
||||
|
||||
func Upload(cmd *cobra.Command, args []string) error {
|
||||
storname, err := cmd.Flags().GetString("storage")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fp, err := cmd.Flags().GetString("file")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dirPath, err := cmd.Flags().GetString("dir")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
noProgress, err := cmd.Flags().GetBool("no-progress")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx := cmd.Context()
|
||||
log := log.FromContext(ctx)
|
||||
configFile := config.GetConfigFile(cmd)
|
||||
if err := config.Init(ctx, configFile); err != nil {
|
||||
return fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
i18n.Init(config.C().Lang)
|
||||
cache.Init()
|
||||
database.Init(ctx)
|
||||
|
||||
stor, err := storage.GetStorageByName(ctx, storname)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to get storage", "error", err)
|
||||
}
|
||||
|
||||
switch stor.Type() {
|
||||
case stortype.Telegram:
|
||||
bot.Init(ctx)
|
||||
default:
|
||||
// placeholder for other storage types that may need special initialization
|
||||
}
|
||||
|
||||
file, err := os.Open(filepath.Clean(fp))
|
||||
if err != nil {
|
||||
log.Fatal("Failed to open file", "error", err)
|
||||
}
|
||||
defer file.Close()
|
||||
fileInfo, err := file.Stat()
|
||||
if err != nil {
|
||||
log.Fatal("Failed to get file info", "error", err)
|
||||
}
|
||||
fileName := fileInfo.Name()
|
||||
fileSize := fileInfo.Size()
|
||||
|
||||
uploadPath := stor.JoinStoragePath(path.Join(dirPath, fileName))
|
||||
|
||||
ctx = context.WithValue(ctx, ctxkey.ContentLength, fileSize)
|
||||
ctx = tgutil.ExtWithContext(ctx, bot.ExtContext())
|
||||
|
||||
// Create progress reader and UI
|
||||
var reader io.Reader
|
||||
var progressUI *UploadProgress
|
||||
log.Info("Uploading file...", "file", fp, "to", storname, "as", uploadPath)
|
||||
|
||||
if !noProgress && fileSize > 0 {
|
||||
progressUI = NewUploadProgress(ctx, fileName, fileSize)
|
||||
progressUI.Start()
|
||||
|
||||
reader = ioutil.NewProgressReader(file, fileSize, func(read int64, total int64) {
|
||||
if total > 0 {
|
||||
progressUI.UpdateProgress(float64(read) / float64(total))
|
||||
}
|
||||
})
|
||||
} else {
|
||||
reader = file
|
||||
}
|
||||
|
||||
if err := stor.Save(ctx, reader, uploadPath); err != nil {
|
||||
if progressUI != nil {
|
||||
progressUI.SetError(err)
|
||||
progressUI.Wait()
|
||||
}
|
||||
log.Fatal("Failed to upload file", "error", err)
|
||||
}
|
||||
|
||||
if progressUI != nil {
|
||||
progressUI.Done()
|
||||
progressUI.Wait()
|
||||
}
|
||||
log.Info("File uploaded successfully")
|
||||
return nil
|
||||
}
|
||||
35
cmd/upload/progress_stub.go
Normal file
35
cmd/upload/progress_stub.go
Normal file
@@ -0,0 +1,35 @@
|
||||
//go:build no_bubbletea
|
||||
|
||||
package upload
|
||||
|
||||
import "context"
|
||||
|
||||
type uploadModel struct {
|
||||
}
|
||||
|
||||
// UploadProgress manages the progress UI for uploads
|
||||
type UploadProgress struct {
|
||||
}
|
||||
|
||||
// NewUploadProgress creates a new upload progress tracker
|
||||
func NewUploadProgress(ctx context.Context, fileName string, fileSize int64) *UploadProgress {
|
||||
return &UploadProgress{}
|
||||
}
|
||||
|
||||
// Start starts the progress UI in a goroutine and returns immediately
|
||||
func (up *UploadProgress) Start() {}
|
||||
|
||||
// UpdateProgress updates the progress bar with a new percentage (0.0 - 1.0)
|
||||
func (up *UploadProgress) UpdateProgress(percent float64) {}
|
||||
|
||||
// SetError sets an error and quits the progress UI
|
||||
func (up *UploadProgress) SetError(err error) {}
|
||||
|
||||
// Done signals that the upload is complete
|
||||
func (up *UploadProgress) Done() {}
|
||||
|
||||
// Wait waits for the progress UI to finish
|
||||
func (up *UploadProgress) Wait() {}
|
||||
|
||||
// Quit quits the progress UI
|
||||
func (up *UploadProgress) Quit() {}
|
||||
178
cmd/upload/progress_tea.go
Normal file
178
cmd/upload/progress_tea.go
Normal file
@@ -0,0 +1,178 @@
|
||||
//go:build !no_bubbletea
|
||||
|
||||
package upload
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/progress"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/dustin/go-humanize"
|
||||
)
|
||||
|
||||
var (
|
||||
helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#626262"))
|
||||
)
|
||||
|
||||
// progressMsg is sent to update the progress bar
|
||||
type progressMsg float64
|
||||
|
||||
// progressErrMsg is sent when an error occurs
|
||||
type progressErrMsg struct{ err error }
|
||||
|
||||
// progressDoneMsg is sent when the upload is complete
|
||||
type progressDoneMsg struct{}
|
||||
|
||||
// uploadModel is the bubbletea model for the upload progress UI
|
||||
type uploadModel struct {
|
||||
progress progress.Model
|
||||
fileName string
|
||||
fileSize int64
|
||||
bytesRead int64
|
||||
err error
|
||||
done bool
|
||||
quitting bool
|
||||
width int
|
||||
}
|
||||
|
||||
func newUploadModel(fileName string, fileSize int64) uploadModel {
|
||||
p := progress.New(
|
||||
progress.WithDefaultGradient(),
|
||||
progress.WithWidth(50),
|
||||
)
|
||||
return uploadModel{
|
||||
progress: p,
|
||||
fileName: fileName,
|
||||
fileSize: fileSize,
|
||||
}
|
||||
}
|
||||
|
||||
func (m uploadModel) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m uploadModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.progress.Width = min(msg.Width-10, 80)
|
||||
return m, nil
|
||||
|
||||
case progressMsg:
|
||||
var cmds []tea.Cmd
|
||||
percent := float64(msg)
|
||||
m.bytesRead = int64(percent * float64(m.fileSize))
|
||||
|
||||
cmds = append(cmds, m.progress.SetPercent(percent))
|
||||
return m, tea.Batch(cmds...)
|
||||
|
||||
case progressErrMsg:
|
||||
m.err = msg.err
|
||||
return m, tea.Quit
|
||||
|
||||
case progressDoneMsg:
|
||||
m.done = true
|
||||
m.progress.SetPercent(1.0)
|
||||
return m, tea.Quit
|
||||
|
||||
case progress.FrameMsg:
|
||||
// Don't process frame messages if we're done or quitting
|
||||
if m.done || m.quitting {
|
||||
return m, nil
|
||||
}
|
||||
progressModel, cmd := m.progress.Update(msg)
|
||||
m.progress = progressModel.(progress.Model)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m uploadModel) View() string {
|
||||
if m.err != nil {
|
||||
return fmt.Sprintf("\n ❌ Error: %s\n\n", m.err.Error())
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("\n")
|
||||
|
||||
// File info
|
||||
sb.WriteString(fmt.Sprintf(" 📁 %s\n", m.fileName))
|
||||
sb.WriteString(fmt.Sprintf(" 📊 %s / %s\n\n",
|
||||
humanize.Bytes(uint64(m.bytesRead)),
|
||||
humanize.Bytes(uint64(m.fileSize)),
|
||||
))
|
||||
|
||||
// Progress bar
|
||||
sb.WriteString(" ")
|
||||
sb.WriteString(m.progress.View())
|
||||
sb.WriteString("\n\n")
|
||||
|
||||
if m.done {
|
||||
sb.WriteString(" √ Upload complete!\n\n")
|
||||
} else {
|
||||
sb.WriteString(helpStyle.Render(" Press Ctrl+C to cancel"))
|
||||
sb.WriteString("\n\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// UploadProgress manages the progress UI for uploads
|
||||
type UploadProgress struct {
|
||||
program *tea.Program
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// NewUploadProgress creates a new upload progress tracker
|
||||
func NewUploadProgress(ctx context.Context, fileName string, fileSize int64) *UploadProgress {
|
||||
model := newUploadModel(fileName, fileSize)
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
p := tea.NewProgram(
|
||||
model,
|
||||
tea.WithoutSignalHandler(),
|
||||
tea.WithContext(ctx),
|
||||
tea.WithInput(nil), // Disable keyboard input, rely on context cancellation
|
||||
)
|
||||
return &UploadProgress{
|
||||
program: p,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
}
|
||||
|
||||
// Start starts the progress UI in a goroutine and returns immediately
|
||||
func (up *UploadProgress) Start() {
|
||||
go func() {
|
||||
up.program.Run()
|
||||
}()
|
||||
}
|
||||
|
||||
// UpdateProgress updates the progress bar with a new percentage (0.0 - 1.0)
|
||||
func (up *UploadProgress) UpdateProgress(percent float64) {
|
||||
up.program.Send(progressMsg(percent))
|
||||
}
|
||||
|
||||
// SetError sets an error and quits the progress UI
|
||||
func (up *UploadProgress) SetError(err error) {
|
||||
up.program.Send(progressErrMsg{err: err})
|
||||
}
|
||||
|
||||
// Done signals that the upload is complete
|
||||
func (up *UploadProgress) Done() {
|
||||
up.program.Send(progressDoneMsg{})
|
||||
}
|
||||
|
||||
// Wait waits for the progress UI to finish
|
||||
func (up *UploadProgress) Wait() {
|
||||
up.program.Wait()
|
||||
}
|
||||
|
||||
// Quit quits the progress UI
|
||||
func (up *UploadProgress) Quit() {
|
||||
up.program.Quit()
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"runtime"
|
||||
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"github.com/rhysd/go-github-selfupdate/selfupdate"
|
||||
"github.com/unvgo/ghselfupdate"
|
||||
|
||||
"github.com/blang/semver"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -26,17 +26,32 @@ var upgradeCmd = &cobra.Command{
|
||||
Short: "Upgrade saveany-bot to the latest version",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
v := semver.MustParse(config.Version)
|
||||
latest, err := selfupdate.UpdateSelf(v, config.GitRepo)
|
||||
latest, found, err := ghselfupdate.DetectLatest(config.GitRepo)
|
||||
if err != nil {
|
||||
fmt.Println("Error occurred while detecting latest version:", err)
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
fmt.Println("No releases found")
|
||||
return
|
||||
}
|
||||
if latest.Version.Major != v.Major {
|
||||
fmt.Printf("Major version upgrade detected: %s -> %s. Please manually download the latest version and check the migration guide.\n", v, latest.Version)
|
||||
return
|
||||
}
|
||||
if latest.Version.Equals(v) || latest.Version.LT(v) {
|
||||
fmt.Println("Current binary is the latest version", config.Version)
|
||||
return
|
||||
}
|
||||
fmt.Printf("Updating to version %s...\n", latest.Version)
|
||||
latest, err = ghselfupdate.UpdateSelf(v, config.GitRepo)
|
||||
if err != nil {
|
||||
fmt.Println("Update failed:", err)
|
||||
return
|
||||
}
|
||||
if latest.Version.Equals(v) {
|
||||
fmt.Println("Current binary is the latest version", config.Version)
|
||||
} else {
|
||||
fmt.Println("Successfully updated to version", latest.Version)
|
||||
fmt.Println("Release note:\n", latest.ReleaseNotes)
|
||||
}
|
||||
fmt.Println("Successfully updated to version", latest.Version)
|
||||
fmt.Println("Release note:\n", latest.ReleaseNotes)
|
||||
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// [TODO] complete the i18n support
|
||||
|
||||
package i18n
|
||||
|
||||
import (
|
||||
@@ -42,57 +44,7 @@ func Init(lang string) {
|
||||
|
||||
func T(key i18nk.Key, templateData ...map[string]any) string {
|
||||
if localizer == nil || bundle == nil {
|
||||
panic("localizer or bundle is not initialized, call Init() first")
|
||||
}
|
||||
templateDataMap := make(map[string]any)
|
||||
for _, data := range templateData {
|
||||
maps.Copy(templateDataMap, data)
|
||||
}
|
||||
msg, err := localizer.Localize(&i18n.LocalizeConfig{
|
||||
MessageID: string(key),
|
||||
TemplateData: templateDataMap,
|
||||
})
|
||||
if err != nil {
|
||||
return string(key)
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
func TWithLang(lang, key string, templateData ...map[string]any) string {
|
||||
if bundle == nil {
|
||||
panic("bundle is not initialized, call Init() first")
|
||||
}
|
||||
templateDataMap := make(map[string]any)
|
||||
for _, data := range templateData {
|
||||
maps.Copy(templateDataMap, data)
|
||||
}
|
||||
localizerWithLang := i18n.NewLocalizer(bundle, lang)
|
||||
msg, err := localizerWithLang.Localize(&i18n.LocalizeConfig{
|
||||
MessageID: key,
|
||||
TemplateData: templateDataMap,
|
||||
})
|
||||
if err != nil {
|
||||
return key
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// Only use in tests or packages that load before i18n
|
||||
func TWithoutInit(lang string, key i18nk.Key, templateData ...map[string]any) string {
|
||||
bundle := i18n.NewBundle(language.SimplifiedChinese)
|
||||
bundle.RegisterUnmarshalFunc("yaml", yaml.Unmarshal)
|
||||
files, err := localesFS.ReadDir("locale")
|
||||
if err != nil {
|
||||
return string(key)
|
||||
}
|
||||
for _, file := range files {
|
||||
if _, err := bundle.LoadMessageFileFS(localesFS, "locale/"+file.Name()); err != nil {
|
||||
return string(key)
|
||||
}
|
||||
}
|
||||
localizer := i18n.NewLocalizer(bundle, lang)
|
||||
if localizer == nil {
|
||||
return string(key)
|
||||
Init("zh-Hans")
|
||||
}
|
||||
templateDataMap := make(map[string]any)
|
||||
for _, data := range templateData {
|
||||
|
||||
@@ -4,21 +4,257 @@ package i18nk
|
||||
type Key string
|
||||
|
||||
const (
|
||||
BotMsgHelpTextFmt Key = "bot.msg.help_text_fmt"
|
||||
BotMsgSaveHelpText Key = "bot.msg.save_help_text"
|
||||
BotMsgWatchHelpText Key = "bot.msg.watch_help_text"
|
||||
ConfigErrDuplicateStorageName Key = "config.err.duplicate_storage_name"
|
||||
ConfigErrInvalidCacheDir Key = "config.err.invalid_cache_dir"
|
||||
ConfigLoadedStorages Key = "config.loaded_storages"
|
||||
ErrCleanCacheFailed Key = "err.clean_cache_failed"
|
||||
ErrGetCacheAbsPathFailed Key = "err.get_cache_abs_path_failed"
|
||||
ErrGetWorkdirFailed Key = "err.get_workdir_failed"
|
||||
LifetimeBye Key = "lifetime.bye"
|
||||
LifetimeCleaningCache Key = "lifetime.cleaning_cache"
|
||||
LifetimeExiting Key = "lifetime.exiting"
|
||||
LifetimeInitfailed Key = "lifetime.initfailed"
|
||||
LifetimeIniting Key = "lifetime.initing"
|
||||
LifetimeUserLoginFailed Key = "lifetime.user_login_failed"
|
||||
ParserPluginLoadFailed Key = "parser.plugin.load_failed"
|
||||
ParserPluginLoadedDir Key = "parser.plugin.loaded_dir"
|
||||
BotMsgAria2ErrorAddingAria2Download Key = "bot.msg.aria2.error_adding_aria2_download"
|
||||
BotMsgAria2ErrorAria2ClientInitFailed Key = "bot.msg.aria2.error_aria2_client_init_failed"
|
||||
BotMsgAria2ErrorAria2NotEnabled Key = "bot.msg.aria2.error_aria2_not_enabled"
|
||||
BotMsgAria2InfoAddingAria2Download Key = "bot.msg.aria2.info_adding_aria2_download"
|
||||
BotMsgAria2InfoAria2DownloadAdded Key = "bot.msg.aria2.info_aria2_download_added"
|
||||
BotMsgAria2InfoSelectStorage Key = "bot.msg.aria2.info_select_storage"
|
||||
BotMsgCancelErrorCancelFailed Key = "bot.msg.cancel.error_cancel_failed"
|
||||
BotMsgCancelInfoCancelRequested Key = "bot.msg.cancel.info_cancel_requested"
|
||||
BotMsgCancelInfoCancellingTask Key = "bot.msg.cancel.info_cancelling_task"
|
||||
BotMsgCancelUsage Key = "bot.msg.cancel.usage"
|
||||
BotMsgCmdAria2dl Key = "bot.msg.cmd.aria2dl"
|
||||
BotMsgCmdCancel Key = "bot.msg.cmd.cancel"
|
||||
BotMsgCmdConfig Key = "bot.msg.cmd.config"
|
||||
BotMsgCmdDir Key = "bot.msg.cmd.dir"
|
||||
BotMsgCmdDl Key = "bot.msg.cmd.dl"
|
||||
BotMsgCmdFnametmpl Key = "bot.msg.cmd.fnametmpl"
|
||||
BotMsgCmdHelp Key = "bot.msg.cmd.help"
|
||||
BotMsgCmdLswatch Key = "bot.msg.cmd.lswatch"
|
||||
BotMsgCmdParser Key = "bot.msg.cmd.parser"
|
||||
BotMsgCmdRule Key = "bot.msg.cmd.rule"
|
||||
BotMsgCmdSave Key = "bot.msg.cmd.save"
|
||||
BotMsgCmdSilent Key = "bot.msg.cmd.silent"
|
||||
BotMsgCmdStart Key = "bot.msg.cmd.start"
|
||||
BotMsgCmdStorage Key = "bot.msg.cmd.storage"
|
||||
BotMsgCmdSyncpeers Key = "bot.msg.cmd.syncpeers"
|
||||
BotMsgCmdTask Key = "bot.msg.cmd.task"
|
||||
BotMsgCmdUnwatch Key = "bot.msg.cmd.unwatch"
|
||||
BotMsgCmdUpdate Key = "bot.msg.cmd.update"
|
||||
BotMsgCmdWatch Key = "bot.msg.cmd.watch"
|
||||
BotMsgCmdYtdlp Key = "bot.msg.cmd.ytdlp"
|
||||
BotMsgCommonCancelButtonText Key = "bot.msg.common.cancel_button_text"
|
||||
BotMsgCommonErrorBuildDirSelectKeyboardFailed Key = "bot.msg.common.error_build_dir_select_keyboard_failed"
|
||||
BotMsgCommonErrorBuildStorageSelectKeyboardFailed Key = "bot.msg.common.error_build_storage_select_keyboard_failed"
|
||||
BotMsgCommonErrorBuildStorageSelectMessageFailed Key = "bot.msg.common.error_build_storage_select_message_failed"
|
||||
BotMsgCommonErrorDataExpired Key = "bot.msg.common.error_data_expired"
|
||||
BotMsgCommonErrorDefaultStorageNotSet Key = "bot.msg.common.error_default_storage_not_set"
|
||||
BotMsgCommonErrorGetDirFailed Key = "bot.msg.common.error_get_dir_failed"
|
||||
BotMsgCommonErrorGetFileFailed Key = "bot.msg.common.error_get_file_failed"
|
||||
BotMsgCommonErrorGetMessagesFailed Key = "bot.msg.common.error_get_messages_failed"
|
||||
BotMsgCommonErrorGetStorageFailed Key = "bot.msg.common.error_get_storage_failed"
|
||||
BotMsgCommonErrorGetTelegraphPageFailed Key = "bot.msg.common.error_get_telegraph_page_failed"
|
||||
BotMsgCommonErrorGetUserFailed Key = "bot.msg.common.error_get_user_failed"
|
||||
BotMsgCommonErrorGetUserInfoFailed Key = "bot.msg.common.error_get_user_info_failed"
|
||||
BotMsgCommonErrorGetUserWithErrFailed Key = "bot.msg.common.error_get_user_with_err_failed"
|
||||
BotMsgCommonErrorInvalidIdOrUsername Key = "bot.msg.common.error_invalid_id_or_username"
|
||||
BotMsgCommonErrorInvalidMsgIdRange Key = "bot.msg.common.error_invalid_msg_id_range"
|
||||
BotMsgCommonErrorInvalidRegex Key = "bot.msg.common.error_invalid_regex"
|
||||
BotMsgCommonErrorNoAvailableStorage Key = "bot.msg.common.error_no_available_storage"
|
||||
BotMsgCommonErrorNoImagesInTelegraphPage Key = "bot.msg.common.error_no_images_in_telegraph_page"
|
||||
BotMsgCommonErrorNoMessagesInRange Key = "bot.msg.common.error_no_messages_in_range"
|
||||
BotMsgCommonErrorNoPermission Key = "bot.msg.common.error_no_permission"
|
||||
BotMsgCommonErrorNoSavableFilesFound Key = "bot.msg.common.error_no_savable_files_found"
|
||||
BotMsgCommonErrorNoSavableMessagesInRange Key = "bot.msg.common.error_no_savable_messages_in_range"
|
||||
BotMsgCommonErrorParseTelegraphPathFailed Key = "bot.msg.common.error_parse_telegraph_path_failed"
|
||||
BotMsgCommonErrorTaskAddFailed Key = "bot.msg.common.error_task_add_failed"
|
||||
BotMsgCommonErrorTaskCreateFailed Key = "bot.msg.common.error_task_create_failed"
|
||||
BotMsgCommonErrorUpdateUserInfoFailed Key = "bot.msg.common.error_update_user_info_failed"
|
||||
BotMsgCommonInfoBatchTasksAdded Key = "bot.msg.common.info_batch_tasks_added"
|
||||
BotMsgCommonInfoDefaultStorageSet Key = "bot.msg.common.info_default_storage_set"
|
||||
BotMsgCommonInfoDefaultStorageWithDirSet Key = "bot.msg.common.info_default_storage_with_dir_set"
|
||||
BotMsgCommonInfoFetchingFileInfo Key = "bot.msg.common.info_fetching_file_info"
|
||||
BotMsgCommonInfoFetchingMessages Key = "bot.msg.common.info_fetching_messages"
|
||||
BotMsgCommonInfoFetchingTelegraphPage Key = "bot.msg.common.info_fetching_telegraph_page"
|
||||
BotMsgCommonInfoFoundFilesSelectStorage Key = "bot.msg.common.info_found_files_select_storage"
|
||||
BotMsgCommonInfoSilentModeOff Key = "bot.msg.common.info_silent_mode_off"
|
||||
BotMsgCommonInfoSilentModeOn Key = "bot.msg.common.info_silent_mode_on"
|
||||
BotMsgCommonInfoTaskAdded Key = "bot.msg.common.info_task_added"
|
||||
BotMsgCommonPromptSelectDefaultDir Key = "bot.msg.common.prompt_select_default_dir"
|
||||
BotMsgCommonPromptSelectDefaultStorage Key = "bot.msg.common.prompt_select_default_storage"
|
||||
BotMsgCommonPromptSelectDir Key = "bot.msg.common.prompt_select_dir"
|
||||
BotMsgConfigButtonFilenameStrategy Key = "bot.msg.config.button_filename_strategy"
|
||||
BotMsgConfigErrorInvalidCallbackData Key = "bot.msg.config.error_invalid_callback_data"
|
||||
BotMsgConfigErrorInvalidTemplate Key = "bot.msg.config.error_invalid_template"
|
||||
BotMsgConfigFnametmplHelp Key = "bot.msg.config.fnametmpl_help"
|
||||
BotMsgConfigInfoCurrentTemplatePrefix Key = "bot.msg.config.info_current_template_prefix"
|
||||
BotMsgConfigInfoFilenameStrategySet Key = "bot.msg.config.info_filename_strategy_set"
|
||||
BotMsgConfigInfoTemplateUpdated Key = "bot.msg.config.info_template_updated"
|
||||
BotMsgConfigPromptSelectFilenameStrategy Key = "bot.msg.config.prompt_select_filename_strategy"
|
||||
BotMsgConfigPromptSelectOption Key = "bot.msg.config.prompt_select_option"
|
||||
BotMsgDirButtonDefault Key = "bot.msg.dir.button_default"
|
||||
BotMsgDirErrorCreateDirFailed Key = "bot.msg.dir.error_create_dir_failed"
|
||||
BotMsgDirErrorDeleteDirFailed Key = "bot.msg.dir.error_delete_dir_failed"
|
||||
BotMsgDirErrorGetUserDirsFailed Key = "bot.msg.dir.error_get_user_dirs_failed"
|
||||
BotMsgDirErrorGetUserFailed Key = "bot.msg.dir.error_get_user_failed"
|
||||
BotMsgDirErrorInvalidDirId Key = "bot.msg.dir.error_invalid_dir_id"
|
||||
BotMsgDirErrorUnknownOperation Key = "bot.msg.dir.error_unknown_operation"
|
||||
BotMsgDirHelpAddExampleCmd Key = "bot.msg.dir.help_add_example_cmd"
|
||||
BotMsgDirHelpAddExamplePrefix Key = "bot.msg.dir.help_add_example_prefix"
|
||||
BotMsgDirHelpAddSuffix Key = "bot.msg.dir.help_add_suffix"
|
||||
BotMsgDirHelpAvailableOps Key = "bot.msg.dir.help_available_ops"
|
||||
BotMsgDirHelpDelExampleCmd Key = "bot.msg.dir.help_del_example_cmd"
|
||||
BotMsgDirHelpDelExamplePrefix Key = "bot.msg.dir.help_del_example_prefix"
|
||||
BotMsgDirHelpDelSuffix Key = "bot.msg.dir.help_del_suffix"
|
||||
BotMsgDirHelpExistingDirsPrefix Key = "bot.msg.dir.help_existing_dirs_prefix"
|
||||
BotMsgDirHelpUsage Key = "bot.msg.dir.help_usage"
|
||||
BotMsgDirInfoCreateDirSuccess Key = "bot.msg.dir.info_create_dir_success"
|
||||
BotMsgDirInfoDeleteDirSuccess Key = "bot.msg.dir.info_delete_dir_success"
|
||||
BotMsgDlErrorNoValidLinks Key = "bot.msg.dl.error_no_valid_links"
|
||||
BotMsgDlInfoFilesSelectStorage Key = "bot.msg.dl.info_files_select_storage"
|
||||
BotMsgDlUsage Key = "bot.msg.dl.usage"
|
||||
BotMsgHelpTextFmt Key = "bot.msg.help_text_fmt"
|
||||
BotMsgMediaGroupErrorBuildStorageSelectKeyboardFailed Key = "bot.msg.media_group.error_build_storage_select_keyboard_failed"
|
||||
BotMsgMediaGroupInfoGroupFoundFilesSelectStorage Key = "bot.msg.media_group.info_group_found_files_select_storage"
|
||||
BotMsgMediaGroupInfoSavingFiles Key = "bot.msg.media_group.info_saving_files"
|
||||
BotMsgParseErrorBuildParsedTextEntityFailed Key = "bot.msg.parse.error_build_parsed_text_entity_failed"
|
||||
BotMsgParseErrorBuildStorageSelectKeyboardFailed Key = "bot.msg.parse.error_build_storage_select_keyboard_failed"
|
||||
BotMsgParseErrorParseTextFailed Key = "bot.msg.parse.error_parse_text_failed"
|
||||
BotMsgParseInfoAuthorPrefix Key = "bot.msg.parse.info_author_prefix"
|
||||
BotMsgParseInfoDescriptionPrefix Key = "bot.msg.parse.info_description_prefix"
|
||||
BotMsgParseInfoFileCountPrefix Key = "bot.msg.parse.info_file_count_prefix"
|
||||
BotMsgParseInfoLinkPrefix Key = "bot.msg.parse.info_link_prefix"
|
||||
BotMsgParseInfoParsing Key = "bot.msg.parse.info_parsing"
|
||||
BotMsgParseInfoPromptSelectStorage Key = "bot.msg.parse.info_prompt_select_storage"
|
||||
BotMsgParseInfoTotalSizePrefix Key = "bot.msg.parse.info_total_size_prefix"
|
||||
BotMsgParserErrorDownloadFileFailed Key = "bot.msg.parser.error_download_file_failed"
|
||||
BotMsgParserErrorFileTooLarge Key = "bot.msg.parser.error_file_too_large"
|
||||
BotMsgParserErrorGetFilenameFailed Key = "bot.msg.parser.error_get_filename_failed"
|
||||
BotMsgParserErrorInstallPluginFailed Key = "bot.msg.parser.error_install_plugin_failed"
|
||||
BotMsgParserErrorNoValidFileInReply Key = "bot.msg.parser.error_no_valid_file_in_reply"
|
||||
BotMsgParserErrorOnlyJsSupported Key = "bot.msg.parser.error_only_js_supported"
|
||||
BotMsgParserErrorWrongFileType Key = "bot.msg.parser.error_wrong_file_type"
|
||||
BotMsgParserHelpText Key = "bot.msg.parser.help_text"
|
||||
BotMsgParserInfoInstallPluginSuccess Key = "bot.msg.parser.info_install_plugin_success"
|
||||
BotMsgParserPluginNotEnabled Key = "bot.msg.parser.plugin_not_enabled"
|
||||
BotMsgParserPromptReplyWithParserFile Key = "bot.msg.parser.prompt_reply_with_parser_file"
|
||||
BotMsgProgressAria2Done Key = "bot.msg.progress.aria2_done"
|
||||
BotMsgProgressAria2Downloading Key = "bot.msg.progress.aria2_downloading"
|
||||
BotMsgProgressAria2Start Key = "bot.msg.progress.aria2_start"
|
||||
BotMsgProgressAvgSpeedPrefix Key = "bot.msg.progress.avg_speed_prefix"
|
||||
BotMsgProgressBatchDonePrefix Key = "bot.msg.progress.batch_done_prefix"
|
||||
BotMsgProgressBatchProcessingPrefix Key = "bot.msg.progress.batch_processing_prefix"
|
||||
BotMsgProgressBatchStartPrefix Key = "bot.msg.progress.batch_start_prefix"
|
||||
BotMsgProgressCurrentProgressPrefix Key = "bot.msg.progress.current_progress_prefix"
|
||||
BotMsgProgressCurrentSpeedPrefix Key = "bot.msg.progress.current_speed_prefix"
|
||||
BotMsgProgressDirectDonePrefix Key = "bot.msg.progress.direct_done_prefix"
|
||||
BotMsgProgressDirectStart Key = "bot.msg.progress.direct_start"
|
||||
BotMsgProgressDownloadDonePrefix Key = "bot.msg.progress.download_done_prefix"
|
||||
BotMsgProgressDownloadFailedPrefix Key = "bot.msg.progress.download_failed_prefix"
|
||||
BotMsgProgressDownloadedPrefix Key = "bot.msg.progress.downloaded_prefix"
|
||||
BotMsgProgressDownloadingPrefix Key = "bot.msg.progress.downloading_prefix"
|
||||
BotMsgProgressErrorPrefix Key = "bot.msg.progress.error_prefix"
|
||||
BotMsgProgressFileNamePrefix Key = "bot.msg.progress.file_name_prefix"
|
||||
BotMsgProgressFileProcessingPrefix Key = "bot.msg.progress.file_processing_prefix"
|
||||
BotMsgProgressFileSizePrefix Key = "bot.msg.progress.file_size_prefix"
|
||||
BotMsgProgressFileStartPrefix Key = "bot.msg.progress.file_start_prefix"
|
||||
BotMsgProgressParsedDonePrefix Key = "bot.msg.progress.parsed_done_prefix"
|
||||
BotMsgProgressParsedStartPrefix Key = "bot.msg.progress.parsed_start_prefix"
|
||||
BotMsgProgressProcessingListPrefix Key = "bot.msg.progress.processing_list_prefix"
|
||||
BotMsgProgressProcessingNone Key = "bot.msg.progress.processing_none"
|
||||
BotMsgProgressSavePathPrefix Key = "bot.msg.progress.save_path_prefix"
|
||||
BotMsgProgressTaskCanceled Key = "bot.msg.progress.task_canceled"
|
||||
BotMsgProgressTaskCanceledWithId Key = "bot.msg.progress.task_canceled_with_id"
|
||||
BotMsgProgressTaskFailedWithError Key = "bot.msg.progress.task_failed_with_error"
|
||||
BotMsgProgressTelegraphDonePrefix Key = "bot.msg.progress.telegraph_done_prefix"
|
||||
BotMsgProgressTelegraphProgressPrefix Key = "bot.msg.progress.telegraph_progress_prefix"
|
||||
BotMsgProgressTelegraphStartPrefix Key = "bot.msg.progress.telegraph_start_prefix"
|
||||
BotMsgProgressTotalSizePrefix Key = "bot.msg.progress.total_size_prefix"
|
||||
BotMsgProgressYtdlpDone Key = "bot.msg.progress.ytdlp_done"
|
||||
BotMsgProgressYtdlpDownloading Key = "bot.msg.progress.ytdlp_downloading"
|
||||
BotMsgProgressYtdlpStart Key = "bot.msg.progress.ytdlp_start"
|
||||
BotMsgRuleErrorCreateRuleFailed Key = "bot.msg.rule.error_create_rule_failed"
|
||||
BotMsgRuleErrorDeleteRuleFailed Key = "bot.msg.rule.error_delete_rule_failed"
|
||||
BotMsgRuleErrorGetUserRulesFailed Key = "bot.msg.rule.error_get_user_rules_failed"
|
||||
BotMsgRuleErrorInvalidRuleId Key = "bot.msg.rule.error_invalid_rule_id"
|
||||
BotMsgRuleErrorInvalidRuleType Key = "bot.msg.rule.error_invalid_rule_type"
|
||||
BotMsgRuleErrorUpdateUserFailed Key = "bot.msg.rule.error_update_user_failed"
|
||||
BotMsgRuleHelpAddSuffix Key = "bot.msg.rule.help_add_suffix"
|
||||
BotMsgRuleHelpAvailableOps Key = "bot.msg.rule.help_available_ops"
|
||||
BotMsgRuleHelpCurrentModeDisabled Key = "bot.msg.rule.help_current_mode_disabled"
|
||||
BotMsgRuleHelpCurrentModeEnabled Key = "bot.msg.rule.help_current_mode_enabled"
|
||||
BotMsgRuleHelpDelSuffix Key = "bot.msg.rule.help_del_suffix"
|
||||
BotMsgRuleHelpExistingRulesPrefix Key = "bot.msg.rule.help_existing_rules_prefix"
|
||||
BotMsgRuleHelpSwitchSuffix Key = "bot.msg.rule.help_switch_suffix"
|
||||
BotMsgRuleHelpUsage Key = "bot.msg.rule.help_usage"
|
||||
BotMsgRuleInfoCreateRuleSuccess Key = "bot.msg.rule.info_create_rule_success"
|
||||
BotMsgRuleInfoDeleteRuleSuccess Key = "bot.msg.rule.info_delete_rule_success"
|
||||
BotMsgRuleInfoRuleModeDisabled Key = "bot.msg.rule.info_rule_mode_disabled"
|
||||
BotMsgRuleInfoRuleModeEnabled Key = "bot.msg.rule.info_rule_mode_enabled"
|
||||
BotMsgRulePromptProvideRuleId Key = "bot.msg.rule.prompt_provide_rule_id"
|
||||
BotMsgSaveErrorInvalidIdOrUsername Key = "bot.msg.save.error_invalid_id_or_username"
|
||||
BotMsgSaveHelpText Key = "bot.msg.save_help_text"
|
||||
BotMsgStorageInfoFilenamePrefix Key = "bot.msg.storage.info_filename_prefix"
|
||||
BotMsgStorageInfoPromptSelectStorage Key = "bot.msg.storage.info_prompt_select_storage"
|
||||
BotMsgSyncpeersDone Key = "bot.msg.syncpeers.done"
|
||||
BotMsgSyncpeersFailed Key = "bot.msg.syncpeers.failed"
|
||||
BotMsgSyncpeersStart Key = "bot.msg.syncpeers.start"
|
||||
BotMsgSyncpeersSuccess Key = "bot.msg.syncpeers.success"
|
||||
BotMsgTasksCancelFailed Key = "bot.msg.tasks.cancel_failed"
|
||||
BotMsgTasksCancelRequestedPrefix Key = "bot.msg.tasks.cancel_requested_prefix"
|
||||
BotMsgTasksFieldCreated Key = "bot.msg.tasks.field_created"
|
||||
BotMsgTasksFieldId Key = "bot.msg.tasks.field_id"
|
||||
BotMsgTasksFieldStatus Key = "bot.msg.tasks.field_status"
|
||||
BotMsgTasksFieldTitle Key = "bot.msg.tasks.field_title"
|
||||
BotMsgTasksInfoAddedToQueueFull Key = "bot.msg.tasks.info_added_to_queue_full"
|
||||
BotMsgTasksInfoAddedToQueuePrefix Key = "bot.msg.tasks.info_added_to_queue_prefix"
|
||||
BotMsgTasksInfoFilenamePrefix Key = "bot.msg.tasks.info_filename_prefix"
|
||||
BotMsgTasksInfoQueueLengthPrefix Key = "bot.msg.tasks.info_queue_length_prefix"
|
||||
BotMsgTasksQueuedEmpty Key = "bot.msg.tasks.queued_empty"
|
||||
BotMsgTasksQueuedTitle Key = "bot.msg.tasks.queued_title"
|
||||
BotMsgTasksRunningEmpty Key = "bot.msg.tasks.running_empty"
|
||||
BotMsgTasksRunningTitle Key = "bot.msg.tasks.running_title"
|
||||
BotMsgTasksStatusCancelRequested Key = "bot.msg.tasks.status_cancel_requested"
|
||||
BotMsgTasksStatusQueued Key = "bot.msg.tasks.status_queued"
|
||||
BotMsgTasksStatusRunning Key = "bot.msg.tasks.status_running"
|
||||
BotMsgTasksTotalPrefix Key = "bot.msg.tasks.total_prefix"
|
||||
BotMsgTasksTruncatedNote Key = "bot.msg.tasks.truncated_note"
|
||||
BotMsgTasksUsage Key = "bot.msg.tasks.usage"
|
||||
BotMsgTasksUsageCancel Key = "bot.msg.tasks.usage_cancel"
|
||||
BotMsgTelegraphErrorBuildStorageSelectKeyboardFailed Key = "bot.msg.telegraph.error_build_storage_select_keyboard_failed"
|
||||
BotMsgTelegraphInfoPicCountPrefix Key = "bot.msg.telegraph.info_pic_count_prefix"
|
||||
BotMsgTelegraphInfoPromptSelectStorage Key = "bot.msg.telegraph.info_prompt_select_storage"
|
||||
BotMsgTelegraphInfoTitlePrefix Key = "bot.msg.telegraph.info_title_prefix"
|
||||
BotMsgUpdateButtonUpgrade Key = "bot.msg.update.button_upgrade"
|
||||
BotMsgUpdateErrorCheckLatestFailed Key = "bot.msg.update.error_check_latest_failed"
|
||||
BotMsgUpdateErrorNoReleaseFound Key = "bot.msg.update.error_no_release_found"
|
||||
BotMsgUpdateErrorUpgradeFailed Key = "bot.msg.update.error_upgrade_failed"
|
||||
BotMsgUpdateErrorVersionVarInvalid Key = "bot.msg.update.error_version_var_invalid"
|
||||
BotMsgUpdateInfoAlreadyLatest Key = "bot.msg.update.info_already_latest"
|
||||
BotMsgUpdateInfoMajorUpgradeRequired Key = "bot.msg.update.info_major_upgrade_required"
|
||||
BotMsgUpdateInfoNewVersionInDocker Key = "bot.msg.update.info_new_version_in_docker"
|
||||
BotMsgUpdateInfoNewVersionPromptUpgrade Key = "bot.msg.update.info_new_version_prompt_upgrade"
|
||||
BotMsgUpdateInfoUpgradeSuccess Key = "bot.msg.update.info_upgrade_success"
|
||||
BotMsgUpdateInfoUpgradingWithVersion Key = "bot.msg.update.info_upgrading_with_version"
|
||||
BotMsgWatchErrorFilterFormatInvalid Key = "bot.msg.watch.error_filter_format_invalid"
|
||||
BotMsgWatchErrorFilterTypeUnsupported Key = "bot.msg.watch.error_filter_type_unsupported"
|
||||
BotMsgWatchErrorUnwatchChatFailed Key = "bot.msg.watch.error_unwatch_chat_failed"
|
||||
BotMsgWatchErrorUnwatchNoChatProvided Key = "bot.msg.watch.error_unwatch_no_chat_provided"
|
||||
BotMsgWatchErrorWatchChatFailed Key = "bot.msg.watch.error_watch_chat_failed"
|
||||
BotMsgWatchInfoAlreadyWatchingChat Key = "bot.msg.watch.info_already_watching_chat"
|
||||
BotMsgWatchInfoWatchChatStarted Key = "bot.msg.watch.info_watch_chat_started"
|
||||
BotMsgWatchInfoWatchChatStopped Key = "bot.msg.watch.info_watch_chat_stopped"
|
||||
BotMsgWatchInfoWatchListEmpty Key = "bot.msg.watch.info_watch_list_empty"
|
||||
BotMsgWatchInfoWatchListFilterPrefix Key = "bot.msg.watch.info_watch_list_filter_prefix"
|
||||
BotMsgWatchInfoWatchListHeader Key = "bot.msg.watch.info_watch_list_header"
|
||||
BotMsgWatchHelpText Key = "bot.msg.watch_help_text"
|
||||
BotMsgYtdlpErrorDownloadFailed Key = "bot.msg.ytdlp.error_download_failed"
|
||||
BotMsgYtdlpErrorNoValidUrls Key = "bot.msg.ytdlp.error_no_valid_urls"
|
||||
BotMsgYtdlpInfoDownloading Key = "bot.msg.ytdlp.info_downloading"
|
||||
BotMsgYtdlpInfoUrlsSelectStorage Key = "bot.msg.ytdlp.info_urls_select_storage"
|
||||
BotMsgYtdlpUsage Key = "bot.msg.ytdlp.usage"
|
||||
ConfigErrDuplicateStorageName Key = "config.err.duplicate_storage_name"
|
||||
ConfigErrInvalidCacheDir Key = "config.err.invalid_cache_dir"
|
||||
ErrCleanCacheFailed Key = "err.clean_cache_failed"
|
||||
ErrGetCacheAbsPathFailed Key = "err.get_cache_abs_path_failed"
|
||||
ErrGetWorkdirFailed Key = "err.get_workdir_failed"
|
||||
LifetimeBye Key = "lifetime.bye"
|
||||
LifetimeCleaningCache Key = "lifetime.cleaning_cache"
|
||||
LifetimeExiting Key = "lifetime.exiting"
|
||||
LifetimeInitfailed Key = "lifetime.initfailed"
|
||||
LifetimeIniting Key = "lifetime.initing"
|
||||
LifetimeUserLoginFailed Key = "lifetime.user_login_failed"
|
||||
ParserPluginLoadFailed Key = "parser.plugin.load_failed"
|
||||
ParserPluginLoadedDir Key = "parser.plugin.loaded_dir"
|
||||
)
|
||||
|
||||
355
common/i18n/locale/en.yaml
Normal file
355
common/i18n/locale/en.yaml
Normal file
@@ -0,0 +1,355 @@
|
||||
lifetime:
|
||||
initing: Starting up
|
||||
initfailed: Initialization failed
|
||||
exiting: Shutting down
|
||||
user_login_failed: "User login failed: {{.Error}}"
|
||||
cleaning_cache: "Cleaning cache {{.Path}}"
|
||||
bye: Exited
|
||||
config:
|
||||
err:
|
||||
invalid_cache_dir: "Invalid cache directory: {{.Path}}, please check the config file"
|
||||
duplicate_storage_name: "Storage name '{{.Name}}' is duplicated, please check the config file"
|
||||
err:
|
||||
get_workdir_failed: "Failed to get working directory: {{.Error}}"
|
||||
get_cache_abs_path_failed: "Failed to get absolute cache path: {{.Error}}"
|
||||
clean_cache_failed: "Failed to clean cache: {{.Error}}"
|
||||
parser:
|
||||
plugin:
|
||||
load_failed: Failed to load parser plugins
|
||||
loaded_dir: Parser plugins loaded
|
||||
bot:
|
||||
msg:
|
||||
help_text_fmt: |
|
||||
Save Any Bot - Save your Telegram files
|
||||
Version: %s , Commit: %s
|
||||
|
||||
Commands:
|
||||
/start - Start using the bot
|
||||
/help - Show help
|
||||
/silent - Toggle silent mode
|
||||
/storage - Set default storage
|
||||
/save [custom filename] - Save file
|
||||
/dir - Manage storage directories
|
||||
/rule - Manage rules
|
||||
/config - Modify configuration
|
||||
/fnametmpl - Set custom filename template
|
||||
/parser - Manage parser plugins
|
||||
/task - Manage task queue
|
||||
/watch - Watch chats and auto save (UserBot)
|
||||
/unwatch - Stop watching chats (UserBot)
|
||||
/lswatch - List watched chats (UserBot)
|
||||
/syncpeers - Sync peer chats (UserBot)
|
||||
/update - Check and upgrade to latest version
|
||||
|
||||
Usage guide: https://sabot.unv.app/usage
|
||||
cmd:
|
||||
start: "Start using"
|
||||
silent: "Toggle silent mode"
|
||||
storage: "Set default storage"
|
||||
dir: "Manage storage directories"
|
||||
rule: "Manage auto-save rules"
|
||||
save: "Save files"
|
||||
dl: "Download files from given links"
|
||||
aria2dl: "Download files using Aria2"
|
||||
ytdlp: "Download video/audio using yt-dlp"
|
||||
task: "Manage task queue"
|
||||
cancel: "Cancel task"
|
||||
watch: "Watch chats (UserBot)"
|
||||
unwatch: "Stop watching chats (UserBot)"
|
||||
lswatch: "List watched chats (UserBot)"
|
||||
config: "Modify configuration"
|
||||
fnametmpl: "Set filename template"
|
||||
help: "Show help"
|
||||
parser: "Manage parsers"
|
||||
update: "Check for updates"
|
||||
syncpeers: "Sync peer chats (UserBot)"
|
||||
save_help_text: |
|
||||
Usage:
|
||||
|
||||
1. Reply to the file you want to save with this command, optional filename parameter.
|
||||
Example:
|
||||
/save custom_file_name.mp4
|
||||
|
||||
2. After setting default storage, send /save <channel_id/username> <message_id_range> to batch save files. Rules will be applied; if no rule matches, default storage will be used.
|
||||
Example:
|
||||
/save @acherkrau 114-514
|
||||
watch_help_text: |
|
||||
Use /watch to watch messages in a chat and automatically save them to the default storage, following storage rules.
|
||||
|
||||
Syntax:
|
||||
/watch <chat_id> [filter]
|
||||
|
||||
Parameters:
|
||||
- <chat_id>: Chat ID or username
|
||||
- [filter]: Optional, format is filter_type:expression , see docs for all supported filters
|
||||
|
||||
Example:
|
||||
/watch -1002229835658 msgre:.*plana.*
|
||||
|
||||
This will watch chat with ID -1002229835658 and save all media messages containing "plana".
|
||||
common:
|
||||
cancel_button_text: "Cancel"
|
||||
error_invalid_regex: "Invalid regex: {{.Error}}"
|
||||
error_invalid_msg_id_range: "Invalid message ID range: {{.Error}}"
|
||||
error_invalid_id_or_username: "Invalid ID or username: {{.Error}}"
|
||||
error_get_messages_failed: "Failed to get messages: {{.Error}}"
|
||||
info_fetching_messages: "Fetching messages..."
|
||||
error_no_messages_in_range: "No messages found in the specified range"
|
||||
error_no_savable_messages_in_range: "No savable messages found in the specified range"
|
||||
error_build_storage_select_message_failed: "Failed to build storage selection message: {{.Error}}"
|
||||
error_build_storage_select_keyboard_failed: "Failed to build storage selection keyboard: {{.Error}}"
|
||||
info_found_files_select_storage: "Found {{.Count}} files, please select storage"
|
||||
error_get_user_failed: "Failed to get user"
|
||||
error_get_user_with_err_failed: "Failed to get user: {{.Error}}"
|
||||
error_default_storage_not_set: "Please set a default storage first with /storage"
|
||||
error_no_available_storage: "No available storage"
|
||||
error_get_storage_failed: "Failed to get storage: {{.Error}}"
|
||||
prompt_select_default_storage: "Please select a storage to set as default"
|
||||
error_data_expired: "Data has expired or is invalid"
|
||||
error_task_add_failed: "Failed to add task: {{.Error}}"
|
||||
info_task_added: "Task added"
|
||||
info_batch_tasks_added: "Batch tasks added, total {{.Count}} files"
|
||||
error_task_create_failed: "Failed to create task: {{.Error}}"
|
||||
error_get_dir_failed: "Failed to get directory: {{.Error}}"
|
||||
prompt_select_dir: "Please select a directory to store to"
|
||||
prompt_select_default_dir: "Please select a default directory to save to"
|
||||
info_default_storage_set: "Default storage set to: {{.Name}}"
|
||||
info_default_storage_with_dir_set: "Default storage set to: {{.Name}}:/{{.Dir}}"
|
||||
error_get_user_info_failed: "Failed to get user info: {{.Error}}"
|
||||
error_update_user_info_failed: "Failed to update user info: {{.Error}}"
|
||||
info_silent_mode_on: "Silent mode enabled"
|
||||
info_silent_mode_off: "Silent mode disabled"
|
||||
error_get_file_failed: "Failed to get file: {{.Error}}"
|
||||
info_fetching_file_info: "Fetching file info..."
|
||||
error_no_savable_files_found: "No savable files found"
|
||||
error_parse_telegraph_path_failed: "Failed to parse telegraph path: {{.Error}}"
|
||||
info_fetching_telegraph_page: "Fetching telegraph page..."
|
||||
error_get_telegraph_page_failed: "Failed to get telegraph page: {{.Error}}"
|
||||
error_no_images_in_telegraph_page: "No images found in telegraph page"
|
||||
error_build_dir_select_keyboard_failed: "Failed to build directory selection keyboard: {{.Error}}"
|
||||
error_no_permission: |
|
||||
You are not in the whitelist and cannot use this bot.
|
||||
You can deploy your own instance: https://github.com/krau/SaveAny-Bot
|
||||
save:
|
||||
error_invalid_id_or_username: "Invalid ID or username: {{.Error}}"
|
||||
watch:
|
||||
error_filter_format_invalid: "Invalid filter format, please use <type>:<expression>"
|
||||
error_filter_type_unsupported: "Unsupported filter type, please see the docs"
|
||||
error_watch_chat_failed: "Failed to watch chat: {{.Error}}"
|
||||
info_watch_chat_started: "Started watching chat: {{.Chat}}"
|
||||
info_already_watching_chat: "Already watching this chat"
|
||||
info_watch_list_empty: "No chats are being watched currently"
|
||||
info_watch_list_header: "Currently watched chats:\n"
|
||||
info_watch_list_filter_prefix: " (filter: "
|
||||
error_unwatch_no_chat_provided: "Please provide a chat ID or username to unwatch"
|
||||
error_unwatch_chat_failed: "Failed to unwatch chat: {{.Error}}"
|
||||
info_watch_chat_stopped: "Stopped watching chat: {{.Chat}}"
|
||||
tasks:
|
||||
usage_cancel: "Usage: /tasks cancel <task_id>"
|
||||
usage: "Usage: /tasks [running|queued|cancel <task_id>]"
|
||||
cancel_failed: "Failed to cancel task: {{.Error}}"
|
||||
cancel_requested_prefix: "Cancel requested for task: "
|
||||
running_empty: "No running tasks"
|
||||
running_title: "Currently running tasks:"
|
||||
total_prefix: "Total: {{.Count}}\n"
|
||||
field_id: "ID: "
|
||||
field_title: "Title: "
|
||||
field_created: "Created at: "
|
||||
field_status: "Status: "
|
||||
status_running: "Running"
|
||||
status_queued: "Queued"
|
||||
status_cancel_requested: "Cancel requested"
|
||||
queued_empty: "No queued tasks"
|
||||
queued_title: "Currently queued tasks:"
|
||||
truncated_note: "...\nShowing first 10 tasks, total {{.Count}} tasks"
|
||||
info_added_to_queue_full: "Added to task queue\nFilename: {{.Filename}}\nCurrent queued tasks: {{.QueueLength}}"
|
||||
info_added_to_queue_prefix: "Added to task queue\n"
|
||||
info_filename_prefix: "Filename: "
|
||||
info_queue_length_prefix: "\nCurrent queued tasks: "
|
||||
rule:
|
||||
error_get_user_rules_failed: "Failed to get user rules"
|
||||
error_update_user_failed: "Failed to update user"
|
||||
info_rule_mode_enabled: "Rule mode enabled"
|
||||
info_rule_mode_disabled: "Rule mode disabled"
|
||||
error_invalid_rule_type: "Invalid rule type: {{.Type}}\nAvailable: {{.Available}}"
|
||||
error_create_rule_failed: "Failed to create rule"
|
||||
info_create_rule_success: "Rule created successfully"
|
||||
prompt_provide_rule_id: "Please provide rule ID"
|
||||
error_invalid_rule_id: "Invalid rule ID"
|
||||
error_delete_rule_failed: "Failed to delete rule"
|
||||
info_delete_rule_success: "Rule deleted successfully"
|
||||
help_usage: "Usage: /rule <op> <args...>"
|
||||
help_current_mode_enabled: "\nRule mode is currently enabled"
|
||||
help_current_mode_disabled: "\nRule mode is currently disabled"
|
||||
help_available_ops: "\n\nAvailable operations:\n"
|
||||
help_switch_suffix: " - Toggle rule mode\n"
|
||||
help_add_suffix: " <type> <data> <storage_name> <path> - Add rule\n"
|
||||
help_del_suffix: " <rule_id> - Delete rule\n"
|
||||
help_existing_rules_prefix: "\nCurrent rules:\n"
|
||||
dir:
|
||||
error_get_user_dirs_failed: "Failed to get user directories"
|
||||
error_get_user_failed: "Failed to get user"
|
||||
error_create_dir_failed: "Failed to create directory"
|
||||
info_create_dir_success: "Directory created successfully"
|
||||
error_invalid_dir_id: "Invalid directory ID"
|
||||
error_delete_dir_failed: "Failed to delete directory"
|
||||
info_delete_dir_success: "Directory deleted successfully"
|
||||
error_unknown_operation: "Unknown operation"
|
||||
help_usage: "Usage: /dir <op> <args...>"
|
||||
help_available_ops: "\n\nAvailable operations:\n"
|
||||
help_add_suffix: " <storage_name> <path> - Add path\n"
|
||||
help_del_suffix: " <path_id> - Delete path\n"
|
||||
help_add_example_prefix: "\nAdd path example:\n"
|
||||
help_add_example_cmd: "/dir add local1 path/to/dir"
|
||||
help_del_example_prefix: "\n\nDelete path example:\n"
|
||||
help_del_example_cmd: "/dir del 3"
|
||||
help_existing_dirs_prefix: "\n\nCurrent paths:\n"
|
||||
button_default: "Default"
|
||||
parser:
|
||||
help_text: |
|
||||
Usage:
|
||||
|
||||
/parser install <reply a file> - Install parser
|
||||
plugin_not_enabled: "Parser plugin feature is not enabled"
|
||||
prompt_reply_with_parser_file: "Please reply with a message containing the parser file"
|
||||
error_no_valid_file_in_reply: "The replied message does not contain a valid file"
|
||||
error_wrong_file_type: "Wrong file type"
|
||||
error_file_too_large: "File too large"
|
||||
error_get_filename_failed: "Failed to get filename"
|
||||
error_only_js_supported: "Only .js files are supported as parsers"
|
||||
error_download_file_failed: "Failed to download file: {{.Error}}"
|
||||
error_install_plugin_failed: "Failed to install plugin: {{.Error}}"
|
||||
info_install_plugin_success: "Plugin installed: {{.Name}}"
|
||||
parse:
|
||||
info_parsing: "Parsing..."
|
||||
error_parse_text_failed: "Failed to parse text: {{.Error}}"
|
||||
error_build_storage_select_keyboard_failed: "Failed to build storage selection keyboard: {{.Error}}"
|
||||
error_build_parsed_text_entity_failed: "Failed to build parsed text entity: {{.Error}}"
|
||||
info_link_prefix: "\nLink: "
|
||||
info_author_prefix: "\nAuthor: "
|
||||
info_description_prefix: "\nDescription: "
|
||||
info_file_count_prefix: "\nFile count: "
|
||||
info_total_size_prefix: "\nEstimated total size: "
|
||||
info_prompt_select_storage: "\nPlease select storage"
|
||||
telegraph:
|
||||
error_build_storage_select_keyboard_failed: "Failed to build storage selection keyboard: {{.Error}}"
|
||||
info_title_prefix: "Title: "
|
||||
info_pic_count_prefix: "\nImage count: "
|
||||
info_prompt_select_storage: "\nPlease select storage"
|
||||
update:
|
||||
error_version_var_invalid: "Currently in development version or version info injection failed: {{.Error}}"
|
||||
error_check_latest_failed: "Failed to check latest version: {{.Error}}"
|
||||
error_no_release_found: "No release found"
|
||||
info_major_upgrade_required: "Major upgrade detected: {{.Current}} -> {{.Latest}} , please download the latest version from GitHub and check the migration guide"
|
||||
info_already_latest: "Already on latest version: {{.Version}}"
|
||||
info_new_version_in_docker: |-
|
||||
New version found: {{.Latest}}
|
||||
Current version: {{.Current}}
|
||||
Published at: {{.PublishedAt}}
|
||||
Since you are using Docker, please update it on your deployment platform
|
||||
info_new_version_prompt_upgrade: |-
|
||||
New version found: {{.Latest}}
|
||||
Current version: {{.Current}}
|
||||
|
||||
File size: {{.SizeMB}} MB
|
||||
Download URL: {{.URL}}
|
||||
Published at: {{.PublishedAt}}
|
||||
|
||||
Upgrading will restart the bot. Proceed?
|
||||
info_upgrading_with_version: "Upgrading, current version: {{.Current}}"
|
||||
error_upgrade_failed: "Upgrade failed: {{.Error}}"
|
||||
info_upgrade_success: "Upgraded to version {{.Version}}\nIf the bot did not restart automatically, please start it manually"
|
||||
button_upgrade: "Upgrade"
|
||||
config:
|
||||
prompt_select_option: "Please select an option to configure"
|
||||
button_filename_strategy: "Filename strategy"
|
||||
error_invalid_callback_data: "Invalid callback data"
|
||||
error_invalid_template: "Invalid template, please check syntax\n{{.Error}}"
|
||||
info_filename_strategy_set: "Filename strategy set to: {{.Strategy}}"
|
||||
prompt_select_filename_strategy: "Please select filename strategy, current strategy: {{.Strategy}}"
|
||||
fnametmpl_help: |-
|
||||
Use this command to set filename template, for example:
|
||||
/fnametmpl Image_{{"{{.msgid}}"}}_{{"{{.msgdate}}"}}.jpg
|
||||
|
||||
Available variables:
|
||||
- {{"{{.msgid}}"}}: Message ID
|
||||
- {{"{{.msgtags}}"}}: Tags in the message, joined with underscore
|
||||
- {{"{{.msggen}}"}}: Generated filename from the message
|
||||
- {{"{{.msgdate}}"}}: Message date, format YYYY-MM-DD_HH-MM-SS
|
||||
- {{"{{.msgraw}}"}}: Raw message text (unprocessed)
|
||||
- {{"{{.origname}}"}}: Original media filename (if any)
|
||||
- {{"{{.chatid}}"}}: Chat ID of the message
|
||||
|
||||
Template only takes effect when filename strategy is set to 'Custom template'.
|
||||
If template parsing fails, it will fall back to default filename.
|
||||
info_template_updated: "Filename template updated"
|
||||
info_current_template_prefix: "Current template: {{.Template}}"
|
||||
dl:
|
||||
usage: "Usage: /dl <url1> <url2> ..."
|
||||
error_no_valid_links: "No valid links to download"
|
||||
info_files_select_storage: "Total {{.Count}} files, please select storage"
|
||||
ytdlp:
|
||||
usage: "Usage: /ytdlp <URL1> <URL2> ..."
|
||||
error_no_valid_urls: "No valid URLs"
|
||||
info_urls_select_storage: "Found {{.Count}} links, please select storage"
|
||||
info_downloading: "Downloading via yt-dlp..."
|
||||
error_download_failed: "yt-dlp download failed: {{.Error}}"
|
||||
cancel:
|
||||
usage: "Usage: /cancel <task_id>"
|
||||
error_cancel_failed: "Failed to cancel task: {{.Error}}"
|
||||
info_cancel_requested: "Cancel requested for task: {{.TaskID}}"
|
||||
info_cancelling_task: "Cancelling task..."
|
||||
media_group:
|
||||
info_saving_files: "Saving files..."
|
||||
error_build_storage_select_keyboard_failed: "Failed to build storage selection keyboard: {{.Error}}"
|
||||
info_group_found_files_select_storage: "Total {{.Count}} files, please select storage"
|
||||
storage:
|
||||
info_filename_prefix: "Filename: "
|
||||
info_prompt_select_storage: "\nPlease select storage"
|
||||
progress:
|
||||
batch_start_prefix: "Starting batch download task\nTotal size: "
|
||||
batch_processing_prefix: "Processing batch download task\nTotal size: "
|
||||
downloading_prefix: "Downloading\nTotal size: "
|
||||
processing_list_prefix: "\nProcessing:\n"
|
||||
processing_none: " - None"
|
||||
avg_speed_prefix: "\nAverage speed: "
|
||||
current_progress_prefix: "\nCurrent progress: "
|
||||
task_canceled: "Task canceled"
|
||||
task_canceled_with_id: "Processing canceled: {{.TaskID}}"
|
||||
task_failed_with_error: "Processing failed: {{.Error}}"
|
||||
batch_done_prefix: "Completed\nFile count: "
|
||||
direct_done_prefix: "Completed, file count: "
|
||||
parsed_start_prefix: "Starting download from {{.Site}}\nTotal size: "
|
||||
parsed_done_prefix: "Completed, resource count: "
|
||||
telegraph_start_prefix: "Starting Telegraph download\nImage count: "
|
||||
telegraph_progress_prefix: "Downloading\nCurrent progress: "
|
||||
telegraph_done_prefix: "Completed\nImage count: "
|
||||
file_start_prefix: "Starting download\nFilename: "
|
||||
file_processing_prefix: "Processing download task\nFilename: "
|
||||
download_failed_prefix: "Download failed\nFilename: "
|
||||
download_done_prefix: "Download completed\nFilename: "
|
||||
file_size_prefix: "\nFile size: "
|
||||
save_path_prefix: "\nSave path: "
|
||||
total_size_prefix: "\nTotal size: "
|
||||
direct_start: "Starting download, total size: {{.SizeMB}} MB ({{.Count}} files)"
|
||||
file_name_prefix: "Filename: "
|
||||
error_prefix: "\nError: "
|
||||
aria2_start: "Waiting for Aria2 to complete download (GID: {{.GID}})..."
|
||||
aria2_downloading: "Aria2 downloading (GID: {{.GID}})\n"
|
||||
aria2_done: "Aria2 download completed and transferred (GID: {{.GID}})\n"
|
||||
ytdlp_start: "Starting yt-dlp download ({{.Count}} links)..."
|
||||
ytdlp_downloading: "yt-dlp downloading ({{.Count}} links)\n"
|
||||
ytdlp_done: "yt-dlp download completed and transferred ({{.Count}} files)\n"
|
||||
downloaded_prefix: "\nDownloaded: "
|
||||
current_speed_prefix: "\nCurrent speed: "
|
||||
syncpeers:
|
||||
start: "Starting to sync peers..."
|
||||
done: "Peer sync completed, total {{.Count}} chats synced"
|
||||
failed: "Peer sync failed: {{.Error}}"
|
||||
aria2:
|
||||
error_aria2_not_enabled: "Aria2 feature is not enabled in the configuration"
|
||||
error_aria2_client_init_failed: "Aria2 client initialization failed: {{.Error}}"
|
||||
info_adding_aria2_download: "Adding Aria2 download task..."
|
||||
error_adding_aria2_download: "Failed to add Aria2 download task: {{.Error}}"
|
||||
info_aria2_download_added: "Aria2 download task added, GID: {{.GID}}"
|
||||
info_select_storage: "Please select storage, the task will be added to Aria2 download queue after selection"
|
||||
@@ -6,7 +6,6 @@ lifetime:
|
||||
cleaning_cache: "正在清理缓存 {{.Path}}"
|
||||
bye: 已退出
|
||||
config:
|
||||
loaded_storages: "已加载 {{.Count}} 个存储后端"
|
||||
err:
|
||||
invalid_cache_dir: "无效的缓存目录: {{.Path}},请检查配置文件"
|
||||
duplicate_storage_name: "存储名称 '{{.Name}}' 重复,请检查配置文件"
|
||||
@@ -30,12 +29,41 @@ bot:
|
||||
/silent - 开关静默模式
|
||||
/storage - 设置默认存储位置
|
||||
/save [自定义文件名] - 保存文件
|
||||
/dl <链接1> <链接2> ... - 下载给定链接的文件
|
||||
/dir - 管理存储目录
|
||||
/rule - 管理规则
|
||||
/config - 修改配置
|
||||
/fnametmpl - 设置文件自定义命名模板
|
||||
/parser - 管理解析器插件
|
||||
/task - 管理任务队列
|
||||
/watch - 监听聊天并自动保存 (UserBot)
|
||||
/unwatch - 取消监听聊天 (UserBot)
|
||||
/lswatch - 列出正在监听的聊天 (UserBot)
|
||||
/syncpeers - 同步对话列表 (UserBot)
|
||||
/update - 检查更新并升级
|
||||
|
||||
使用帮助: https://sabot.unv.app/usage
|
||||
反馈群组: https://t.me/ProjectSaveAny
|
||||
cmd:
|
||||
start: "开始使用"
|
||||
silent: "切换静默模式"
|
||||
storage: "设置默认存储端"
|
||||
dir: "管理存储文件夹"
|
||||
rule: "管理自动存储规则"
|
||||
save: "保存文件"
|
||||
dl: "下载给定链接的文件"
|
||||
aria2dl: "使用 Aria2 下载给定链接的文件"
|
||||
ytdlp: "使用 yt-dlp 下载视频/音频"
|
||||
task: "管理任务队列"
|
||||
cancel: "取消任务"
|
||||
watch: "监听聊天(UserBot)"
|
||||
unwatch: "取消监听聊天(UserBot)"
|
||||
lswatch: "列出监听的聊天(UserBot)"
|
||||
syncpeers: "同步对话列表(UserBot)"
|
||||
config: "修改配置"
|
||||
fnametmpl: "设置文件命名模板"
|
||||
help: "显示帮助"
|
||||
parser: "管理解析器"
|
||||
update: "检查更新"
|
||||
save_help_text: |
|
||||
使用方法:
|
||||
|
||||
@@ -57,6 +85,272 @@ bot:
|
||||
- [filter]: 可选, 格式为 过滤器类型:表达式 , 所有支持类型的过滤器请查看文档
|
||||
|
||||
命令示例:
|
||||
/watch 2229835658 msgre:.*plana.*
|
||||
/watch -1002229835658 msgre:.*plana.*
|
||||
|
||||
这将监听 ID 为 2229835658 的聊天, 并转存所有包含 "plana" 的媒体消息
|
||||
这将监听 ID 为 -1002229835658 的聊天, 并转存所有包含 "plana" 的媒体消息
|
||||
common:
|
||||
cancel_button_text: "取消任务"
|
||||
error_invalid_regex: "无效的正则表达式: {{.Error}}"
|
||||
error_invalid_msg_id_range: "无效的消息ID范围: {{.Error}}"
|
||||
error_invalid_id_or_username: "无效的ID或用户名: {{.Error}}"
|
||||
error_get_messages_failed: "获取消息失败: {{.Error}}"
|
||||
info_fetching_messages: "正在获取消息..."
|
||||
error_no_messages_in_range: "没有找到指定范围内的消息"
|
||||
error_no_savable_messages_in_range: "没有找到指定范围内的可保存消息"
|
||||
error_build_storage_select_message_failed: "构建存储选择消息失败: {{.Error}}"
|
||||
error_build_storage_select_keyboard_failed: "构建存储选择键盘失败: {{.Error}}"
|
||||
info_found_files_select_storage: "找到 {{.Count}} 个文件, 请选择存储位置"
|
||||
error_get_user_failed: "获取用户失败"
|
||||
error_get_user_with_err_failed: "获取用户失败: {{.Error}}"
|
||||
error_default_storage_not_set: "请先设置默认存储, 使用 /storage 命令"
|
||||
error_no_available_storage: "无可用的存储"
|
||||
error_get_storage_failed: "获取存储失败: {{.Error}}"
|
||||
prompt_select_default_storage: "请选择要设为默认的存储位置"
|
||||
error_data_expired: "数据已过期或无效"
|
||||
error_task_add_failed: "任务添加失败: {{.Error}}"
|
||||
info_task_added: "任务已添加"
|
||||
info_batch_tasks_added: "已添加批量任务, 共 {{.Count}} 个文件"
|
||||
error_task_create_failed: "任务创建失败: {{.Error}}"
|
||||
error_get_dir_failed: "获取目录失败: {{.Error}}"
|
||||
prompt_select_dir: "请选择要存储到的目录"
|
||||
prompt_select_default_dir: "请选择要保存到的默认文件夹"
|
||||
info_default_storage_set: "已将默认存储位置设为: {{.Name}}"
|
||||
info_default_storage_with_dir_set: "已将默认存储位置设为: {{.Name}}:/{{.Dir}}"
|
||||
error_get_user_info_failed: "获取用户信息失败: {{.Error}}"
|
||||
error_update_user_info_failed: "更新用户信息失败: {{.Error}}"
|
||||
info_silent_mode_on: "已开启静默模式"
|
||||
info_silent_mode_off: "已关闭静默模式"
|
||||
error_get_file_failed: "获取文件失败: {{.Error}}"
|
||||
info_fetching_file_info: "正在获取文件信息..."
|
||||
error_no_savable_files_found: "没有找到可保存的文件"
|
||||
error_parse_telegraph_path_failed: "解析 telegraph 路径失败: {{.Error}}"
|
||||
info_fetching_telegraph_page: "正在获取 telegraph 页面..."
|
||||
error_get_telegraph_page_failed: "获取 telegraph 页面失败: {{.Error}}"
|
||||
error_no_images_in_telegraph_page: "在 telegraph 页面中未找到图片"
|
||||
error_build_dir_select_keyboard_failed: "构建目录选择键盘失败: {{.Error}}"
|
||||
error_no_permission: |
|
||||
您不在白名单中, 无法使用此 Bot.
|
||||
您可以部署自己的实例: https://github.com/krau/SaveAny-Bot
|
||||
save:
|
||||
error_invalid_id_or_username: "无效的ID或用户名: {{.Error}}"
|
||||
watch:
|
||||
error_filter_format_invalid: "过滤器格式错误, 请使用 <过滤器类型>:<表达式>"
|
||||
error_filter_type_unsupported: "不支持的过滤器类型, 请参阅文档"
|
||||
error_watch_chat_failed: "监听聊天失败: {{.Error}}"
|
||||
info_watch_chat_started: "已开始监听聊天: {{.Chat}}"
|
||||
info_already_watching_chat: "已经在监听此聊天"
|
||||
info_watch_list_empty: "当前没有监听任何聊天"
|
||||
info_watch_list_header: "当前监听的聊天:\n"
|
||||
info_watch_list_filter_prefix: " (过滤器: "
|
||||
error_unwatch_no_chat_provided: "请提供要取消监听的聊天ID或用户名"
|
||||
error_unwatch_chat_failed: "取消监听聊天失败: {{.Error}}"
|
||||
info_watch_chat_stopped: "已取消监听聊天: {{.Chat}}"
|
||||
tasks:
|
||||
usage_cancel: "用法: /tasks cancel <task_id>"
|
||||
usage: "用法: /tasks [running|queued|cancel <task_id>]"
|
||||
cancel_failed: "取消任务失败: {{.Error}}"
|
||||
cancel_requested_prefix: "已请求取消任务: "
|
||||
running_empty: "当前没有正在运行的任务"
|
||||
running_title: "当前正在运行的任务:"
|
||||
total_prefix: "总数: {{.Count}}\n"
|
||||
field_id: "ID: "
|
||||
field_title: "名称: "
|
||||
field_created: "创建时间: "
|
||||
field_status: "状态: "
|
||||
status_running: "运行中"
|
||||
status_queued: "排队中"
|
||||
status_cancel_requested: "已请求取消"
|
||||
queued_empty: "当前没有排队中的任务"
|
||||
queued_title: "当前排队中的任务:"
|
||||
truncated_note: "...\n只显示前 10 个任务, 共 {{.Count}} 个任务"
|
||||
info_added_to_queue_full: "已添加到任务队列\n文件名: {{.Filename}}\n当前排队任务数: {{.QueueLength}}"
|
||||
info_added_to_queue_prefix: "已添加到任务队列\n"
|
||||
info_filename_prefix: "文件名: "
|
||||
info_queue_length_prefix: "\n当前排队任务数: "
|
||||
rule:
|
||||
error_get_user_rules_failed: "获取用户规则失败"
|
||||
error_update_user_failed: "更新用户失败"
|
||||
info_rule_mode_enabled: "已启用规则模式"
|
||||
info_rule_mode_disabled: "已禁用规则模式"
|
||||
error_invalid_rule_type: "无效的规则类型: {{.Type}}\n可用: {{.Available}}"
|
||||
error_create_rule_failed: "创建规则失败"
|
||||
info_create_rule_success: "创建规则成功"
|
||||
prompt_provide_rule_id: "请提供规则ID"
|
||||
error_invalid_rule_id: "无效的规则ID"
|
||||
error_delete_rule_failed: "删除规则失败"
|
||||
info_delete_rule_success: "删除规则成功"
|
||||
help_usage: "使用方法: /rule <操作> <参数...>"
|
||||
help_current_mode_enabled: "\n当前已启用规则模式"
|
||||
help_current_mode_disabled: "\n当前已禁用规则模式"
|
||||
help_available_ops: "\n\n可用操作:\n"
|
||||
help_switch_suffix: " - 开关规则模式\n"
|
||||
help_add_suffix: " <类型> <数据> <存储名> <路径> - 添加规则\n"
|
||||
help_del_suffix: " <规则ID> - 删除规则\n"
|
||||
help_existing_rules_prefix: "\n当前已添加的规则:\n"
|
||||
dir:
|
||||
error_get_user_dirs_failed: "获取用户文件夹失败"
|
||||
error_get_user_failed: "获取用户失败"
|
||||
error_create_dir_failed: "创建文件夹失败"
|
||||
info_create_dir_success: "文件夹添加成功"
|
||||
error_invalid_dir_id: "文件夹ID无效"
|
||||
error_delete_dir_failed: "删除文件夹失败"
|
||||
info_delete_dir_success: "文件夹删除成功"
|
||||
error_unknown_operation: "未知操作"
|
||||
help_usage: "使用方法: /dir <操作> <参数...>"
|
||||
help_available_ops: "\n\n可用操作:\n"
|
||||
help_add_suffix: " <存储名> <路径> - 添加路径\n"
|
||||
help_del_suffix: " <路径ID> - 删除路径\n"
|
||||
help_add_example_prefix: "\n添加路径示例:\n"
|
||||
help_add_example_cmd: "/dir add local1 path/to/dir"
|
||||
help_del_example_prefix: "\n\n删除路径示例:\n"
|
||||
help_del_example_cmd: "/dir del 3"
|
||||
help_existing_dirs_prefix: "\n\n当前已添加的路径:\n"
|
||||
button_default: "默认"
|
||||
parser:
|
||||
help_text: |
|
||||
用法:
|
||||
|
||||
/parser install <回复一个文件> - 安装解析器
|
||||
plugin_not_enabled: "解析器插件功能未启用"
|
||||
prompt_reply_with_parser_file: "请回复一个包含解析器文件的消息"
|
||||
error_no_valid_file_in_reply: "回复的消息不包含有效的文件"
|
||||
error_wrong_file_type: "错误的文件类型"
|
||||
error_file_too_large: "文件过大"
|
||||
error_get_filename_failed: "无法获取文件名"
|
||||
error_only_js_supported: "仅支持 .js 文件作为解析器"
|
||||
error_download_file_failed: "文件下载失败: {{.Error}}"
|
||||
error_install_plugin_failed: "插件安装失败: {{.Error}}"
|
||||
info_install_plugin_success: "插件安装成功: {{.Name}}"
|
||||
parse:
|
||||
info_parsing: "正在解析..."
|
||||
error_parse_text_failed: "Failed to parse text: {{.Error}}"
|
||||
error_build_storage_select_keyboard_failed: "Failed to build storage selection keyboard: {{.Error}}"
|
||||
error_build_parsed_text_entity_failed: "Failed to build parsed text entity: {{.Error}}"
|
||||
info_link_prefix: "\n链接: "
|
||||
info_author_prefix: "\n作者: "
|
||||
info_description_prefix: "\n描述: "
|
||||
info_file_count_prefix: "\n文件数量: "
|
||||
info_total_size_prefix: "\n预计总大小: "
|
||||
info_prompt_select_storage: "\n请选择存储位置"
|
||||
telegraph:
|
||||
error_build_storage_select_keyboard_failed: "构建存储选择键盘失败: {{.Error}}"
|
||||
info_title_prefix: "标题: "
|
||||
info_pic_count_prefix: "\n图片数量: "
|
||||
info_prompt_select_storage: "\n请选择存储位置"
|
||||
update:
|
||||
error_version_var_invalid: "当前处于开发版本或版本信息注入失败: {{.Error}}"
|
||||
error_check_latest_failed: "检测最新版本失败: {{.Error}}"
|
||||
error_no_release_found: "没有找到版本信息"
|
||||
info_major_upgrade_required: "检测到大版本更新: {{.Current}} -> {{.Latest}} , 请前往 GitHub 手动下载最新版本并查看迁移指南"
|
||||
info_already_latest: "当前已经是最新版本: {{.Version}}"
|
||||
info_new_version_in_docker: |-
|
||||
发现新版本: {{.Latest}}
|
||||
当前版本: {{.Current}}
|
||||
发布时间: {{.PublishedAt}}
|
||||
由于您正在使用 Docker 部署, 请自行在部署平台上执行更新命令
|
||||
info_new_version_prompt_upgrade: |-
|
||||
发现新版本: {{.Latest}}
|
||||
当前版本: {{.Current}}
|
||||
|
||||
文件大小: {{.SizeMB}} MB
|
||||
下载链接: {{.URL}}
|
||||
发布时间: {{.PublishedAt}}
|
||||
|
||||
升级将重启 Bot , 是否升级?
|
||||
info_upgrading_with_version: "正在升级中, 当前版本: {{.Current}}"
|
||||
error_upgrade_failed: "升级失败: {{.Error}}"
|
||||
info_upgrade_success: "已升级至版本 {{.Version}}\n若 Bot 未自动重启请手动启动"
|
||||
button_upgrade: "升级"
|
||||
config:
|
||||
prompt_select_option: "请选择要配置的选项"
|
||||
button_filename_strategy: "文件名策略"
|
||||
error_invalid_callback_data: "无效的回调数据"
|
||||
error_invalid_template: "无效的模板, 请检查语法\n{{.Error}}"
|
||||
info_filename_strategy_set: "已将文件名策略设置为: {{.Strategy}}"
|
||||
prompt_select_filename_strategy: "请选择文件名策略, 当前策略: {{.Strategy}}"
|
||||
fnametmpl_help: |-
|
||||
使用该命令设置文件名模板, 示例:
|
||||
/fnametmpl 图片_{{"{{.msgid}}"}}_{{"{{.msgdate}}"}}.jpg
|
||||
|
||||
可用变量:
|
||||
- {{"{{.msgid}}"}}: 消息ID
|
||||
- {{"{{.msgtags}}"}}: 消息中的标签, 将以下划线分隔输出
|
||||
- {{"{{.msggen}}"}}: 根据消息生成的文件名
|
||||
- {{"{{.msgdate}}"}}: 消息日期, 格式 YYYY-MM-DD_HH-MM-SS
|
||||
- {{"{{.msgraw}}"}}: 消息的原始文本内容 (不经任何处理)
|
||||
- {{"{{.origname}}"}}: 媒体的原始文件名 (如果有)
|
||||
- {{"{{.chatid}}"}}: 消息的聊天ID
|
||||
|
||||
模板仅在文件名策略设置为 '自定义模板' 时生效,
|
||||
且模板解析错误时会回退到默认文件名
|
||||
info_template_updated: "已更新文件名模板"
|
||||
info_current_template_prefix: "当前模板: {{.Template}}"
|
||||
dl:
|
||||
usage: "用法: /dl <链接1> <链接2> ..."
|
||||
error_no_valid_links: "没有有效的链接可供下载"
|
||||
info_files_select_storage: "共 {{.Count}} 个文件, 请选择存储位置"
|
||||
ytdlp:
|
||||
usage: "用法: /ytdlp <URL1> <URL2> ..."
|
||||
error_no_valid_urls: "没有有效的 URL"
|
||||
info_urls_select_storage: "共 {{.Count}} 个链接, 请选择存储位置"
|
||||
info_downloading: "正在通过 yt-dlp 下载..."
|
||||
error_download_failed: "yt-dlp 下载失败: {{.Error}}"
|
||||
cancel:
|
||||
usage: "用法: /cancel <task_id>"
|
||||
error_cancel_failed: "取消任务失败: {{.Error}}"
|
||||
info_cancel_requested: "已请求取消任务: {{.TaskID}}"
|
||||
info_cancelling_task: "正在取消任务..."
|
||||
media_group:
|
||||
info_saving_files: "正在保存文件..."
|
||||
error_build_storage_select_keyboard_failed: "构建存储选择键盘失败: {{.Error}}"
|
||||
info_group_found_files_select_storage: "共 {{.Count}} 个文件, 请选择存储位置"
|
||||
storage:
|
||||
info_filename_prefix: "文件名: "
|
||||
info_prompt_select_storage: "\n请选择存储位置"
|
||||
progress:
|
||||
batch_start_prefix: "开始执行批量下载任务\n总大小: "
|
||||
batch_processing_prefix: "正在处理批量下载任务\n总大小: "
|
||||
downloading_prefix: "正在下载\n总大小: "
|
||||
processing_list_prefix: "\n正在处理:\n"
|
||||
processing_none: " - 无"
|
||||
avg_speed_prefix: "\n平均速度: "
|
||||
current_progress_prefix: "\n当前进度: "
|
||||
task_canceled: "任务已取消"
|
||||
task_canceled_with_id: "处理已取消: {{.TaskID}}"
|
||||
task_failed_with_error: "处理失败: {{.Error}}"
|
||||
batch_done_prefix: "处理完成\n文件数: "
|
||||
direct_done_prefix: "处理完成, 文件数量: "
|
||||
parsed_start_prefix: "开始下载 {{.Site}} 的资源\n总大小: "
|
||||
parsed_done_prefix: "处理完成, 资源数量: "
|
||||
telegraph_start_prefix: "开始下载Telegraph\n图片数量: "
|
||||
telegraph_progress_prefix: "正在下载\n当前进度: "
|
||||
telegraph_done_prefix: "处理完成\n图片数量: "
|
||||
file_start_prefix: "开始下载\n文件名: "
|
||||
file_processing_prefix: "正在处理下载任务\n文件名: "
|
||||
download_failed_prefix: "下载失败\n文件名: "
|
||||
download_done_prefix: "下载完成\n文件名: "
|
||||
file_size_prefix: "\n文件大小: "
|
||||
save_path_prefix: "\n保存路径: "
|
||||
total_size_prefix: "\n总大小: "
|
||||
direct_start: "开始下载, 总大小: {{.SizeMB}} MB ({{.Count}} 个文件)"
|
||||
file_name_prefix: "文件名: "
|
||||
error_prefix: "\n错误: "
|
||||
aria2_start: "等待 Aria2 下载完成 (GID: {{.GID}})..."
|
||||
aria2_downloading: "Aria2 正在下载 (GID: {{.GID}})\n"
|
||||
aria2_done: "Aria2 下载完成并已转存 (GID: {{.GID}})\n"
|
||||
ytdlp_start: "开始使用 yt-dlp 下载 ({{.Count}} 个链接)..."
|
||||
ytdlp_downloading: "yt-dlp 正在下载 ({{.Count}} 个链接)\n"
|
||||
ytdlp_done: "yt-dlp 下载完成并已转存 ({{.Count}} 个文件)\n"
|
||||
downloaded_prefix: "\n已下载: "
|
||||
current_speed_prefix: "\n当前速度: "
|
||||
syncpeers:
|
||||
start: "正在同步对话列表..."
|
||||
success: "对话列表同步完成, 共同步 {{.Count}} 个对话"
|
||||
failed: "对话列表同步失败: {{.Error}}"
|
||||
aria2:
|
||||
error_aria2_not_enabled: "Aria2 功能未启用, 请在配置文件中启用"
|
||||
error_aria2_client_init_failed: "Aria2 客户端初始化失败: {{.Error}}"
|
||||
info_adding_aria2_download: "正在添加 Aria2 下载任务..."
|
||||
error_adding_aria2_download: "添加 Aria2 下载任务失败: {{.Error}}"
|
||||
info_aria2_download_added: "Aria2 下载任务已添加, GID: {{.GID}}"
|
||||
info_select_storage: "请选择存储位置, 选择后将添加到 Aria2 下载队列"
|
||||
@@ -1,13 +1,14 @@
|
||||
package tfile
|
||||
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"
|
||||
)
|
||||
|
||||
func NewDownloader(file TGFile) *downloader.Builder {
|
||||
func NewDownloader(file tfile.TGFile) *downloader.Builder {
|
||||
return downloader.NewDownloader().WithPartSize(tglimit.MaxPartSize).
|
||||
Download(file.Dler(), file.Location()).WithThreads(dlutil.BestThreads(file.Size(), config.C().Threads))
|
||||
}
|
||||
65
common/utils/ioutil/progress_reader.go
Normal file
65
common/utils/ioutil/progress_reader.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package ioutil
|
||||
|
||||
import (
|
||||
"io"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
var _ io.ReadSeeker = (*ProgressReadSeeker)(nil)
|
||||
|
||||
// ProgressReadSeeker wraps an io.ReadSeeker and tracks read progress
|
||||
type ProgressReadSeeker struct {
|
||||
reader io.ReadSeeker
|
||||
total atomic.Int64
|
||||
read atomic.Int64
|
||||
onProgress func(read int64, total int64)
|
||||
}
|
||||
|
||||
// Seek implements io.ReadSeeker.
|
||||
func (pr *ProgressReadSeeker) Seek(offset int64, whence int) (int64, error) {
|
||||
return pr.reader.Seek(offset, whence)
|
||||
}
|
||||
|
||||
// NewProgressReader creates a new ProgressReader
|
||||
func NewProgressReader(rs io.ReadSeeker, total int64, onProgress func(read int64, total int64)) *ProgressReadSeeker {
|
||||
prs := &ProgressReadSeeker{
|
||||
reader: rs,
|
||||
total: atomic.Int64{},
|
||||
read: atomic.Int64{},
|
||||
onProgress: onProgress,
|
||||
}
|
||||
prs.total.Store(total)
|
||||
return prs
|
||||
}
|
||||
|
||||
// Read implements io.Reader
|
||||
func (pr *ProgressReadSeeker) Read(p []byte) (int, error) {
|
||||
n, err := pr.reader.Read(p)
|
||||
if n > 0 {
|
||||
pr.read.Add(int64(n))
|
||||
read := pr.read.Load()
|
||||
|
||||
if pr.onProgress != nil {
|
||||
pr.onProgress(read, pr.total.Load())
|
||||
}
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
// Progress returns the current progress as a float64 between 0 and 1
|
||||
func (pr *ProgressReadSeeker) Progress() float64 {
|
||||
if pr.total.Load() <= 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(pr.read.Load()) / float64(pr.total.Load())
|
||||
}
|
||||
|
||||
// Read returns the number of bytes read so far
|
||||
func (pr *ProgressReadSeeker) BytesRead() int64 {
|
||||
return pr.read.Load()
|
||||
}
|
||||
|
||||
// Total returns the total number of bytes
|
||||
func (pr *ProgressReadSeeker) Total() int64 {
|
||||
return pr.total.Load()
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package ioutil
|
||||
|
||||
import "io"
|
||||
import (
|
||||
"io"
|
||||
)
|
||||
|
||||
type ProgressWriterAt struct {
|
||||
wrAt io.WriterAt
|
||||
@@ -46,4 +48,4 @@ func NewProgressWriter(
|
||||
wr: wr,
|
||||
onWrite: onWrite,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,56 +7,24 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"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)
|
||||
}
|
||||
|
||||
func NewProxyHTTPClient(proxyUrl string) (*http.Client, error) {
|
||||
if proxyUrl == "" {
|
||||
return &http.Client{
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
},
|
||||
}, nil
|
||||
return http.DefaultClient, nil
|
||||
}
|
||||
|
||||
u, err := url.Parse(proxyUrl)
|
||||
transport, err := NewProxyTransport(proxyUrl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch u.Scheme {
|
||||
case "http", "https":
|
||||
return &http.Client{
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyURL(u),
|
||||
},
|
||||
}, nil
|
||||
case "socks5":
|
||||
dialer, err := proxy.FromURL(u, proxy.Direct)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &http.Client{
|
||||
Transport: &http.Transport{
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return dialer.Dial(network, addr)
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported proxy scheme: %s", u.Scheme)
|
||||
}
|
||||
return &http.Client{
|
||||
Transport: transport,
|
||||
}, nil
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -76,3 +44,35 @@ func DefaultParserHTTPClient() *http.Client {
|
||||
})
|
||||
return defaultProxyHttpClient
|
||||
}
|
||||
|
||||
func NewProxyTransport(proxyStr string) (*http.Transport, error) {
|
||||
proxyURL, err := url.Parse(proxyStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
transport := &http.Transport{
|
||||
ForceAttemptHTTP2: true,
|
||||
MaxIdleConns: 100,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
}
|
||||
switch proxyURL.Scheme {
|
||||
case "http", "https":
|
||||
transport.Proxy = http.ProxyURL(proxyURL)
|
||||
|
||||
case "socks5", "socks5h":
|
||||
dialer, err := proxy.FromURL(proxyURL, proxy.Direct)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return dialer.(proxy.ContextDialer).DialContext(ctx, network, addr)
|
||||
}
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported proxy type: %s", proxyURL.Scheme)
|
||||
}
|
||||
|
||||
return transport, nil
|
||||
}
|
||||
|
||||
@@ -9,14 +9,16 @@ import (
|
||||
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/duke-git/lancet/v2/maputil"
|
||||
|
||||
"github.com/duke-git/lancet/v2/mathutil"
|
||||
"github.com/duke-git/lancet/v2/slice"
|
||||
lcstrutil "github.com/duke-git/lancet/v2/strutil"
|
||||
"github.com/duke-git/lancet/v2/validator"
|
||||
"github.com/gabriel-vasile/mimetype"
|
||||
"github.com/gotd/td/constant"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/krau/SaveAny-Bot/common/cache"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/strutil"
|
||||
"github.com/rs/xid"
|
||||
)
|
||||
@@ -96,7 +98,7 @@ func GenFileNameFromMessage(message tg.Message) string {
|
||||
|
||||
func BuildCancelButton(taskID string) tg.KeyboardButtonClass {
|
||||
return &tg.KeyboardButtonCallback{
|
||||
Text: "取消任务",
|
||||
Text: i18n.T(i18nk.BotMsgCommonCancelButtonText, nil),
|
||||
Data: fmt.Appendf(nil, "cancel %s", taskID),
|
||||
}
|
||||
}
|
||||
@@ -111,7 +113,31 @@ func InputMessageClassSliceFromInt(ids []int) []tg.InputMessageClass {
|
||||
return result
|
||||
}
|
||||
|
||||
func GetMessagesRange(ctx *ext.Context, chatID int64, minId, maxId int) ([]*tg.Message, error) {
|
||||
func GetMessagesRange(ctx *ext.Context, chatID int64, minId, maxId int) (msg []*tg.Message, err error) {
|
||||
if msg, err = getMessagesRange(ctx, chatID, minId, maxId); err == nil {
|
||||
return
|
||||
}
|
||||
in := constant.TDLibPeerID(chatID)
|
||||
plain := in.ToPlain()
|
||||
var channel constant.TDLibPeerID
|
||||
channel.Channel(plain)
|
||||
if msg, err = getMessagesRange(ctx, int64(channel), minId, maxId); err == nil {
|
||||
return
|
||||
}
|
||||
var userID constant.TDLibPeerID
|
||||
userID.User(plain)
|
||||
if msg, err = getMessagesRange(ctx, int64(userID), minId, maxId); err == nil {
|
||||
return
|
||||
}
|
||||
var chat constant.TDLibPeerID
|
||||
chat.Chat(plain)
|
||||
if msg, err = getMessagesRange(ctx, int64(chat), minId, maxId); err == nil {
|
||||
return
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get messages range for chat %d: %w", chatID, err)
|
||||
}
|
||||
|
||||
func getMessagesRange(ctx *ext.Context, chatID int64, minId, maxId int) ([]*tg.Message, error) {
|
||||
if minId > maxId {
|
||||
return nil, fmt.Errorf("minId (%d) cannot be greater than maxId (%d)", minId, maxId)
|
||||
}
|
||||
@@ -167,97 +193,98 @@ func GetMessagesRange(ctx *ext.Context, chatID int64, minId, maxId int) ([]*tg.M
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type MessageItem struct {
|
||||
Message *tg.Message
|
||||
Error error
|
||||
}
|
||||
// [TODO]
|
||||
// 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)
|
||||
// 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
|
||||
}
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
// 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
|
||||
}
|
||||
// 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)
|
||||
if msg, ok := cache.Get[*tg.Message](key); ok {
|
||||
return msg, nil
|
||||
@@ -280,6 +307,33 @@ func GetMessageByID(ctx *ext.Context, chatID int64, msgID int) (*tg.Message, err
|
||||
return tgm, nil
|
||||
}
|
||||
|
||||
// f**k gotgproto's breaking changes
|
||||
func GetMessageByID(ctx *ext.Context, chatID int64, msgID int) (*tg.Message, error) {
|
||||
// we don't know what the input chatID is bot api style(e.g. channel with -100 prefix) or plain tdlib style(no any prefix and every id is positive)
|
||||
if msg, err := getMessageByID(ctx, chatID, msgID); err == nil {
|
||||
return msg, nil
|
||||
}
|
||||
in := constant.TDLibPeerID(chatID)
|
||||
plain := in.ToPlain()
|
||||
var channel constant.TDLibPeerID
|
||||
channel.Channel(plain)
|
||||
if msg, err := getMessageByID(ctx, int64(channel), msgID); err == nil {
|
||||
return msg, nil
|
||||
}
|
||||
var chat constant.TDLibPeerID
|
||||
chat.Chat(plain)
|
||||
if msg, err := getMessageByID(ctx, int64(chat), msgID); err == nil {
|
||||
return msg, nil
|
||||
}
|
||||
var userID constant.TDLibPeerID
|
||||
userID.User(plain)
|
||||
if msg, err := getMessageByID(ctx, int64(userID), msgID); err == nil {
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("failed to get message by ID: chatID=%d, msgID=%d", chatID, msgID)
|
||||
}
|
||||
|
||||
func GetGroupedMessages(ctx *ext.Context, chatID int64, msg *tg.Message) ([]*tg.Message, error) {
|
||||
groupID, isGroup := msg.GetGroupedID()
|
||||
if !isGroup || groupID == 0 {
|
||||
|
||||
131
common/utils/tgutil/net.go
Normal file
131
common/utils/tgutil/net.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package tgutil
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/gotd/td/telegram/dcs"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"golang.org/x/net/proxy"
|
||||
)
|
||||
|
||||
// httpProxyDialer implements proxy.ContextDialer for HTTP CONNECT proxies
|
||||
type httpProxyDialer struct {
|
||||
proxyURL *url.URL
|
||||
forward proxy.Dialer
|
||||
}
|
||||
|
||||
func (d *httpProxyDialer) Dial(network, addr string) (net.Conn, error) {
|
||||
return d.DialContext(context.Background(), network, addr)
|
||||
}
|
||||
|
||||
func (d *httpProxyDialer) DialContext(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
proxyAddr := d.proxyURL.Host
|
||||
if d.proxyURL.Port() == "" {
|
||||
if d.proxyURL.Scheme == "https" {
|
||||
proxyAddr = net.JoinHostPort(d.proxyURL.Hostname(), "443")
|
||||
} else {
|
||||
proxyAddr = net.JoinHostPort(d.proxyURL.Hostname(), "80")
|
||||
}
|
||||
}
|
||||
|
||||
var conn net.Conn
|
||||
var err error
|
||||
if ctxDialer, ok := d.forward.(proxy.ContextDialer); ok {
|
||||
conn, err = ctxDialer.DialContext(ctx, "tcp", proxyAddr)
|
||||
} else {
|
||||
conn, err = d.forward.Dial("tcp", proxyAddr)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to proxy: %w", err)
|
||||
}
|
||||
|
||||
// Send CONNECT request
|
||||
connectReq := &http.Request{
|
||||
Method: "CONNECT",
|
||||
URL: &url.URL{Opaque: addr},
|
||||
Host: addr,
|
||||
Header: make(http.Header),
|
||||
}
|
||||
|
||||
// Add proxy authentication if provided
|
||||
if d.proxyURL.User != nil {
|
||||
username := d.proxyURL.User.Username()
|
||||
password, _ := d.proxyURL.User.Password()
|
||||
auth := base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
|
||||
connectReq.Header.Set("Proxy-Authorization", "Basic "+auth)
|
||||
}
|
||||
|
||||
if err := connectReq.Write(conn); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("failed to write CONNECT request: %w", err)
|
||||
}
|
||||
|
||||
// Read response
|
||||
br := bufio.NewReader(conn)
|
||||
resp, err := http.ReadResponse(br, connectReq)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("failed to read CONNECT response: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("proxy CONNECT failed with status: %s", resp.Status)
|
||||
}
|
||||
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func newProxyDialer(proxyUrl string) (proxy.ContextDialer, error) {
|
||||
parsedURL, err := url.Parse(proxyUrl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch parsedURL.Scheme {
|
||||
case "http", "https":
|
||||
return &httpProxyDialer{
|
||||
proxyURL: parsedURL,
|
||||
forward: proxy.Direct,
|
||||
}, nil
|
||||
case "socks5", "socks5h":
|
||||
dialer, err := proxy.FromURL(parsedURL, proxy.Direct)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dialer.(proxy.ContextDialer), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported proxy scheme: %s", parsedURL.Scheme)
|
||||
}
|
||||
}
|
||||
|
||||
func NewConfigProxyResolver() (dcs.Resolver, error) {
|
||||
resolver := dcs.DefaultResolver()
|
||||
if config.C().Proxy != "" {
|
||||
// global proxy, which has lower priority
|
||||
dialer, err := newProxyDialer(config.C().Proxy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resolver = dcs.Plain(dcs.PlainOptions{
|
||||
Dial: dialer.DialContext,
|
||||
})
|
||||
}
|
||||
if config.C().Telegram.Proxy.Enable && config.C().Telegram.Proxy.URL != "" {
|
||||
dialer, err := newProxyDialer(config.C().Telegram.Proxy.URL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resolver = dcs.Plain(dcs.PlainOptions{
|
||||
Dial: dialer.DialContext,
|
||||
})
|
||||
}
|
||||
return resolver, nil
|
||||
}
|
||||
@@ -14,15 +14,37 @@ token = ""
|
||||
# app_id = 1025907
|
||||
# app_hash = "452b0359b988148995f22ff0f4229750"
|
||||
[telegram.proxy]
|
||||
# 启用代理连接 telegram, 只支持 socks5
|
||||
# 启用代理连接 telegram
|
||||
enable = false
|
||||
url = "socks5://127.0.0.1:7890"
|
||||
|
||||
# Aria2 配置
|
||||
[aria2]
|
||||
# 启用 Aria2 下载支持
|
||||
enable = false
|
||||
# Aria2 RPC URL
|
||||
url = "http://localhost:6800/jsonrpc"
|
||||
# Aria2 RPC Secret (如果配置了 rpc-secret)
|
||||
secret = ""
|
||||
# 转存完成后删除 Aria2 下载的本地文件
|
||||
remove_after_transfer = true
|
||||
|
||||
# HTTP API 配置
|
||||
[api]
|
||||
# 启用 HTTP API 服务
|
||||
enable = false
|
||||
# API 服务监听端口
|
||||
port = 8080
|
||||
# API 访问令牌 (留空则不验证)
|
||||
token = ""
|
||||
# 任务完成回调 Webhook URL (留空则不回调)
|
||||
webhook_url = ""
|
||||
|
||||
# 存储列表
|
||||
[[storages]]
|
||||
# 标识名, 需要唯一
|
||||
name = "本机1"
|
||||
# 存储类型, 目前可用: local, alist, webdav, minio, telegram
|
||||
# 存储类型, 目前可用: local, alist, webdav, s3, telegram
|
||||
type = "local"
|
||||
# 启用存储
|
||||
enable = true
|
||||
|
||||
83
config/flags.go
Normal file
83
config/flags.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
func RegisterFlags(cmd *cobra.Command) {
|
||||
flags := cmd.Flags()
|
||||
|
||||
// 基础配置
|
||||
flags.StringP("config", "c", "", "config file path")
|
||||
flags.StringP("lang", "l", "", "language (e.g., zh-Hans, en)")
|
||||
flags.IntP("workers", "w", 0, "number of workers")
|
||||
flags.Int("retry", 0, "retry times")
|
||||
flags.Int("threads", 0, "number of threads")
|
||||
flags.Bool("stream", false, "enable stream mode")
|
||||
flags.Bool("no-clean-cache", false, "do not clean cache on exit")
|
||||
flags.String("proxy", "", "proxy URL (http, https, socks5, socks5h)")
|
||||
|
||||
// Telegram 配置
|
||||
flags.String("telegram-token", "", "telegram bot token")
|
||||
flags.Int("telegram-app-id", 0, "telegram app id")
|
||||
flags.String("telegram-app-hash", "", "telegram app hash")
|
||||
flags.Int("telegram-rpc-retry", 0, "telegram rpc retry times")
|
||||
flags.Bool("telegram-userbot-enable", false, "enable userbot")
|
||||
flags.String("telegram-userbot-session", "", "userbot session path")
|
||||
flags.Bool("telegram-proxy-enable", false, "enable telegram proxy")
|
||||
flags.String("telegram-proxy-url", "", "telegram proxy URL")
|
||||
|
||||
// 数据库配置
|
||||
flags.String("db-path", "", "database path")
|
||||
flags.String("db-session", "", "session database path")
|
||||
|
||||
// 临时目录配置
|
||||
flags.String("temp-base-path", "", "temp directory base path")
|
||||
|
||||
// Parser 配置
|
||||
flags.Bool("parser-plugin-enable", false, "enable parser plugins")
|
||||
flags.StringSlice("parser-plugin-dirs", nil, "parser plugin directories")
|
||||
flags.String("parser-proxy", "", "parser proxy URL")
|
||||
|
||||
// 绑定到 viper
|
||||
bindFlags(cmd)
|
||||
}
|
||||
|
||||
func bindFlags(cmd *cobra.Command) {
|
||||
flags := cmd.Flags()
|
||||
|
||||
viper.BindPFlag("lang", flags.Lookup("lang"))
|
||||
viper.BindPFlag("workers", flags.Lookup("workers"))
|
||||
viper.BindPFlag("retry", flags.Lookup("retry"))
|
||||
viper.BindPFlag("threads", flags.Lookup("threads"))
|
||||
viper.BindPFlag("stream", flags.Lookup("stream"))
|
||||
viper.BindPFlag("no_clean_cache", flags.Lookup("no-clean-cache"))
|
||||
viper.BindPFlag("proxy", flags.Lookup("proxy"))
|
||||
|
||||
// Telegram
|
||||
viper.BindPFlag("telegram.token", flags.Lookup("telegram-token"))
|
||||
viper.BindPFlag("telegram.app_id", flags.Lookup("telegram-app-id"))
|
||||
viper.BindPFlag("telegram.app_hash", flags.Lookup("telegram-app-hash"))
|
||||
viper.BindPFlag("telegram.rpc_retry", flags.Lookup("telegram-rpc-retry"))
|
||||
viper.BindPFlag("telegram.userbot.enable", flags.Lookup("telegram-userbot-enable"))
|
||||
viper.BindPFlag("telegram.userbot.session", flags.Lookup("telegram-userbot-session"))
|
||||
viper.BindPFlag("telegram.proxy.enable", flags.Lookup("telegram-proxy-enable"))
|
||||
viper.BindPFlag("telegram.proxy.url", flags.Lookup("telegram-proxy-url"))
|
||||
|
||||
// database
|
||||
viper.BindPFlag("db.path", flags.Lookup("db-path"))
|
||||
viper.BindPFlag("db.session", flags.Lookup("db-session"))
|
||||
// 临时目录
|
||||
viper.BindPFlag("temp.base_path", flags.Lookup("temp-base-path"))
|
||||
|
||||
// Parser
|
||||
viper.BindPFlag("parser.plugin_enable", flags.Lookup("parser-plugin-enable"))
|
||||
viper.BindPFlag("parser.plugin_dirs", flags.Lookup("parser-plugin-dirs"))
|
||||
viper.BindPFlag("parser.proxy", flags.Lookup("parser-proxy"))
|
||||
}
|
||||
|
||||
func GetConfigFile(cmd *cobra.Command) string {
|
||||
configFile, _ := cmd.Flags().GetString("config")
|
||||
return configFile
|
||||
}
|
||||
@@ -14,6 +14,7 @@ var storageFactories = map[storenum.StorageType]func(cfg *BaseConfig) (StorageCo
|
||||
storenum.Alist: createStorageConfig(&AlistStorageConfig{}),
|
||||
storenum.Webdav: createStorageConfig(&WebdavStorageConfig{}),
|
||||
storenum.Minio: createStorageConfig(&MinioStorageConfig{}),
|
||||
storenum.S3: createStorageConfig(&S3StorageConfig{}),
|
||||
storenum.Telegram: createStorageConfig(&TelegramStorageConfig{}),
|
||||
}
|
||||
|
||||
|
||||
43
config/storage/s3.go
Normal file
43
config/storage/s3.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
storenum "github.com/krau/SaveAny-Bot/pkg/enums/storage"
|
||||
)
|
||||
|
||||
type S3StorageConfig struct {
|
||||
BaseConfig
|
||||
Endpoint string `toml:"endpoint" mapstructure:"endpoint" json:"endpoint"`
|
||||
AccessKeyID string `toml:"access_key_id" mapstructure:"access_key_id" json:"access_key_id"`
|
||||
SecretAccessKey string `toml:"secret_access_key" mapstructure:"secret_access_key" json:"secret_access_key"`
|
||||
BucketName string `toml:"bucket_name" mapstructure:"bucket_name" json:"bucket_name"`
|
||||
UseSSL bool `toml:"use_ssl" mapstructure:"use_ssl" json:"use_ssl"`
|
||||
BasePath string `toml:"base_path" mapstructure:"base_path" json:"base_path"`
|
||||
Region string `toml:"region" mapstructure:"region" json:"region"`
|
||||
VirtualHost bool `toml:"virtual_host" mapstructure:"virtual_host" json:"virtual_host"`
|
||||
}
|
||||
|
||||
func (m *S3StorageConfig) Validate() error {
|
||||
if m.Endpoint == "" {
|
||||
return fmt.Errorf("endpoint is required for s3 storage")
|
||||
}
|
||||
if m.AccessKeyID == "" || m.SecretAccessKey == "" {
|
||||
return fmt.Errorf("access_key_id and secret_access_key are required for s3 storage")
|
||||
}
|
||||
if m.BucketName == "" {
|
||||
return fmt.Errorf("bucket_name is required for s3 storage")
|
||||
}
|
||||
if m.BasePath == "" {
|
||||
return fmt.Errorf("base_path is required for s3 storage")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *S3StorageConfig) GetType() storenum.StorageType {
|
||||
return storenum.S3
|
||||
}
|
||||
|
||||
func (m *S3StorageConfig) GetName() string {
|
||||
return m.Name
|
||||
}
|
||||
@@ -12,6 +12,11 @@ type TelegramStorageConfig struct {
|
||||
ForceFile bool `toml:"force_file" mapstructure:"force_file" json:"force_file"`
|
||||
RateLimit int `toml:"rate_limit" mapstructure:"rate_limit" json:"rate_limit"`
|
||||
RateBurst int `toml:"rate_burst" mapstructure:"rate_burst" json:"rate_burst"`
|
||||
SkipLarge bool `toml:"skip_large" mapstructure:"skip_large" json:"skip_large"` // skip files larger than Telegram limit(2GB)
|
||||
// split files larger than Telegram limit(2GB) into parts of specified size, in MB, leave 0 to set default(2000MB)
|
||||
// only effective when SkipLarge is false
|
||||
// use zip when splitting
|
||||
SplitSizeMB int64 `toml:"split_size_mb" mapstructure:"split_size_mb" json:"split_size_mb"`
|
||||
}
|
||||
|
||||
func (m *TelegramStorageConfig) Validate() error {
|
||||
|
||||
@@ -6,8 +6,9 @@ var (
|
||||
Version string = "dev"
|
||||
BuildTime string = "unknown"
|
||||
GitCommit string = "unknown"
|
||||
Docker string = "false" // whether built inside Docker
|
||||
)
|
||||
|
||||
const (
|
||||
GitRepo = "krau/SaveAny-Bot"
|
||||
)
|
||||
)
|
||||
|
||||
123
config/viper.go
123
config/viper.go
@@ -2,24 +2,28 @@ package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/duke-git/lancet/v2/slice"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||
"github.com/krau/SaveAny-Bot/config/storage"
|
||||
"github.com/spf13/viper"
|
||||
"golang.org/x/net/proxy"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Lang string `toml:"lang" mapstructure:"lang" json:"lang"`
|
||||
Workers int `toml:"workers" mapstructure:"workers"`
|
||||
Retry int `toml:"retry" mapstructure:"retry"`
|
||||
NoCleanCache bool `toml:"no_clean_cache" mapstructure:"no_clean_cache" json:"no_clean_cache"`
|
||||
Threads int `toml:"threads" mapstructure:"threads" json:"threads"`
|
||||
Stream bool `toml:"stream" mapstructure:"stream" json:"stream"`
|
||||
Lang string `toml:"lang" mapstructure:"lang" json:"lang"`
|
||||
Workers int `toml:"workers" mapstructure:"workers"`
|
||||
Retry int `toml:"retry" mapstructure:"retry"`
|
||||
NoCleanCache bool `toml:"no_clean_cache" mapstructure:"no_clean_cache" json:"no_clean_cache"`
|
||||
Threads int `toml:"threads" mapstructure:"threads" json:"threads"`
|
||||
Stream bool `toml:"stream" mapstructure:"stream" json:"stream"`
|
||||
Proxy string `toml:"proxy" mapstructure:"proxy" json:"proxy"`
|
||||
Aria2 aria2Config `toml:"aria2" mapstructure:"aria2" json:"aria2"`
|
||||
|
||||
Cache cacheConfig `toml:"cache" mapstructure:"cache" json:"cache"`
|
||||
Users []userConfig `toml:"users" mapstructure:"users" json:"users"`
|
||||
@@ -29,6 +33,21 @@ type Config struct {
|
||||
Storages []storage.StorageConfig `toml:"-" mapstructure:"-" json:"storages"`
|
||||
Parser parserConfig `toml:"parser" mapstructure:"parser" json:"parser"`
|
||||
Hook hookConfig `toml:"hook" mapstructure:"hook" json:"hook"`
|
||||
API apiConfig `toml:"api" mapstructure:"api" json:"api"`
|
||||
}
|
||||
|
||||
type aria2Config struct {
|
||||
Enable bool `toml:"enable" mapstructure:"enable" json:"enable"`
|
||||
Url string `toml:"url" mapstructure:"url" json:"url"`
|
||||
Secret string `toml:"secret" mapstructure:"secret" json:"secret"`
|
||||
KeepFile bool `toml:"keep_file" mapstructure:"keep_file" json:"keep_file"`
|
||||
}
|
||||
|
||||
type apiConfig struct {
|
||||
Enable bool `toml:"enable" mapstructure:"enable" json:"enable"`
|
||||
Port int `toml:"port" mapstructure:"port" json:"port"`
|
||||
Token string `toml:"token" mapstructure:"token" json:"token"`
|
||||
WebhookURL string `toml:"webhook_url" mapstructure:"webhook_url" json:"webhook_url"`
|
||||
}
|
||||
|
||||
var cfg = &Config{}
|
||||
@@ -46,16 +65,39 @@ func (c Config) GetStorageByName(name string) storage.StorageConfig {
|
||||
return nil
|
||||
}
|
||||
|
||||
func Init(ctx context.Context) error {
|
||||
viper.SetConfigName("config")
|
||||
viper.AddConfigPath(".")
|
||||
viper.AddConfigPath("/etc/saveany/")
|
||||
func Init(ctx context.Context, configFile ...string) error {
|
||||
viper.SetConfigType("toml")
|
||||
viper.SetEnvPrefix("SAVEANY")
|
||||
viper.AutomaticEnv()
|
||||
replacer := strings.NewReplacer(".", "_")
|
||||
viper.SetEnvKeyReplacer(replacer)
|
||||
|
||||
// 如果指定了配置文件路径,则使用指定的配置文件
|
||||
// 配置文件支持传入一个 http(s) URL 地址
|
||||
if len(configFile) > 0 && configFile[0] != "" {
|
||||
cfg := configFile[0]
|
||||
if strings.HasPrefix(cfg, "http://") || strings.HasPrefix(cfg, "https://") {
|
||||
// 使用远程配置文件
|
||||
resp, err := http.Get(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch remote config file: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("failed to fetch remote config file: status code %d", resp.StatusCode)
|
||||
}
|
||||
if err := viper.ReadConfig(resp.Body); err != nil {
|
||||
return fmt.Errorf("failed to read remote config file: %w", err)
|
||||
}
|
||||
} else {
|
||||
viper.SetConfigFile(cfg)
|
||||
}
|
||||
} else {
|
||||
viper.SetConfigName("config")
|
||||
viper.AddConfigPath(".")
|
||||
viper.AddConfigPath("/etc/saveany/")
|
||||
}
|
||||
|
||||
defaultConfigs := map[string]any{
|
||||
// 基础配置
|
||||
"lang": "zh-Hans",
|
||||
@@ -81,6 +123,12 @@ func Init(ctx context.Context) error {
|
||||
// 数据库
|
||||
"db.path": "data/saveany.db",
|
||||
"db.session": "data/session.db",
|
||||
|
||||
// API
|
||||
"api.enable": false,
|
||||
"api.port": 8080,
|
||||
"api.token": "",
|
||||
"api.webhook_url": "",
|
||||
}
|
||||
|
||||
for key, value := range defaultConfigs {
|
||||
@@ -112,20 +160,11 @@ func Init(ctx context.Context) error {
|
||||
storageNames := make(map[string]struct{})
|
||||
for _, storage := range cfg.Storages {
|
||||
if _, ok := storageNames[storage.GetName()]; ok {
|
||||
return errors.New(i18n.TWithoutInit(cfg.Lang, i18nk.ConfigErrDuplicateStorageName, map[string]any{
|
||||
"Name": storage.GetName(),
|
||||
}))
|
||||
return fmt.Errorf("duplicate storage name: %s", storage.GetName())
|
||||
}
|
||||
storageNames[storage.GetName()] = struct{}{}
|
||||
}
|
||||
|
||||
fmt.Println(i18n.TWithoutInit(cfg.Lang, i18nk.ConfigLoadedStorages, map[string]any{
|
||||
"Count": len(cfg.Storages),
|
||||
}))
|
||||
for _, storage := range cfg.Storages {
|
||||
fmt.Printf(" - %s (%s)\n", storage.GetName(), storage.GetType())
|
||||
}
|
||||
|
||||
if cfg.Workers < 1 {
|
||||
cfg.Workers = 1
|
||||
}
|
||||
@@ -147,5 +186,43 @@ func Init(ctx context.Context) error {
|
||||
userStorages[user.ID] = user.Storages
|
||||
}
|
||||
}
|
||||
if cfg.Proxy != "" {
|
||||
http.DefaultTransport, err = newProxyTransport(cfg.Proxy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create proxy transport: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func newProxyTransport(proxyStr string) (*http.Transport, error) {
|
||||
proxyURL, err := url.Parse(proxyStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
transport := &http.Transport{
|
||||
ForceAttemptHTTP2: true,
|
||||
MaxIdleConns: 100,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
}
|
||||
switch proxyURL.Scheme {
|
||||
case "http", "https":
|
||||
transport.Proxy = http.ProxyURL(proxyURL)
|
||||
|
||||
case "socks5", "socks5h":
|
||||
dialer, err := proxy.FromURL(proxyURL, proxy.Direct)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return dialer.(proxy.ContextDialer).DialContext(ctx, network, addr)
|
||||
}
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported proxy type: %s", proxyURL.Scheme)
|
||||
}
|
||||
|
||||
return transport, nil
|
||||
}
|
||||
|
||||
44
core/core.go
44
core/core.go
@@ -10,15 +10,16 @@ import (
|
||||
"github.com/krau/SaveAny-Bot/pkg/queue"
|
||||
)
|
||||
|
||||
var queueInstance *queue.TaskQueue[Exectable]
|
||||
var queueInstance *queue.TaskQueue[Executable]
|
||||
|
||||
type Exectable interface {
|
||||
type Executable interface {
|
||||
Type() tasktype.TaskType
|
||||
Title() string
|
||||
TaskID() string
|
||||
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[Executable], semaphore chan struct{}) {
|
||||
logger := log.FromContext(ctx)
|
||||
execHooks := config.C().Hook.Exec
|
||||
for {
|
||||
@@ -28,27 +29,27 @@ func worker(ctx context.Context, qe *queue.TaskQueue[Exectable], semaphore chan
|
||||
logger.Error("Failed to get task from queue:", err)
|
||||
break // queue closed and empty
|
||||
}
|
||||
task := qtask.Data
|
||||
logger.Infof("Processing task: %s", task.TaskID())
|
||||
exe := qtask.Data
|
||||
logger.Infof("Processing task: %s", exe.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)
|
||||
logger.Errorf("Failed to execute before start hook for task %s: %v", exe.TaskID(), err)
|
||||
}
|
||||
if err := task.Execute(qtask.Context()); err != nil {
|
||||
if err := exe.Execute(qtask.Context()); err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
logger.Infof("Task %s was canceled", task.TaskID())
|
||||
logger.Infof("Task %s was canceled", exe.TaskID())
|
||||
if err := ExecCommandString(ctx, execHooks.TaskCancel); err != nil {
|
||||
logger.Errorf("Failed to execute cancel hook for task %s: %v", task.TaskID(), err)
|
||||
logger.Errorf("Failed to execute cancel hook for task %s: %v", exe.TaskID(), err)
|
||||
}
|
||||
} else {
|
||||
logger.Errorf("Failed to execute task %s: %v", task.TaskID(), err)
|
||||
logger.Errorf("Failed to execute task %s: %v", exe.TaskID(), err)
|
||||
if err := ExecCommandString(ctx, execHooks.TaskFail); err != nil {
|
||||
logger.Errorf("Failed to execute fail hook for task %s: %v", task.TaskID(), err)
|
||||
logger.Errorf("Failed to execute fail hook for task %s: %v", exe.TaskID(), err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.Infof("Task %s completed successfully", task.TaskID())
|
||||
logger.Infof("Task %s completed successfully", exe.TaskID())
|
||||
if err := ExecCommandString(ctx, execHooks.TaskSuccess); err != nil {
|
||||
logger.Errorf("Failed to execute success hook for task %s: %v", task.TaskID(), err)
|
||||
logger.Errorf("Failed to execute success hook for task %s: %v", exe.TaskID(), err)
|
||||
}
|
||||
}
|
||||
qe.Done(qtask.ID)
|
||||
@@ -60,7 +61,7 @@ func Run(ctx context.Context) {
|
||||
log.FromContext(ctx).Info("Start processing tasks...")
|
||||
semaphore := make(chan struct{}, config.C().Workers)
|
||||
if queueInstance == nil {
|
||||
queueInstance = queue.NewTaskQueue[Exectable]()
|
||||
queueInstance = queue.NewTaskQueue[Executable]()
|
||||
}
|
||||
for range config.C().Workers {
|
||||
go worker(ctx, queueInstance, semaphore)
|
||||
@@ -68,8 +69,8 @@ func Run(ctx context.Context) {
|
||||
|
||||
}
|
||||
|
||||
func AddTask(ctx context.Context, task Exectable) error {
|
||||
return queueInstance.Add(queue.NewTask(ctx, task.TaskID(), task))
|
||||
func AddTask(ctx context.Context, task Executable) error {
|
||||
return queueInstance.Add(queue.NewTask(ctx, task.TaskID(), task.Title(), task))
|
||||
}
|
||||
|
||||
func CancelTask(ctx context.Context, id string) error {
|
||||
@@ -78,8 +79,13 @@ func CancelTask(ctx context.Context, id string) error {
|
||||
}
|
||||
|
||||
func GetLength(ctx context.Context) int {
|
||||
if queueInstance == nil {
|
||||
return 0
|
||||
}
|
||||
return queueInstance.ActiveLength()
|
||||
}
|
||||
|
||||
func GetRunningTasks(ctx context.Context) []queue.TaskInfo {
|
||||
return queueInstance.RunningTasks()
|
||||
}
|
||||
|
||||
func GetQueuedTasks(ctx context.Context) []queue.TaskInfo {
|
||||
return queueInstance.QueuedTasks()
|
||||
}
|
||||
|
||||
250
core/tasks/aria2dl/execute.go
Normal file
250
core/tasks/aria2dl/execute.go
Normal file
@@ -0,0 +1,250 @@
|
||||
package aria2dl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"github.com/krau/SaveAny-Bot/pkg/aria2"
|
||||
"github.com/krau/SaveAny-Bot/pkg/enums/ctxkey"
|
||||
)
|
||||
|
||||
// Execute implements core.Executable.
|
||||
func (t *Task) Execute(ctx context.Context) error {
|
||||
logger := log.FromContext(ctx)
|
||||
logger.Infof("Starting aria2 download task %s (GID: %s)", t.ID, t.GID)
|
||||
|
||||
if t.Progress != nil {
|
||||
t.Progress.OnStart(ctx, t)
|
||||
}
|
||||
|
||||
// Wait for aria2 download to complete
|
||||
if err := t.waitForDownload(ctx); err != nil {
|
||||
// If context was canceled, also cancel the aria2 download
|
||||
if errors.Is(err, context.Canceled) {
|
||||
t.cancelAria2Download()
|
||||
}
|
||||
logger.Errorf("Aria2 download failed: %v", err)
|
||||
if t.Progress != nil {
|
||||
t.Progress.OnDone(ctx, t, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Transfer downloaded files to storage
|
||||
if err := t.transferFiles(ctx); err != nil {
|
||||
logger.Errorf("File transfer failed: %v", err)
|
||||
if t.Progress != nil {
|
||||
t.Progress.OnDone(ctx, t, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Infof("Aria2 task %s completed successfully", t.ID)
|
||||
if t.Progress != nil {
|
||||
t.Progress.OnDone(ctx, t, nil)
|
||||
}
|
||||
|
||||
// Clean up aria2 download result
|
||||
if _, err := t.Aria2Client.RemoveDownloadResult(context.Background(), t.GID); err != nil {
|
||||
logger.Warnf("Failed to remove aria2 download result: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// waitForDownload waits for aria2 to complete the download
|
||||
func (t *Task) waitForDownload(ctx context.Context) error {
|
||||
logger := log.FromContext(ctx)
|
||||
ticker := time.NewTicker(2 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
status, err := t.getStatus(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if t.Progress != nil {
|
||||
t.Progress.OnProgress(ctx, t, status)
|
||||
}
|
||||
|
||||
// Check if download is complete
|
||||
if status.IsDownloadComplete() {
|
||||
// Handle metadata downloads (torrent/magnet) that spawn follow-up downloads
|
||||
if len(status.FollowedBy) > 0 {
|
||||
logger.Infof("Switching from metadata GID %s to actual download GID: %s", t.GID, status.FollowedBy[0])
|
||||
t.GID = status.FollowedBy[0]
|
||||
continue
|
||||
}
|
||||
logger.Infof("Download completed for GID %s", t.GID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check for errors
|
||||
if status.IsDownloadError() {
|
||||
return fmt.Errorf("aria2 download error: %s (code: %s)", status.ErrorMessage, status.ErrorCode)
|
||||
}
|
||||
|
||||
if status.IsDownloadRemoved() {
|
||||
return errors.New("aria2 download was removed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// getStatus retrieves the current status of the download
|
||||
func (t *Task) getStatus(ctx context.Context) (*aria2.Status, error) {
|
||||
logger := log.FromContext(ctx)
|
||||
|
||||
// Try active/waiting queue first
|
||||
status, err := t.Aria2Client.TellStatus(ctx, t.GID)
|
||||
if err == nil {
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// Check stopped queue
|
||||
logger.Debugf("Task not in active queue, checking stopped queue")
|
||||
stoppedTasks, stopErr := t.Aria2Client.TellStopped(ctx, -1, 100)
|
||||
if stopErr != nil {
|
||||
return nil, fmt.Errorf("failed to get aria2 status: %w", err)
|
||||
}
|
||||
|
||||
for _, task := range stoppedTasks {
|
||||
if task.GID == t.GID {
|
||||
logger.Debugf("Found task in stopped queue with status: %s", task.Status)
|
||||
return &task, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("task GID %s not found: %w", t.GID, err)
|
||||
}
|
||||
|
||||
// transferFiles transfers downloaded files from aria2 to storage
|
||||
func (t *Task) transferFiles(ctx context.Context) error {
|
||||
logger := log.FromContext(ctx)
|
||||
|
||||
status, err := t.Aria2Client.TellStatus(ctx, t.GID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get final status: %w", err)
|
||||
}
|
||||
|
||||
if len(status.Files) == 0 {
|
||||
return errors.New("no files in aria2 download")
|
||||
}
|
||||
|
||||
logger.Infof("Transferring %d file(s) to storage %s", len(status.Files), t.Storage.Name())
|
||||
transferredCount := 0
|
||||
|
||||
for _, file := range status.Files {
|
||||
if file.Selected != "true" {
|
||||
logger.Debugf("Skipping unselected file: %s", file.Path)
|
||||
continue
|
||||
}
|
||||
|
||||
fileName := filepath.Base(file.Path)
|
||||
|
||||
// Skip torrent metadata files
|
||||
if filepath.Ext(fileName) == ".torrent" {
|
||||
logger.Debugf("Skipping torrent metadata file: %s", fileName)
|
||||
t.removeFileIfNeeded(file.Path)
|
||||
continue
|
||||
}
|
||||
|
||||
if err := t.transferFile(ctx, file.Path); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
transferredCount++
|
||||
t.removeFileIfNeeded(file.Path)
|
||||
}
|
||||
|
||||
if transferredCount == 0 {
|
||||
return errors.New("no files were transferred")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// transferFile transfers a single file to storage
|
||||
func (t *Task) transferFile(ctx context.Context, filePath string) error {
|
||||
logger := log.FromContext(ctx)
|
||||
|
||||
// Check if file exists
|
||||
fileInfo, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
logger.Warnf("Downloaded file not found: %s", filePath)
|
||||
return nil // Not a fatal error, continue with other files
|
||||
}
|
||||
return fmt.Errorf("failed to stat file %s: %w", filePath, err)
|
||||
}
|
||||
|
||||
// Open file
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open file %s: %w", filePath, err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// Set content length in context for storage
|
||||
ctx = context.WithValue(ctx, ctxkey.ContentLength, fileInfo.Size())
|
||||
|
||||
// Save to storage
|
||||
fileName := filepath.Base(filePath)
|
||||
destPath := filepath.Join(t.StorPath, fileName)
|
||||
|
||||
logger.Infof("Transferring file %s to %s:%s", fileName, t.Storage.Name(), destPath)
|
||||
|
||||
if err := t.Storage.Save(ctx, f, destPath); err != nil {
|
||||
return fmt.Errorf("failed to save file %s to storage: %w", fileName, err)
|
||||
}
|
||||
|
||||
logger.Infof("Successfully transferred file %s", fileName)
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeFileIfNeeded removes a file if RemoveAfterTransfer is enabled
|
||||
func (t *Task) removeFileIfNeeded(filePath string) {
|
||||
if config.C().Aria2.KeepFile {
|
||||
return
|
||||
}
|
||||
|
||||
logger := log.FromContext(t.ctx)
|
||||
if err := os.Remove(filePath); err != nil {
|
||||
logger.Warnf("Failed to remove local file %s: %v", filePath, err)
|
||||
} else {
|
||||
logger.Debugf("Removed local file %s", filePath)
|
||||
}
|
||||
}
|
||||
|
||||
// cancelAria2Download cancels the aria2 download task
|
||||
func (t *Task) cancelAria2Download() {
|
||||
logger := log.FromContext(t.ctx)
|
||||
logger.Infof("Canceling aria2 download GID: %s", t.GID)
|
||||
|
||||
// Use a background context with timeout for cleanup
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Try to force remove the download
|
||||
if _, err := t.Aria2Client.ForceRemove(ctx, t.GID); err != nil {
|
||||
logger.Warnf("Failed to cancel aria2 download %s: %v", t.GID, err)
|
||||
} else {
|
||||
logger.Infof("Successfully canceled aria2 download %s", t.GID)
|
||||
}
|
||||
|
||||
// Also remove the download result to clean up
|
||||
if _, err := t.Aria2Client.RemoveDownloadResult(ctx, t.GID); err != nil {
|
||||
logger.Debugf("Failed to remove download result for %s: %v", t.GID, err)
|
||||
}
|
||||
}
|
||||
189
core/tasks/aria2dl/progress.go
Normal file
189
core/tasks/aria2dl/progress.go
Normal file
@@ -0,0 +1,189 @@
|
||||
package aria2dl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/gotd/td/telegram/message/entity"
|
||||
"github.com/gotd/td/telegram/message/styling"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/dlutil"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||
"github.com/krau/SaveAny-Bot/pkg/aria2"
|
||||
)
|
||||
|
||||
type ProgressTracker interface {
|
||||
OnStart(ctx context.Context, task *Task)
|
||||
OnProgress(ctx context.Context, task *Task, status *aria2.Status)
|
||||
OnDone(ctx context.Context, task *Task, err error)
|
||||
}
|
||||
|
||||
type Progress struct {
|
||||
msgID int
|
||||
chatID int64
|
||||
start time.Time
|
||||
lastUpdatePercent atomic.Int32
|
||||
}
|
||||
|
||||
// OnStart implements ProgressTracker.
|
||||
func (p *Progress) OnStart(ctx context.Context, task *Task) {
|
||||
logger := log.FromContext(ctx)
|
||||
p.start = time.Now()
|
||||
p.lastUpdatePercent.Store(0)
|
||||
logger.Infof("Aria2 task started: message_id=%d, chat_id=%d, gid=%s", p.msgID, p.chatID, task.GID)
|
||||
ext := tgutil.ExtFromContext(ctx)
|
||||
if ext == nil {
|
||||
return
|
||||
}
|
||||
entityBuilder := entity.Builder{}
|
||||
if err := styling.Perform(&entityBuilder,
|
||||
styling.Plain(i18n.T(i18nk.BotMsgProgressAria2Start, map[string]any{
|
||||
"GID": task.GID,
|
||||
}))); err != nil {
|
||||
log.FromContext(ctx).Errorf("Failed to build entities: %s", err)
|
||||
return
|
||||
}
|
||||
text, entities := entityBuilder.Complete()
|
||||
req := &tg.MessagesEditMessageRequest{
|
||||
ID: p.msgID,
|
||||
}
|
||||
req.SetMessage(text)
|
||||
req.SetEntities(entities)
|
||||
req.SetReplyMarkup(&tg.ReplyInlineMarkup{
|
||||
Rows: []tg.KeyboardButtonRow{
|
||||
{
|
||||
Buttons: []tg.KeyboardButtonClass{
|
||||
tgutil.BuildCancelButton(task.TaskID()),
|
||||
},
|
||||
},
|
||||
}},
|
||||
)
|
||||
ext.EditMessage(p.chatID, req)
|
||||
}
|
||||
|
||||
// OnProgress implements ProgressTracker.
|
||||
func (p *Progress) OnProgress(ctx context.Context, task *Task, status *aria2.Status) {
|
||||
totalLength, _ := strconv.ParseInt(status.TotalLength, 10, 64)
|
||||
completedLength, _ := strconv.ParseInt(status.CompletedLength, 10, 64)
|
||||
downloadSpeed, _ := strconv.ParseInt(status.DownloadSpeed, 10, 64)
|
||||
|
||||
if totalLength == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
percent := int((completedLength * 100) / totalLength)
|
||||
if p.lastUpdatePercent.Load() == int32(percent) {
|
||||
return
|
||||
}
|
||||
p.lastUpdatePercent.Store(int32(percent))
|
||||
|
||||
log.FromContext(ctx).Debugf("Aria2 progress update: %s, %d/%d", task.GID, completedLength, totalLength)
|
||||
|
||||
entityBuilder := entity.Builder{}
|
||||
if err := styling.Perform(&entityBuilder,
|
||||
styling.Plain(i18n.T(i18nk.BotMsgProgressAria2Downloading, map[string]any{
|
||||
"GID": task.GID,
|
||||
})),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgProgressDownloadedPrefix, nil)),
|
||||
styling.Code(fmt.Sprintf("%.2f MB / %.2f MB", float64(completedLength)/(1024*1024), float64(totalLength)/(1024*1024))),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgProgressCurrentSpeedPrefix, nil)),
|
||||
styling.Bold(fmt.Sprintf("%.2f MB/s", float64(downloadSpeed)/(1024*1024))),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgProgressAvgSpeedPrefix, nil)),
|
||||
styling.Bold(fmt.Sprintf("%.2f MB/s", dlutil.GetSpeed(completedLength, p.start)/(1024*1024))),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgProgressCurrentProgressPrefix, nil)),
|
||||
styling.Bold(fmt.Sprintf("%.2f%%", float64(percent))),
|
||||
); err != nil {
|
||||
log.FromContext(ctx).Errorf("Failed to build entities: %s", err)
|
||||
return
|
||||
}
|
||||
text, entities := entityBuilder.Complete()
|
||||
req := &tg.MessagesEditMessageRequest{
|
||||
ID: p.msgID,
|
||||
}
|
||||
req.SetMessage(text)
|
||||
req.SetEntities(entities)
|
||||
req.SetReplyMarkup(&tg.ReplyInlineMarkup{
|
||||
Rows: []tg.KeyboardButtonRow{
|
||||
{
|
||||
Buttons: []tg.KeyboardButtonClass{
|
||||
tgutil.BuildCancelButton(task.TaskID()),
|
||||
},
|
||||
},
|
||||
}},
|
||||
)
|
||||
ext := tgutil.ExtFromContext(ctx)
|
||||
if ext != nil {
|
||||
ext.EditMessage(p.chatID, req)
|
||||
}
|
||||
}
|
||||
|
||||
// OnDone implements ProgressTracker.
|
||||
func (p *Progress) OnDone(ctx context.Context, task *Task, err error) {
|
||||
logger := log.FromContext(ctx)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
logger.Infof("Aria2 task %s was canceled", task.TaskID())
|
||||
ext := tgutil.ExtFromContext(ctx)
|
||||
if ext != nil {
|
||||
ext.EditMessage(p.chatID, &tg.MessagesEditMessageRequest{
|
||||
ID: p.msgID,
|
||||
Message: i18n.T(i18nk.BotMsgProgressTaskCanceledWithId, map[string]any{
|
||||
"TaskID": task.TaskID(),
|
||||
}),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
logger.Errorf("Aria2 task %s failed: %s", task.TaskID(), err)
|
||||
ext := tgutil.ExtFromContext(ctx)
|
||||
if ext != nil {
|
||||
ext.EditMessage(p.chatID, &tg.MessagesEditMessageRequest{
|
||||
ID: p.msgID,
|
||||
Message: i18n.T(i18nk.BotMsgProgressTaskFailedWithError, map[string]any{
|
||||
"Error": err.Error(),
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
logger.Infof("Aria2 task %s completed successfully", task.TaskID())
|
||||
|
||||
entityBuilder := entity.Builder{}
|
||||
if err := styling.Perform(&entityBuilder,
|
||||
styling.Plain(i18n.T(i18nk.BotMsgProgressAria2Done, map[string]any{
|
||||
"GID": task.GID,
|
||||
})),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgProgressSavePathPrefix, nil)),
|
||||
styling.Code(fmt.Sprintf("[%s]:%s", task.Storage.Name(), task.StorPath)),
|
||||
); err != nil {
|
||||
logger.Errorf("Failed to build entities: %s", err)
|
||||
return
|
||||
}
|
||||
text, entities := entityBuilder.Complete()
|
||||
req := &tg.MessagesEditMessageRequest{
|
||||
ID: p.msgID,
|
||||
}
|
||||
req.SetMessage(text)
|
||||
req.SetEntities(entities)
|
||||
|
||||
ext := tgutil.ExtFromContext(ctx)
|
||||
if ext != nil {
|
||||
ext.EditMessage(p.chatID, req)
|
||||
}
|
||||
}
|
||||
|
||||
var _ ProgressTracker = (*Progress)(nil)
|
||||
|
||||
func NewProgress(msgID int, userID int64) ProgressTracker {
|
||||
return &Progress{
|
||||
msgID: msgID,
|
||||
chatID: userID,
|
||||
}
|
||||
}
|
||||
61
core/tasks/aria2dl/task.go
Normal file
61
core/tasks/aria2dl/task.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package aria2dl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/krau/SaveAny-Bot/core"
|
||||
"github.com/krau/SaveAny-Bot/pkg/aria2"
|
||||
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
)
|
||||
|
||||
var _ core.Executable = (*Task)(nil)
|
||||
|
||||
type Task struct {
|
||||
ID string
|
||||
ctx context.Context
|
||||
GID string
|
||||
URIs []string
|
||||
Aria2Client *aria2.Client
|
||||
Storage storage.Storage
|
||||
StorPath string
|
||||
Progress ProgressTracker
|
||||
}
|
||||
|
||||
// Title implements core.Executable.
|
||||
func (t *Task) Title() string {
|
||||
return fmt.Sprintf("[%s](Aria2 GID:%s->%s:%s)", t.Type(), t.GID, t.Storage.Name(), t.StorPath)
|
||||
}
|
||||
|
||||
// Type implements core.Executable.
|
||||
func (t *Task) Type() tasktype.TaskType {
|
||||
return tasktype.TaskTypeAria2
|
||||
}
|
||||
|
||||
// TaskID implements core.Executable.
|
||||
func (t *Task) TaskID() string {
|
||||
return t.ID
|
||||
}
|
||||
|
||||
func NewTask(
|
||||
id string,
|
||||
ctx context.Context,
|
||||
gid string,
|
||||
uris []string,
|
||||
aria2Client *aria2.Client,
|
||||
stor storage.Storage,
|
||||
storPath string,
|
||||
progressTracker ProgressTracker,
|
||||
) *Task {
|
||||
return &Task{
|
||||
ID: id,
|
||||
ctx: ctx,
|
||||
GID: gid,
|
||||
URIs: uris,
|
||||
Aria2Client: aria2Client,
|
||||
Storage: stor,
|
||||
StorPath: storPath,
|
||||
Progress: progressTracker,
|
||||
}
|
||||
}
|
||||
209
core/tasks/aria2dl/task_test.go
Normal file
209
core/tasks/aria2dl/task_test.go
Normal file
@@ -0,0 +1,209 @@
|
||||
package aria2dl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
storconfig "github.com/krau/SaveAny-Bot/config/storage"
|
||||
"github.com/krau/SaveAny-Bot/pkg/aria2"
|
||||
storenum "github.com/krau/SaveAny-Bot/pkg/enums/storage"
|
||||
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
|
||||
)
|
||||
|
||||
type mockStorage struct {
|
||||
name string
|
||||
savePath string
|
||||
}
|
||||
|
||||
func (m *mockStorage) Name() string {
|
||||
return m.name
|
||||
}
|
||||
|
||||
func (m *mockStorage) Type() storenum.StorageType {
|
||||
return storenum.StorageType("mock")
|
||||
}
|
||||
|
||||
func (m *mockStorage) Init(ctx context.Context, config storconfig.StorageConfig) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockStorage) Save(ctx context.Context, reader io.Reader, path string) error {
|
||||
m.savePath = path
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockStorage) Exists(ctx context.Context, path string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *mockStorage) JoinStoragePath(path string) string {
|
||||
return path
|
||||
}
|
||||
|
||||
type mockProgress struct {
|
||||
started bool
|
||||
done bool
|
||||
doneErr error
|
||||
progress int
|
||||
}
|
||||
|
||||
func (m *mockProgress) OnStart(ctx context.Context, task *Task) {
|
||||
m.started = true
|
||||
}
|
||||
|
||||
func (m *mockProgress) OnProgress(ctx context.Context, task *Task, status *aria2.Status) {
|
||||
m.progress++
|
||||
}
|
||||
|
||||
func (m *mockProgress) OnDone(ctx context.Context, task *Task, err error) {
|
||||
m.done = true
|
||||
m.doneErr = err
|
||||
}
|
||||
|
||||
func TestTaskCreation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mockStor := &mockStorage{name: "test-storage"}
|
||||
mockProg := &mockProgress{}
|
||||
|
||||
task := NewTask(
|
||||
"test-task-id",
|
||||
ctx,
|
||||
"test-gid",
|
||||
[]string{"http://example.com/file.zip"},
|
||||
nil,
|
||||
mockStor,
|
||||
"/test/path",
|
||||
mockProg,
|
||||
)
|
||||
|
||||
if task.ID != "test-task-id" {
|
||||
t.Errorf("Expected task ID to be 'test-task-id', got '%s'", task.ID)
|
||||
}
|
||||
|
||||
if task.GID != "test-gid" {
|
||||
t.Errorf("Expected GID to be 'test-gid', got '%s'", task.GID)
|
||||
}
|
||||
|
||||
if task.Type() != tasktype.TaskTypeAria2 {
|
||||
t.Errorf("Expected task type to be TaskTypeAria2, got '%s'", task.Type())
|
||||
}
|
||||
|
||||
if task.TaskID() != "test-task-id" {
|
||||
t.Errorf("Expected TaskID() to return 'test-task-id', got '%s'", task.TaskID())
|
||||
}
|
||||
|
||||
if task.Storage.Name() != "test-storage" {
|
||||
t.Errorf("Expected storage name to be 'test-storage', got '%s'", task.Storage.Name())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressTracker(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mockStor := &mockStorage{name: "test-storage"}
|
||||
mockProg := &mockProgress{}
|
||||
|
||||
task := NewTask(
|
||||
"test-task-id",
|
||||
ctx,
|
||||
"test-gid",
|
||||
[]string{"http://example.com/file.zip"},
|
||||
nil,
|
||||
mockStor,
|
||||
"/test/path",
|
||||
mockProg,
|
||||
)
|
||||
|
||||
// Test OnStart
|
||||
mockProg.OnStart(ctx, task)
|
||||
if !mockProg.started {
|
||||
t.Error("Expected OnStart to set started to true")
|
||||
}
|
||||
|
||||
// Test OnProgress
|
||||
status := &aria2.Status{
|
||||
GID: "test-gid",
|
||||
Status: "active",
|
||||
TotalLength: "1000000",
|
||||
CompletedLength: "500000",
|
||||
DownloadSpeed: "100000",
|
||||
}
|
||||
mockProg.OnProgress(ctx, task, status)
|
||||
if mockProg.progress != 1 {
|
||||
t.Errorf("Expected progress to be 1, got %d", mockProg.progress)
|
||||
}
|
||||
|
||||
// Test OnDone
|
||||
mockProg.OnDone(ctx, task, nil)
|
||||
if !mockProg.done {
|
||||
t.Error("Expected OnDone to set done to true")
|
||||
}
|
||||
if mockProg.doneErr != nil {
|
||||
t.Errorf("Expected doneErr to be nil, got %v", mockProg.doneErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskTitle(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mockStor := &mockStorage{name: "test-storage"}
|
||||
|
||||
task := NewTask(
|
||||
"test-task-id",
|
||||
ctx,
|
||||
"test-gid-123",
|
||||
[]string{"http://example.com/file.zip"},
|
||||
nil,
|
||||
mockStor,
|
||||
"/test/path",
|
||||
nil,
|
||||
)
|
||||
|
||||
title := task.Title()
|
||||
expectedSubstr := "test-gid-123"
|
||||
if len(title) == 0 {
|
||||
t.Error("Expected title to not be empty")
|
||||
}
|
||||
|
||||
// Check if title contains the GID
|
||||
found := false
|
||||
for i := 0; i < len(title)-len(expectedSubstr)+1; i++ {
|
||||
if title[i:i+len(expectedSubstr)] == expectedSubstr {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("Expected title to contain GID '%s', got '%s'", expectedSubstr, title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextCancellation(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
mockStor := &mockStorage{name: "test-storage"}
|
||||
mockProg := &mockProgress{}
|
||||
|
||||
task := NewTask(
|
||||
"test-task-id",
|
||||
ctx,
|
||||
"test-gid",
|
||||
[]string{"http://example.com/file.zip"},
|
||||
nil, // nil client will cause Execute to fail/timeout
|
||||
mockStor,
|
||||
"/test/path",
|
||||
mockProg,
|
||||
)
|
||||
|
||||
// Just verify the task structure is valid
|
||||
if task.ctx.Err() != nil {
|
||||
t.Error("Context should not be cancelled yet")
|
||||
}
|
||||
|
||||
// Wait for context to timeout
|
||||
<-ctx.Done()
|
||||
if ctx.Err() == nil {
|
||||
t.Error("Context should be cancelled after timeout")
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,11 @@ import (
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
"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/ioutil"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"github.com/krau/SaveAny-Bot/pkg/enums/ctxkey"
|
||||
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
@@ -24,7 +24,7 @@ func (t *Task) Execute(ctx context.Context) error {
|
||||
workers := config.C().Workers
|
||||
eg, gctx := errgroup.WithContext(ctx)
|
||||
eg.SetLimit(workers)
|
||||
for _, elem := range t.Elems {
|
||||
for _, elem := range t.elems {
|
||||
eg.Go(func() error {
|
||||
t.processingMu.RLock()
|
||||
if t.processing[elem.ID] != nil {
|
||||
@@ -68,7 +68,7 @@ func (t *Task) processElement(ctx context.Context, elem TaskElement) error {
|
||||
errg.Go(func() error {
|
||||
defer pw.Close()
|
||||
logger.Info("Starting file download in stream mode")
|
||||
_, err := tfile.NewDownloader(elem.File).Stream(uploadCtx, wr)
|
||||
_, err := tdler.NewDownloader(elem.File).Stream(uploadCtx, wr)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to download file: %v", err)
|
||||
pw.CloseWithError(err)
|
||||
@@ -95,7 +95,7 @@ func (t *Task) processElement(ctx context.Context, elem TaskElement) error {
|
||||
t.downloaded.Add(int64(n))
|
||||
t.Progress.OnProgress(ctx, t)
|
||||
})
|
||||
_, err = tfile.NewDownloader(elem.File).Parallel(ctx, wrAt)
|
||||
_, err = tdler.NewDownloader(elem.File).Parallel(ctx, wrAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download file: %w", err)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"github.com/gotd/td/telegram/message/entity"
|
||||
"github.com/gotd/td/telegram/message/styling"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/dlutil"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||
)
|
||||
@@ -37,7 +39,7 @@ func (p *Progress) OnStart(ctx context.Context, info TaskInfo) {
|
||||
entityBuilder := entity.Builder{}
|
||||
var entities []tg.MessageEntityClass
|
||||
if err := styling.Perform(&entityBuilder,
|
||||
styling.Plain("开始执行批量下载任务\n总大小: "),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgProgressBatchStartPrefix, nil)),
|
||||
styling.Code(fmt.Sprintf("%.2f MB (%d个文件)", float64(info.TotalSize())/(1024*1024), info.Count())),
|
||||
); err != nil {
|
||||
log.FromContext(ctx).Errorf("Failed to build entities: %s", err)
|
||||
@@ -78,22 +80,22 @@ func (p *Progress) OnProgress(ctx context.Context, info TaskInfo) {
|
||||
entityBuilder := entity.Builder{}
|
||||
var entities []tg.MessageEntityClass
|
||||
if err := styling.Perform(&entityBuilder,
|
||||
styling.Plain("正在处理批量下载任务\n总大小: "),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgProgressBatchProcessingPrefix, nil)),
|
||||
styling.Code(fmt.Sprintf("%.2f MB (%d个文件)", float64(info.TotalSize())/(1024*1024), info.Count())),
|
||||
styling.Plain("\n正在处理:\n"),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgProgressProcessingListPrefix, nil)),
|
||||
func() styling.StyledTextOption {
|
||||
var lines []string
|
||||
for _, elem := range info.Processing() {
|
||||
lines = append(lines, fmt.Sprintf(" - %s (%.2f MB)", elem.FileName(), float64(elem.FileSize())/(1024*1024)))
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
lines = append(lines, " - 无")
|
||||
lines = append(lines, i18n.T(i18nk.BotMsgProgressProcessingNone, nil))
|
||||
}
|
||||
return styling.Plain(slice.Join(lines, "\n"))
|
||||
}(),
|
||||
styling.Plain("\n平均速度: "),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgProgressAvgSpeedPrefix, nil)),
|
||||
styling.Bold(fmt.Sprintf("%.2f MB/s", dlutil.GetSpeed(info.Downloaded(), p.start)/(1024*1024))),
|
||||
styling.Plain("\n当前进度: "),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgProgressCurrentProgressPrefix, nil)),
|
||||
styling.Bold(fmt.Sprintf("%.2f%%", float64(info.Downloaded())/float64(info.TotalSize())*100)),
|
||||
); err != nil {
|
||||
log.FromContext(ctx).Errorf("Failed to build entities: %s", err)
|
||||
@@ -133,19 +135,21 @@ func (p *Progress) OnDone(ctx context.Context, info TaskInfo, err error) {
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
stylingErr = styling.Perform(&entityBuilder,
|
||||
styling.Plain("任务已取消"),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgProgressTaskCanceled, nil)),
|
||||
)
|
||||
} else {
|
||||
stylingErr = styling.Perform(&entityBuilder,
|
||||
styling.Plain("处理失败, 错误:\n "),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgProgressTaskFailedWithError, map[string]any{
|
||||
"Error": "",
|
||||
})),
|
||||
styling.Code(err.Error()),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
stylingErr = styling.Perform(&entityBuilder,
|
||||
styling.Plain("处理完成\n文件数: "),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgProgressBatchDonePrefix, nil)),
|
||||
styling.Code(strconv.Itoa(info.Count())),
|
||||
styling.Plain("\n总大小: "),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgProgressTotalSizePrefix, nil)),
|
||||
styling.Code(fmt.Sprintf("%.2f MB", float64(info.TotalSize())/(1024*1024))),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,12 +8,15 @@ import (
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"github.com/krau/SaveAny-Bot/core"
|
||||
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
|
||||
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
"github.com/rs/xid"
|
||||
)
|
||||
|
||||
var _ core.Executable = (*Task)(nil)
|
||||
|
||||
type TaskElement struct {
|
||||
ID string
|
||||
Storage storage.Storage
|
||||
@@ -25,8 +28,8 @@ type TaskElement struct {
|
||||
|
||||
type Task struct {
|
||||
ID string
|
||||
Ctx context.Context
|
||||
Elems []TaskElement
|
||||
ctx context.Context
|
||||
elems []TaskElement
|
||||
Progress ProgressTracker
|
||||
IgnoreErrors bool // if true, errors during processing will be ignored
|
||||
downloaded atomic.Int64
|
||||
@@ -36,6 +39,11 @@ type Task struct {
|
||||
failed map[string]error // [TODO] errors for each element
|
||||
}
|
||||
|
||||
// Title implements core.Exectable.
|
||||
func (t *Task) Title() string {
|
||||
return fmt.Sprintf("[%s](%d files/%.2fMB)", t.Type(), len(t.elems), float64(t.totalSize)/(1024*1024))
|
||||
}
|
||||
|
||||
func (t *Task) Type() tasktype.TaskType {
|
||||
return tasktype.TaskTypeTgfiles
|
||||
}
|
||||
@@ -78,8 +86,8 @@ func NewBatchTGFileTask(
|
||||
) *Task {
|
||||
task := &Task{
|
||||
ID: id,
|
||||
Ctx: ctx,
|
||||
Elems: files,
|
||||
ctx: ctx,
|
||||
elems: files,
|
||||
Progress: progress,
|
||||
downloaded: atomic.Int64{},
|
||||
totalSize: func() int64 {
|
||||
|
||||
@@ -44,11 +44,11 @@ func (t *Task) Downloaded() int64 {
|
||||
}
|
||||
|
||||
func (t *Task) Count() int {
|
||||
return len(t.Elems)
|
||||
return len(t.elems)
|
||||
}
|
||||
|
||||
func (t *Task) Processing() []TaskElementInfo {
|
||||
processing := make([]TaskElementInfo, 0, len(t.Elems))
|
||||
processing := make([]TaskElementInfo, 0, len(t.elems))
|
||||
for _, elem := range t.processing {
|
||||
processing = append(processing, elem)
|
||||
}
|
||||
|
||||
167
core/tasks/directlinks/execute.go
Normal file
167
core/tasks/directlinks/execute.go
Normal file
@@ -0,0 +1,167 @@
|
||||
package directlinks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/duke-git/lancet/v2/retry"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/fsutil"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/ioutil"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"github.com/krau/SaveAny-Bot/pkg/enums/ctxkey"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
func (t *Task) Execute(ctx context.Context) error {
|
||||
logger := log.FromContext(ctx)
|
||||
logger.Infof("Starting directlinks task %s", t.ID)
|
||||
if t.Progress != nil {
|
||||
t.Progress.OnStart(ctx, t)
|
||||
}
|
||||
// head all links to get file info
|
||||
eg, gctx := errgroup.WithContext(ctx)
|
||||
eg.SetLimit(config.C().Workers)
|
||||
fetchedTotalBytes := atomic.Int64{}
|
||||
for _, file := range t.files {
|
||||
eg.Go(func() error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodHead, file.URL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create HEAD request for %s: %w", file.URL, err)
|
||||
}
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to HEAD %s: %w", file.URL, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("HEAD %s returned status %d", file.URL, resp.StatusCode)
|
||||
}
|
||||
fetchedTotalBytes.Add(resp.ContentLength)
|
||||
file.Size = resp.ContentLength
|
||||
if name := resp.Header.Get("Content-Disposition"); name != "" {
|
||||
// Set file name
|
||||
filename := parseFilename(name)
|
||||
file.Name = filename
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
err := eg.Wait()
|
||||
if err != nil {
|
||||
logger.Errorf("Error during HEAD requests: %v", err)
|
||||
if t.Progress != nil {
|
||||
t.Progress.OnDone(ctx, t, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
t.totalBytes = fetchedTotalBytes.Load()
|
||||
// start downloading
|
||||
eg, gctx = errgroup.WithContext(ctx)
|
||||
eg.SetLimit(config.C().Workers)
|
||||
for _, file := range t.files {
|
||||
eg.Go(func() error {
|
||||
t.processingMu.RLock()
|
||||
if _, ok := t.processing[file.URL]; ok {
|
||||
return fmt.Errorf("file %s is already being processed", file.URL)
|
||||
}
|
||||
t.processingMu.RUnlock()
|
||||
t.processingMu.Lock()
|
||||
t.processing[file.URL] = file
|
||||
t.processingMu.Unlock()
|
||||
defer func() {
|
||||
t.processingMu.Lock()
|
||||
delete(t.processing, file.URL)
|
||||
t.processingMu.Unlock()
|
||||
}()
|
||||
err := t.processLink(gctx, file)
|
||||
t.downloaded.Add(1)
|
||||
if errors.Is(err, context.Canceled) {
|
||||
logger.Debug("Link processing canceled")
|
||||
return err
|
||||
}
|
||||
if err != nil {
|
||||
logger.Errorf("Error processing link %s: %v", file.URL, err)
|
||||
return fmt.Errorf("failed to process link %s: %w", file.URL, err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
err = eg.Wait()
|
||||
if err != nil {
|
||||
logger.Errorf("Error during directlinks task execution: %v", err)
|
||||
} else {
|
||||
logger.Infof("Directlinks task %s completed successfully", t.ID)
|
||||
}
|
||||
if t.Progress != nil {
|
||||
t.Progress.OnDone(ctx, t, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (t *Task) processLink(ctx context.Context, file *File) error {
|
||||
logger := log.FromContext(ctx)
|
||||
err := retry.Retry(func() error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, file.URL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create GET request for %s: %w", file.URL, err)
|
||||
}
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to GET %s: %w", file.URL, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("GET %s returned status %d", file.URL, resp.StatusCode)
|
||||
}
|
||||
ctx = context.WithValue(ctx, ctxkey.ContentLength, file.Size)
|
||||
if t.stream {
|
||||
return t.Storage.Save(ctx, resp.Body, filepath.Join(t.StorPath, file.Name))
|
||||
}
|
||||
cacheFile, err := fsutil.CreateFile(filepath.Join(config.C().Temp.BasePath,
|
||||
fmt.Sprintf("direct_%s_%s", t.ID, file.Name)))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := cacheFile.CloseAndRemove(); err != nil {
|
||||
logger.Errorf("Failed to close and remove cache file: %v", err)
|
||||
}
|
||||
}()
|
||||
wr := ioutil.NewProgressWriter(cacheFile, func(n int) {
|
||||
t.downloadedBytes.Add(int64(n))
|
||||
if t.Progress != nil {
|
||||
t.Progress.OnProgress(ctx, t)
|
||||
}
|
||||
})
|
||||
|
||||
copyResultCh := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := io.Copy(wr, resp.Body)
|
||||
copyResultCh <- err
|
||||
}()
|
||||
select {
|
||||
case err := <-copyResultCh:
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to copy file %s to cache file: %w", file.URL, err)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
_, err = cacheFile.Seek(0, 0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to seek cache file for resource %s: %w", file.URL, err)
|
||||
}
|
||||
return t.Storage.Save(ctx, cacheFile, filepath.Join(t.StorPath, file.Name))
|
||||
}, retry.RetryTimes(uint(config.C().Retry)), retry.Context(ctx))
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
return err
|
||||
}
|
||||
205
core/tasks/directlinks/progress.go
Normal file
205
core/tasks/directlinks/progress.go
Normal file
@@ -0,0 +1,205 @@
|
||||
package directlinks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/duke-git/lancet/v2/slice"
|
||||
"github.com/gotd/td/telegram/message/entity"
|
||||
"github.com/gotd/td/telegram/message/styling"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/dlutil"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||
)
|
||||
|
||||
type TaskInfo interface {
|
||||
TotalBytes() int64
|
||||
TotalFiles() int
|
||||
TaskID() string
|
||||
StorageName() string
|
||||
StoragePath() string
|
||||
DownloadedBytes() int64
|
||||
Processing() []FileInfo
|
||||
}
|
||||
|
||||
type FileInfo interface {
|
||||
FileName() string
|
||||
FileSize() int64
|
||||
}
|
||||
|
||||
type ProgressTracker interface {
|
||||
OnStart(ctx context.Context, info TaskInfo)
|
||||
OnProgress(ctx context.Context, info TaskInfo)
|
||||
OnDone(ctx context.Context, info TaskInfo, err error)
|
||||
}
|
||||
|
||||
type Progress struct {
|
||||
msgID int
|
||||
chatID int64
|
||||
start time.Time
|
||||
lastUpdatePercent atomic.Int32
|
||||
}
|
||||
|
||||
// OnDone implements ProgressTracker.
|
||||
func (p *Progress) OnDone(ctx context.Context, info TaskInfo, err error) {
|
||||
logger := log.FromContext(ctx)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
logger.Infof("Parsed task %s was canceled", info.TaskID())
|
||||
ext := tgutil.ExtFromContext(ctx)
|
||||
if ext != nil {
|
||||
ext.EditMessage(p.chatID, &tg.MessagesEditMessageRequest{
|
||||
ID: p.msgID,
|
||||
Message: i18n.T(i18nk.BotMsgProgressTaskCanceledWithId, map[string]any{
|
||||
"TaskID": info.TaskID(),
|
||||
}),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
logger.Errorf("Parsed task %s failed: %s", info.TaskID(), err)
|
||||
ext := tgutil.ExtFromContext(ctx)
|
||||
if ext != nil {
|
||||
ext.EditMessage(p.chatID, &tg.MessagesEditMessageRequest{
|
||||
ID: p.msgID,
|
||||
Message: i18n.T(i18nk.BotMsgProgressTaskFailedWithError, map[string]any{
|
||||
"Error": err.Error(),
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
logger.Infof("Parsed task %s completed successfully", info.TaskID())
|
||||
|
||||
entityBuilder := entity.Builder{}
|
||||
if err := styling.Perform(&entityBuilder,
|
||||
styling.Plain(i18n.T(i18nk.BotMsgProgressDirectDonePrefix, nil)),
|
||||
styling.Code(fmt.Sprintf("%d", info.TotalFiles())),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgProgressSavePathPrefix, nil)),
|
||||
styling.Code(fmt.Sprintf("[%s]:%s", info.StorageName(), info.StoragePath())),
|
||||
); err != nil {
|
||||
logger.Errorf("Failed to build entities: %s", err)
|
||||
return
|
||||
}
|
||||
text, entities := entityBuilder.Complete()
|
||||
req := &tg.MessagesEditMessageRequest{
|
||||
ID: p.msgID,
|
||||
}
|
||||
req.SetMessage(text)
|
||||
req.SetEntities(entities)
|
||||
|
||||
ext := tgutil.ExtFromContext(ctx)
|
||||
if ext != nil {
|
||||
ext.EditMessage(p.chatID, req)
|
||||
}
|
||||
}
|
||||
|
||||
// OnProgress implements ProgressTracker.
|
||||
func (p *Progress) OnProgress(ctx context.Context, info TaskInfo) {
|
||||
if !shouldUpdateProgress(info.TotalBytes(), info.DownloadedBytes(), int(p.lastUpdatePercent.Load())) {
|
||||
return
|
||||
}
|
||||
percent := int((info.DownloadedBytes() * 100) / info.TotalBytes())
|
||||
if p.lastUpdatePercent.Load() == int32(percent) {
|
||||
return
|
||||
}
|
||||
p.lastUpdatePercent.Store(int32(percent))
|
||||
log.FromContext(ctx).Debugf("Progress update: %s, %d/%d", info.TaskID(), info.DownloadedBytes(), info.TotalBytes())
|
||||
entityBuilder := entity.Builder{}
|
||||
var entities []tg.MessageEntityClass
|
||||
if err := styling.Perform(&entityBuilder,
|
||||
styling.Plain(i18n.T(i18nk.BotMsgProgressDownloadingPrefix, nil)),
|
||||
styling.Code(fmt.Sprintf("%.2f MB (%d个文件)", float64(info.TotalBytes())/(1024*1024), info.TotalFiles())),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgProgressProcessingListPrefix, nil)),
|
||||
func() styling.StyledTextOption {
|
||||
var lines []string
|
||||
for _, elem := range info.Processing() {
|
||||
lines = append(lines, fmt.Sprintf(" - %s (%.2f MB)", elem.FileName(), float64(elem.FileSize())/(1024*1024)))
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
lines = append(lines, i18n.T(i18nk.BotMsgProgressProcessingNone, nil))
|
||||
}
|
||||
return styling.Plain(slice.Join(lines, "\n"))
|
||||
}(),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgProgressAvgSpeedPrefix, nil)),
|
||||
styling.Bold(fmt.Sprintf("%.2f MB/s", dlutil.GetSpeed(info.DownloadedBytes(), p.start)/(1024*1024))),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgProgressCurrentProgressPrefix, nil)),
|
||||
styling.Bold(fmt.Sprintf("%.2f%%", float64(info.DownloadedBytes())/float64(info.TotalBytes())*100)),
|
||||
); err != nil {
|
||||
log.FromContext(ctx).Errorf("Failed to build entities: %s", err)
|
||||
return
|
||||
}
|
||||
text, entities := entityBuilder.Complete()
|
||||
req := &tg.MessagesEditMessageRequest{
|
||||
ID: p.msgID,
|
||||
}
|
||||
req.SetMessage(text)
|
||||
req.SetEntities(entities)
|
||||
req.SetReplyMarkup(&tg.ReplyInlineMarkup{
|
||||
Rows: []tg.KeyboardButtonRow{
|
||||
{
|
||||
Buttons: []tg.KeyboardButtonClass{
|
||||
tgutil.BuildCancelButton(info.TaskID()),
|
||||
},
|
||||
},
|
||||
}},
|
||||
)
|
||||
ext := tgutil.ExtFromContext(ctx)
|
||||
if ext != nil {
|
||||
ext.EditMessage(p.chatID, req)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// OnStart implements ProgressTracker.
|
||||
func (p *Progress) OnStart(ctx context.Context, info TaskInfo) {
|
||||
logger := log.FromContext(ctx)
|
||||
p.start = time.Now()
|
||||
p.lastUpdatePercent.Store(0)
|
||||
logger.Infof("Direct links task started: message_id=%d, chat_id=%d", p.msgID, p.chatID)
|
||||
ext := tgutil.ExtFromContext(ctx)
|
||||
if ext == nil {
|
||||
return
|
||||
}
|
||||
entityBuilder := entity.Builder{}
|
||||
var entities []tg.MessageEntityClass
|
||||
if err := styling.Perform(&entityBuilder,
|
||||
styling.Plain(i18n.T(i18nk.BotMsgProgressDirectStart, map[string]any{
|
||||
"SizeMB": float64(info.TotalBytes()) / (1024 * 1024),
|
||||
"Count": info.TotalFiles(),
|
||||
}))); err != nil {
|
||||
log.FromContext(ctx).Errorf("Failed to build entities: %s", err)
|
||||
return
|
||||
}
|
||||
text, entities := entityBuilder.Complete()
|
||||
req := &tg.MessagesEditMessageRequest{
|
||||
ID: p.msgID,
|
||||
}
|
||||
req.SetMessage(text)
|
||||
req.SetEntities(entities)
|
||||
req.SetReplyMarkup(&tg.ReplyInlineMarkup{
|
||||
Rows: []tg.KeyboardButtonRow{
|
||||
{
|
||||
Buttons: []tg.KeyboardButtonClass{
|
||||
tgutil.BuildCancelButton(info.TaskID()),
|
||||
},
|
||||
},
|
||||
}},
|
||||
)
|
||||
ext.EditMessage(p.chatID, req)
|
||||
}
|
||||
|
||||
var _ ProgressTracker = (*Progress)(nil)
|
||||
|
||||
func NewProgress(msgID int, userID int64) ProgressTracker {
|
||||
return &Progress{
|
||||
msgID: msgID,
|
||||
chatID: userID,
|
||||
}
|
||||
}
|
||||
130
core/tasks/directlinks/task.go
Normal file
130
core/tasks/directlinks/task.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package directlinks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"github.com/krau/SaveAny-Bot/core"
|
||||
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
)
|
||||
|
||||
type File struct {
|
||||
Name string
|
||||
URL string
|
||||
Size int64
|
||||
}
|
||||
|
||||
func (f *File) FileName() string {
|
||||
return f.Name
|
||||
}
|
||||
|
||||
func (f *File) FileSize() int64 {
|
||||
return f.Size
|
||||
}
|
||||
|
||||
var _ core.Executable = (*Task)(nil)
|
||||
|
||||
type Task struct {
|
||||
ID string
|
||||
ctx context.Context
|
||||
files []*File
|
||||
Storage storage.Storage
|
||||
StorPath string
|
||||
Progress ProgressTracker
|
||||
|
||||
client *http.Client // [TODO] parallel download
|
||||
stream bool
|
||||
totalBytes int64 // total bytes to download
|
||||
downloadedBytes atomic.Int64 // downloaded bytes
|
||||
totalFiles int64 // total files to download
|
||||
downloaded atomic.Int64 // downloaded files count
|
||||
processing map[string]*File // {"url": File}
|
||||
processingMu sync.RWMutex
|
||||
failed map[string]error // [TODO] errors for each file
|
||||
}
|
||||
|
||||
// Title implements core.Exectable.
|
||||
func (t *Task) Title() string {
|
||||
return fmt.Sprintf("[%s](%s...->%s:%s)", t.Type(), t.files[0].Name, t.Storage.Name(), t.StorPath)
|
||||
}
|
||||
|
||||
// DownloadedBytes implements TaskInfo.
|
||||
func (t *Task) DownloadedBytes() int64 {
|
||||
return t.downloadedBytes.Load()
|
||||
}
|
||||
|
||||
// Processing implements TaskInfo.
|
||||
func (t *Task) Processing() []FileInfo {
|
||||
t.processingMu.RLock()
|
||||
defer t.processingMu.RUnlock()
|
||||
infos := make([]FileInfo, 0, len(t.processing))
|
||||
for _, f := range t.processing {
|
||||
infos = append(infos, f)
|
||||
}
|
||||
return infos
|
||||
}
|
||||
|
||||
// StorageName implements TaskInfo.
|
||||
func (t *Task) StorageName() string {
|
||||
return t.Storage.Name()
|
||||
}
|
||||
|
||||
// StoragePath implements TaskInfo.
|
||||
func (t *Task) StoragePath() string {
|
||||
return t.StorPath
|
||||
}
|
||||
|
||||
// TotalBytes implements TaskInfo.
|
||||
func (t *Task) TotalBytes() int64 {
|
||||
return t.totalBytes
|
||||
}
|
||||
|
||||
// TotalFiles implements TaskInfo.
|
||||
func (t *Task) TotalFiles() int {
|
||||
return int(t.totalFiles)
|
||||
}
|
||||
|
||||
func (t *Task) Type() tasktype.TaskType {
|
||||
return tasktype.TaskTypeDirectlinks
|
||||
}
|
||||
|
||||
func (t *Task) TaskID() string {
|
||||
return t.ID
|
||||
}
|
||||
|
||||
func NewTask(
|
||||
id string,
|
||||
ctx context.Context,
|
||||
links []string,
|
||||
stor storage.Storage,
|
||||
storPath string,
|
||||
progressTracker ProgressTracker,
|
||||
) *Task {
|
||||
_, ok := stor.(storage.StorageCannotStream)
|
||||
stream := config.C().Stream && !ok
|
||||
files := make([]*File, 0, len(links))
|
||||
for _, link := range links {
|
||||
files = append(files, &File{
|
||||
URL: link,
|
||||
})
|
||||
}
|
||||
return &Task{
|
||||
ID: id,
|
||||
ctx: ctx,
|
||||
files: files,
|
||||
Storage: stor,
|
||||
StorPath: storPath,
|
||||
Progress: progressTracker,
|
||||
stream: stream,
|
||||
client: http.DefaultClient,
|
||||
processing: make(map[string]*File),
|
||||
processingMu: sync.RWMutex{},
|
||||
failed: make(map[string]error),
|
||||
totalFiles: int64(len(files)),
|
||||
}
|
||||
}
|
||||
205
core/tasks/directlinks/util.go
Normal file
205
core/tasks/directlinks/util.go
Normal file
@@ -0,0 +1,205 @@
|
||||
package directlinks
|
||||
|
||||
import (
|
||||
"mime"
|
||||
"net/url"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/text/encoding/simplifiedchinese"
|
||||
)
|
||||
|
||||
// parseFilename extracts filename from Content-Disposition header
|
||||
// It handles multiple encoding scenarios:
|
||||
// 1. RFC 5987/RFC 2231 format: filename*=UTF-8”%E6%B5%8B%E8%AF%95.zip (preferred, checked first)
|
||||
// 2. MIME encoded-word: filename="=?UTF-8?B?5rWL6K+VLnppcA==?="
|
||||
// 3. URL-encoded: filename="%E6%B5%8B%E8%AF%95.zip"
|
||||
// 4. Plain ASCII filename
|
||||
//
|
||||
// The key fix is checking filename*= first before mime.ParseMediaType, because
|
||||
// some servers send Content-Disposition headers with invalid characters that cause
|
||||
// mime.ParseMediaType to fail, but the filename*= parameter is still valid.
|
||||
func parseFilename(contentDisposition string) string {
|
||||
// First, try to find filename*= (RFC 5987 format, most reliable for non-ASCII)
|
||||
if filename := parseFilenameExtended(contentDisposition); filename != "" {
|
||||
return filename
|
||||
}
|
||||
|
||||
// Try standard MIME parsing for regular filename= parameter
|
||||
_, params, err := mime.ParseMediaType(contentDisposition)
|
||||
if err == nil {
|
||||
if filename := params["filename"]; filename != "" {
|
||||
return decodeFilenameParam(filename)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: manual parsing if mime.ParseMediaType fails
|
||||
return parseFilenameFallback(contentDisposition)
|
||||
}
|
||||
|
||||
// parseFilenameExtended parses RFC 5987/RFC 2231 extended parameter format
|
||||
// Format: filename*=charset'language'value (e.g., UTF-8”%E6%B5%8B%E8%AF%95.zip)
|
||||
func parseFilenameExtended(cd string) string {
|
||||
// Look for filename*= (case-insensitive)
|
||||
lower := strings.ToLower(cd)
|
||||
idx := strings.Index(lower, "filename*=")
|
||||
if idx == -1 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Extract the value after filename*=
|
||||
value := cd[idx+len("filename*="):]
|
||||
|
||||
// Find the end of the value (next ; or end of string)
|
||||
if endIdx := strings.Index(value, ";"); endIdx != -1 {
|
||||
value = value[:endIdx]
|
||||
}
|
||||
value = strings.TrimSpace(value)
|
||||
|
||||
// Parse charset'language'encoded-value format
|
||||
// Common format: UTF-8''%E6%B5%8B%E8%AF%95.zip
|
||||
parts := strings.SplitN(value, "''", 2)
|
||||
if len(parts) == 2 {
|
||||
// parts[0] is charset (e.g., "UTF-8")
|
||||
// parts[1] is percent-encoded value
|
||||
decoded, err := url.QueryUnescape(parts[1])
|
||||
if err == nil {
|
||||
return decoded
|
||||
}
|
||||
}
|
||||
|
||||
// Try with single quote delimiter as well (some servers use this)
|
||||
parts = strings.SplitN(value, "'", 3)
|
||||
if len(parts) >= 3 {
|
||||
decoded, err := url.QueryUnescape(parts[2])
|
||||
if err == nil {
|
||||
return decoded
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// TryUrlQueryUnescape tries to unescape a URL-encoded string.
|
||||
//
|
||||
// If unescaping fails, it returns the original string.
|
||||
func tryUrlQueryUnescape(s string) string {
|
||||
if decoded, err := url.QueryUnescape(s); err == nil {
|
||||
return decoded
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// decodeFilenameParam decodes a filename parameter value
|
||||
// Handles MIME encoded-word, URL encoding, and GBK encoding fallback
|
||||
func decodeFilenameParam(filename string) string {
|
||||
// Check if the filename is MIME encoded-word (e.g., =?UTF-8?B?...?=)
|
||||
if strings.HasPrefix(filename, "=?") {
|
||||
decoder := new(mime.WordDecoder)
|
||||
// Some servers use "UTF8" instead of "UTF-8", create a normalized copy
|
||||
normalizedFilename := strings.Replace(filename, "UTF8", "UTF-8", 1)
|
||||
if decoded, err := decoder.Decode(normalizedFilename); err == nil {
|
||||
return decoded
|
||||
}
|
||||
}
|
||||
|
||||
// Try URL decoding
|
||||
decoded := tryUrlQueryUnescape(filename)
|
||||
|
||||
// Check if the result is valid UTF-8. If not, try GBK decoding.
|
||||
// This handles the case where Chinese Windows servers send GBK-encoded filenames
|
||||
// which appear as garbled characters (e.g., "下载地址.zip" -> "<22><><EFBFBD>ص<EFBFBD>ַ.zip")
|
||||
if !utf8.ValidString(decoded) {
|
||||
if gbkDecoded := tryDecodeGBK(decoded); gbkDecoded != "" {
|
||||
return gbkDecoded
|
||||
}
|
||||
}
|
||||
|
||||
return decoded
|
||||
}
|
||||
|
||||
// gbkDecoder is a reusable GBK decoder for better performance
|
||||
var gbkDecoder = simplifiedchinese.GBK.NewDecoder()
|
||||
|
||||
// tryDecodeGBK attempts to decode a string as GBK/GB2312/GB18030 encoding
|
||||
// Returns empty string if decoding fails or result is not valid UTF-8
|
||||
func tryDecodeGBK(s string) string {
|
||||
// GBK uses 1-2 bytes per character. Single-byte chars are 0x00-0x7F (ASCII compatible).
|
||||
// Double-byte chars have first byte 0x81-0xFE and second byte 0x40-0xFE.
|
||||
// Skip if string is empty or all ASCII (valid UTF-8)
|
||||
if len(s) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Create a fresh decoder since the transform state may be corrupted
|
||||
decoder := gbkDecoder
|
||||
decoded, err := decoder.Bytes([]byte(s))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
result := string(decoded)
|
||||
if utf8.ValidString(result) {
|
||||
return result
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// parseFilenameFallback manually parses filename= when mime.ParseMediaType fails
|
||||
func parseFilenameFallback(cd string) string {
|
||||
// Look for filename= (case-insensitive)
|
||||
lower := strings.ToLower(cd)
|
||||
idx := strings.Index(lower, "filename=")
|
||||
if idx == -1 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Skip "filename=" prefix
|
||||
value := cd[idx+len("filename="):]
|
||||
|
||||
// Find the end of the value
|
||||
if endIdx := strings.Index(value, ";"); endIdx != -1 {
|
||||
value = value[:endIdx]
|
||||
}
|
||||
value = strings.TrimSpace(value)
|
||||
|
||||
// Remove quotes if present
|
||||
if len(value) >= 2 {
|
||||
if (value[0] == '"' && value[len(value)-1] == '"') ||
|
||||
(value[0] == '\'' && value[len(value)-1] == '\'') {
|
||||
value = value[1 : len(value)-1]
|
||||
}
|
||||
}
|
||||
|
||||
return decodeFilenameParam(value)
|
||||
}
|
||||
|
||||
var progressUpdatesLevels = []struct {
|
||||
size int64 // 文件大小阈值
|
||||
stepPercent int // 每多少 % 更新一次
|
||||
}{
|
||||
{10 << 20, 100},
|
||||
{50 << 20, 50},
|
||||
{200 << 20, 20},
|
||||
{500 << 20, 10},
|
||||
}
|
||||
|
||||
func shouldUpdateProgress(total, downloaded int64, lastUpdatePercent int) bool {
|
||||
if total <= 0 || downloaded <= 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
percent := int((downloaded * 100) / total)
|
||||
if percent <= lastUpdatePercent {
|
||||
return false
|
||||
}
|
||||
|
||||
step := progressUpdatesLevels[len(progressUpdatesLevels)-1].stepPercent
|
||||
for _, lvl := range progressUpdatesLevels {
|
||||
if total < lvl.size {
|
||||
step = lvl.stepPercent
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return percent >= lastUpdatePercent+step
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"github.com/gotd/td/telegram/message/entity"
|
||||
"github.com/gotd/td/telegram/message/styling"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/dlutil"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||
)
|
||||
@@ -68,7 +70,9 @@ func (p *Progress) OnStart(ctx context.Context, info TaskInfo) {
|
||||
entityBuilder := entity.Builder{}
|
||||
var entities []tg.MessageEntityClass
|
||||
if err := styling.Perform(&entityBuilder,
|
||||
styling.Plain(fmt.Sprintf("开始下载 %s 的资源\n总大小: ", info.Site())),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgProgressParsedStartPrefix, map[string]any{
|
||||
"Site": info.Site(),
|
||||
})),
|
||||
styling.Code(fmt.Sprintf("%.2f MB (%d个资源)", float64(info.TotalBytes())/(1024*1024), info.TotalResources())),
|
||||
); err != nil {
|
||||
log.FromContext(ctx).Errorf("Failed to build entities: %s", err)
|
||||
@@ -109,22 +113,22 @@ func (p *Progress) OnProgress(ctx context.Context, info TaskInfo) {
|
||||
entityBuilder := entity.Builder{}
|
||||
var entities []tg.MessageEntityClass
|
||||
if err := styling.Perform(&entityBuilder,
|
||||
styling.Plain("正在下载\n总大小: "),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgProgressDownloadingPrefix, nil)),
|
||||
styling.Code(fmt.Sprintf("%.2f MB (%d个文件)", float64(info.TotalBytes())/(1024*1024), info.TotalResources())),
|
||||
styling.Plain("\n正在处理:\n"),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgProgressProcessingListPrefix, nil)),
|
||||
func() styling.StyledTextOption {
|
||||
var lines []string
|
||||
for _, elem := range info.Processing() {
|
||||
lines = append(lines, fmt.Sprintf(" - %s (%.2f MB)", elem.FileName(), float64(elem.FileSize())/(1024*1024)))
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
lines = append(lines, " - 无")
|
||||
lines = append(lines, i18n.T(i18nk.BotMsgProgressProcessingNone, nil))
|
||||
}
|
||||
return styling.Plain(slice.Join(lines, "\n"))
|
||||
}(),
|
||||
styling.Plain("\n平均速度: "),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgProgressAvgSpeedPrefix, nil)),
|
||||
styling.Bold(fmt.Sprintf("%.2f MB/s", dlutil.GetSpeed(info.DownloadedBytes(), p.start)/(1024*1024))),
|
||||
styling.Plain("\n当前进度: "),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgProgressCurrentProgressPrefix, nil)),
|
||||
styling.Bold(fmt.Sprintf("%.2f%%", float64(info.DownloadedBytes())/float64(info.TotalBytes())*100)),
|
||||
); err != nil {
|
||||
log.FromContext(ctx).Errorf("Failed to build entities: %s", err)
|
||||
@@ -160,8 +164,10 @@ func (p *Progress) OnDone(ctx context.Context, info TaskInfo, err error) {
|
||||
ext := tgutil.ExtFromContext(ctx)
|
||||
if ext != nil {
|
||||
ext.EditMessage(p.ChatID, &tg.MessagesEditMessageRequest{
|
||||
ID: p.MessageID,
|
||||
Message: fmt.Sprintf("处理已取消: %s", info.TaskID()),
|
||||
ID: p.MessageID,
|
||||
Message: i18n.T(i18nk.BotMsgProgressTaskCanceledWithId, map[string]any{
|
||||
"TaskID": info.TaskID(),
|
||||
}),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
@@ -169,8 +175,10 @@ func (p *Progress) OnDone(ctx context.Context, info TaskInfo, err error) {
|
||||
ext := tgutil.ExtFromContext(ctx)
|
||||
if ext != nil {
|
||||
ext.EditMessage(p.ChatID, &tg.MessagesEditMessageRequest{
|
||||
ID: p.MessageID,
|
||||
Message: fmt.Sprintf("处理失败: %s", err.Error()),
|
||||
ID: p.MessageID,
|
||||
Message: i18n.T(i18nk.BotMsgProgressTaskFailedWithError, map[string]any{
|
||||
"Error": err.Error(),
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -180,9 +188,9 @@ func (p *Progress) OnDone(ctx context.Context, info TaskInfo, err error) {
|
||||
|
||||
entityBuilder := entity.Builder{}
|
||||
if err := styling.Perform(&entityBuilder,
|
||||
styling.Plain("处理完成, 资源数量: "),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgProgressParsedDonePrefix, nil)),
|
||||
styling.Code(fmt.Sprintf("%d", info.TotalResources())),
|
||||
styling.Plain("\n保存路径: "),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgProgressSavePathPrefix, nil)),
|
||||
styling.Code(fmt.Sprintf("[%s]:%s", info.StorageName(), info.StoragePath())),
|
||||
); err != nil {
|
||||
logger.Errorf("Failed to build entities: %s", err)
|
||||
|
||||
@@ -2,24 +2,28 @@ package parsed
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/krau/SaveAny-Bot/common/utils/netutil"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"github.com/krau/SaveAny-Bot/core"
|
||||
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
|
||||
"github.com/krau/SaveAny-Bot/pkg/parser"
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
)
|
||||
|
||||
var _ core.Executable = (*Task)(nil)
|
||||
|
||||
type Task struct {
|
||||
ID string
|
||||
Ctx context.Context
|
||||
Stor storage.Storage
|
||||
StorPath string
|
||||
item *parser.Item
|
||||
httpClient *http.Client
|
||||
httpClient *http.Client // [TODO] btorrent support?
|
||||
progress ProgressTracker
|
||||
stream bool
|
||||
|
||||
@@ -32,6 +36,11 @@ type Task struct {
|
||||
failed map[string]error // [TODO] errors for each resource
|
||||
}
|
||||
|
||||
// Title implements core.Exectable.
|
||||
func (t *Task) Title() string {
|
||||
return fmt.Sprintf("[%s](%s->%s:%s)", t.Type(), t.item.Title, t.Stor.Name(), t.StorPath)
|
||||
}
|
||||
|
||||
func (t *Task) Type() tasktype.TaskType {
|
||||
return tasktype.TaskTypeParseditem
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"github.com/duke-git/lancet/v2/retry"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/fsutil"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"go.uber.org/multierr"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
@@ -48,13 +47,10 @@ func (t *Task) processPic(ctx context.Context, picUrl string, index int) error {
|
||||
retry.Context(ctx),
|
||||
retry.RetryTimes(uint(config.C().Retry)),
|
||||
}
|
||||
var lastErr error
|
||||
err := retry.Retry(func() error {
|
||||
var body io.ReadCloser
|
||||
body, lastErr = t.client.Download(ctx, picUrl)
|
||||
if lastErr != nil {
|
||||
lastErr = fmt.Errorf("failed to download picture %s: %w", picUrl, lastErr)
|
||||
return lastErr
|
||||
body, err := t.client.Download(ctx, picUrl)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download picture %s: %w", picUrl, err)
|
||||
}
|
||||
defer body.Close()
|
||||
filename := fmt.Sprintf("%d%s", index+1, path.Ext(picUrl))
|
||||
@@ -63,8 +59,7 @@ func (t *Task) processPic(ctx context.Context, picUrl string, index int) error {
|
||||
fmt.Sprintf("tph_%s_%s", t.TaskID(), filename),
|
||||
))
|
||||
if err != nil {
|
||||
lastErr = fmt.Errorf("failed to create cache file for picture %s: %w", filename, err)
|
||||
return lastErr
|
||||
return fmt.Errorf("failed to create cache file for picture %s: %w", filename, err)
|
||||
}
|
||||
defer func() {
|
||||
if err := cacheFile.CloseAndRemove(); err != nil {
|
||||
@@ -72,26 +67,26 @@ func (t *Task) processPic(ctx context.Context, picUrl string, index int) error {
|
||||
logger.Errorf("Failed to close and remove cache file for picture %s: %v", filename, err)
|
||||
}
|
||||
}()
|
||||
_, lastErr = io.Copy(cacheFile, body)
|
||||
if lastErr != nil {
|
||||
lastErr = fmt.Errorf("failed to copy picture %s to cache file: %w", filename, lastErr)
|
||||
return lastErr
|
||||
_, err = io.Copy(cacheFile, body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to copy picture %s to cache file: %w", filename, err)
|
||||
}
|
||||
_, err = cacheFile.Seek(0, 0)
|
||||
if err != nil {
|
||||
lastErr = fmt.Errorf("failed to seek cache file for picture %s: %w", filename, err)
|
||||
return lastErr
|
||||
return fmt.Errorf("failed to seek cache file for picture %s: %w", filename, err)
|
||||
}
|
||||
err = t.Stor.Save(ctx, cacheFile, path.Join(t.StorPath, filename))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to save picture %s: %w", filename, err)
|
||||
}
|
||||
lastErr = t.Stor.Save(ctx, cacheFile, path.Join(t.StorPath, filename))
|
||||
} else {
|
||||
lastErr = t.Stor.Save(ctx, body, path.Join(t.StorPath, filename))
|
||||
err = t.Stor.Save(ctx, body, path.Join(t.StorPath, filename))
|
||||
}
|
||||
|
||||
if lastErr != nil {
|
||||
lastErr = fmt.Errorf("failed to save picture %s: %w", filename, lastErr)
|
||||
return lastErr
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to save picture %s: %w", filename, err)
|
||||
}
|
||||
return nil
|
||||
}, retryOpts...)
|
||||
return multierr.Combine(err, lastErr)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"github.com/gotd/td/telegram/message/entity"
|
||||
"github.com/gotd/td/telegram/message/styling"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||
)
|
||||
|
||||
@@ -29,7 +31,7 @@ func (p *Progress) OnStart(ctx context.Context, info TaskInfo) {
|
||||
entityBuilder := entity.Builder{}
|
||||
var entities []tg.MessageEntityClass
|
||||
if err := styling.Perform(&entityBuilder,
|
||||
styling.Plain("开始下载Telegraph\n图片数量: "),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgProgressTelegraphStartPrefix, nil)),
|
||||
styling.Code(fmt.Sprintf("%d", info.TotalPics())),
|
||||
); err != nil {
|
||||
log.FromContext(ctx).Errorf("Failed to build entities: %s", err)
|
||||
@@ -65,7 +67,7 @@ func (p *Progress) OnProgress(ctx context.Context, info TaskInfo) {
|
||||
entityBuilder := entity.Builder{}
|
||||
var entities []tg.MessageEntityClass
|
||||
if err := styling.Perform(&entityBuilder,
|
||||
styling.Plain("正在下载\n当前进度: "),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgProgressTelegraphProgressPrefix, nil)),
|
||||
styling.Code(fmt.Sprintf("%d/%d", info.Downloaded(), info.TotalPics())),
|
||||
); err != nil {
|
||||
log.FromContext(ctx).Errorf("Failed to build entities: %s", err)
|
||||
@@ -101,8 +103,10 @@ func (p *Progress) OnDone(ctx context.Context, info TaskInfo, err error) {
|
||||
ext := tgutil.ExtFromContext(ctx)
|
||||
if ext != nil {
|
||||
ext.EditMessage(p.ChatID, &tg.MessagesEditMessageRequest{
|
||||
ID: p.MessageID,
|
||||
Message: fmt.Sprintf("处理已取消: %s", info.TaskID()),
|
||||
ID: p.MessageID,
|
||||
Message: i18n.T(i18nk.BotMsgProgressTaskCanceledWithId, map[string]any{
|
||||
"TaskID": info.TaskID(),
|
||||
}),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
@@ -110,8 +114,10 @@ func (p *Progress) OnDone(ctx context.Context, info TaskInfo, err error) {
|
||||
ext := tgutil.ExtFromContext(ctx)
|
||||
if ext != nil {
|
||||
ext.EditMessage(p.ChatID, &tg.MessagesEditMessageRequest{
|
||||
ID: p.MessageID,
|
||||
Message: fmt.Sprintf("处理失败: %s", err.Error()),
|
||||
ID: p.MessageID,
|
||||
Message: i18n.T(i18nk.BotMsgProgressTaskFailedWithError, map[string]any{
|
||||
"Error": err.Error(),
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -121,9 +127,9 @@ func (p *Progress) OnDone(ctx context.Context, info TaskInfo, err error) {
|
||||
|
||||
entityBuilder := entity.Builder{}
|
||||
if err := styling.Perform(&entityBuilder,
|
||||
styling.Plain("处理完成\n图片数量: "),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgProgressTelegraphDonePrefix, nil)),
|
||||
styling.Code(fmt.Sprintf("%d", info.TotalPics())),
|
||||
styling.Plain("\n保存路径: "),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgProgressSavePathPrefix, nil)),
|
||||
styling.Code(fmt.Sprintf("[%s]:%s", info.StorageName(), info.StoragePath())),
|
||||
); err != nil {
|
||||
logger.Errorf("Failed to build entities: %s", err)
|
||||
|
||||
@@ -2,13 +2,17 @@ package telegraph
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/krau/SaveAny-Bot/core"
|
||||
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
|
||||
"github.com/krau/SaveAny-Bot/pkg/telegraph"
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
)
|
||||
|
||||
var _ core.Executable = (*Task)(nil)
|
||||
|
||||
type Task struct {
|
||||
ID string
|
||||
Ctx context.Context
|
||||
@@ -24,6 +28,11 @@ type Task struct {
|
||||
downloaded atomic.Int64
|
||||
}
|
||||
|
||||
// Title implements core.Exectable.
|
||||
func (t *Task) Title() string {
|
||||
return fmt.Sprintf("[%s](%s->%s:%s)", t.Type(), t.PhPath, t.Stor.Name(), t.StorPath)
|
||||
}
|
||||
|
||||
func (t *Task) Type() tasktype.TaskType {
|
||||
return tasktype.TaskTypeTphpics
|
||||
}
|
||||
|
||||
@@ -5,13 +5,13 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
"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/config"
|
||||
"github.com/krau/SaveAny-Bot/pkg/enums/ctxkey"
|
||||
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||
)
|
||||
|
||||
func (t *Task) Execute(ctx context.Context) error {
|
||||
@@ -40,7 +40,7 @@ func (t *Task) Execute(ctx context.Context) error {
|
||||
t.Progress.OnDone(ctx, t, err)
|
||||
}
|
||||
}()
|
||||
_, err = tfile.NewDownloader(t.File).Parallel(ctx, wrAt)
|
||||
_, err = tdler.NewDownloader(t.File).Parallel(ctx, wrAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download file: %w", err)
|
||||
}
|
||||
@@ -57,30 +57,19 @@ func (t *Task) Execute(ctx context.Context) error {
|
||||
return fmt.Errorf("failed to get file stat: %w", err)
|
||||
}
|
||||
vctx := context.WithValue(ctx, ctxkey.ContentLength, fileStat.Size())
|
||||
for i := range config.C().Retry + 1 {
|
||||
if err = vctx.Err(); err != nil {
|
||||
return fmt.Errorf("context canceled while saving file: %w", err)
|
||||
}
|
||||
var file *os.File
|
||||
file, err = os.Open(t.localPath)
|
||||
err = retry.Retry(func() error {
|
||||
file, err := os.Open(t.localPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open cache file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
if err = t.Storage.Save(vctx, file, t.Path); err != nil {
|
||||
if i == config.C().Retry {
|
||||
return fmt.Errorf("failed to save file: %w", err)
|
||||
}
|
||||
logger.Errorf("Failed to save file: %s, retrying...", err)
|
||||
select {
|
||||
case <-vctx.Done():
|
||||
return fmt.Errorf("context canceled during retry delay: %w", vctx.Err())
|
||||
case <-time.After(time.Duration(i*500) * time.Millisecond):
|
||||
}
|
||||
continue
|
||||
return fmt.Errorf("failed to save file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}, retry.RetryTimes(uint(config.C().Retry)), retry.Context(vctx))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to save file after retries: %w", err)
|
||||
}
|
||||
return fmt.Errorf("failed to save file after retries")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"github.com/gotd/td/telegram/message/entity"
|
||||
"github.com/gotd/td/telegram/message/styling"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n"
|
||||
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/dlutil"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||
)
|
||||
@@ -35,11 +37,11 @@ func (p *Progress) OnStart(ctx context.Context, info TaskInfo) {
|
||||
entityBuilder := entity.Builder{}
|
||||
var entities []tg.MessageEntityClass
|
||||
if err := styling.Perform(&entityBuilder,
|
||||
styling.Plain("开始下载\n文件名: "),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgProgressFileStartPrefix, nil)),
|
||||
styling.Code(info.FileName()),
|
||||
styling.Plain("\n保存路径: "),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgProgressSavePathPrefix, nil)),
|
||||
styling.Code(fmt.Sprintf("[%s]:%s", info.StorageName(), info.StoragePath())),
|
||||
styling.Plain("\n文件大小: "),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgProgressFileSizePrefix, nil)),
|
||||
styling.Code(fmt.Sprintf("%.2f MB", float64(info.FileSize())/(1024*1024))),
|
||||
); err != nil {
|
||||
log.FromContext(ctx).Errorf("Failed to build entities: %s", err)
|
||||
@@ -80,15 +82,15 @@ func (p *Progress) OnProgress(ctx context.Context, info TaskInfo, downloaded, to
|
||||
entityBuilder := entity.Builder{}
|
||||
var entities []tg.MessageEntityClass
|
||||
if err := styling.Perform(&entityBuilder,
|
||||
styling.Plain("正在处理下载任务\n文件名: "),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgProgressFileProcessingPrefix, nil)),
|
||||
styling.Code(info.FileName()),
|
||||
styling.Plain("\n保存路径: "),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgProgressSavePathPrefix, nil)),
|
||||
styling.Code(fmt.Sprintf("[%s]:%s", info.StorageName(), info.StoragePath())),
|
||||
styling.Plain("\n文件大小: "),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgProgressFileSizePrefix, nil)),
|
||||
styling.Code(fmt.Sprintf("%.2f MB", float64(total)/(1024*1024))),
|
||||
styling.Plain("\n平均速度: "),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgProgressAvgSpeedPrefix, nil)),
|
||||
styling.Bold(fmt.Sprintf("%.2f MB/s", dlutil.GetSpeed(downloaded, p.start)/(1024*1024))),
|
||||
styling.Plain("\n当前进度: "),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgProgressCurrentProgressPrefix, nil)),
|
||||
styling.Bold(fmt.Sprintf("%.2f%%", float64(downloaded)/float64(total)*100)),
|
||||
); err != nil {
|
||||
log.FromContext(ctx).Errorf("Failed to build entities: %s", err)
|
||||
@@ -130,22 +132,24 @@ func (p *Progress) OnDone(ctx context.Context, info TaskInfo, err error) {
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
stylingErr = styling.Perform(&entityBuilder,
|
||||
styling.Plain("任务已取消\n文件名: "),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgProgressTaskCanceled, nil)),
|
||||
styling.Plain("\n"),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgProgressFileNamePrefix, nil)),
|
||||
styling.Code(info.FileName()),
|
||||
)
|
||||
} else {
|
||||
stylingErr = styling.Perform(&entityBuilder,
|
||||
styling.Plain("下载失败\n文件名: "),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgProgressDownloadFailedPrefix, nil)),
|
||||
styling.Code(info.FileName()),
|
||||
styling.Plain("\n错误: "),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgProgressErrorPrefix, nil)),
|
||||
styling.Bold(err.Error()),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
stylingErr = styling.Perform(&entityBuilder,
|
||||
styling.Plain("下载完成\n文件名: "),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgProgressDownloadDonePrefix, nil)),
|
||||
styling.Code(info.FileName()),
|
||||
styling.Plain("\n保存路径: "),
|
||||
styling.Plain(i18n.T(i18nk.BotMsgProgressSavePathPrefix, nil)),
|
||||
styling.Code(fmt.Sprintf("[%s]:%s", info.StorageName(), info.StoragePath())),
|
||||
)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user