diff --git a/.gitignore b/.gitignore index 5b90e79..65bcfc7 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,6 @@ go.work.sum # env file .env +# build artifacts +bin/ +dist/ \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..7203cb3 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,5 @@ +{ + "recommendations": [ + "golang.go" + ] +} \ No newline at end of file diff --git a/Makefile b/Makefile index 724679d..97df9ad 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -App=skacksend +App=slacksend .PHONY: fmt vet test build run check diff --git a/README.md b/README.md index 60818d0..b0671d3 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Slackにメッセージを送信するツール - [Aiuto Docs](#aiuto-docs) - [Markdown](#markdown) - [Webserver](#webserver) + - [Deploy](#deploy) ## Environments @@ -82,3 +83,17 @@ go install golang.org/x/pkgsite/cmd/pkgsite@latest pkgsite ``` +## Deploy + +```sh +make build +# go build -o bin/slacksend ./cmd/slacksend +``` + +デバッグ情報を削ってビルドする場合 + +```sh +go build -o bin/slacksend \ + -ldflags "-s -w" \ + ./cmd/slacksend +``` diff --git a/cmd/slacksend/main.go b/cmd/slacksend/main.go index 26604ba..fca33d6 100644 --- a/cmd/slacksend/main.go +++ b/cmd/slacksend/main.go @@ -10,7 +10,6 @@ func hello() string { return "hello" } - func main() { os.Exit(cli.Run(os.Args)) } diff --git a/cmd/slacksend/main_test.go b/cmd/slacksend/main_test.go index c632ea1..f96a6a2 100644 --- a/cmd/slacksend/main_test.go +++ b/cmd/slacksend/main_test.go @@ -9,4 +9,4 @@ func TestHello(t *testing.T) { if got != want { t.Errorf("hello() = %q, want %q", got, want) } -} \ No newline at end of file +} diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 6721d42..19ec2ae 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -6,14 +6,17 @@ import ( "io" "os" "strings" + "time" + "gitea.pglikers.com/tools/slacksend/internal/slack" "gitea.pglikers.com/tools/slacksend/internal/version" ) type Options struct { ShowVersion bool WebhookURL string - TimeoutSec int + TimeoutSec int + Title string } func Run(args []string) int { @@ -31,7 +34,7 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { // other options fs.StringVar(&opt.WebhookURL, "webhook", "", "Slack Incoming Webhook URL (or env SLACK_WEBHOOK_URL)") fs.IntVar(&opt.TimeoutSec, "timeout", 10, "HTTP timeout seconds") - + fs.StringVar(&opt.Title, "title", "", "message title (shown as bold)") if err := fs.Parse(args[1:]); err != nil { if err == flag.ErrHelp { @@ -48,6 +51,12 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { return 0 } + if len(fs.Args()) == 0 && isTerminal(stdin) { + fmt.Fprintln(stderr, "message is required. provide args or pipe via stdin") + Usage(stderr) + return 2 + } + // webhook URL の解決: flag > env webhookURL := strings.TrimSpace(opt.WebhookURL) if webhookURL == "" { @@ -68,8 +77,17 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { return 2 } - // TODO: Slack送信(ここはあとで internal/slack に逃がす) - fmt.Fprintln(stdout, msg) + client := slack.WebhookClient{ + URL: webhookURL, + Timeout: time.Duration(opt.TimeoutSec) * time.Second, + } + + if err := client.Send(opt.Title, msg); err != nil { + fmt.Fprintln(stderr, "send failed:", err) + return 1 + } + + fmt.Fprintln(stdout, "ok") return 0 } @@ -84,3 +102,17 @@ func message(args []string, stdin io.Reader) (string, error) { return strings.TrimSpace(string(b)), nil } +// isTerminal reports whether the given reader is a character device (e.g., TTY). +func isTerminal(r io.Reader) bool { + fd, ok := r.(interface { + Stat() (os.FileInfo, error) + }) + if !ok { + return false + } + info, err := fd.Stat() + if err != nil { + return false + } + return info.Mode()&os.ModeCharDevice != 0 +} diff --git a/internal/cli/usage.go b/internal/cli/usage.go index 9a6b89d..7642f84 100644 --- a/internal/cli/usage.go +++ b/internal/cli/usage.go @@ -13,6 +13,8 @@ Usage: echo "message" | slacksend [options] Options: + --title Message title (shown as bold) + --webhook <url> Slack Incoming Webhook URL -V, --version show version -h, --help show this help diff --git a/internal/slack/webhook.go b/internal/slack/webhook.go new file mode 100644 index 0000000..bc1e4e5 --- /dev/null +++ b/internal/slack/webhook.go @@ -0,0 +1,108 @@ +package slack + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +type WebhookClient struct { + URL string + Timeout time.Duration +} + +type block struct { + Type string `json:"type"` + Text *blockText `json:"text,omitempty"` +} + +type blockText struct { + Type string `json:"type"` + Text string `json:"text"` +} + +type payload struct { + // Slackはblocksがあってもtextを求めるケースがあるので fallback 用に入れておく + Text string `json:"text,omitempty"` + Blocks []block `json:"blocks,omitempty"` +} + +func (c WebhookClient) Send(title, text string) error { + if c.URL == "" { + return fmt.Errorf("webhook URL is empty") + } + if c.Timeout <= 0 { + c.Timeout = 10 * time.Second + } + + p := makePayload(title, text) + + body, err := json.Marshal(p) + if err != nil { + return err + } + + req, err := http.NewRequest(http.MethodPost, c.URL, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + + res, err := (&http.Client{Timeout: c.Timeout}).Do(req) + if err != nil { + return err + } + defer res.Body.Close() + + if res.StatusCode < 200 || res.StatusCode >= 300 { + b, _ := io.ReadAll(io.LimitReader(res.Body, 2048)) + return fmt.Errorf("slack webhook failed: status=%d body=%s", res.StatusCode, string(bytes.TrimSpace(b))) + } + return nil +} + +func makePayload(title, text string) payload { + title = strings.TrimSpace(title) + text = strings.TrimSpace(text) + + if title == "" { + // blocks なしで普通に送る + return payload{Text: text} + } + + // blocks で「太字タイトル + 本文」 + blocks := []block{ + { + Type: "section", + Text: &blockText{ + Type: "mrkdwn", + Text: "*" + escapeMrkdwn(title) + "*", + }, + }, + { + Type: "section", + Text: &blockText{ + Type: "mrkdwn", + Text: escapeMrkdwn(text), + }, + }, + } + + // fallback text はtitle + newline + body にしておく + return payload{ + Text: title + "\n" + text, + Blocks: blocks, + } +} + +// 最小限のエスケープ( _ ` などはそのまま使いたい人もいるので控えめ) +// 必要なら後で強化できます。 +func escapeMrkdwn(s string) string { + // Slack mrkdwnは基本そのまま送れるので、ここは今は何もしない方が自然です。 + // 将来的に必要になったらここで置換します。 + return s +} diff --git a/internal/version/version.go b/internal/version/version.go index cb47f30..6af17f6 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -13,4 +13,4 @@ var ( func String() string { return Version + " (" + Commit + ", " + Date + ")" -} \ No newline at end of file +}