diff --git a/.goreleaser.yaml b/.goreleaser.yaml index ffa9d5e..ab6185a 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -1,5 +1,19 @@ project_name: tg builds: + - id: tgsend + main: ./cmd/tgsend + ldflags: + - -s -w + binary: tgsend + goos: + - darwin + - linux + goarch: + - amd64 + - arm64 + ignore: + - goos: linux + goarch: arm64 - id: tg main: ./cmd/tg ldflags: @@ -15,6 +29,13 @@ builds: - goos: linux goarch: arm64 archives: + - id: tgsend + builds: + - tgsend + name_template: '{{ .ProjectName }}_v{{ .Version }}_{{ .Os }}_{{ .Arch }}' + format: tar.gz + files: + - xyz* - id: tg builds: - tg diff --git a/cmd/tg/internal/cmd/cmd.go b/cmd/tg/internal/cmd/cmd.go new file mode 100644 index 0000000..58be037 --- /dev/null +++ b/cmd/tg/internal/cmd/cmd.go @@ -0,0 +1,266 @@ +package cmd + +import ( + "errors" + "flag" + "fmt" + "io" + "os" + "path" + "reflect" + "sort" + "strings" +) + +type FlagFunc func(*flag.FlagSet) + +func EmptyFlagFunc() func(*flag.FlagSet) { + return func(_ *flag.FlagSet) {} +} + +type RunFunc func() error + +type command struct { + name string + desc string + flag *flag.FlagSet + run RunFunc +} + +type Commander struct { + output io.Writer + cmds map[string]*command +} + +const rootCmd = "_" + +func New() *Commander { + cm := new(Commander) + cm.output = os.Stderr + cm.cmds = make(map[string]*command) + + cm.cmds[rootCmd] = new(command) + cm.cmds[rootCmd].name = path.Base(os.Args[0]) + cm.cmds[rootCmd].flag = flag.NewFlagSet("", flag.ContinueOnError) + cm.cmds[rootCmd].flag.SetOutput(io.Discard) + + return cm +} + +func (c *Commander) SetOutput(out io.Writer) { + c.output = out +} + +func defaultValue(value flag.Value, defValue string) string { + flagType := reflect.TypeOf(value) + + var newValue reflect.Value + + if flagType.Kind() == reflect.Pointer { + newValue = reflect.New(flagType.Elem()) + } else { + newValue = reflect.Zero(flagType) + } + + newFlag, ok := newValue.Interface().(flag.Value) + if !ok || defValue == newFlag.String() || defValue == "" { + return "" + } + + if newValue.String() == "<*flag.stringValue Value>" { + return fmt.Sprintf(" (default %q)", defValue) + } + + return fmt.Sprintf(" (default %v)", defValue) +} + +func (c *Commander) usage(fset *flag.FlagSet) { + maxLen := 0 + + fset.VisitAll(func(ff *flag.Flag) { + name, _ := flag.UnquoteUsage(ff) + + strLen := len(name) + len(ff.Name) + + if strLen > maxLen { + maxLen = strLen + } + }) + + maxLen += 4 + + fset.VisitAll(func(ff *flag.Flag) { + name, _ := flag.UnquoteUsage(ff) + + spaces := strings.Repeat(" ", maxLen-(len(name)+len(ff.Name))) + def := defaultValue(ff.Value, ff.DefValue) + + fmt.Fprintf(c.output, " --%s %s%s%s%s\n", ff.Name, name, spaces, ff.Usage, def) + }) +} + +func (c *Commander) Root(name string, fn FlagFunc) { + c.cmds[rootCmd].name = name + + fn(c.cmds[rootCmd].flag) +} + +func (c *Commander) rootHelp() { + fmt.Fprintf(c.output, "Usage:\n %s [flags]", c.cmds[rootCmd].name) + + if len(c.cmds) > 1 { + fmt.Fprintf(c.output, " [command]\n\nAvailable Commands:\n") + + cmds := make([]string, 0, len(c.cmds)) + + maxCmdLen := 0 + + for cmd := range c.cmds { + if cmd == "_" { + continue + } + + cmds = append(cmds, cmd) + + if len(cmd) > maxCmdLen { + maxCmdLen = len(cmd) + } + } + + sort.Strings(cmds) + + maxCmdLen += 4 + + for _, cmd := range cmds { + spaces := strings.Repeat(" ", maxCmdLen-len(cmd)) + + fmt.Fprintf(c.output, " %s%s%s\n", cmd, spaces, c.cmds[cmd].desc) + } + } else { + fmt.Fprint(c.output, "\n") + } + + fmt.Fprint(c.output, "\nFlags:\n") + + c.usage(c.cmds[rootCmd].flag) +} + +func (c *Commander) rootError(err error) { + fmt.Fprintf(c.output, "Error: %s\n\nRun '%s --help' for usage.\n", err, c.cmds[rootCmd].name) +} + +func (c *Commander) Command(name, desc string, fn FlagFunc, runFn RunFunc) { + fset := flag.NewFlagSet(name, flag.ContinueOnError) + fset.SetOutput(io.Discard) + + fn(fset) + + c.cmds[name] = &command{ + name: name, + desc: desc, + flag: fset, + run: runFn, + } +} + +func (c *Commander) commandHelp(name string) { + if cmd, ok := c.cmds[name]; ok { + if cmd.desc != "" { + fmt.Fprintf(c.output, "Description: %s\n\n", cmd.desc) + } + } + + flags := false + + c.cmds[name].flag.VisitAll( + func(*flag.Flag) { + if !flags { + flags = true + } + }, + ) + + fmt.Fprintf(c.output, "Usage:\n %s [global flags] %s", c.cmds[rootCmd].name, name) + + if flags { + fmt.Fprint(c.output, " [flags]\n\nFlags:\n") + + if cmd, ok := c.cmds[name]; ok { + c.usage(cmd.flag) + } + } else { + fmt.Fprint(c.output, "\n") + } + + fmt.Fprint(c.output, "\nGlobal Flags:\n") + + c.usage(c.cmds[rootCmd].flag) +} + +func (c *Commander) commandError(name string, err error) { + fmt.Fprintf(c.output, "Error: %s\n\nRun '%s %s--help' for usage.\n", err, c.cmds[rootCmd].name, name) +} + +func (c *Commander) Run() { //nolint:cyclop + if err := c.cmds[rootCmd].flag.Parse(os.Args[1:]); err != nil { + if errors.Is(flag.ErrHelp, err) { + c.rootHelp() + } else { + c.rootError(err) + } + + os.Exit(1) + } + + if len(c.cmds[rootCmd].flag.Args()) == 0 { + c.rootHelp() + + os.Exit(1) + } + + name := c.cmds[rootCmd].flag.Args()[0] + args := c.cmds[rootCmd].flag.Args()[1:] + + cmd, ok := c.cmds[name] + if !ok || name == rootCmd { + err := fmt.Sprintf("unknown command %q", name) + + c.rootError(errors.New(err)) //nolint:goerr113 + + os.Exit(1) + } + + if len(args) == 0 { + flags := false + + c.cmds[name].flag.VisitAll(func(_ *flag.Flag) { + if !flags { + flags = true + } + }) + + if flags { + c.commandHelp(name) + + os.Exit(1) + } + } + + if err := cmd.flag.Parse(args); err != nil { + if errors.Is(flag.ErrHelp, err) { + c.commandHelp(name) + } else { + c.commandError(name, err) + } + + os.Exit(1) + } + + if err := cmd.run(); err != nil { + fmt.Fprintln(c.output, "Error: ", err.Error()) + + os.Exit(1) + } + + os.Exit(0) +} diff --git a/cmd/tg/main.go b/cmd/tg/main.go index 36b946d..9a7b35b 100644 --- a/cmd/tg/main.go +++ b/cmd/tg/main.go @@ -1,93 +1,182 @@ +//nolint:wrapcheck package main import ( "context" "errors" "flag" - "fmt" "io" "log/slog" "os" "github.com/a-kataev/tg" + "github.com/a-kataev/tg/cmd/tg/internal/cmd" ) -func logFatal(log *slog.Logger, msg string) { - log.Error(msg) +type flags struct { + token string + chatID int64 + text string + parseMode string + messageID int64 + messageThreadID int64 + disableWebPagePreview bool + disableNotification bool + protectContent bool +} - os.Exit(1) +func (f *flags) tokenFormEnv() { + if f.token == "" { + f.token = os.Getenv("TG_TOKEN") + } } -func main() { - fset := flag.NewFlagSet("", flag.ContinueOnError) - fset.SetOutput(io.Discard) - - token := fset.String("token", "", "token (environment TG_TOKEN)") - chatID := fset.Int64("chat_id", 0, "chat id") - text := fset.String("text", "", "text (use - for read pipe)") - parseMode := fset.String("parse_mode", "Markdown", "parse mode") - messageThreadID := fset.Int64("message_thread_id", 0, "message thread id") - disableWebPagePreview := fset.Bool("disable_web_page_preview", false, "disable web page preview") - disableNotification := fset.Bool("disable_notification", false, "disable notification") - protectContent := fset.Bool("protect_content", false, "protect content") +func (f *flags) textFromPipe() error { + if f.text == "-" { + stdin, err := io.ReadAll(io.LimitReader(os.Stdin, int64(tg.MaxTextSize))) + if err != nil { + if !errors.Is(err, io.EOF) { + return err + } + } - log := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + f.text = string(stdin) + } + + return nil +} + +func (f *flags) rootFlags() func(*flag.FlagSet) { + return func(fset *flag.FlagSet) { + fset.StringVar(&f.token, "token", "", "bot token") + } +} + +func (f *flags) sendFlags() func(*flag.FlagSet) { + return func(fset *flag.FlagSet) { + fset.Int64Var(&f.chatID, "chat-id", 0, "chat id") + fset.StringVar(&f.text, "text", "", "text (use - for read pipe)") + fset.StringVar(&f.parseMode, "parse-mode", "Markdown", "parse mode") + fset.Int64Var(&f.messageThreadID, "message-thread-id", 0, "message thread id") + fset.BoolVar(&f.disableWebPagePreview, "disable-web-page-preview", false, "disable web page preview") + fset.BoolVar(&f.disableNotification, "disable-notification", false, "disable notification") + fset.BoolVar(&f.protectContent, "protect-content", false, "protect content") + } +} + +func (f *flags) sendRun(ctx context.Context, log *slog.Logger) func() error { + return func() error { + f.tokenFormEnv() + + if err := f.textFromPipe(); err != nil { + return err + } - err := fset.Parse(os.Args[1:]) - if err != nil { - if errors.Is(flag.ErrHelp, err) { - fmt.Fprintln(os.Stderr, "tg - telegram bot 🤖 send message ✉️") - fset.SetOutput(os.Stderr) - fset.Usage() + client, err := tg.NewClient(f.token) + if err != nil { + return err + } - os.Exit(1) + msg, err := client.SendMessage(ctx, f.chatID, f.text, + tg.ParseModeSendOption(tg.ParseMode(f.parseMode)), + tg.MessageThreadIDSendOption(f.messageThreadID), + tg.DisableWebPagePreviewSendOption(f.disableWebPagePreview), + tg.DisableNotificationSendOption(f.disableNotification), + tg.ProtectContentSendOption(f.protectContent), + ) + if err != nil { + return err } - logFatal(log, err.Error()) + log.Info("Success send message", + slog.Int64("chat_id", f.chatID), + slog.Any("message_id", msg.MessageID), + ) + + return nil } +} - if *token == "" { - *token = os.Getenv("TG_TOKEN") +func (f *flags) editFlags() func(*flag.FlagSet) { + return func(fset *flag.FlagSet) { + fset.Int64Var(&f.chatID, "chat-id", 0, "chat id") + fset.StringVar(&f.text, "text", "", "text (use - for read pipe)") + fset.StringVar(&f.parseMode, "parse-mode", "Markdown", "parse mode") + fset.Int64Var(&f.messageID, "message-id", 0, "message id") } +} - if *text == "-" { - stdin, err := io.ReadAll(io.LimitReader(os.Stdin, int64(tg.MaxTextSize))) +func (f *flags) editRun(ctx context.Context, log *slog.Logger) func() error { + return func() error { + f.tokenFormEnv() + + if err := f.textFromPipe(); err != nil { + return err + } + + client, err := tg.NewClient(f.token) if err != nil { - if !errors.Is(err, io.EOF) { - logFatal(log, err.Error()) - } + return err + } + + msg, err := client.EditMessage(ctx, f.chatID, f.messageID, f.text, + tg.ParseModeEditOption(tg.ParseMode(f.parseMode)), + ) + if err != nil { + return err } - *text = string(stdin) + log.Info("Success edit message", + slog.Int64("chat_id", f.chatID), + slog.Any("message_id", msg.MessageID), + ) + + return nil } +} - client, err := tg.NewClient(*token) - if err != nil { - logFatal(log, err.Error()) +func (f *flags) deleteFlags() func(*flag.FlagSet) { + return func(fset *flag.FlagSet) { + fset.Int64Var(&f.chatID, "chat-id", 0, "chat id") + fset.Int64Var(&f.messageID, "message-id", 0, "message id") } +} - ctx := context.Background() +func (f *flags) deleteRun(ctx context.Context, log *slog.Logger) func() error { + return func() error { + f.tokenFormEnv() - bot, err := client.GetMe(ctx) - if err != nil { - logFatal(log, err.Error()) - } + client, err := tg.NewClient(f.token) + if err != nil { + return err + } + + _, err = client.DeleteMessage(ctx, f.chatID, f.messageID) + if err != nil { + return err + } - log = log.With(slog.String("bot_name", bot.UserName)) - - msg, err := client.SendMessage(ctx, *chatID, *text, - tg.ParseModeSendOption(tg.ParseMode(*parseMode)), - tg.MessageThreadIDSendOption(*messageThreadID), - tg.DisableWebPagePreviewSendOption(*disableWebPagePreview), - tg.DisableNotificationSendOption(*disableNotification), - tg.ProtectContentSendOption(*protectContent), - ) - if err != nil { - logFatal(log, err.Error()) + log.Info("Success delete message", + slog.Int64("chat_id", f.chatID), + slog.Any("message_id", f.messageID), + ) + + return nil } +} + +func main() { + ctx := context.Background() + log := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + + app := cmd.New() + + flags := new(flags) + + app.Root("tg", flags.rootFlags()) + app.Command("send", "send message", flags.sendFlags(), flags.sendRun(ctx, log)) + app.Command("edit", "edit message", flags.editFlags(), flags.editRun(ctx, log)) + app.Command("delete", "delete message", flags.deleteFlags(), flags.deleteRun(ctx, log)) - log.Info("Success send message", - slog.Int64("chat_id", *chatID), - slog.Any("message_id", msg.MessageID), - ) + app.Run() } diff --git a/cmd/tgsend/main.go b/cmd/tgsend/main.go new file mode 100644 index 0000000..f501635 --- /dev/null +++ b/cmd/tgsend/main.go @@ -0,0 +1,93 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "log/slog" + "os" + + "github.com/a-kataev/tg" +) + +func logFatal(log *slog.Logger, msg string) { + log.Error(msg) + + os.Exit(1) +} + +func main() { + fset := flag.NewFlagSet("tgsend", flag.ContinueOnError) + fset.SetOutput(io.Discard) + + token := fset.String("token", "", "token (environment TG_TOKEN)") + chatID := fset.Int64("chat_id", 0, "chat id") + text := fset.String("text", "", "text (use - for read pipe)") + parseMode := fset.String("parse_mode", "Markdown", "parse mode") + messageThreadID := fset.Int64("message_thread_id", 0, "message thread id") + disableWebPagePreview := fset.Bool("disable_web_page_preview", false, "disable web page preview") + disableNotification := fset.Bool("disable_notification", false, "disable notification") + protectContent := fset.Bool("protect_content", false, "protect content") + + log := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + + err := fset.Parse(os.Args[1:]) + if err != nil { + if errors.Is(flag.ErrHelp, err) { + fmt.Fprintln(os.Stderr, "tg - telegram bot 🤖 send message ✉️") + fset.SetOutput(os.Stderr) + fset.Usage() + + os.Exit(1) + } + + logFatal(log, err.Error()) + } + + if *token == "" { + *token = os.Getenv("TG_TOKEN") + } + + if *text == "-" { + stdin, err := io.ReadAll(io.LimitReader(os.Stdin, int64(tg.MaxTextSize))) + if err != nil { + if !errors.Is(err, io.EOF) { + logFatal(log, err.Error()) + } + } + + *text = string(stdin) + } + + client, err := tg.NewClient(*token) + if err != nil { + logFatal(log, err.Error()) + } + + ctx := context.Background() + + bot, err := client.GetMe(ctx) + if err != nil { + logFatal(log, err.Error()) + } + + log = log.With(slog.String("bot_name", bot.UserName)) + + msg, err := client.SendMessage(ctx, *chatID, *text, + tg.ParseModeSendOption(tg.ParseMode(*parseMode)), + tg.MessageThreadIDSendOption(*messageThreadID), + tg.DisableWebPagePreviewSendOption(*disableWebPagePreview), + tg.DisableNotificationSendOption(*disableNotification), + tg.ProtectContentSendOption(*protectContent), + ) + if err != nil { + logFatal(log, err.Error()) + } + + log.Info("Success send message", + slog.Int64("chat_id", *chatID), + slog.Any("message_id", msg.MessageID), + ) +}