diff --git a/cmd/gh-slack/cmd/api.go b/cmd/gh-slack/cmd/api.go new file mode 100644 index 0000000..37453b3 --- /dev/null +++ b/cmd/gh-slack/cmd/api.go @@ -0,0 +1,136 @@ +package cmd + +import ( + "fmt" + "io" + "log" + "strings" + + "github.com/cli/go-gh/pkg/config" + "github.com/rneatherway/gh-slack/internal/slackclient" + "github.com/spf13/cobra" +) + +var apiCmd = &cobra.Command{ + Use: "api [verb] path", + Short: "Send an API call to slack", + Long: "Send an API call to slack", + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := config.Read() + if err != nil { + return err + } + + team, err := getFlagOrElseConfig(cfg, cmd.Flags(), "team") + if err != nil { + return err + } + + fields, err := cmd.Flags().GetStringArray("field") + if err != nil { + return err + } + + mappedFields, err := mapFields(fields) + if err != nil { + return err + } + + logger := log.New(io.Discard, "", log.LstdFlags) + if verbose { + logger = log.Default() + } + + client, err := slackclient.New(team, logger) + if err != nil { + return err + } + + var verb, path string + if len(args) == 2 { + verb = strings.ToUpper(args[0]) + path = args[1] + } else if len(args) == 1 { + path = args[0] + if body == "" { + verb = "GET" + } else { + verb = "POST" + } + } else { + return fmt.Errorf("Expected 1 or 2 arguments: verb and/or path, see help") + } + + response, err := client.API(verb, path, mappedFields, body) + if err != nil { + return err + } + + fmt.Println(string(response)) + return nil + }, + Example: ` gh-slack api get conversations.list -f types=public_channel,private_channel + gh-slack api post chat.postMessage -b '{"channel":"123","blocks":[...]}`, +} + +var fields []string +var body string + +func init() { + apiCmd.Flags().StringArrayVarP(&fields, "field", "f", nil, "Fields to pass to the api call") + apiCmd.Flags().StringVarP(&body, "body", "b", "", "Body to send as JSON") + apiCmd.Flags().StringP("team", "t", "", "Slack team name (required here or in config)") + apiCmd.SetHelpTemplate(apiCmdUsage) + apiCmd.SetUsageTemplate(apiCmdUsage) +} + +func mapFields(fields []string) (map[string]string, error) { + mappedFields := map[string]string{} + + for _, field := range fields { + parts := strings.SplitN(field, "=", 2) + + if len(parts) != 2 || parts[1] == "" { + return nil, fmt.Errorf("field '%s' is missing a value", field) + } + + mappedFields[parts[0]] = parts[1] + } + + return mappedFields, nil +} + +const apiCmdUsage string = `Usage:{{if .Runnable}} + {{.UseLine}}{{end}}{{if .HasAvailableSubCommands}} + {{.CommandPath}}{{end}}{{if gt (len .Aliases) 0}} +Aliases: + {{.NameAndAliases}}{{end}}{{if .HasExample}} + +Examples: +{{.Example}} + +The verb is optional: +- If no body is sent, GET will be used. +- If a body is sent, POST will be used. +{{end}}{{if .HasAvailableSubCommands}}{{$cmds := .Commands}}{{if eq (len .Groups) 0}} + +Available Commands:{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name "help"))}} + {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{else}}{{range $group := .Groups}} + +{{.Title}}{{range $cmds}}{{if (and (eq .GroupID $group.ID) (or .IsAvailableCommand (eq .Name "help")))}} + {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if not .AllChildCommandsHaveGroup}} + +Additional Commands:{{range $cmds}}{{if (and (eq .GroupID "") (or .IsAvailableCommand (eq .Name "help")))}} + {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}} + +Flags: +{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}} + +Global Flags: +{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}} + +Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}} + {{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}} + +Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}} +` diff --git a/cmd/gh-slack/cmd/root.go b/cmd/gh-slack/cmd/root.go index 81cbc85..b417f36 100644 --- a/cmd/gh-slack/cmd/root.go +++ b/cmd/gh-slack/cmd/root.go @@ -24,6 +24,7 @@ var rootCmd = &cobra.Command{ gh-slack read gh-slack read -i gh-slack send -m -c -t + gh-slack api post chat.postMessage -b '{"channel":"123","blocks":[...]} ` + sendConfigExample, } @@ -41,6 +42,7 @@ var verbose bool = false func init() { rootCmd.AddCommand(readCmd) rootCmd.AddCommand(sendCmd) + rootCmd.AddCommand(apiCmd) rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Show verbose debug information") rootCmd.SetHelpTemplate(rootCmdUsageTemplate) rootCmd.SetUsageTemplate(rootCmdUsageTemplate) diff --git a/internal/slackclient/client.go b/internal/slackclient/client.go index 6e679db..a0f40cf 100644 --- a/internal/slackclient/client.go +++ b/internal/slackclient/client.go @@ -184,25 +184,36 @@ func (c *SlackClient) UsernameForMessage(message Message) (string, error) { return "ghost", nil } -func (c *SlackClient) get(path string, params map[string]string) ([]byte, error) { +func (c *SlackClient) API(verb, path string, params map[string]string, body string) ([]byte, error) { u, err := url.Parse(fmt.Sprintf("https://%s.slack.com/api/", c.team)) if err != nil { return nil, err } u.Path += path q := u.Query() - q.Add("token", c.auth.Token) for p := range params { q.Add(p, params[p]) } u.RawQuery = q.Encode() - var body []byte + messageBytes := []byte(body) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal message: %w", err) + } + + reqBody := bytes.NewReader(messageBytes) + + resBody := []byte{} + for { - req, err := http.NewRequest("GET", u.String(), nil) + req, err := http.NewRequest(verb, u.String(), reqBody) if err != nil { return nil, err } + // FIXME: this doesn't seem to break non-POST/non-data requests, but migth + // be polluting the headers. + req.Header.Set("Content-Type", "application/json; charset=utf-8") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.auth.Token)) for key := range c.auth.Cookies { req.AddCookie(&http.Cookie{Name: key, Value: c.auth.Cookies[key]}) } @@ -212,7 +223,7 @@ func (c *SlackClient) get(path string, params map[string]string) ([]byte, error) return nil, err } - body, err = io.ReadAll(resp.Body) + resBody, err = io.ReadAll(resp.Body) if err != nil { return nil, err } @@ -232,65 +243,20 @@ func (c *SlackClient) get(path string, params map[string]string) ([]byte, error) } } - return body, nil + return resBody, nil } -func (c *SlackClient) post(path string, params map[string]string, msg *SendMessage) ([]byte, error) { - u, err := url.Parse(fmt.Sprintf("https://%s.slack.com/api/", c.team)) - if err != nil { - return nil, err - } - u.Path += path - q := u.Query() - for p := range params { - q.Add(p, params[p]) - } - u.RawQuery = q.Encode() +func (c *SlackClient) get(path string, params map[string]string) ([]byte, error) { + return c.API("GET", path, params, "{}") +} - var body []byte +func (c *SlackClient) post(path string, params map[string]string, msg *SendMessage) ([]byte, error) { messageBytes, err := json.Marshal(msg) if err != nil { return nil, fmt.Errorf("failed to unmarshal message: %w", err) } - reqBody := bytes.NewReader(messageBytes) - - for { - req, err := http.NewRequest(http.MethodPost, u.String(), reqBody) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/json; charset=utf-8") - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.auth.Token)) - for key := range c.auth.Cookies { - req.AddCookie(&http.Cookie{Name: key, Value: c.auth.Cookies[key]}) - } - - resp, err := httpclient.Client.Do(req) - if err != nil { - return nil, err - } - - body, err = io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - - if resp.StatusCode == 429 { - s, err := strconv.Atoi(resp.Header["Retry-After"][0]) - if err != nil { - return nil, err - } - d := time.Duration(s) - c.log.Printf("rate limited, waiting %ds", d) - time.Sleep(d * time.Second) - } else if resp.StatusCode >= 300 { - return nil, fmt.Errorf("status code %d, headers: %q, body: %q", resp.StatusCode, resp.Header, body) - } else { - break - } - } - return body, nil + return c.API("POST", path, params, string(messageBytes)) } func (c *SlackClient) ChannelInfo(id string) (*Channel, error) {