mirror of
https://github.com/iyear/tdl
synced 2025-01-07 03:16:41 +08:00
feat(prj): support tdl extensions (#780)
This commit is contained in:
parent
ace2402c06
commit
98dac73585
1
.github/dependabot.yml
vendored
1
.github/dependabot.yml
vendored
@ -9,6 +9,7 @@ updates:
|
||||
directories:
|
||||
- "/"
|
||||
- "core"
|
||||
- "extension"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
assignees:
|
||||
|
1
.github/workflows/master.yml
vendored
1
.github/workflows/master.yml
vendored
@ -24,6 +24,7 @@ jobs:
|
||||
directory:
|
||||
- .
|
||||
- core
|
||||
- extension
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
159
app/extension/extension.go
Normal file
159
app/extension/extension.go
Normal 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
147
cmd/extension.go
Normal 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,
|
||||
}
|
||||
}
|
61
cmd/root.go
61
cmd/root.go
@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
172
docs/content/en/guide/extensions.md
Normal file
172
docs/content/en/guide/extensions.md
Normal 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.
|
172
docs/content/zh/guide/extensions.md
Normal file
172
docs/content/zh/guide/extensions.md
Normal 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
169
extension/extension.go
Normal 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
36
extension/go.mod
Normal 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
64
extension/go.sum
Normal 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
12
go.mod
@ -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
6
go.sum
@ -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=
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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"
|
||||
)
|
||||
|
45
pkg/extensions/extensions.go
Normal file
45
pkg/extensions/extensions.go
Normal 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"`
|
||||
}
|
87
pkg/extensions/extensions_enum.go
Normal file
87
pkg/extensions/extensions_enum.go
Normal 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"
|
||||
}
|
33
pkg/extensions/extensions_test.go
Normal file
33
pkg/extensions/extensions_test.go
Normal 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
120
pkg/extensions/github.go
Normal 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
34
pkg/extensions/local.go
Normal 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
|
||||
}
|
41
pkg/extensions/local_test.go
Normal file
41
pkg/extensions/local_test.go
Normal 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
398
pkg/extensions/manager.go
Normal 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 ""
|
||||
}
|
@ -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.
|
||||
|
@ -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{
|
||||
|
Loading…
Reference in New Issue
Block a user