Compare commits

...

6 Commits

Author SHA1 Message Date
8f8b8490e8 buildを作成 2025-12-24 23:41:54 +09:00
8b57a363b1 ファイルを追加 2025-12-24 22:42:50 +09:00
72407f2c8d タイトルを追加する 2025-12-24 22:36:06 +09:00
66fdf87fc0 エラーチェック 2025-12-24 20:57:08 +09:00
71ea80bd4a Merge branch 'main' into develop 2025-12-24 20:49:23 +09:00
c49288194e オプションを追加する 2025-12-24 20:48:51 +09:00
11 changed files with 288 additions and 13 deletions

3
.gitignore vendored
View File

@ -25,3 +25,6 @@ go.work.sum
# env file # env file
.env .env
# build artifacts
bin/
dist/

5
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,5 @@
{
"recommendations": [
"golang.go"
]
}

View File

@ -1,4 +1,4 @@
App=skacksend App=slacksend
.PHONY: fmt vet test build run check .PHONY: fmt vet test build run check

109
README.md
View File

@ -1,16 +1,50 @@
# slacksend # slacksend
Slackにメッセージを送信する Slackにメッセージを送信するツール
- [slacksend](#slacksend)
- [Environments](#environments)
- [Functions](#functions)
- [How To Use](#how-to-use)
- [Develop](#develop)
- [Init](#init)
- [Aiuto Docs](#aiuto-docs)
- [Markdown](#markdown)
- [Webserver](#webserver)
- [Deploy](#deploy)
- [ローカルへインストールする](#ローカルへインストールする)
- [aptパッケージを作成する](#aptパッケージを作成する)
## Environments
Slack Appで`Incoming Webhook`を有効にする必要があります。
[Slack App](https://api.slack.com/apps)はこちらからどうぞ
* 参考記事: https://qiita.com/to3izo/items/c2d16f8b3e52b09e543e
## Functions ## Functions
* Slackのチャンネルにメッセージを送信する * Slackのチャンネルにメッセージを送信する
* Incoming Webhookに対応 * Incoming Webhookに対応
* `SLACK_WEBHOOK_URL` : URLを指定する
* `--title`: タイトルを設定する
* 現在の仕様ではチャンネル設定はできません。
* 宛先によりチャンネルを設定します。
--- ---
## Dev ## How To Use
Incoming Webhookでメッセージを送信する場合
```sh
export SLACK_WEBHOOK_URL="https://hooks.slack.com/services/XXX/YYY/ZZZ"
slacksend --title "タイトル" "本文を指定してください"
```
## Develop
実行 実行
@ -25,15 +59,15 @@ go run ./cmd/slacksend
go build -o slacksend ./cmd/slacksend go build -o slacksend ./cmd/slacksend
``` ```
## Init ### Init
```sh ```sh
go mod init gitea.pglikers.com/tools/slacksen go mod init gitea.pglikers.com/tools/slacksen
``` ```
## Docs ### Aiuto Docs
### Markdown #### Markdown
`gomarkdoc`のInstallが必要です `gomarkdoc`のInstallが必要です
@ -42,7 +76,7 @@ go install github.com/princjef/gomarkdoc/cmd/gomarkdoc@latest
gomarkdoc ./... > docs/api.md gomarkdoc ./... > docs/api.md
``` ```
### Webserver #### Webserver
`pkgsite`のInstallが必要です `pkgsite`のInstallが必要です
@ -51,3 +85,64 @@ go install golang.org/x/pkgsite/cmd/pkgsite@latest
pkgsite pkgsite
``` ```
## Deploy
```sh
make build
# go build -o bin/slacksend ./cmd/slacksend
```
デバッグ情報を削ってビルドする場合
```sh
go build -o bin/slacksend \
-ldflags "-s -w" \
./cmd/slacksend
```
ビルド時にGit情報を埋め込む方法
```sh
VERSION=$(git describe --tags --always --dirty)
COMMIT=$(git rev-parse --short HEAD)
DATE=$(date +"%Y-%m-%d")
go build \
-ldflags "\
-X main.version=$VERSION \
-X main.commit=$COMMIT \
-X main.date=$DATE" \
-o slacksend \
./cmd/slacksend
```
### ローカルへインストールする
インストールする
```sh
go install ./cmd/slacksend
slacksend -v
# Goのルートを確認する
go env GOPATH
```
`~/go/bin``PATH`に入ってない場合は .zshrcに設定する
```sh
vim ~/.zshrc
export PATH="$(go env GOPATH)/bin:$PATH"
```
アンインストール方法
```sh
which slacksend
# パスにあるバイナリファイルを削除する
rm /Users/xxx/go/bin/slacksend
```
### aptパッケージを作成する

View File

@ -10,7 +10,6 @@ func hello() string {
return "hello" return "hello"
} }
func main() { func main() {
os.Exit(cli.Run(os.Args)) os.Exit(cli.Run(os.Args))
} }

View File

@ -9,4 +9,4 @@ func TestHello(t *testing.T) {
if got != want { if got != want {
t.Errorf("hello() = %q, want %q", got, want) t.Errorf("hello() = %q, want %q", got, want)
} }
} }

View File

@ -6,12 +6,17 @@ import (
"io" "io"
"os" "os"
"strings" "strings"
"time"
"gitea.pglikers.com/tools/slacksend/internal/slack"
"gitea.pglikers.com/tools/slacksend/internal/version" "gitea.pglikers.com/tools/slacksend/internal/version"
) )
type Options struct { type Options struct {
ShowVersion bool ShowVersion bool
WebhookURL string
TimeoutSec int
Title string
} }
func Run(args []string) int { func Run(args []string) int {
@ -26,6 +31,10 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
var opt Options var opt Options
fs.BoolVar(&opt.ShowVersion, "version", false, "show version") fs.BoolVar(&opt.ShowVersion, "version", false, "show version")
fs.BoolVar(&opt.ShowVersion, "V", false, "show version (short)") fs.BoolVar(&opt.ShowVersion, "V", false, "show version (short)")
// 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 := fs.Parse(args[1:]); err != nil {
if err == flag.ErrHelp { if err == flag.ErrHelp {
@ -42,6 +51,22 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
return 0 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 == "" {
webhookURL = strings.TrimSpace(os.Getenv("SLACK_WEBHOOK_URL"))
}
if webhookURL == "" {
fmt.Fprintln(stderr, "Slack webhook URL is required. Set --webhook or SLACK_WEBHOOK_URL.")
return 2
}
msg, err := message(fs.Args(), stdin) msg, err := message(fs.Args(), stdin)
if err != nil { if err != nil {
fmt.Fprintln(stderr, "read message error:", err) fmt.Fprintln(stderr, "read message error:", err)
@ -52,8 +77,17 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
return 2 return 2
} }
// TODO: Slack送信ここはあとで internal/slack に逃がす) client := slack.WebhookClient{
fmt.Fprintln(stdout, msg) 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 return 0
} }
@ -68,3 +102,17 @@ func message(args []string, stdin io.Reader) (string, error) {
return strings.TrimSpace(string(b)), nil 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
}

View File

@ -13,6 +13,8 @@ Usage:
echo "message" | slacksend [options] echo "message" | slacksend [options]
Options: Options:
--title <title> Message title (shown as bold)
--webhook <url> Slack Incoming Webhook URL
-V, --version show version -V, --version show version
-h, --help show this help -h, --help show this help

108
internal/slack/webhook.go Normal file
View File

@ -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
}

View File

@ -13,4 +13,4 @@ var (
func String() string { func String() string {
return Version + " (" + Commit + ", " + Date + ")" return Version + " (" + Commit + ", " + Date + ")"
} }

15
scripts/build.sh Normal file
View File

@ -0,0 +1,15 @@
#!/bin/bash
VERSION=$(git describe --tags --always --dirty)
COMMIT=$(git rev-parse --short HEAD)
DATE=$(date +"%Y-%m-%d")
echo "Building slacksend version: $VERSION, commit: $COMMIT, date: $DATE"
go build \
-ldflags "\
-X gitea.pglikers.com/tools/slacksend/internal/version.Version=$VERSION \
-X gitea.pglikers.com/tools/slacksend/internal/version.Commit=$COMMIT \
-X gitea.pglikers.com/tools/slacksend/internal/version.Date=$DATE" \
-o ./bin/slacksend \
./cmd/slacksend