feat(prj): support tdl extensions (#780)

This commit is contained in:
Junyu Liu 2024-11-07 14:31:14 +08:00 committed by GitHub
parent ace2402c06
commit 98dac73585
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 1781 additions and 22 deletions

View File

@ -9,6 +9,7 @@ updates:
directories:
- "/"
- "core"
- "extension"
schedule:
interval: "daily"
assignees:

View File

@ -24,6 +24,7 @@ jobs:
directory:
- .
- core
- extension
steps:
- name: Checkout
uses: actions/checkout@v4

159
app/extension/extension.go Normal file
View File

@ -0,0 +1,159 @@
package extension
import (
"context"
"fmt"
"strings"
"github.com/fatih/color"
"github.com/go-faster/errors"
"github.com/jedib0t/go-pretty/v6/table"
"github.com/iyear/tdl/pkg/extensions"
)
var (
colorPrint = func(attrs ...color.Attribute) func(padding int, format string, a ...interface{}) {
return func(padding int, format string, a ...interface{}) {
color.New(attrs...).Print(strings.Repeat(" ", padding) + "• ")
fmt.Printf(format+"\n", a...)
}
}
info = colorPrint(color.FgBlue, color.Bold)
succ = colorPrint(color.FgGreen, color.Bold)
fail = colorPrint(color.FgRed, color.Bold)
)
func List(ctx context.Context, em *extensions.Manager) error {
exts, err := em.List(ctx, false)
if err != nil {
return errors.New("list extensions failed")
}
tb := table.NewWriter()
style := table.StyleColoredDark
tb.SetStyle(style)
tb.AppendHeader(table.Row{"NAME", "AUTHOR", "VERSION"})
for _, e := range exts {
tb.AppendRow(table.Row{normalizeExtName(e.Name()), e.Owner(), e.CurrentVersion()})
}
fmt.Println(tb.Render())
return nil
}
func Install(ctx context.Context, em *extensions.Manager, targets []string, force bool) error {
for _, target := range targets {
info(0, "installing extension %s...", normalizeExtName(target))
if err := em.Install(ctx, target, force); err != nil {
fail(1, "install extension %s failed: %s", normalizeExtName(target), err)
continue
}
if em.DryRun() {
succ(1, "extension %s will be installed", normalizeExtName(target))
} else {
succ(1, "extension %s installed", normalizeExtName(target))
}
}
return nil
}
func Upgrade(ctx context.Context, em *extensions.Manager, targets []string) error {
upgradeAll := len(targets) == 0
exts, err := em.List(ctx, upgradeAll)
if err != nil {
return errors.Wrap(err, "list extensions with metadata")
}
if len(exts) == 0 {
return errors.New("no extensions installed")
}
extMap := make(map[string]extensions.Extension)
for _, e := range exts {
extMap[e.Name()] = e
if upgradeAll {
targets = append(targets, e.Name())
}
}
for _, target := range targets {
e, ok := extMap[strings.TrimPrefix(target, extensions.Prefix)]
if !ok {
fail(0, "extension %s not found", normalizeExtName(target))
continue
}
info(0, "upgrading %s...", normalizeExtName(e.Name()))
if err = em.Upgrade(ctx, e); err != nil {
switch {
case errors.Is(err, extensions.ErrAlreadyUpToDate):
succ(1, "extension %s already up-to-date", normalizeExtName(e.Name()))
case errors.Is(err, extensions.ErrOnlyGitHub):
fail(1, "extension %s can't be automatically upgraded by tdl", normalizeExtName(e.Name()))
default:
fail(1, "upgrade extension %s failed: %s", normalizeExtName(e.Name()), err)
}
continue
}
if em.DryRun() {
succ(1, "extension %s will be upgraded", normalizeExtName(e.Name()))
} else {
succ(1, "extension %s upgraded", normalizeExtName(e.Name()))
}
}
return nil
}
func Remove(ctx context.Context, em *extensions.Manager, targets []string) error {
exts, err := em.List(ctx, false)
if err != nil {
return errors.Wrap(err, "list extensions")
}
extMap := make(map[string]extensions.Extension)
for _, e := range exts {
extMap[e.Name()] = e
}
for _, target := range targets {
e, ok := extMap[strings.TrimPrefix(target, extensions.Prefix)]
if !ok {
fail(0, "extension %s not found", normalizeExtName(target))
continue
}
if err = em.Remove(e); err != nil {
fail(0, "remove extension %s failed: %s", normalizeExtName(e.Name()), err)
continue
}
if em.DryRun() {
succ(0, "extension %s will be removed", normalizeExtName(e.Name()))
} else {
succ(0, "extension %s removed", normalizeExtName(e.Name()))
}
}
return nil
}
func normalizeExtName(n string) string {
if idx := strings.IndexRune(n, '/'); idx >= 0 {
n = n[idx+1:]
}
if !strings.HasPrefix(n, extensions.Prefix) {
n = extensions.Prefix + n
}
return color.New(color.Bold, color.FgCyan).Sprint(n)
}

147
cmd/extension.go Normal file
View File

@ -0,0 +1,147 @@
package cmd
import (
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"github.com/go-faster/errors"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/iyear/tdl/app/extension"
extbase "github.com/iyear/tdl/extension"
"github.com/iyear/tdl/pkg/consts"
"github.com/iyear/tdl/pkg/extensions"
"github.com/iyear/tdl/pkg/storage"
"github.com/iyear/tdl/pkg/tclient"
)
func NewExtension(em *extensions.Manager) *cobra.Command {
var dryRun bool
cmd := &cobra.Command{
Use: "extension",
Short: "Manage tdl extensions",
GroupID: groupTools.ID,
Aliases: []string{"extensions", "ext"},
PersistentPreRun: func(cmd *cobra.Command, args []string) {
em.SetDryRun(dryRun)
},
}
cmd.AddCommand(NewExtensionList(em), NewExtensionInstall(em), NewExtensionRemove(em), NewExtensionUpgrade(em))
cmd.PersistentFlags().BoolVar(&dryRun, "dry-run", false, "only print what would be done without actually doing it")
return cmd
}
func NewExtensionList(em *extensions.Manager) *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: "List installed extension commands",
Aliases: []string{"ls"},
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return extension.List(cmd.Context(), em)
},
}
return cmd
}
func NewExtensionInstall(em *extensions.Manager) *cobra.Command {
var force bool
cmd := &cobra.Command{
Use: "install",
Short: "Install a tdl extension",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return extension.Install(cmd.Context(), em, args, force)
},
}
cmd.Flags().BoolVar(&force, "force", false, "force install even if extension already exists")
return cmd
}
func NewExtensionUpgrade(em *extensions.Manager) *cobra.Command {
cmd := &cobra.Command{
Use: "upgrade",
Short: "Upgrade a tdl extension",
RunE: func(cmd *cobra.Command, args []string) error {
return extension.Upgrade(cmd.Context(), em, args)
},
}
return cmd
}
func NewExtensionRemove(em *extensions.Manager) *cobra.Command {
cmd := &cobra.Command{
Use: "remove",
Short: "Remove an installed extension",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return extension.Remove(cmd.Context(), em, args)
},
}
return cmd
}
func NewExtensionCmd(em *extensions.Manager, ext extensions.Extension, stdin io.Reader, stdout, stderr io.Writer) *cobra.Command {
return &cobra.Command{
Use: ext.Name(),
Short: fmt.Sprintf("Extension %s", ext.Name()),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
opts, err := tOptions(ctx)
if err != nil {
return errors.Wrap(err, "build telegram options")
}
app, err := tclient.GetApp(opts.KV)
if err != nil {
return errors.Wrap(err, "get app")
}
session, err := storage.NewSession(opts.KV, false).LoadSession(ctx)
if err != nil {
return errors.Wrap(err, "load session")
}
dataDir := filepath.Join(consts.ExtensionsDataPath, ext.Name())
if err = os.MkdirAll(dataDir, 0o755); err != nil {
return errors.Wrap(err, "create extension data dir")
}
env := &extbase.Env{
Name: ext.Name(),
AppID: app.AppID,
AppHash: app.AppHash,
Session: session,
DataDir: dataDir,
NTP: opts.NTP,
Proxy: opts.Proxy,
Debug: viper.GetBool(consts.FlagDebug),
}
if err = em.Dispatch(ctx, ext, args, env, stdin, stdout, stderr); err != nil {
var execError *exec.ExitError
if errors.As(err, &execError) {
return execError
}
return fmt.Errorf("failed to run extension: %w\n", err)
}
return nil
},
GroupID: groupExtensions.ID,
DisableFlagParsing: true,
}
}

