refactor: refactor task logic for better scalability (#76)
* refactor: a big refactor. wip * refactor: port handle file * refactor: place all handlers * fix: task info nil pointer * feat: enhance task progress tracking and context management * feat: cancel task * feat: stream mode * feat: silent mode * feat: dir cmd * refactor: remove unused old file * feat: rule cmd * feat: handle silent mode * feat: batch task * fix: batch task progress and temp file cleanup * refactor: update file creation and cleanup methods for better resource management * feat: add save command with silent mode handling * feat: message link * feat: update message prompts to include file count in storage selection * feat: slient save links * refactor: reduce dup code * feat: rule type * feat: chose dir * feat: refactor file handling and storage rules, improve error handling and logging * feat: rule mode * feat: telegraph pics * fix: tphpics nil pointer and inaccurate dirpath * feat: silent save telegraph * feat: add suffix to avoid file overwrite * feat: new storage telegram * chore: tidy go mod
This commit is contained in:
@@ -1,38 +0,0 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/eko/gocache/lib/v4/cache"
|
||||
gocachestore "github.com/eko/gocache/store/go_cache/v4"
|
||||
gocache "github.com/patrickmn/go-cache"
|
||||
)
|
||||
|
||||
var Cache *cache.Cache[any]
|
||||
|
||||
func initCache() {
|
||||
gocacheClient := gocache.New(time.Hour*1, time.Minute*10)
|
||||
gocacheStore := gocachestore.NewGoCache(gocacheClient)
|
||||
cacheManager := cache.New[any](gocacheStore)
|
||||
Cache = cacheManager
|
||||
}
|
||||
|
||||
func CacheGet[T any](ctx context.Context, key string) (T, error) {
|
||||
data, err := Cache.Get(ctx, key)
|
||||
if err != nil {
|
||||
return *new(T), err
|
||||
}
|
||||
if v, ok := data.(T); ok {
|
||||
return v, nil
|
||||
}
|
||||
return *new(T), nil
|
||||
}
|
||||
|
||||
func CacheSet(ctx context.Context, key string, value any) error {
|
||||
return Cache.Set(ctx, key, value)
|
||||
}
|
||||
|
||||
func CacheDelete(ctx context.Context, key string) error {
|
||||
return Cache.Delete(ctx, key)
|
||||
}
|
||||
50
common/cache/ristretto.go
vendored
Normal file
50
common/cache/ristretto.go
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/dgraph-io/ristretto/v2"
|
||||
)
|
||||
|
||||
var cache *ristretto.Cache[string, any]
|
||||
|
||||
|
||||
// TODO: maybe we should use simple ttl cache instead of ristretto...
|
||||
func init() {
|
||||
c, err := ristretto.NewCache(&ristretto.Config[string, any]{
|
||||
NumCounters: 1e5,
|
||||
MaxCost: 1e6, // 1000000 / 112 ≈ 8928
|
||||
BufferItems: 64,
|
||||
OnReject: func(item *ristretto.Item[any]) {
|
||||
log.Warnf("Cache item rejected: key=%d, value=%v", item.Key, item.Value)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("failed to create ristretto cache: %v", err)
|
||||
}
|
||||
cache = c
|
||||
}
|
||||
|
||||
func Set(key string, value any) error {
|
||||
ok := cache.Set(key, value, 0)
|
||||
if !ok {
|
||||
return fmt.Errorf("failed to set value in cache")
|
||||
}
|
||||
cache.Wait()
|
||||
return nil
|
||||
}
|
||||
|
||||
func Get[T any](key string) (T, bool) {
|
||||
v, ok := cache.Get(key)
|
||||
if !ok {
|
||||
var zero T
|
||||
return zero, false
|
||||
}
|
||||
vT, ok := v.(T)
|
||||
if !ok {
|
||||
var zero T
|
||||
return zero, false
|
||||
}
|
||||
return vT, true
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
package common
|
||||
|
||||
func Init() {
|
||||
initCache()
|
||||
}
|
||||
108
common/i18n/i18n.go
Normal file
108
common/i18n/i18n.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"embed"
|
||||
|
||||
"maps"
|
||||
|
||||
"github.com/nicksnyder/go-i18n/v2/i18n"
|
||||
"github.com/pelletier/go-toml/v2"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
//go:embed locale/*.toml
|
||||
var localesFS embed.FS
|
||||
|
||||
var (
|
||||
bundle *i18n.Bundle
|
||||
localizer *i18n.Localizer
|
||||
)
|
||||
|
||||
func Init(lang string) {
|
||||
bundle = i18n.NewBundle(language.SimplifiedChinese)
|
||||
bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal)
|
||||
files, err := localesFS.ReadDir("locale")
|
||||
if err != nil {
|
||||
panic("failed to read locale directory: " + err.Error())
|
||||
}
|
||||
for _, file := range files {
|
||||
if _, err := bundle.LoadMessageFileFS(localesFS, "locale/"+file.Name()); err != nil {
|
||||
panic("failed to load message file: " + err.Error())
|
||||
}
|
||||
}
|
||||
if lang == "" {
|
||||
lang = "zh-Hans"
|
||||
}
|
||||
localizer = i18n.NewLocalizer(bundle, lang)
|
||||
if localizer == nil {
|
||||
panic("failed to create localizer, check your config for valid language setting")
|
||||
}
|
||||
}
|
||||
|
||||
func T(key string, 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: key,
|
||||
TemplateData: templateDataMap,
|
||||
})
|
||||
if err != nil {
|
||||
return 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, key string, templateData ...map[string]any) string {
|
||||
bundle := i18n.NewBundle(language.SimplifiedChinese)
|
||||
bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal)
|
||||
files, err := localesFS.ReadDir("locale")
|
||||
if err != nil {
|
||||
return key
|
||||
}
|
||||
for _, file := range files {
|
||||
if _, err := bundle.LoadMessageFileFS(localesFS, "locale/"+file.Name()); err != nil {
|
||||
return key
|
||||
}
|
||||
}
|
||||
localizer := i18n.NewLocalizer(bundle, lang)
|
||||
if localizer == nil {
|
||||
return key
|
||||
}
|
||||
templateDataMap := make(map[string]any)
|
||||
for _, data := range templateData {
|
||||
maps.Copy(templateDataMap, data)
|
||||
}
|
||||
msg, err := localizer.Localize(&i18n.LocalizeConfig{
|
||||
MessageID: key,
|
||||
TemplateData: templateDataMap,
|
||||
})
|
||||
if err != nil {
|
||||
return key
|
||||
}
|
||||
return msg
|
||||
}
|
||||
19
common/i18n/i18nk/keys.go
Normal file
19
common/i18n/i18nk/keys.go
Normal file
@@ -0,0 +1,19 @@
|
||||
// Code generated by cmd/gen_i18n. DO NOT EDIT.
|
||||
package i18nk
|
||||
|
||||
const (
|
||||
CleanCacheFailed = "CleanCacheFailed"
|
||||
CleaningCache = "CleaningCache"
|
||||
ConfigInvalidDuplicateStorageName = "ConfigInvalid.DuplicateStorageName"
|
||||
ConfigInvalidWorkersOrRetry = "ConfigInvalid.WorkersOrRetry"
|
||||
CreateRmTimerFailed = "CreateRmTimerFailed"
|
||||
GetCacheAbsPathFailed = "GetCacheAbsPathFailed"
|
||||
GetWorkdirFailed = "GetWorkdirFailed"
|
||||
InvalidCacheDir = "InvalidCacheDir"
|
||||
LoadedStorages = "LoadedStorages"
|
||||
RemoveFileAfter = "RemoveFileAfter"
|
||||
RemoveFileFailed = "RemoveFileFailed"
|
||||
Bye = "bye"
|
||||
Exiting = "exiting"
|
||||
Initing = "initing"
|
||||
)
|
||||
28
common/i18n/locale/zh-Hans.toml
Normal file
28
common/i18n/locale/zh-Hans.toml
Normal file
@@ -0,0 +1,28 @@
|
||||
[initing]
|
||||
other = "正在启动..."
|
||||
[exiting]
|
||||
other = "正在退出..."
|
||||
[bye]
|
||||
other = "已退出"
|
||||
[InvalidCacheDir]
|
||||
other = "无效的缓存文件夹: {{.Path}}"
|
||||
[GetWorkdirFailed]
|
||||
other = "获取工作目录失败: {{.Error}}"
|
||||
[GetCacheAbsPathFailed]
|
||||
other = "获取缓存绝对路径失败: {{.Error}}"
|
||||
[CleaningCache]
|
||||
other = "正在清理缓存文件夹: {{.Path}}"
|
||||
[CleanCacheFailed]
|
||||
other = "清理缓存失败: {{.Error}}"
|
||||
[CreateRmTimerFailed]
|
||||
other = "创建清理定时器失败, 路径: {{.Path}}, 错误: {{.Error}}"
|
||||
[RemoveFileAfter]
|
||||
other = "将在 {{.Duration}} 后删除文件: {{.Path}}"
|
||||
[RemoveFileFailed]
|
||||
other = "删除文件失败: {{.Path}}, 错误: {{.Error}}"
|
||||
[LoadedStorages]
|
||||
other = "已加载 {{.Count}} 个存储"
|
||||
[ConfigInvalid.WorkersOrRetry]
|
||||
other = "配置无效: workers 或 retry 必须大于 0, 但当前值为: workers={{.Workers}}, retry={{.Retry}}"
|
||||
[ConfigInvalid.DuplicateStorageName]
|
||||
other = "存储名称重复: {{.Name}}"
|
||||
@@ -1,43 +0,0 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"github.com/gookit/slog"
|
||||
"github.com/gookit/slog/handler"
|
||||
"github.com/gookit/slog/rotatefile"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
)
|
||||
|
||||
var Log *slog.Logger
|
||||
|
||||
func InitLogger() {
|
||||
if Log != nil {
|
||||
return
|
||||
}
|
||||
Log = slog.New()
|
||||
logLevel := slog.LevelByName(config.Cfg.Log.Level)
|
||||
logFilePath := config.Cfg.Log.File
|
||||
logBackupNum := config.Cfg.Log.BackupCount
|
||||
var logLevels []slog.Level
|
||||
for _, level := range slog.AllLevels {
|
||||
if level <= logLevel {
|
||||
logLevels = append(logLevels, level)
|
||||
}
|
||||
}
|
||||
tem := "[{{datetime}}] [{{level}}] [{{caller}}] {{message}} {{data}} {{extra}}\n"
|
||||
consoleH := handler.NewConsoleHandler(logLevels)
|
||||
consoleH.Formatter().(*slog.TextFormatter).SetTemplate(tem)
|
||||
Log.AddHandler(consoleH)
|
||||
if logFilePath != "" && logBackupNum > 0 {
|
||||
fileH, err := handler.NewTimeRotateFile(
|
||||
logFilePath,
|
||||
rotatefile.EveryDay,
|
||||
handler.WithLogLevels(slog.AllLevels),
|
||||
handler.WithBackupNum(logBackupNum),
|
||||
)
|
||||
fileH.Formatter().(*slog.TextFormatter).SetTemplate(tem)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
Log.AddHandler(fileH)
|
||||
}
|
||||
}
|
||||
48
common/os.go
48
common/os.go
@@ -1,48 +0,0 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/krau/SaveAny-Bot/i18n"
|
||||
"github.com/krau/SaveAny-Bot/i18n/i18nk"
|
||||
)
|
||||
|
||||
func RmFileAfter(path string, td time.Duration) {
|
||||
_, err := os.Stat(path)
|
||||
if err != nil {
|
||||
Log.Errorf(i18n.T(i18nk.CreateRmTimerFailed, map[string]any{
|
||||
"Path": path,
|
||||
"Error": err,
|
||||
}))
|
||||
return
|
||||
}
|
||||
Log.Debugf(i18n.T(i18nk.RemoveFileAfter, map[string]any{
|
||||
"Duration": td.String(),
|
||||
"Path": path,
|
||||
}))
|
||||
time.AfterFunc(td, func() {
|
||||
if err := os.Remove(path); err != nil {
|
||||
Log.Errorf(i18n.T(i18nk.RemoveFileFailed, map[string]any{
|
||||
"Path": path,
|
||||
"Error": err,
|
||||
}))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 删除目录下的所有内容, 但不删除目录本身
|
||||
func RemoveAllInDir(dirPath string) error {
|
||||
entries, err := os.ReadDir(dirPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
entryPath := filepath.Join(dirPath, entry.Name())
|
||||
if err := os.RemoveAll(entryPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
18
common/tdler/dler.go
Normal file
18
common/tdler/dler.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package tdler
|
||||
|
||||
import (
|
||||
"github.com/gotd/td/telegram/downloader"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/dlutil"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"github.com/krau/SaveAny-Bot/pkg/consts/tglimit"
|
||||
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||
)
|
||||
|
||||
type Client interface {
|
||||
downloader.Client
|
||||
}
|
||||
|
||||
func NewDownloader(client Client, file tfile.TGFile) *downloader.Builder {
|
||||
return downloader.NewDownloader().WithPartSize(tglimit.MaxPartSize).
|
||||
Download(client, file.Location()).WithThreads(dlutil.BestThreads(file.Size(), config.Cfg.Threads))
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
func HashString(s string) string {
|
||||
hash := md5.New()
|
||||
hash.Write([]byte(s))
|
||||
return hex.EncodeToString(hash.Sum(nil))
|
||||
}
|
||||
|
||||
var TagRe = regexp.MustCompile(`(?:^|[\p{Zs}\s.,!?(){}[\]<>\"\',。!?():;、])#([\p{L}\d_]+)`)
|
||||
|
||||
func ExtractTagsFromText(text string) []string {
|
||||
matches := TagRe.FindAllStringSubmatch(text, -1)
|
||||
tags := make([]string, 0)
|
||||
for _, match := range matches {
|
||||
if len(match) > 1 {
|
||||
tags = append(tags, match[1])
|
||||
}
|
||||
}
|
||||
return tags
|
||||
}
|
||||
33
common/utils/dlutil/dl.go
Normal file
33
common/utils/dlutil/dl.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package dlutil
|
||||
|
||||
import "time"
|
||||
|
||||
var threadsLevels = []struct {
|
||||
threads int
|
||||
size int64
|
||||
}{
|
||||
{1, 10 << 20},
|
||||
{2, 50 << 20},
|
||||
{4, 200 << 20},
|
||||
{8, 500 << 20},
|
||||
}
|
||||
|
||||
func BestThreads(size int64, max int) int {
|
||||
for _, thread := range threadsLevels {
|
||||
if size < thread.size {
|
||||
return min(thread.threads, max)
|
||||
}
|
||||
}
|
||||
return max
|
||||
}
|
||||
|
||||
func GetSpeed(downloaded int64, startTime time.Time) float64 {
|
||||
if startTime.IsZero() {
|
||||
return 0
|
||||
}
|
||||
elapsed := time.Since(startTime).Seconds()
|
||||
if elapsed <= 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(downloaded) / elapsed
|
||||
}
|
||||
57
common/utils/fsutil/fs.go
Normal file
57
common/utils/fsutil/fs.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package fsutil
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/gabriel-vasile/mimetype"
|
||||
)
|
||||
|
||||
// 删除文件夹内的所有文件和子目录, 但不删除文件夹本身
|
||||
func RemoveAllInDir(dirPath string) error {
|
||||
entries, err := os.ReadDir(dirPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
entryPath := filepath.Join(dirPath, entry.Name())
|
||||
if err := os.RemoveAll(entryPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func DetectFileExt(fp string) string {
|
||||
mt, err := mimetype.DetectFile(fp)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return mt.Extension()
|
||||
}
|
||||
|
||||
type File struct {
|
||||
*os.File
|
||||
}
|
||||
|
||||
func (f *File) Remove() error {
|
||||
return os.Remove(f.Name())
|
||||
}
|
||||
|
||||
func (f *File) CloseAndRemove() error {
|
||||
if err := f.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return f.Remove()
|
||||
}
|
||||
|
||||
func CreateFile(fp string) (*File, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(fp), os.ModePerm); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file, err := os.Create(fp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &File{File: file}, nil
|
||||
}
|
||||
49
common/utils/ioutil/writer.go
Normal file
49
common/utils/ioutil/writer.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package ioutil
|
||||
|
||||
import "io"
|
||||
|
||||
type ProgressWriterAt struct {
|
||||
wrAt io.WriterAt
|
||||
onWrite func(n int)
|
||||
}
|
||||
|
||||
func (p *ProgressWriterAt) WriteAt(buf []byte, off int64) (n int, err error) {
|
||||
n, err = p.wrAt.WriteAt(buf, off)
|
||||
if n > 0 {
|
||||
p.onWrite(n)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func NewProgressWriterAt(
|
||||
wrAt io.WriterAt,
|
||||
onWrite func(n int),
|
||||
) *ProgressWriterAt {
|
||||
return &ProgressWriterAt{
|
||||
wrAt: wrAt,
|
||||
onWrite: onWrite,
|
||||
}
|
||||
}
|
||||
|
||||
type ProgressWriter struct {
|
||||
wr io.Writer
|
||||
onWrite func(n int)
|
||||
}
|
||||
|
||||
func (p *ProgressWriter) Write(buf []byte) (n int, err error) {
|
||||
n, err = p.wr.Write(buf)
|
||||
if n > 0 {
|
||||
p.onWrite(n)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func NewProgressWriter(
|
||||
wr io.Writer,
|
||||
onWrite func(n int),
|
||||
) *ProgressWriter {
|
||||
return &ProgressWriter{
|
||||
wr: wr,
|
||||
onWrite: onWrite,
|
||||
}
|
||||
}
|
||||
50
common/utils/strutil/string.go
Normal file
50
common/utils/strutil/string.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package strutil
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/duke-git/lancet/v2/slice"
|
||||
)
|
||||
|
||||
func HashString(s string) string {
|
||||
hash := md5.New()
|
||||
hash.Write([]byte(s))
|
||||
return hex.EncodeToString(hash.Sum(nil))
|
||||
}
|
||||
|
||||
var TagRe = regexp.MustCompile(`(?:^|[\p{Zs}\s.,!?(){}[\]<>\"\',。!?():;、])#([\p{L}\d_]+)`)
|
||||
|
||||
func ExtractTagsFromText(text string) []string {
|
||||
matches := TagRe.FindAllStringSubmatch(text, -1)
|
||||
tags := make([]string, 0)
|
||||
for _, match := range matches {
|
||||
if len(match) > 1 {
|
||||
tags = append(tags, match[1])
|
||||
}
|
||||
}
|
||||
return slice.Compact(tags)
|
||||
}
|
||||
|
||||
func ParseIntStrRange(input string, sep string) (int64, int64, error) {
|
||||
parts := strings.Split(input, sep)
|
||||
if len(parts) != 2 {
|
||||
return 0, 0, fmt.Errorf("invalid range format: %s", input)
|
||||
}
|
||||
min, err := strconv.ParseInt(strings.TrimSpace(parts[0]), 10, 64)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("invalid minimum value: %s", parts[0])
|
||||
}
|
||||
max, err := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("invalid maximum value: %s", parts[1])
|
||||
}
|
||||
if min > max {
|
||||
min, max = max, min
|
||||
}
|
||||
return min, max, nil
|
||||
}
|
||||
22
common/utils/tgutil/context.go
Normal file
22
common/utils/tgutil/context.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package tgutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
)
|
||||
|
||||
type contextKey struct{}
|
||||
|
||||
var extKey = contextKey{}
|
||||
|
||||
func ExtFromContext(ctx context.Context) *ext.Context {
|
||||
if extCtx, ok := ctx.Value(extKey).(*ext.Context); ok {
|
||||
return extCtx
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ExtWithContext(ctx context.Context, extCtx *ext.Context) context.Context {
|
||||
return context.WithValue(ctx, extKey, extCtx)
|
||||
}
|
||||
183
common/utils/tgutil/message.go
Normal file
183
common/utils/tgutil/message.go
Normal file
@@ -0,0 +1,183 @@
|
||||
package tgutil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"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/tg"
|
||||
"github.com/krau/SaveAny-Bot/common/cache"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/strutil"
|
||||
"github.com/rs/xid"
|
||||
)
|
||||
|
||||
func GenFileNameFromMessage(message tg.Message) string {
|
||||
ext := func(media tg.MessageMediaClass) string {
|
||||
switch media := media.(type) {
|
||||
case *tg.MessageMediaDocument:
|
||||
doc, ok := media.Document.AsNotEmpty()
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
ext := mimetype.Lookup(doc.MimeType).Extension()
|
||||
if ext == "" {
|
||||
return ""
|
||||
}
|
||||
return ext
|
||||
case *tg.MessageMediaPhoto:
|
||||
return ".jpg"
|
||||
}
|
||||
return ""
|
||||
}(message.Media)
|
||||
text := strings.TrimSpace(message.GetMessage())
|
||||
if text == "" {
|
||||
return fmt.Sprintf("%d_%s%s", message.GetID(), xid.New().String(), ext)
|
||||
}
|
||||
filename := func() string {
|
||||
tags := strutil.ExtractTagsFromText(text)
|
||||
if len(tags) > 0 {
|
||||
tagStrRunes := make([]rune, 0, 64)
|
||||
for i, tag := range tags {
|
||||
if i > 0 {
|
||||
tagStrRunes = append(tagStrRunes, '_')
|
||||
}
|
||||
tagStrRunes = append(tagStrRunes, []rune(tag)...)
|
||||
if len(tagStrRunes) >= 64 {
|
||||
break
|
||||
}
|
||||
}
|
||||
tagStr := string(tagStrRunes)
|
||||
return fmt.Sprintf("%s_%s", tagStr, strconv.Itoa(message.GetID()))
|
||||
}
|
||||
text = lcstrutil.Substring(strings.Map(func(r rune) rune {
|
||||
if r < 0x20 || r == 0x7F {
|
||||
return '_'
|
||||
}
|
||||
switch r {
|
||||
// invalid characters
|
||||
case '/', '\\',
|
||||
':', '*', '?', '"', '<', '>', '|':
|
||||
return '_'
|
||||
// empty
|
||||
case ' ', '\t', '\r', '\n':
|
||||
return '_'
|
||||
}
|
||||
if validator.IsPrintable(string(r)) {
|
||||
return r
|
||||
}
|
||||
return '_'
|
||||
}, text), 0, 64)
|
||||
text = strings.Join(strings.FieldsFunc(text, func(r rune) bool {
|
||||
return r == '_' || r == ' '
|
||||
}), "_")
|
||||
return text
|
||||
}()
|
||||
|
||||
if filename == "" {
|
||||
filename = fmt.Sprintf("%d_%s", message.GetID(), xid.New().String())
|
||||
}
|
||||
return filename + ext
|
||||
}
|
||||
|
||||
func BuildCancelButton(taskID string) tg.KeyboardButtonClass {
|
||||
return &tg.KeyboardButtonCallback{
|
||||
Text: "取消任务",
|
||||
Data: fmt.Appendf(nil, "cancel %s", taskID),
|
||||
}
|
||||
}
|
||||
|
||||
func InputMessageClassSliceFromInt(ids []int) []tg.InputMessageClass {
|
||||
result := make([]tg.InputMessageClass, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
result = append(result, &tg.InputMessageID{
|
||||
ID: id,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
total := maxId - minId + 1
|
||||
msgIds := mathutil.Range(minId, total)
|
||||
toFetchIds := make([]int, 0, total)
|
||||
cached := make(map[int]*tg.Message, total)
|
||||
for _, id := range msgIds {
|
||||
if msg, ok := cache.Get[*tg.Message](fmt.Sprintf("tgmsg:%d:%d:%d", ctx.Self.ID, chatID, id)); ok {
|
||||
cached[id] = msg
|
||||
} else {
|
||||
toFetchIds = append(toFetchIds, id)
|
||||
}
|
||||
}
|
||||
if len(toFetchIds) == 0 {
|
||||
return maputil.Values(cached), nil
|
||||
}
|
||||
|
||||
result := make([]*tg.Message, 0, total)
|
||||
chunks := slice.Chunk(toFetchIds, 100)
|
||||
for _, chunk := range chunks {
|
||||
msgs, err := ctx.GetMessages(chatID, InputMessageClassSliceFromInt(chunk))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(msgs) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, msg := range msgs {
|
||||
if msg == nil {
|
||||
continue
|
||||
}
|
||||
tgMessage, ok := msg.(*tg.Message)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if tgMessage.GetID() < minId || tgMessage.GetID() > maxId {
|
||||
continue
|
||||
}
|
||||
result = append(result, tgMessage)
|
||||
}
|
||||
}
|
||||
|
||||
for _, msg := range result {
|
||||
cache.Set(fmt.Sprintf("tgmsg:%d:%d:%d", ctx.Self.ID, chatID, msg.GetID()), msg)
|
||||
}
|
||||
for _, msg := range cached {
|
||||
if msg == nil {
|
||||
continue
|
||||
}
|
||||
result = append(result, msg)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func GetMessageByID(ctx *ext.Context, chatID int64, msgID int) (*tg.Message, error) {
|
||||
key := fmt.Sprintf("tgmsg:%d:%d:%d", ctx.Self.ID, chatID, msgID)
|
||||
if msg, ok := cache.Get[*tg.Message](key); ok {
|
||||
return msg, nil
|
||||
}
|
||||
msgs, err := ctx.GetMessages(chatID, []tg.InputMessageClass{
|
||||
&tg.InputMessageID{ID: msgID},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get message by ID: %w", err)
|
||||
}
|
||||
if len(msgs) == 0 {
|
||||
return nil, fmt.Errorf("message not found: chatID=%d, msgID=%d", chatID, msgID)
|
||||
}
|
||||
msg := msgs[0]
|
||||
tgm, ok := msg.(*tg.Message)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected message type: %T", msg)
|
||||
}
|
||||
cache.Set(key, tgm)
|
||||
return tgm, nil
|
||||
}
|
||||
119
common/utils/tgutil/resolve.go
Normal file
119
common/utils/tgutil/resolve.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package tgutil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/duke-git/lancet/v2/validator"
|
||||
"github.com/gotd/td/tg"
|
||||
)
|
||||
|
||||
func ParseChatID(ctx *ext.Context, idOrUsername string) (int64, error) {
|
||||
idOrUsername = strings.TrimPrefix(idOrUsername, "@")
|
||||
if validator.IsIntStr(idOrUsername) {
|
||||
chatID, err := strconv.Atoi(idOrUsername)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int64(chatID), nil
|
||||
}
|
||||
username := idOrUsername
|
||||
peer := ctx.PeerStorage.GetPeerByUsername(username)
|
||||
if peer != nil && peer.ID != 0 {
|
||||
return peer.ID, nil
|
||||
}
|
||||
chat, err := ctx.ResolveUsername(username)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if chat == nil {
|
||||
return 0, fmt.Errorf("no chat found for username: %s", idOrUsername)
|
||||
}
|
||||
chatID := chat.GetID()
|
||||
if chatID == 0 {
|
||||
return 0, fmt.Errorf("chat ID is zero for username: %s", idOrUsername)
|
||||
}
|
||||
return chatID, nil
|
||||
}
|
||||
|
||||
// return: ChatID, MessageID, error
|
||||
func ParseMessageLink(ctx *ext.Context, link string) (int64, int, error) {
|
||||
u, err := url.Parse(link)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
paths := strings.Split(strings.TrimPrefix(u.Path, "/"), "/")
|
||||
|
||||
if cmt := u.Query().Get("comment"); cmt != "" {
|
||||
// 频道评论的消息链接
|
||||
// https://t.me/acherkrau/123?comment=2
|
||||
chid, err := ParseChatID(ctx, paths[0])
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("failed to parse chat ID: %w", err)
|
||||
}
|
||||
chatfull, err := ctx.GetChat(chid)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("failed to get chat: %w", err)
|
||||
}
|
||||
chfull, ok := chatfull.(*tg.ChannelFull)
|
||||
if !ok {
|
||||
return 0, 0, fmt.Errorf("chat is not a channel: %s", chatfull.TypeName())
|
||||
}
|
||||
linkChatId, ok := chfull.GetLinkedChatID()
|
||||
if !ok {
|
||||
return 0, 0, fmt.Errorf("channel has no linked chat")
|
||||
}
|
||||
msgID, err := strconv.Atoi(cmt)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("failed to parse comment ID: %w", err)
|
||||
}
|
||||
return linkChatId, msgID, nil
|
||||
}
|
||||
|
||||
switch len(paths) {
|
||||
case 2: // https://t.me/acherkrau/123
|
||||
chatID, err := ParseChatID(ctx, paths[0])
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("failed to parse chat ID: %w", err)
|
||||
}
|
||||
msgID, err := strconv.Atoi(paths[1])
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("failed to parse message ID: %w", err)
|
||||
}
|
||||
return chatID, msgID, nil
|
||||
case 3:
|
||||
// https://t.me/c/123456789/123
|
||||
// https://t.me/acherkrau/123/456 , 456: message thread ID
|
||||
chatPart, msgPart := paths[1], paths[2]
|
||||
if paths[0] != "c" {
|
||||
chatPart = paths[0]
|
||||
}
|
||||
chatID, err := ParseChatID(ctx, chatPart)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("failed to parse chat ID: %w", err)
|
||||
}
|
||||
msgID, err := strconv.Atoi(msgPart)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("failed to parse message ID: %w", err)
|
||||
}
|
||||
return chatID, msgID, nil
|
||||
case 4:
|
||||
// https://t.me/c/123456789/111/456 111: topic id
|
||||
if paths[0] != "c" {
|
||||
return 0, 0, fmt.Errorf("invalid message link format: %s", link)
|
||||
}
|
||||
chatID, err := ParseChatID(ctx, paths[1])
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("failed to parse chat ID: %w", err)
|
||||
}
|
||||
msgID, err := strconv.Atoi(paths[3])
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("failed to parse message ID: %w", err)
|
||||
}
|
||||
return chatID, msgID, nil
|
||||
}
|
||||
return 0, 0, fmt.Errorf("invalid message link format: %s", link)
|
||||
}
|
||||
51
common/utils/tphutil/tph.go
Normal file
51
common/utils/tphutil/tph.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package tphutil
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"github.com/krau/SaveAny-Bot/pkg/telegraph"
|
||||
)
|
||||
|
||||
var tphClient *telegraph.Client
|
||||
|
||||
func DefaultClient() *telegraph.Client {
|
||||
if tphClient != nil {
|
||||
return tphClient
|
||||
}
|
||||
if config.Cfg.Telegram.Proxy.Enable && config.Cfg.Telegram.Proxy.URL != "" {
|
||||
proxyUrl := config.Cfg.Telegram.Proxy.URL
|
||||
var err error
|
||||
tphClient, err = telegraph.NewClientWithProxy(proxyUrl)
|
||||
if err != nil {
|
||||
tphClient = telegraph.NewClient()
|
||||
}
|
||||
} else {
|
||||
tphClient = telegraph.NewClient()
|
||||
}
|
||||
return tphClient
|
||||
}
|
||||
|
||||
func GetNodeImages(node telegraph.Node) []string {
|
||||
var srcs []string
|
||||
|
||||
var nodeElement telegraph.NodeElement
|
||||
data, err := json.Marshal(node)
|
||||
if err != nil {
|
||||
return srcs
|
||||
}
|
||||
err = json.Unmarshal(data, &nodeElement)
|
||||
if err != nil {
|
||||
return srcs
|
||||
}
|
||||
|
||||
if nodeElement.Tag == "img" {
|
||||
if src, exists := nodeElement.Attrs["src"]; exists {
|
||||
srcs = append(srcs, src)
|
||||
}
|
||||
}
|
||||
for _, child := range nodeElement.Children {
|
||||
srcs = append(srcs, GetNodeImages(child)...)
|
||||
}
|
||||
return srcs
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package common
|
||||
|
||||
var (
|
||||
Version string = "dev"
|
||||
BuildTime string = "unknown"
|
||||
GitCommit string = "unknown"
|
||||
)
|
||||
Reference in New Issue
Block a user