feat: implement internationalization support and update help commands

This commit is contained in:
krau
2025-08-27 11:09:38 +08:00
parent a7b93e57fc
commit 215e082028
16 changed files with 173 additions and 149 deletions

View File

@@ -5,31 +5,16 @@ import (
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/config"
)
func handleHelpCmd(ctx *ext.Context, update *ext.Update) error {
const helpText string = `
Save Any Bot - 转存你的 Telegram 文件
版本: %s , 提交: %s
命令:
/start - 开始使用
/help - 显示帮助
/silent - 开关静默模式
/storage - 设置默认存储位置
/save [自定义文件名] - 保存文件
/dir - 管理存储目录
/rule - 管理规则
/update - 检查更新并升级
使用帮助: https://sabot.unv.app/usage
反馈群组: https://t.me/ProjectSaveAny
`
shortHash := config.GitCommit
if len(shortHash) > 7 {
shortHash = shortHash[:7]
}
ctx.Reply(update, ext.ReplyTextString(fmt.Sprintf(helpText, config.Version, shortHash)), nil)
ctx.Reply(update, ext.ReplyTextString(fmt.Sprintf(i18n.T(i18nk.BotMsgHelpTextFmt), config.Version, shortHash)), nil)
return dispatcher.EndGroups
}

View File

@@ -12,6 +12,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/strutil"
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
@@ -28,7 +30,7 @@ func handleSaveCmd(ctx *ext.Context, update *ext.Update) error {
}
replyTo := update.EffectiveMessage.ReplyToMessage
if replyTo == nil || replyTo.Message == nil {
ctx.Reply(update, ext.ReplyTextString(msgelem.SaveHelpText), nil)
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgSaveHelpText)), nil)
return dispatcher.EndGroups
}
genFilename := func() string {
@@ -72,7 +74,7 @@ func handleSilentSaveReplied(ctx *ext.Context, update *ext.Update) error {
}
replyTo := update.EffectiveMessage.ReplyToMessage
if replyTo == nil || replyTo.Message == nil {
ctx.Reply(update, ext.ReplyTextString(msgelem.SaveHelpText), nil)
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgSaveHelpText)), nil)
return dispatcher.EndGroups
}
genFilename := func() string {

View File

@@ -1,15 +0,0 @@
package msgelem
const (
SaveHelpText = `
使用方法:
1. 使用该命令回复要保存的文件, 可选文件名参数.
示例:
/save custom_file_name.mp4
2. 设置默认存储后, 发送 /save <频道ID/用户名> <消息ID范围> 来批量保存文件. 遵从存储规则, 若未匹配到任何规则则使用默认存储.
示例:
/save @acherkrau 114-514
`
)

View File

@@ -1,19 +0,0 @@
package msgelem
const (
WatchHelpText = `
使用 /watch 命令监听一个聊天的消息, 并自动保存到默认存储中, 遵从存储规则.
命令语法:
/watch <chat_id> [filter]
参数:
- <chat_id>: 聊天的 ID 或用户名
- [filter]: 可选, 格式为 过滤器类型:表达式 , 所有支持类型的过滤器请查看文档
命令示例:
/watch 2229835658 msgre:.*plana.*
这将监听 ID 为 2229835658 的聊天, 并转存所有包含 "plana" 的媒体消息
`
)

View File

@@ -7,7 +7,8 @@ import (
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/charmbracelet/log"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
"github.com/krau/SaveAny-Bot/database"
)
@@ -16,7 +17,7 @@ func handleWatchCmd(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(msgelem.WatchHelpText), nil)
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgWatchHelpText)), nil)
return dispatcher.EndGroups
}
userChatID := update.GetUserChat().GetID()

View File