View File

@ -3,6 +3,8 @@ package cmd
import (
"context"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"time"
@ -14,12 +16,15 @@ import (
"github.com/spf13/viper"
"go.uber.org/multierr"
"go.uber.org/zap"
"golang.org/x/net/proxy"
"github.com/iyear/tdl/core/logctx"
tclientcore "github.com/iyear/tdl/core/tclient"
"github.com/iyear/tdl/core/util/fsutil"
"github.com/iyear/tdl/core/util/logutil"
"github.com/iyear/tdl/core/util/netutil"
"github.com/iyear/tdl/pkg/consts"
"github.com/iyear/tdl/pkg/extensions"
"github.com/iyear/tdl/pkg/kv"
"github.com/iyear/tdl/pkg/tclient"
)
@ -47,9 +52,18 @@ var (
ID: "tools",
Title: "Tools",
}
groupExtensions = &cobra.Group{
ID: "extensions",
Title: "Extensions",
}
)
func New() *cobra.Command {
// allow PersistentPreRun to be called for every command
cobra.EnableTraverseRunHooks = true
em := extensions.NewManager(consts.ExtensionsPath)
cmd := &cobra.Command{
Use: "tdl",
Short: "Telegram Downloader, but more than a downloader",
@ -83,6 +97,18 @@ func New() *cobra.Command {
}
cmd.SetContext(kv.With(cmd.Context(), storage))
// extension manager client proxy
var dialer proxy.ContextDialer = proxy.Direct
if p := viper.GetString(consts.FlagProxy); p != "" {
if t, err := netutil.NewProxy(p); err == nil {
dialer = t
}
}
em.SetClient(&http.Client{Transport: &http.Transport{
DialContext: dialer.DialContext,
}})
return nil
},
PersistentPostRunE: func(cmd *cobra.Command, args []string) error {
@ -108,10 +134,17 @@ func New() *cobra.Command {
NoBottomNewline: true,
})
cmd.AddGroup(groupAccount, groupTools)
cmd.AddGroup(groupAccount, groupTools, groupExtensions)
cmd.AddCommand(NewVersion(), NewLogin(), NewDownload(), NewForward(),
NewChat(), NewUpload(), NewBackup(), NewRecover(), NewMigrate(), NewGen())
NewChat(), NewUpload(), NewBackup(), NewRecover(), NewMigrate(),
NewGen(), NewExtension(em))
// append extension command to root
exts, _ := em.List(context.Background(), false)
for _, e := range exts {
cmd.AddCommand(NewExtensionCmd(em, e, os.Stdin, os.Stdout, os.Stderr))
}
cmd.PersistentFlags().StringToString(consts.FlagStorage,
DefaultBoltStorage,
@ -147,6 +180,15 @@ func New() *cobra.Command {
viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_"))
viper.AutomaticEnv()
// extension command format: <global-flags> <extension-name> <extension-flags>,
// which means parse args layer by layer. But common command flags are flat.
// To keep compatibility, we only set TraverseChildren to true for extension
// command instead of other commands.
foundCmd, _, err := cmd.Find(os.Args[1:])
if err == nil && foundCmd.GroupID == groupExtensions.ID {
cmd.TraverseChildren = true // allow global config to be parsed before extension command is executed
}
return cmd
}
@ -167,11 +209,11 @@ func completeExtFiles(ext ...string) completeFunc {
}
}
func tRun(ctx context.Context, f func(ctx context.Context, c *telegram.Client, kvd kv.KV) error, middlewares ...telegram.Middleware) error {
func tOptions(ctx context.Context) (tclient.Options, error) {
// init tclient kv
kvd, err := kv.From(ctx).Open(viper.GetString(consts.FlagNamespace))
if err != nil {
return errors.Wrap(err, "open kv storage")
return tclient.Options{}, errors.Wrap(err, "open kv storage")
}
o := tclient.Options{
KV: kvd,
@ -181,13 +223,22 @@ func tRun(ctx context.Context, f func(ctx context.Context, c *telegram.Client, k
UpdateHandler: nil,
}
return o, nil
}
func tRun(ctx context.Context, f func(ctx context.Context, c *telegram.Client, kvd kv.KV) error, middlewares ...telegram.Middleware) error {
o, err := tOptions(ctx)
if err != nil {
return errors.Wrap(err, "build telegram options")
}
client, err := tclient.New(ctx, o, false, middlewares...)
if err != nil {
return errors.Wrap(err, "create client")
}
return tclientcore.RunWithAuth(ctx, client, func(ctx context.Context) error {
return f(ctx, client, kvd)
return f(ctx, client, o.KV)
})
}

View File

@ -0,0 +1,172 @@
---
title: "Extensions 🆕"
weight: 70
---
# Extensions
{{< hint warning >}}
Extensions are a new feature in tdl. They are still in the experimental stage, and the CLI may change in future versions.
If you encounter any problems or have any suggestions, please [open an issue](https://github.com/iyear/tdl/issues/new/choose) on GitHub.
{{< /hint >}}
## Overview
tdl extensions are add-on tools that integrate seamlessly with tdl. They provide a way to extend the core feature set of tdl, but without requiring every new feature to be added to the core.
tdl extensions have the following features:
- They can be added and removed without impacting the core tdl tool.
- They integrate with tdl, and will show up in tdl help and other places.
tdl extensions live in `~/.tdl/extensions`, which is controlled by `tdl extension` commands.
To get started with extensions, you can use the following commands:
{{< command >}}
tdl extension install iyear/tdl-whoami
{{< /command >}}
{{< command >}}
tdl whoami
{{< /command >}}
You can see the output of the `tdl-whoami` extension. Refer to the [tdl-whoami](https://github.com/iyear/tdl-whoami) for details.
```
You are XXXXX. ID: XXXXXXXX
```
## Finding extensions
You can find extensions by browsing [repositories with the `tdl-extension` topic](https://github.com/topics/tdl-extension).
## Installing extensions
To install an extension, use the `extension install` subcommand.
There are two types of extensions:
- `GitHub` : Extensions hosted on GitHub repositories.
{{< command >}}
tdl extension install <owner>/<repo>
{{< /command >}}
To install an extension from a private repository, you must set up a [GitHub personal access token](https://github.com/settings/personal-access-tokens/new)(with `Contents` read permission) in your environment with the `GITHUB_TOKEN` variable.
{{< command >}}
export GITHUB_TOKEN=YOUR_TOKEN
tdl extension install <owner>/<private-repo>
{{< /command >}}
- `Local` : Extensions stored on your local machine.
{{< command >}}
tdl extension install /path/to/extension
{{< /command >}}
To install an extension even if it exists, use the `--force` flag:
{{< command >}}
tdl extension install --force EXTENSION
{{< /command >}}
To install multiple extensions at once, use the following command:
{{< command >}}
tdl extension install <owner>/<repo1> /path/to/extension2 ...
{{< /command >}}
To only print information without actually installing the extension, use the `--dry-run` flag:
{{< command >}}
tdl extension install --dry-run EXTENSION
{{< /command >}}
If you already have an extension by the same name installed, the command will fail. For example, if you have installed `foo/tdl-whoami`, you must uninstall it before installing `bar/tdl-whoami`.
## Running extensions
When you have installed an extension, you run the extension as you would run a native tdl command, using `tdl EXTENSION-NAME`. The `EXTENSION-NAME` is the name of the repository that contains the extension, minus the `tdl-` prefix.
For example, if you installed the extension from the `iyear/tdl-whoami` repository, you would run the extension with the following command.
{{< command >}}
tdl whoami
{{< /command >}}
Global config flags are still available when running an extension. For example, you can run the following command to specify namespace and proxy when running the `tdl-whoami` extension.
{{< command >}}
tdl -n foo --proxy socks5://localhost:1080 whoami
{{< /command >}}
Flags specific to an extension can also be used. For example, you can run the following command to enable verbose mode when running the `tdl-whoami` extension.
{{< hint info >}}
Remember to write global flags before extension subcommands and write extension flags after extension subcommands:
{{< command >}}
tdl <global-config-flags> <extension-name> <extension-flags>
{{< /command >}}
{{< /hint >}}
{{< command >}}
tdl -n foo whoami -v
{{< /command >}}
You can usually find specific information about how to use an extension in the README of the repository that contains the extension.
## Viewing installed extensions
To view all installed extensions, use the `extension list` subcommand. This command will list all installed extensions, along with their authors and versions.
{{< command >}}
tdl extension list
{{< /command >}}
## Updating extensions
To update an extension, use the `extension upgrade` subcommand. Replace the `EXTENSION` parameters with the name of extensions.
{{< command >}}
tdl extension upgrade EXTENSION1 EXTENSION2 ...
{{< /command >}}
To update all installed extensions, keep the `EXTENSION` parameter empty.
{{< command >}}
tdl extension upgrade
{{< /command >}}
To upgrade an extension from a GitHub private repository, you must set up a [GitHub personal access token](https://github.com/settings/personal-access-tokens/new)(with `Contents` read permission) in your environment with the `GITHUB_TOKEN` variable.
{{< command >}}
export GITHUB_TOKEN=YOUR_TOKEN
tdl extension upgrade EXTENSION
{{< /command >}}
To only print information without actually upgrading the extension, use the `--dry-run` flag:
{{< command >}}
tdl extension upgrade --dry-run EXTENSION
{{< /command >}}
## Uninstalling extensions
To uninstall an extension, use the `extension remove` subcommand. Replace the `EXTENSION` parameters with the name of extensions.
{{< command >}}
tdl extension remove EXTENSION1 EXTENSION2 ...
{{< /command >}}
To only print information without actually uninstalling the extension, use the `--dry-run` flag:
{{< command >}}
tdl extension remove --dry-run EXTENSION
{{< /command >}}
## Developing extensions
Please refer to the [tdl-extension-template](https://github.com/iyear/tdl-extension-template) repository for instructions on how to create, build, and publish extensions for tdl.

View File

@ -0,0 +1,172 @@
---
title: "扩展 🆕"
weight: 70
---
# 扩展
{{< hint warning >}}
扩展是 tdl 的一项新功能仍处于实验阶段CLI 可能会在未来版本中发生变化。
如果你遇到任何问题或有任何建议,请在 GitHub 上[创建 Issue](https://github.com/iyear/tdl/issues/new/choose)。
{{< /hint >}}
## 概览
tdl 扩展是与 tdl 核心无缝集成的独立工具。它们提供了一种扩展 tdl 核心的方法,但不需要将每个新功能添加到核心代码中。
tdl 扩展具有以下特点:
- 它们可以添加和删除,而不会影响 tdl 核心。
- 它们与 tdl 集成,并会显示在 tdl 命令和其他地方。
tdl 扩展位于 `~/.tdl/extensions`,由 `tdl extension` 子命令控制。
使用以下命令快速体验 tdl 扩展:
{{< command >}}
tdl extension install iyear/tdl-whoami
{{< /command >}}
{{< command >}}
tdl whoami
{{< /command >}}
你可以看到 `tdl-whoami` 扩展的输出。详情请参阅 [tdl-whoami](https://github.com/iyear/tdl-whoami)。
```
You are XXXXX. ID: XXXXXXXX
```
## 查找扩展
你可以通过浏览[带有 `tdl-extension` 主题的代码库](https://github.com/topics/tdl-extension)来查找扩展。
## 安装扩展
要安装扩展,请使用 `extension install` 子命令。
扩展有两种类型:
- `GitHub` : 托管在 GitHub 代码库上的扩展。
{{< command >}}
tdl extension install <owner>/<repo>
{{< /command >}}
要从私有代码库安装扩展,必须设置 `GITHUB_TOKEN` 环境变量为 [GitHub 个人访问令牌](https://github.com/settings/personal-access-tokens/new)(具有 `Contents` 读取权限)。
{{< command >}}
export GITHUB_TOKEN=YOUR_TOKEN
tdl extension install <owner>/<private-repo>
{{< /command >}}
- `Local` : 存储在本地计算机上的扩展。
{{< command >}}
tdl extension install /path/to/extension
{{< /command >}}
强制安装已经存在的扩展,请使用 `--force` 选项:
{{< command >}}
tdl extension install --force EXTENSION
{{< /command >}}
一次安装多个扩展,请使用以下命令:
{{< command >}}
tdl extension install <owner>/<repo1> /path/to/extension2 ...
{{< /command >}}
仅打印信息而不实际安装扩展,请使用 `--dry-run` 选项:
{{< command >}}
tdl extension install --dry-run EXTENSION
{{< /command >}}
如果你已经安装了同名的扩展,安装将失败。例如,如果你已经安装了 `foo/tdl-whoami`,则必须在安装 `bar/tdl-whoami` 之前卸载它。
## 运行扩展
安装扩展后,可以像运行本地 tdl 命令一样运行扩展,使用 `tdl EXTENSION-NAME`。`EXTENSION-NAME` 是包含扩展的代码库的名称,去掉 `tdl-` 前缀。
例如,如果你从 `iyear/tdl-whoami` 代码库安装了扩展,可以使用以下命令运行扩展。
{{< command >}}
tdl whoami
{{< /command >}}
运行扩展时,全局配置仍然可用。例如,以下命令在运行 `tdl-whoami` 扩展时指定命名空间和代理。
{{< command >}}
tdl -n foo --proxy socks5://localhost:1080 whoami
{{< /command >}}
扩展自身的选项也可以使用。例如,以下命令在运行 `tdl-whoami` 扩展时启用详细模式。
{{< hint info >}}
请记住在扩展子命令之前写全局选项,在扩展子命令之后写扩展选项:
{{< command >}}
tdl <全局选项> <扩展名> <扩展选项>
{{< /command >}}
{{< /hint >}}
{{< command >}}
tdl -n foo whoami -v
{{< /command >}}
通常可以在包含扩展的代码库的 README 中找到有关如何使用扩展的具体信息。
## 查看已安装的扩展
要查看所有已安装的扩展,请使用 `extension list` 子命令。此命令将列出所有已安装的扩展及其作者和版本。
{{< command >}}
tdl extension list
{{< /command >}}
## 更新扩展
要更新扩展,请使用 `extension upgrade` 子命令。将 `EXTENSION` 参数替换为扩展的名称。
{{< command >}}
tdl extension upgrade EXTENSION1 EXTENSION2 ...
{{< /command >}}
更新所有已安装的扩展,请设置 `EXTENSION` 参数为空。
{{< command >}}
tdl extension upgrade
{{< /command >}}
从 GitHub 私有代码库升级扩展,必须设置 `GITHUB_TOKEN` 环境变量为 [GitHub 个人访问令牌](https://github.com/settings/personal-access-tokens/new)(具有 `Contents` 读取权限)。
{{< command >}}
export GITHUB_TOKEN=YOUR_TOKEN
tdl extension upgrade EXTENSION
{{< /command >}}
仅打印信息而不实际升级扩展,请使用 `--dry-run` 选项:
{{< command >}}
tdl extension upgrade --dry-run EXTENSION
{{< /command >}}
## 卸载扩展
要卸载扩展,请使用 `extension remove` 子命令。将 `EXTENSION` 参数替换为扩展的名称。
{{< command >}}
tdl extension remove EXTENSION1 EXTENSION2 ...
{{< /command >}}
仅打印信息而不实际卸载扩展,请使用 `--dry-run` 选项:
{{< command >}}
tdl extension remove --dry-run EXTENSION
{{< /command >}}
## 开发扩展
请参阅 [tdl-extension-template](https://github.com/iyear/tdl-extension-template) 代码库,了解如何为 tdl 创建、构建和发布扩展。

169
extension/extension.go Normal file
View File

@ -0,0 +1,169 @@
package extension
import (
"context"
"encoding/json"
"fmt"
"os"
"os/signal"
"path/filepath"
"github.com/go-faster/errors"
"github.com/gotd/td/session"
"github.com/gotd/td/telegram"
"go.uber.org/zap"
"github.com/iyear/tdl/core/logctx"
"github.com/iyear/tdl/core/tclient"
"github.com/iyear/tdl/core/util/logutil"
)
const EnvKey = "TDL_EXTENSION"
type Env struct {
Name string `json:"name"`
AppID int `json:"app_id"`
AppHash string `json:"app_hash"`
Session []byte `json:"session"`
DataDir string `json:"data_dir"`
NTP string `json:"ntp"`
Proxy string `json:"proxy"`
Debug bool `json:"debug"`
}
type Options struct {
// UpdateHandler will be passed to telegram.Client Options.
UpdateHandler telegram.UpdateHandler
// Middlewares will be passed to telegram.Client Options,
// and recovery,retry,flood-wait will be used if nil.
Middlewares []telegram.Middleware
// Logger will be used as extension logger,
// and default logger(write to extension data dir) will be used if nil.
Logger *zap.Logger
}
type Extension struct {
name string // extension name
client *telegram.Client // telegram client
log *zap.Logger // logger
config *Config // extension config
}
type Config struct {
DataDir string // data directory for extension
Proxy string // proxy URL
Debug bool // debug mode enabled
}
func (e *Extension) Name() string {
return e.name
}
func (e *Extension) Client() *telegram.Client {
return e.client
}
func (e *Extension) Log() *zap.Logger {
return e.log
}
func (e *Extension) Config() *Config {
return e.config
}
type Handler func(ctx context.Context, e *Extension) error
func New(o Options) func(h Handler) {
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
ext, client, err := buildExtension(ctx, o)
assert(err)
return func(h Handler) {
defer cancel()
assert(tclient.RunWithAuth(ctx, client, func(ctx context.Context) error {
if err := h(ctx, ext); err != nil {
if errors.Is(err, context.Canceled) {
return nil
}
return err
}
return nil
}))
}
}
func buildExtension(ctx context.Context, o Options) (*Extension, *telegram.Client, error) {
envFile := os.Getenv(EnvKey)
if envFile == "" {
return nil, nil, errors.New("please launch extension with `tdl EXTENSION_NAME`")
}
extEnv, err := os.ReadFile(envFile)
if err != nil {
return nil, nil, errors.Wrap(err, "read env file")
}
env := &Env{}
if err = json.Unmarshal(extEnv, env); err != nil {
return nil, nil, errors.Wrap(err, "unmarshal extension environment")
}
if o.Logger == nil {
level := zap.InfoLevel
if env.Debug {
level = zap.DebugLevel
}
o.Logger = logutil.New(level, filepath.Join(env.DataDir, "log", "latest.log"))
}
// save logger to context
ctx = logctx.With(ctx, o.Logger)
if o.Middlewares == nil {
o.Middlewares = tclient.NewDefaultMiddlewares(ctx, 0)
}
client, err := buildClient(ctx, env, o)
if err != nil {
return nil, nil, errors.Wrap(err, "build client")
}
return &Extension{
name: env.Name,
client: client,
log: o.Logger,
config: &Config{
DataDir: env.DataDir,
Proxy: env.Proxy,
Debug: env.Debug,
},
}, client, nil
}
func buildClient(ctx context.Context, env *Env, o Options) (*telegram.Client, error) {
storage := &session.StorageMemory{}
if err := storage.StoreSession(ctx, env.Session); err != nil {
return nil, errors.Wrap(err, "store session")
}
return tclient.New(ctx, tclient.Options{
AppID: env.AppID,
AppHash: env.AppHash,
Session: storage,
Middlewares: o.Middlewares,
Proxy: env.Proxy,
NTP: env.NTP,
ReconnectTimeout: 0, // no timeout
UpdateHandler: o.UpdateHandler,
})
}
func assert(err error) {
if err != nil {
fmt.Println(err)
os.Exit(1)
}
}

36
extension/go.mod Normal file
View File

@ -0,0 +1,36 @@
module github.com/iyear/tdl/extension
go 1.21
replace github.com/iyear/tdl/core => ../core
require (
github.com/go-faster/errors v0.7.1
github.com/gotd/td v0.102.0
github.com/iyear/tdl/core v0.17.6
go.uber.org/zap v1.27.0
)
require (
github.com/beevik/ntp v1.3.1 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/go-faster/jx v1.1.0 // indirect
github.com/go-faster/xor v1.0.0 // indirect
github.com/gotd/contrib v0.20.0 // indirect
github.com/gotd/ige v0.2.2 // indirect
github.com/gotd/neo v0.1.5 // indirect
github.com/iyear/connectproxy v0.1.1 // indirect
github.com/klauspost/compress v1.17.8 // indirect
github.com/segmentio/asm v1.2.0 // indirect
go.opentelemetry.io/otel v1.26.0 // indirect
go.opentelemetry.io/otel/trace v1.26.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.28.0 // indirect
golang.org/x/net v0.30.0 // indirect
golang.org/x/sync v0.8.0 // indirect
golang.org/x/sys v0.26.0 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
nhooyr.io/websocket v1.8.11 // indirect
rsc.io/qr v0.2.0 // indirect
)

64
extension/go.sum Normal file
View File

@ -0,0 +1,64 @@
github.com/beevik/ntp v1.3.1 h1:Y/srlT8L1yQr58kyPWFPZIxRL8ttx2SRIpVYJqZIlAM=
github.com/beevik/ntp v1.3.1/go.mod h1:fT6PylBq86Tsq23ZMEe47b7QQrZfYBFPnpzt0a9kJxw=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
github.com/go-faster/jx v1.1.0 h1:ZsW3wD+snOdmTDy9eIVgQdjUpXRRV4rqW8NS3t+20bg=
github.com/go-faster/jx v1.1.0/go.mod h1:vKDNikrKoyUmpzaJ0OkIkRQClNHFX/nF3dnTJZb3skg=
github.com/go-faster/xor v0.3.0/go.mod h1:x5CaDY9UKErKzqfRfFZdfu+OSTfoZny3w5Ak7UxcipQ=
github.com/go-faster/xor v1.0.0 h1:2o8vTOgErSGHP3/7XwA5ib1FTtUsNtwCoLLBjl31X38=
github.com/go-faster/xor v1.0.0/go.mod h1:x5CaDY9UKErKzqfRfFZdfu+OSTfoZny3w5Ak7UxcipQ=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/gotd/contrib v0.20.0 h1:1Wc4+HMQiIKYQuGHVwVksIx152HFTP6B5n88dDe0ZYw=
github.com/gotd/contrib v0.20.0/go.mod h1:P6o8W4niqhDPHLA0U+SA/L7l3BQHYLULpeHfRSePn9o=
github.com/gotd/ige v0.2.2 h1:XQ9dJZwBfDnOGSTxKXBGP4gMud3Qku2ekScRjDWWfEk=
github.com/gotd/ige v0.2.2/go.mod h1:tuCRb+Y5Y3eNTo3ypIfNpQ4MFjrnONiL2jN2AKZXmb0=
github.com/gotd/neo v0.1.5 h1:oj0iQfMbGClP8xI59x7fE/uHoTJD7NZH9oV1WNuPukQ=
github.com/gotd/neo v0.1.5/go.mod h1:9A2a4bn9zL6FADufBdt7tZt+WMhvZoc5gWXihOPoiBQ=
github.com/gotd/td v0.102.0 h1:V6zNba9FV21YiBm1t42ak5jyBFSQzY8+8fwZpOT5lGM=
github.com/gotd/td v0.102.0/go.mod h1:k9JQ7ktxOs4yTpE7X2ZvNtAl+blARhz1ak+Aw0VUHiQ=
github.com/iyear/connectproxy v0.1.1 h1:JZOF/62vvwRGBWcgSyWRb0BpKD4FSs0++B5/y5pNE4c=
github.com/iyear/connectproxy v0.1.1/go.mod h1:yD4zOmSMQCmwHIT4fk8mg4k2M15z8VoMSoeY6NNJdsA=
github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU=
github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
go.opentelemetry.io/otel v1.26.0 h1:LQwgL5s/1W7YiiRwxf03QGnWLb2HW4pLiAhaA5cZXBs=
go.opentelemetry.io/otel v1.26.0/go.mod h1:UmLkJHUAidDval2EICqBMbnAd0/m2vmpf/dAM+fvFs4=
go.opentelemetry.io/otel/trace v1.26.0 h1:1ieeAUb4y0TE26jUFrCIXKpTuVK7uJGN9/Z/2LP5sQA=
go.opentelemetry.io/otel/trace v1.26.0/go.mod h1:4iDxvGDQuUkHve82hJJ8UqrwswHYsZuWCBllGV2U2y0=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df h1:UA2aFVmmsIlefxMk29Dp2juaUSth8Pyn3Tq5Y5mJGME=
golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc=
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
nhooyr.io/websocket v1.8.11 h1:f/qXNc2/3DpoSZkHt1DQu6rj4zGC8JmkkLkWss0MgN0=
nhooyr.io/websocket v1.8.11/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c=
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=

12
go.mod
View File

@ -2,7 +2,10 @@ module github.com/iyear/tdl
go 1.21
replace github.com/iyear/tdl/core => ./core
replace (
github.com/iyear/tdl/core => ./core
github.com/iyear/tdl/extension => ./extension
)
require (
github.com/AlecAivazis/survey/v2 v2.3.7
@ -14,13 +17,15 @@ require (
github.com/go-faster/errors v0.7.1
github.com/go-faster/jx v1.1.0
github.com/go-playground/validator/v10 v10.22.1
github.com/google/go-github/v62 v62.0.0
github.com/google/uuid v1.6.0
github.com/gorilla/mux v1.8.1
github.com/gotd/contrib v0.20.0
github.com/gotd/td v0.110.1
github.com/iancoleman/strcase v0.3.0
github.com/ivanpirog/coloredcobra v1.0.1
github.com/iyear/tdl/core v0.17.4
github.com/iyear/tdl/core v0.17.6
github.com/iyear/tdl/extension v0.0.0-00010101000000-000000000000
github.com/jedib0t/go-pretty/v6 v6.5.0
github.com/klauspost/compress v1.17.11
github.com/kopoli/go-terminal-size v0.0.0-20170219200355-5c97524c8b54
@ -38,6 +43,7 @@ require (
go.etcd.io/bbolt v1.3.10
go.uber.org/multierr v1.11.0
go.uber.org/zap v1.27.0
golang.org/x/net v0.30.0
golang.org/x/time v0.7.0
)
@ -53,6 +59,7 @@ require (
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 // indirect
github.com/gotd/ige v0.2.2 // indirect
github.com/gotd/neo v0.1.5 // indirect
@ -90,7 +97,6 @@ require (
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/crypto v0.28.0 // indirect
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
golang.org/x/net v0.30.0 // indirect
golang.org/x/sync v0.8.0 // indirect
golang.org/x/sys v0.26.0 // indirect
golang.org/x/term v0.25.0 // indirect

6
go.sum
View File

@ -49,9 +49,14 @@ github.com/go-playground/validator/v10 v10.22.1 h1:40JcKH+bBNGFczGuoBYgX4I6m/i27
github.com/go-playground/validator/v10 v10.22.1/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-github/v62 v62.0.0 h1:/6mGCaRywZz9MuHyw9gD1CwsbmBX8GWsbFkwMmHdhl4=
github.com/google/go-github/v62 v62.0.0/go.mod h1:EMxeUqGJq2xRu9DYBMwel/mr7kZrzUOfQmmpYrZn2a4=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 h1:FKHo8hFI3A+7w0aUQuYXQ+6EN5stWmeY/AZqtM8xk9k=
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@ -257,6 +262,7 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24=
golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@ -13,10 +13,13 @@ func init() {
HomeDir = dir
DataDir = filepath.Join(dir, ".tdl")
if err = os.MkdirAll(DataDir, os.ModePerm); err != nil {
panic(err)
}
LogPath = filepath.Join(DataDir, "log")
ExtensionsPath = filepath.Join(DataDir, "extensions")
ExtensionsDataPath = filepath.Join(ExtensionsPath, "data")
for _, p := range []string{DataDir, ExtensionsPath, ExtensionsDataPath} {
if err = os.MkdirAll(p, 0o755); err != nil {
panic(err)
}
}
}

View File

@ -1,8 +1,10 @@
package consts
var (
HomeDir string
DataDir string
LogPath string
UploadThumbExt = ".thumb"
HomeDir string
DataDir string
LogPath string
ExtensionsPath string
ExtensionsDataPath string
UploadThumbExt = ".thumb"
)

View File

@ -0,0 +1,45 @@
package extensions
import (
"context"
"path/filepath"
"strings"
)
//go:generate go-enum --values --names --flag --nocase
const Prefix = "tdl-"
// ExtensionType ENUM(github, local)
type ExtensionType string
type Extension interface {
Type() ExtensionType
Name() string // Extension Name without tdl- prefix
Path() string // Path to executable
URL() string
Owner() string
CurrentVersion() string
LatestVersion(ctx context.Context) string
UpdateAvailable(ctx context.Context) bool
}
type baseExtension struct {
path string
}
func (e baseExtension) Name() string {
s := strings.TrimPrefix(filepath.Base(e.path), Prefix)
s = strings.TrimSuffix(s, filepath.Ext(s))
return s
}
func (e baseExtension) Path() string {
return e.path
}
type manifest struct {
Owner string `json:"owner,omitempty"`
Repo string `json:"repo,omitempty"`
Tag string `json:"tag,omitempty"`
}

View File

@ -0,0 +1,87 @@
// Code generated by go-enum DO NOT EDIT.
// Version: 0.5.8
// Revision: 3d844c8ecc59661ed7aa17bfd65727bc06a60ad8
// Build Date: 2023-09-18T14:55:21Z
// Built By: goreleaser
package extensions
import (
"fmt"
"strings"
)
const (
// ExtensionTypeGithub is a ExtensionType of type github.
ExtensionTypeGithub ExtensionType = "github"
// ExtensionTypeLocal is a ExtensionType of type local.
ExtensionTypeLocal ExtensionType = "local"
)
var ErrInvalidExtensionType = fmt.Errorf("not a valid ExtensionType, try [%s]", strings.Join(_ExtensionTypeNames, ", "))
var _ExtensionTypeNames = []string{
string(ExtensionTypeGithub),
string(ExtensionTypeLocal),
}
// ExtensionTypeNames returns a list of possible string values of ExtensionType.
func ExtensionTypeNames() []string {
tmp := make([]string, len(_ExtensionTypeNames))
copy(tmp, _ExtensionTypeNames)
return tmp
}
// ExtensionTypeValues returns a list of the values for ExtensionType
func ExtensionTypeValues() []ExtensionType {
return []ExtensionType{
ExtensionTypeGithub,
ExtensionTypeLocal,
}
}
// String implements the Stringer interface.
func (x ExtensionType) String() string {
return string(x)
}
// IsValid provides a quick way to determine if the typed value is
// part of the allowed enumerated values
func (x ExtensionType) IsValid() bool {
_, err := ParseExtensionType(string(x))
return err == nil
}
var _ExtensionTypeValue = map[string]ExtensionType{
"github": ExtensionTypeGithub,
"local": ExtensionTypeLocal,
}
// ParseExtensionType attempts to convert a string to a ExtensionType.
func ParseExtensionType(name string) (ExtensionType, error) {
if x, ok := _ExtensionTypeValue[name]; ok {
return x, nil
}
// Case insensitive parse, do a separate lookup to prevent unnecessary cost of lowercasing a string if we don't need to.
if x, ok := _ExtensionTypeValue[strings.ToLower(name)]; ok {
return x, nil
}
return ExtensionType(""), fmt.Errorf("%s is %w", name, ErrInvalidExtensionType)
}
// Set implements the Golang flag.Value interface func.
func (x *ExtensionType) Set(val string) error {
v, err := ParseExtensionType(val)
*x = v
return err
}
// Get implements the Golang flag.Getter interface func.
func (x *ExtensionType) Get() interface{} {
return *x
}
// Type implements the github.com/spf13/pFlag Value interface.
func (x *ExtensionType) Type() string {
return "ExtensionType"
}

View File

@ -0,0 +1,33 @@
package extensions
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestBaseExtension(t *testing.T) {
tests := []struct {
name string
path string
expectedName string
}{
{
name: "with prefix",
path: "/path/to/tdl-extension",
expectedName: "extension",
},
{
name: "without prefix",
path: "/path/to/extension2",
expectedName: "extension2",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
e := baseExtension{path: tt.path}
assert.Equal(t, tt.expectedName, e.Name())
})
}
}

120
pkg/extensions/github.go Normal file
View File

@ -0,0 +1,120 @@
package extensions
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"github.com/go-faster/errors"
"github.com/google/go-github/v62/github"
)
const (
githubHost = "github.com"
manifestName = "manifest.json"
)
type githubExtension struct {
baseExtension
client *github.Client
mu sync.RWMutex
// lazy loaded
mf *manifest
latestVersion string
}
func (e *githubExtension) Type() ExtensionType {
return ExtensionTypeGithub
}
func (e *githubExtension) URL() string {
if mf, err := e.loadManifest(); err == nil {
return fmt.Sprintf("https://%s/%s/%s", githubHost, mf.Owner, mf.Repo)
}
return ""
}
func (e *githubExtension) Owner() string {
if mf, err := e.loadManifest(); err == nil {
return mf.Owner
}
return ""
}
func (e *githubExtension) CurrentVersion() string {
if mf, err := e.loadManifest(); err == nil {
return mf.Tag
}
return ""
}
func (e *githubExtension) LatestVersion(ctx context.Context) string {
e.mu.RLock()
if e.latestVersion != "" {
defer e.mu.RUnlock()
return e.latestVersion
}
e.mu.RUnlock()
mf, err := e.loadManifest()
if err != nil {
return ""
}
release, _, err := e.client.Repositories.GetLatestRelease(ctx, mf.Owner, mf.Repo)
if err != nil {
return ""
}
e.mu.Lock()
e.latestVersion = release.GetTagName()
e.mu.Unlock()
return e.latestVersion
}
func (e *githubExtension) loadManifest() (*manifest, error) {
e.mu.RLock()
if e.mf != nil {
defer e.mu.RUnlock()
return e.mf, nil
}
e.mu.RUnlock()
dir, _ := filepath.Split(e.Path())
manifestPath := filepath.Join(dir, manifestName)
var mfb []byte
mfb, err := os.ReadFile(manifestPath)
if err != nil {
return nil, errors.Wrapf(err, "read manifest file %s", manifestPath)
}
mf := manifest{}
if err = json.Unmarshal(mfb, &mf); err != nil {
return nil, errors.Wrapf(err, "unmarshal manifest file %s", manifestPath)
}
e.mu.Lock()
e.mf = &mf
e.mu.Unlock()
return e.mf, nil
}
func (e *githubExtension) UpdateAvailable(ctx context.Context) bool {
if e.CurrentVersion() == "" ||
e.LatestVersion(ctx) == "" ||
e.CurrentVersion() == e.LatestVersion(ctx) {
return false
}
return true
}

34
pkg/extensions/local.go Normal file
View File

@ -0,0 +1,34 @@
package extensions
import (
"context"
"fmt"
)
type localExtension struct {
baseExtension
}
func (l *localExtension) Type() ExtensionType {
return ExtensionTypeLocal
}
func (l *localExtension) URL() string {
return fmt.Sprintf("file://%s", l.Path())
}
func (l *localExtension) Owner() string {
return "local"
}
func (l *localExtension) CurrentVersion() string {
return ""
}
func (l *localExtension) LatestVersion(_ context.Context) string {
return ""
}
func (l *localExtension) UpdateAvailable(_ context.Context) bool {
return false
}

View File

@ -0,0 +1,41 @@
package extensions
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
)
func TestLocalExtension(t *testing.T) {
tests := []struct {
name string
ext *localExtension
expectedURL string
}{
{
name: "local 1",
ext: &localExtension{
baseExtension: baseExtension{path: "/path/to/local"},
},
expectedURL: "file:///path/to/local",
},
{
name: "local 2",
ext: &localExtension{
baseExtension: baseExtension{path: "/path/to/local2"},
},
expectedURL: "file:///path/to/local2",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expectedURL, tt.ext.URL())
assert.Equal(t, "local", tt.ext.Owner())
assert.Equal(t, "", tt.ext.CurrentVersion())
assert.Equal(t, "", tt.ext.LatestVersion(context.TODO()))
assert.False(t, tt.ext.UpdateAvailable(context.TODO()))
})
}
}

398
pkg/extensions/manager.go Normal file
View File

@ -0,0 +1,398 @@
package extensions
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"runtime/debug"
"strings"
"sync"
"github.com/go-faster/errors"
"github.com/google/go-github/v62/github"
"go.uber.org/multierr"
"github.com/iyear/tdl/extension"
)
var (
ErrAlreadyUpToDate = errors.New("already up to date")
ErrOnlyGitHub = errors.New("only GitHub extension can be upgraded by tdl")
)
type Manager struct {
dir string
http *http.Client
github *github.Client
dryRun bool
}
func NewManager(dir string) *Manager {
return &Manager{
dir: dir,
http: http.DefaultClient,
github: newGhClient(http.DefaultClient),
dryRun: false,
}
}
func newGhClient(c *http.Client) *github.Client {
ghToken := os.Getenv("GITHUB_TOKEN")
if ghToken == "" {
return github.NewClient(c)
}
return github.NewClient(c).WithAuthToken(ghToken)
}
func (m *Manager) SetDryRun(v bool) {
m.dryRun = v
}
func (m *Manager) DryRun() bool {
return m.dryRun
}
func (m *Manager) SetClient(client *http.Client) {
m.http = client
m.github = newGhClient(client)
}
func (m *Manager) Dispatch(ctx context.Context, ext Extension, args []string, env *extension.Env, stdin io.Reader, stdout, stderr io.Writer) (rerr error) {
cmd := exec.CommandContext(ctx, ext.Path(), args...)
envFile, err := os.CreateTemp("", "*")
if err != nil {
return errors.Wrap(err, "create temp")
}
defer func() { multierr.AppendInto(&rerr, os.Remove(envFile.Name())) }()
envBytes, err := json.Marshal(env)
if err != nil {
return errors.Wrap(err, "marshal env")
}
if _, err = envFile.Write(envBytes); err != nil {
return errors.Wrap(err, "write env to temp")
}
if err = envFile.Close(); err != nil {
return errors.Wrap(err, "close env file")
}
cmd.Env = append(os.Environ(), fmt.Sprintf("%s=%s", extension.EnvKey, envFile.Name()))
cmd.Args = append([]string{Prefix + ext.Name()}, args...) // reset args[0] to extension name instead of binary path
cmd.Stdin = stdin
cmd.Stdout = stdout
cmd.Stderr = stderr
return cmd.Run()
}
func (m *Manager) List(ctx context.Context, includeLatestVersion bool) ([]Extension, error) {
entries, err := os.ReadDir(m.dir)
if err != nil {
return nil, errors.Wrap(err, "read dir entries")
}
extensions := make([]Extension, 0, len(entries))
for _, f := range entries {
if !strings.HasPrefix(f.Name(), Prefix) {
continue
}
if !f.IsDir() {
continue
}
if _, err = os.Stat(filepath.Join(m.dir, f.Name(), manifestName)); err == nil {
extensions = append(extensions, &githubExtension{
baseExtension: baseExtension{path: filepath.Join(m.dir, f.Name(), f.Name())},
client: m.github,
})
} else {
extensions = append(extensions, &localExtension{
baseExtension: baseExtension{path: filepath.Join(m.dir, f.Name(), f.Name())},
})
}
}
if includeLatestVersion {
m.populateLatestVersions(ctx, extensions)
}
return extensions, nil
}
// Upgrade only GitHub extension can be upgraded
func (m *Manager) Upgrade(ctx context.Context, ext Extension) error {
switch e := ext.(type) {
case *githubExtension:
if !ext.UpdateAvailable(ctx) {
return ErrAlreadyUpToDate
}
mf, err := e.loadManifest()
if err != nil {
return errors.Wrapf(err, "load manifest of %q", e.Name())
}
if !m.dryRun {
if err = m.Remove(ext); err != nil {
return errors.Wrapf(err, "remove old version extension")
}
if err = m.installGitHub(ctx, mf.Owner, mf.Repo, false); err != nil {
return errors.Wrapf(err, "install GitHub extension %q", e.Name())
}
}
return nil
default:
return ErrOnlyGitHub
}
}
// Install installs an extension by target.
// Valid targets are:
// - GitHub: owner/repo
// - Local: path to executable.
func (m *Manager) Install(ctx context.Context, target string, force bool) error {
// local
if _, err := os.Stat(target); err == nil {
return m.installLocal(target, force)
}
// github
ownerRepo := strings.Split(target, "/")
if len(ownerRepo) != 2 {
return errors.Errorf("invalid target: %q", target)
}
return m.installGitHub(ctx, ownerRepo[0], ownerRepo[1], force)
}
func (m *Manager) installLocal(path string, force bool) error {
src, err := os.Lstat(path)
if err != nil {
return errors.Wrap(err, "source extension stat")
}
if !src.Mode().IsRegular() {
return errors.Errorf("invalid src extension: %q, only regular file is allowed", path)
}
name := src.Name()
if !strings.HasPrefix(name, Prefix) {
name = Prefix + name
}
targetDir := filepath.Join(m.dir, strings.TrimSuffix(name, filepath.Ext(name)))
binPath := filepath.Join(targetDir, name)
if err = m.maybeExist(binPath, force); err != nil {
return err
}
if !m.dryRun {
if err = os.MkdirAll(targetDir, 0o755); err != nil {
return errors.Wrapf(err, "create target dir %q for extension %q", targetDir, name)
}
if err = copyRegularFile(path, binPath); err != nil {
return errors.Wrapf(err, "install local extension: %q", path)
}
}
return nil
}
func (m *Manager) installGitHub(ctx context.Context, owner, repo string, force bool) (rerr error) {
if !strings.HasPrefix(repo, Prefix) {
return errors.Errorf("invalid repo name: %q, should start with %q", repo, Prefix)
}
platform, ext := platformBinaryName()
targetDir := filepath.Join(m.dir, repo)
binPath := filepath.Join(targetDir, repo) + ext
if err := m.maybeExist(binPath, force); err != nil {
return err
}
release, _, err := m.github.Repositories.GetLatestRelease(ctx, owner, repo)
if err != nil {
return errors.Wrapf(err, "get latest release of %s/%s", owner, repo)
}
// match binary name
var asset *github.ReleaseAsset
for _, a := range release.Assets {
if strings.HasSuffix(a.GetName(), platform+ext) {
asset = a
break
}
}
if asset == nil {
return errors.Errorf("no matched binary(%s) found in the release(%s)", platform+ext, release.GetHTMLURL())
}
if !m.dryRun {
if err = os.MkdirAll(targetDir, 0o755); err != nil {
return errors.Wrapf(err, "create target dir %q for extension %s/%s", targetDir, owner, repo)
}
if err = m.downloadGitHubAsset(ctx, owner, repo, asset, binPath); err != nil {
return errors.Wrapf(err, "download github asset %s", asset.GetBrowserDownloadURL())
}
}
mf := &manifest{
Owner: owner,
Repo: repo,
Tag: release.GetTagName(),
}
mfb, err := json.Marshal(mf)
if err != nil {
return errors.Wrap(err, "marshal manifest")
}
if !m.dryRun {
if err = os.WriteFile(filepath.Join(targetDir, manifestName), mfb, 0o644); err != nil {
return errors.Wrapf(err, "write manifest to %s", targetDir)
}
}
return nil
}
func (m *Manager) maybeExist(binPath string, force bool) error {
targetDir := filepath.Dir(binPath)
extName := filepath.Base(targetDir)
if _, err := os.Lstat(binPath); err != nil {
return nil
}
if !force {
return errors.Errorf("extension already exists, please remove it first")
}
// force remove
if !m.dryRun {
if err := os.RemoveAll(targetDir); err != nil {
return errors.Wrapf(err, "remove existing extension %q", extName)
}
}
return nil
}
// Remove removes an extension by name(without prefix).
func (m *Manager) Remove(ext Extension) error {
target := Prefix + ext.Name()
targetDir := filepath.Join(m.dir, target)
if _, err := os.Lstat(targetDir); os.IsNotExist(err) {
return errors.Errorf("no extension found: %s", targetDir)
}
if !m.dryRun {
return os.RemoveAll(targetDir)
}
return nil
}
func (m *Manager) populateLatestVersions(ctx context.Context, exts []Extension) {
wg := &sync.WaitGroup{}
for _, ext := range exts {
wg.Add(1)
go func(e Extension) {
defer wg.Done()
e.LatestVersion(ctx)
}(ext)
}
wg.Wait()
}
func (m *Manager) downloadGitHubAsset(ctx context.Context, owner, repo string, asset *github.ReleaseAsset, dst string) (rerr error) {
readCloser, _, err := m.github.Repositories.DownloadReleaseAsset(ctx, owner, repo, asset.GetID(), m.http)
if err != nil {
return errors.Wrapf(err, "download release asset %s", asset.GetName())
}
defer multierr.AppendInvoke(&rerr, multierr.Close(readCloser))
file, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
if err != nil {
return errors.Wrapf(err, "open file %s", dst)
}
defer multierr.AppendInvoke(&rerr, multierr.Close(file))
if _, err = io.Copy(file, readCloser); err != nil {
return errors.Wrapf(err, "copy http body to %s", dst)
}
return nil
}
func copyRegularFile(src, dst string) (rerr error) {
r, err := os.Open(src)
if err != nil {
return errors.Wrapf(err, "open src %s", src)
}
defer multierr.AppendInvoke(&rerr, multierr.Close(r))
info, err := r.Stat()
if err != nil {
return errors.Wrapf(err, "stat file %s", src)
}
if !info.Mode().IsRegular() {
return errors.Errorf("invalid source file: %q, only regular file is allowed", src)
}
w, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o666|info.Mode()&0o777)
if err != nil {
return errors.Wrapf(err, "open dst %s", dst)
}
defer multierr.AppendInvoke(&rerr, multierr.Close(w))
if _, err = io.Copy(w, r); err != nil {
return errors.Wrapf(err, "copy file %s to %s", src, dst)
}
return nil
}
func platformBinaryName() (string, string) {
ext := ""
if runtime.GOOS == "windows" {
ext = ".exe"
}
arch := runtime.GOARCH
switch arch {
case "arm":
if goarm := extractGOARM(); goarm != "" {
arch += "v" + goarm
}
}
return fmt.Sprintf("%s-%s", runtime.GOOS, arch), ext
}
func extractGOARM() string {
info, ok := debug.ReadBuildInfo()
if !ok {
return ""
}
for _, setting := range info.Settings {
if setting.Key == "GOARM" {
return setting.Value
}
}
return ""
}

View File

@ -5,10 +5,12 @@ const (
AppDesktop = "desktop"
)
var Apps = map[string]struct {
type App struct {
AppID int
AppHash string
}{
}
var Apps = map[string]App{
// application created by iyear
AppBuiltin: {AppID: 15055931, AppHash: "021d433426cbb920eeb95164498fe3d3"},
// application created by tdesktop.

View File

@ -5,6 +5,7 @@ import (
"fmt"
"time"
"github.com/go-faster/errors"
"github.com/gotd/td/telegram"
"github.com/iyear/tdl/core/tclient"
@ -21,14 +22,23 @@ type Options struct {
UpdateHandler telegram.UpdateHandler
}
func New(ctx context.Context, o Options, login bool, middlewares ...telegram.Middleware) (*telegram.Client, error) {
mode, err := o.KV.Get(key.App())
func GetApp(kv kv.KV) (App, error) {
mode, err := kv.Get(key.App())
if err != nil {
mode = []byte(AppBuiltin)
}
app, ok := Apps[string(mode)]
if !ok {
return nil, fmt.Errorf("can't find app: %s, please try re-login", mode)
return App{}, fmt.Errorf("can't find app: %s, please try re-login", mode)
}
return app, nil
}
func New(ctx context.Context, o Options, login bool, middlewares ...telegram.Middleware) (*telegram.Client, error) {
app, err := GetApp(o.KV)
if err != nil {
return nil, errors.Wrap(err, "get app")
}
return tclient.New(ctx, tclient.Options{