@@ -8,9 +8,10 @@ import (
"io/fs"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"github.com/goccy/go-yaml"
)
func main() {
@@ -20,28 +21,27 @@ func main() {
flag.Parse()
keys := make(map[string]struct{})
re := regexp.MustCompile(`^\s*\[+\s*([^\]\[]+)\s*\]+`)
err := filepath.WalkDir(*dir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || !strings.HasSuffix(d.Name(), ".toml") {
if d.IsDir() || !(strings.HasSuffix(d.Name(), ".yaml") || strings.HasSuffix(d.Name(), ".yml")) {
return nil
}
f, err := os.Open(path)
data, err := os.ReadFile(path)
if err != nil {
return err
}
defer f.Close()
s := bufio.NewScanner(f)
for s.Scan() {
if m := re.FindStringSubmatch(s.Text()); m != nil {
keys[m[1]] = struct{}{}
}
var content map[string]interface{}
if err := yaml.Unmarshal(data, &content); err != nil {
return fmt.Errorf("failed to parse yaml %s: %w", path, err)
}
return s.Err()
collectKeys(content, "", keys)
return nil
})
if err != nil {
fmt.Fprintf(os.Stderr, "Error walking directory: %v\n", err)
@@ -62,23 +62,44 @@ func main() {
defer f.Close()
w := bufio.NewWriter(f)
fmt.Fprintf(w, "// Code generated by cmd/gen_i18n. DO NOT EDIT.\n")
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")
fmt.Fprintf(w, "const (\n")
for _, key := range list {
name := toPascal(key)
fmt.Fprintf(w, "\t%s = %q\n", name, key)
fmt.Fprintf(w, "\t%s Key = %q\n", name, key)
}
fmt.Fprintf(w, ")\n")
w.Flush()
}
func collectKeys(node map[string]interface{}, 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{}:
collectKeys(val, fullKey, keys)
default:
keys[fullKey] = struct{}{}
}
}
}
// 转 PascalCase
func toPascal(key string) string {
parts := strings.Split(key, ".")
for i, p := range parts {
if len(p) > 0 {
parts[i] = strings.ToUpper(string(p[0])) + p[1:]
subs := strings.Split(p, "_")
for j, s := range subs {
if len(s) > 0 {
subs[j] = strings.ToUpper(s[:1]) + s[1:]
}
}
parts[i] = strings.Join(subs, "")
}
return strings.Join(parts, "")
}

View File

@@ -36,7 +36,7 @@ func Run(cmd *cobra.Command, _ []string) {
exitChan, err := initAll(ctx)
if err != nil {
logger.Fatal("Failed to initialize", "error", err)
logger.Fatal(i18n.T(i18nk.LifetimeInitfailed), "error", err)
}
go func() {
<-exitChan
@@ -46,35 +46,36 @@ func Run(cmd *cobra.Command, _ []string) {
core.Run(ctx)
<-ctx.Done()
logger.Info(i18n.T(i18nk.Exiting))
defer logger.Info(i18n.T(i18nk.Bye))
logger.Info(i18n.T(i18nk.LifetimeExiting))
defer logger.Info(i18n.T(i18nk.LifetimeBye))
cleanCache()
}
func initAll(ctx context.Context) (<-chan struct{}, error) {
if err := config.Init(ctx); err != nil {
fmt.Println("Failed to load config:", err)
return nil, err
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.Initing))
logger.Info(i18n.T(i18nk.LifetimeIniting))
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("Failed to load parser plugins", "dir", dir, "error", err)
logger.Error(i18n.T(i18nk.ParserPluginLoadFailed), "dir", dir, "error", err)
} else {
logger.Debug("Loaded parser plugins", "dir", dir)
logger.Debug(i18n.T(i18nk.ParserPluginLoadedDir), "dir", dir)
}
}
}
if config.C().Telegram.Userbot.Enable {
_, err := userclient.Login(ctx)
if err != nil {
logger.Fatalf("User client login failed: %s", err)
logger.Fatal(i18n.T(i18nk.LifetimeUserLoginFailed, map[string]any{
"Error": err,
}))
}
}
return bot.Init(ctx), nil
@@ -86,14 +87,14 @@ 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.InvalidCacheDir, map[string]any{
log.Error(i18n.T(i18nk.ConfigErrInvalidCacheDir, map[string]any{
"Path": config.C().Temp.BasePath,
}))
return
}
currentDir, err := os.Getwd()
if err != nil {
log.Error(i18n.T(i18nk.GetWorkdirFailed, map[string]any{
log.Error(i18n.T(i18nk.ErrGetWorkdirFailed, map[string]any{
"Error": err,
}))
return
@@ -101,16 +102,16 @@ func cleanCache() {
cachePath := filepath.Join(currentDir, config.C().Temp.BasePath)
cachePath, err = filepath.Abs(cachePath)
if err != nil {
log.Error(i18n.T(i18nk.GetCacheAbsPathFailed, map[string]any{
log.Error(i18n.T(i18nk.ErrGetCacheAbsPathFailed, map[string]any{
"Error": err,
}))
return
}
log.Info(i18n.T(i18nk.CleaningCache, map[string]any{
log.Info(i18n.T(i18nk.LifetimeCleaningCache, map[string]any{
"Path": cachePath,
}))
if err := fsutil.RemoveAllInDir(cachePath); err != nil {
log.Error(i18n.T(i18nk.CleanCacheFailed, map[string]any{
log.Error(i18n.T(i18nk.ErrCleanCacheFailed, map[string]any{
"Error": err,
}))
}

View File

@@ -28,7 +28,7 @@ var upgradeCmd = &cobra.Command{
v := semver.MustParse(config.Version)
latest, err := selfupdate.UpdateSelf(v, config.GitRepo)
if err != nil {
fmt.Println("Binary update failed:", err)
fmt.Println("Update failed:", err)
return
}
if latest.Version.Equals(v) {

View File

@@ -5,12 +5,13 @@ import (
"maps"
"github.com/goccy/go-yaml"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/nicksnyder/go-i18n/v2/i18n"
"github.com/pelletier/go-toml/v2"
"golang.org/x/text/language"
)
//go:embed locale/*.toml
//go:embed locale/*
var localesFS embed.FS
var (
@@ -20,7 +21,7 @@ var (
func Init(lang string) {
bundle = i18n.NewBundle(language.SimplifiedChinese)
bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal)
bundle.RegisterUnmarshalFunc("yaml", yaml.Unmarshal)
files, err := localesFS.ReadDir("locale")
if err != nil {
panic("failed to read locale directory: " + err.Error())
@@ -39,7 +40,7 @@ func Init(lang string) {
}
}
func T(key string, templateData ...map[string]any) 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")
}
@@ -48,11 +49,11 @@ func T(key string, templateData ...map[string]any) string {
maps.Copy(templateDataMap, data)
}
msg, err := localizer.Localize(&i18n.LocalizeConfig{
MessageID: key,
MessageID: string(key),
TemplateData: templateDataMap,
})
if err != nil {
return key
return string(key)
}
return msg
}
@@ -77,32 +78,32 @@ func TWithLang(lang, key string, templateData ...map[string]any) string {
}
// Only use in tests or packages that load before i18n
func TWithoutInit(lang, key string, templateData ...map[string]any) string {
func TWithoutInit(lang string, key i18nk.Key, templateData ...map[string]any) string {
bundle := i18n.NewBundle(language.SimplifiedChinese)
bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal)
bundle.RegisterUnmarshalFunc("yaml", yaml.Unmarshal)
files, err := localesFS.ReadDir("locale")
if err != nil {
return key
return string(key)
}
for _, file := range files {
if _, err := bundle.LoadMessageFileFS(localesFS, "locale/"+file.Name()); err != nil {
return key
return string(key)
}
}
localizer := i18n.NewLocalizer(bundle, lang)
if localizer == nil {
return key
return string(key)
}
templateDataMap := make(map[string]any)
for _, data := range templateData {
maps.Copy(templateDataMap, data)
}
msg, err := localizer.Localize(&i18n.LocalizeConfig{
MessageID: key,
MessageID: string(key),
TemplateData: templateDataMap,
})
if err != nil {
return key
return string(key)
}
return msg
}

View File

@@ -1,19 +1,24 @@
// Code generated by cmd/gen_i18n. DO NOT EDIT.
// Code generated by cmd/geni18n. DO NOT EDIT.
package i18nk
type Key string
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"
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"
)

View File

@@ -1,28 +0,0 @@
[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}}"

View File

@@ -0,0 +1,62 @@
lifetime:
initing: 正在启动
initfailed: 初始化失败
exiting: 正在退出
user_login_failed: "用户登录失败: {{.Error}}"
cleaning_cache: "正在清理缓存 {{.Path}}"
bye: 已退出
config:
loaded_storages: "已加载 {{.Count}} 个存储后端"
err:
invalid_cache_dir: "无效的缓存目录: {{.Path}},请检查配置文件"
duplicate_storage_name: "存储名称 '{{.Name}}' 重复,请检查配置文件"
err:
get_workdir_failed: "获取工作目录失败: {{.Error}}"
get_cache_abs_path_failed: "获取缓存绝对路径失败: {{.Error}}"
clean_cache_failed: "清理缓存失败: {{.Error}}"
parser:
plugin:
load_failed: 加载解析器插件失败
loaded_dir: 解析器插件已加载
bot:
msg:
help_text_fmt: |
Save Any Bot - 转存你的 Telegram 文件
版本: %s , 提交: %s
命令:
/start - 开始使用
/help - 显示帮助
/silent - 开关静默模式
/storage - 设置默认存储位置
/save [自定义文件名] - 保存文件
/dir - 管理存储目录
/rule - 管理规则
/update - 检查更新并升级
使用帮助: https://sabot.unv.app/usage
反馈群组: https://t.me/ProjectSaveAny
save_help_text: |
使用方法:
1. 使用该命令回复要保存的文件, 可选文件名参数.
示例:
/save custom_file_name.mp4
2. 设置默认存储后, 发送 /save <频道ID/用户名> <消息ID范围> 来批量保存文件. 遵从存储规则, 若未匹配到任何规则则使用默认存储.
示例:
/save @acherkrau 114-514
watch_help_text: |
使用 /watch 命令监听一个聊天的消息, 并自动保存到默认存储中, 遵从存储规则.
命令语法:
/watch <chat_id> [filter]
参数:
- <chat_id>: 聊天的 ID 或用户名
- [filter]: 可选, 格式为 过滤器类型:表达式 , 所有支持类型的过滤器请查看文档
命令示例:
/watch 2229835658 msgre:.*plana.*
这将监听 ID 为 2229835658 的聊天, 并转存所有包含 "plana" 的媒体消息

View File

@@ -112,25 +112,28 @@ 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.ConfigInvalidDuplicateStorageName, map[string]any{
return errors.New(i18n.TWithoutInit(cfg.Lang, i18nk.ConfigErrDuplicateStorageName, map[string]any{
"Name": storage.GetName(),
}))
}
storageNames[storage.GetName()] = struct{}{}
}
fmt.Println(i18n.TWithoutInit(cfg.Lang, i18nk.LoadedStorages, map[string]any{
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.Retry < 1 {
return errors.New(i18n.TWithoutInit(cfg.Lang, i18nk.ConfigInvalidWorkersOrRetry, map[string]any{
"Workers": cfg.Workers,
"Retry": cfg.Retry,
}))
if cfg.Workers < 1 {
cfg.Workers = 1
}
if cfg.Threads < 1 {
cfg.Threads = 1
}
if cfg.Retry < 1 {
cfg.Retry = 1
}
for _, storage := range cfg.Storages {

1
go.mod
View File

@@ -50,6 +50,7 @@ require (
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/google/go-github/v30 v30.1.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect

2
go.sum
View File

@@ -109,6 +109,8 @@ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9L
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=

View File

@@ -8,6 +8,8 @@ import (
"github.com/krau/SaveAny-Bot/cmd"
)
//go:generate go run cmd/geni18n/main.go -dir ./common/i18n/locale -out common/i18n/i18nk/keys.go -pkg i18nk
func main() {
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()