├── .github └── workflows │ ├── codeql.yml │ ├── go.yml │ ├── release.yml │ └── security.yml ├── .gitignore ├── .goreleaser.yml ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── README.md ├── bin └── .gitkeep ├── cmd ├── notify_slack │ └── main.go └── output │ └── main.go ├── go.mod ├── go.sum ├── internal ├── cli │ ├── cli.go │ ├── cli_test.go │ └── testdata │ │ └── upload.txt ├── config │ ├── config.go │ ├── config_test.go │ ├── export_test.go │ └── testdata │ │ ├── .gitignore │ │ ├── config.toml │ │ ├── config_deprecated.toml │ │ └── etc │ │ └── .gitkeep ├── slack │ ├── client.go │ ├── client_test.go │ ├── export_test.go │ └── testdata │ │ ├── files_complete_upload_external_fail.json │ │ ├── files_complete_upload_external_ok.json │ │ ├── files_get_upload_url_external_fail.json │ │ ├── files_get_upload_url_external_fail_invalid_arguments.json │ │ ├── files_get_upload_url_external_ok.json │ │ ├── post_files_upload_fail.json │ │ ├── post_files_upload_ok.json │ │ ├── post_text_fail.html │ │ ├── post_text_ok.html │ │ ├── upload.txt │ │ └── upload_to_url_ok.txt └── throttle │ ├── exec.go │ └── exec_test.go └── renovate.json /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: ["master"] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: ["master"] 20 | schedule: 21 | - cron: "23 0 * * 6" 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: ["go"] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v4 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v3 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | 52 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 53 | # queries: security-extended,security-and-quality 54 | 55 | # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). 56 | # If this step fails, then you should remove it and run the build manually (see below) 57 | - name: Autobuild 58 | uses: github/codeql-action/autobuild@v3 59 | 60 | # ℹ️ Command-line programs to run using the OS shell. 61 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 62 | 63 | # If the Autobuild fails above, remove it and uncomment the following three lines. 64 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 65 | 66 | # - run: | 67 | # echo "Run, Build Application using script" 68 | # ./location_of_script_within_repo/buildscript.sh 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v3 72 | with: 73 | category: "/language:${{matrix.language}}" 74 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | on: [push] 3 | jobs: 4 | test: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - name: Set up Go 8 | uses: actions/setup-go@v5 9 | with: 10 | go-version: 1.24.3 11 | 12 | - name: Check out code into the Go module directory 13 | uses: actions/checkout@v4 14 | 15 | - name: test 16 | run: | 17 | make vet 18 | make test 19 | make bin/notify_slack 20 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | push: 4 | tags: 5 | - "v[0-9]+.[0-9]+.[0-9]+" 6 | jobs: 7 | goreleaser: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout 11 | uses: actions/checkout@v4 12 | - name: Setup Go 13 | uses: actions/setup-go@v5 14 | with: 15 | go-version: 1.24.3 16 | - name: Run GoReleaser 17 | uses: goreleaser/goreleaser-action@v6 18 | with: 19 | version: latest 20 | args: release --clean 21 | env: 22 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 23 | -------------------------------------------------------------------------------- /.github/workflows/security.yml: -------------------------------------------------------------------------------- 1 | name: govulncheck 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | schedule: 9 | - cron: "0 0 * * *" 10 | 11 | jobs: 12 | govulncheck_job: 13 | runs-on: ubuntu-latest 14 | name: Run govulncheck 15 | steps: 16 | - id: govulncheck 17 | uses: golang/govulncheck-action@v1 18 | with: 19 | go-version-input: 1.24.3 20 | go-package: ./... 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.dll 4 | *.so 5 | *.dylib 6 | 7 | # Test binary, build with `go test -c` 8 | *.test 9 | 10 | # Output of the go coverage tool, specifically when used with LiteIDE 11 | *.out 12 | 13 | bin/ 14 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | project_name: notify_slack 2 | env: 3 | - GO111MODULE=on 4 | before: 5 | hooks: 6 | - go mod tidy 7 | builds: 8 | - main: ./cmd/notify_slack/main.go 9 | binary: notify_slack 10 | ldflags: 11 | - -s -w 12 | - -X github.com/catatsuy/notify_slack/internal/cli.Version=v{{.Version}} 13 | env: 14 | - CGO_ENABLED=0 15 | goarch: 16 | - amd64 17 | - arm64 18 | archives: 19 | - name_template: '{{ .ProjectName }}-{{ .Os }}-{{ .Arch }}' 20 | release: 21 | prerelease: auto 22 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## [v0.5.1] - 2024-06-08 4 | 5 | ### Changed 6 | 7 | * update Go version 8 | * update dependent libraries 9 | 10 | ## [v0.5.0] - 2024-04-28 11 | 12 | ### Breaking Changes 13 | 14 | * As per [Slack's latest API update](https://api.slack.com/changelog/2024-04-a-better-way-to-upload-files-is-here-to-stay), `files.upload` is now deprecated. We have updated our tool to use the new APIs, `files.getUploadURLExternal` and `files.completeUploadExternal`. 15 | * It is no longer possible to specify a `channel` for file uploads. You must now use `channel-id`. 16 | * The `snippet_channel` setting in TOML files and the `NOTIFY_SLACK_SNIPPET_CHANNEL` environment variable have been deprecated and are no longer supported. 17 | * The `-filetype` option has been modified. Please use `-snippet-type` instead for specifying the type of file when uploading to a snippet. 18 | 19 | ### Added 20 | 21 | - A new `-debug` option has been added for development. 22 | 23 | ### Changed 24 | 25 | * update Go version (require Go 1.22.2 or higher) 26 | * update dependent libraries 27 | * update README 28 | 29 | ## [v0.4.14] - 2023-09-30 30 | 31 | ### Changed 32 | 33 | * update Go version (require Go 1.21 or higher) 34 | * update dependent libraries 35 | * update README 36 | 37 | ## [v0.4.13] - 2022-07-10 38 | 39 | ### Changed 40 | 41 | * Fixed a bug #101 @gongqi-zhen 42 | 43 | ## [v0.4.12] - 2022-06-12 44 | 45 | ### Changed 46 | 47 | * update Go version (require Go 1.17 or higher) 48 | * update dependent libraries 49 | * update README 50 | 51 | ## [v0.4.11] - 2021-06-26 52 | 53 | ### Changed 54 | 55 | * update Go version 56 | 57 | ## [v0.4.10] - 2021-03-28 58 | 59 | ### Changed 60 | 61 | * Require Go 1.16 62 | 63 | ## [v0.4.9] - 2021-02-21 64 | 65 | ### Added 66 | 67 | * Added support for `NOTIFY_SLACK_INTERVAL` environment variables 68 | * Added the binary for Mac ARM64 69 | 70 | ### Changed 71 | 72 | * refactoring 73 | 74 | ## [v0.4.8] - 2020-12-19 75 | 76 | ### Changed 77 | 78 | * Changed version number is included by `go get` 79 | 80 | ## [v0.4.7] - 2020-07-26 81 | 82 | ### Added 83 | 84 | * Added the binary for Linux ARM64 85 | * Modify README due to specification changes by Slack 86 | 87 | ## [v0.4.6] - 2020-04-05 88 | 89 | ### Changed 90 | 91 | * Changed to use Go 1.14 92 | * Changed a test 93 | 94 | ## [v0.4.5] - 2020-02-29 95 | 96 | ### Changed 97 | 98 | * Changed to use GoReleaser on GitHub Actions 99 | 100 | ## [v0.4.4] - 2020-02-02 101 | 102 | ### Changed 103 | 104 | * Changed to use GitHub Actions instead of Circle CI 105 | 106 | ## [v0.4.3] - 2019-11-01 107 | 108 | ### Changed 109 | 110 | * Changed `url` not to be a required parameter 111 | * Modified README 112 | 113 | ## [v0.4.2] - 2019-10-30 114 | 115 | ### Changed 116 | 117 | * Reduced the number of upgrade steps 118 | * Reduced the number of dependent libraries 119 | 120 | ## [v0.4.1] - 2019-07-03 121 | 122 | ### Fixed 123 | 124 | * Fixed a bug that caused permission denied error #43 @ryotarai 125 | 126 | ## [v0.4.0] - 2019-06-22 127 | 128 | ### Added 129 | 130 | * Added `-snippet` option 131 | * Added support for `NOTIFY_SLACK_*` environment variables 132 | * Added `~/.notify_slack.toml` as a configuration file 133 | 134 | ## [v0.3.0] - 2019-03-09 135 | 136 | ### Added 137 | 138 | * Added automatically release file by CircleCI 139 | 140 | ### Changed 141 | 142 | * Changed to use Go 1.12 143 | * Modified using Go modules 144 | 145 | ## [v0.2.1] - 2018-09-03 146 | 147 | ### Added 148 | 149 | * Add version output function 150 | * Added support passing interval from toml file 151 | 152 | ## [v0.2] - 2018-09-01 153 | 154 | ### Added 155 | 156 | * Added support for uploading to snippet 157 | 158 | ## [v0.1] - 2017-09-24 159 | 160 | * First release 161 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 KANEKO Tatsuya 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all 2 | all: bin/notify_slack bin/output 3 | 4 | go.mod go.sum: 5 | go mod tidy 6 | 7 | bin/notify_slack: cmd/notify_slack/main.go internal/slack/*.go internal/throttle/*.go internal/config/*.go internal/cli/*.go go.mod go.sum 8 | go build -ldflags "-X github.com/catatsuy/notify_slack/internal/cli.Version=`git rev-list HEAD -n1`" -o bin/notify_slack cmd/notify_slack/main.go 9 | 10 | bin/output: cmd/output/main.go 11 | go build -o bin/output cmd/output/main.go 12 | 13 | .PHONY: test 14 | test: 15 | go test -shuffle on -cover -count 10 ./... 16 | 17 | .PHONY: vet 18 | vet: 19 | go vet ./... 20 | 21 | .PHONY: errcheck 22 | errcheck: 23 | errcheck ./... 24 | 25 | .PHONY: staticcheck 26 | staticcheck: 27 | staticcheck -checks="all,-ST1000" ./... 28 | 29 | .PHONY: cover 30 | cover: 31 | go test -coverprofile=coverage.out ./... 32 | go tool cover -html=coverage.out 33 | 34 | .PHONY: clean 35 | clean: 36 | rm -rf bin/* 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # notify_slack 2 | 3 | The 'notify_slack' command allows you to post messages to Slack from the command line. Simply pipe the standard output of any command to 'notify_slack', and it will send the output to Slack at a rate of once per second (this interval can be modified using the `-interval` option). 4 | 5 | https://user-images.githubusercontent.com/1249910/155869750-48f7500f-4481-49b6-9d65-b93205f2b94f.mp4 6 | 7 | (same movie) https://www.youtube.com/watch?v=wmKSr9Aoz-Y 8 | 9 | ## Installation 10 | 11 | It is recommended that you use the binaries available on [GitHub Releases](https://github.com/catatsuy/notify_slack/releases). It is advisable to download and use the latest version. 12 | 13 | If you have a Go language development environment set up, you can also compile and install the 'notify_slack' tools on your own. 14 | 15 | ``` 16 | go install github.com/catatsuy/notify_slack/cmd/notify_slack@latest 17 | ``` 18 | 19 | To build and modify the 'notify_slack' tools for development purposes, you can use the `make` command. 20 | 21 | ``` 22 | make 23 | ``` 24 | 25 | If you use the `make` command to build and install the 'notify_slack' tool, the output of the `notify_slack -version` command will be the git commit ID of the current version. 26 | 27 | ## usage 28 | 29 | To post messages to Slack using the 'notify_slack' tool, you can either specify the necessary settings as command line options or in a TOML configuration file. If both options are provided, the command line settings will take precedence. 30 | 31 | ```sh 32 | ./bin/output | ./bin/notify_slack 33 | ``` 34 | 35 | The 'output' tool is used for testing purposes and allows you to buffer and then post messages to Slack. 36 | 37 | ``` sh 38 | ./bin/notify_slack README.md 39 | ``` 40 | 41 | To use the Slack Web API and post a file as a snippet, you will need to provide a `token` and `channel`. If you want to upload a snippet via standard input, you must specify the `-snippet` flag. You can also specify a `filename` to change the name of the file on Slack. 42 | 43 | ``` sh 44 | git diff | ./bin/notify_slack -snippet -filename git.diff 45 | ``` 46 | 47 | The Slack API allows you to specify the filetype of a file when posting it as a snippet. You can also use the `-filetype` flag to specify the file type. If this flag is not provided, the file type will be automatically determined based on the file's extension. It is important to ensure that the extension of the file accurately reflects its type. 48 | 49 | [file type | Slack](https://api.slack.com/types/file#file_types) 50 | 51 | 52 | ### CLI options 53 | 54 | ``` 55 | -c string 56 | config file name 57 | -channel string 58 | specify channel (unavailable for new Incoming Webhooks) 59 | -channel-id string 60 | specify channel id (for uploading a file) 61 | -debug 62 | debug mode (for developers) 63 | -filename string 64 | specify a file name (for uploading to snippet) 65 | -filetype string 66 | [compatible] specify a filetype for uploading to snippet. This option is maintained for compatibility. Please use -snippet-type instead. 67 | -icon-emoji string 68 | specify icon emoji (unavailable for new Incoming Webhooks) 69 | -interval duration 70 | interval (default 1s) 71 | -slack-url string 72 | slack url (Incoming Webhooks URL) 73 | -snippet 74 | switch to snippet uploading mode 75 | -snippet-type string 76 | specify a snippet_type (for uploading to snippet) 77 | -token string 78 | token (for uploading to snippet) 79 | -username string 80 | specify username (unavailable for new Incoming Webhooks) 81 | -version 82 | Print version information and quit 83 | ``` 84 | 85 | ### toml configuration file 86 | 87 | By default, check the following files in order. 88 | 89 | 1. a file specified with `-c` 90 | 1. `$HOME/.notify_slack.toml` 91 | 1. `$HOME/etc/notify_slack.toml` 92 | 1. `/etc/notify_slack.toml` 93 | 94 | The toml file contains the following information. 95 | 96 | ```toml:notify_slack.toml 97 | [slack] 98 | url = "https://hooks.slack.com/services/**" 99 | token = "xoxp-xxxxx" 100 | channel = "#general" 101 | channel_id = "C12345678" 102 | username = "tester" 103 | icon_emoji = ":rocket:" 104 | interval = "1s" 105 | ``` 106 | 107 | ### Note 108 | 109 | * You will need to specify a url if you want to post messages to Slack as text 110 | * You can use the following options to customize your message when posting to Slack as text: `channel`, `username`, `icon_emoji`, and `interval`. 111 | * Due to a recent change in the specification for Incoming Webhooks, it is currently not possible to override the `channel`, `username`, and `icon_emoji` options when posting to Slack. For more information, please refer to [this resource](https://api.slack.com/messaging/webhooks#advanced_message_formatting) 112 | * You can create an Incoming Webhooks URL at https://slack.com/services/new/incoming-webhook 113 | * To post a file as a snippet to Slack, you will need to provide both a `token` and a `channel_id`. 114 | * The `username` and `icon_emoji` options will be ignored when posting a file as a snippet to Slack. 115 | * For instructions on how to create a token, please see the next section. 116 | * You cannot specify a channel because the slack api support only the `channel_id`. 117 | * If you don't specify `channel_id`, the file will be private. So, **if you need to post a file public, you must specify `channel_id`**. 118 | * The Slack API can cause delays, so posting might take longer. 119 | 120 | ### Getting Your Slack API Token 121 | 122 | You need to create a token if you use snippet uploading mode. 123 | 124 | For the most up-to-date and easy-to-follow instructions on how to obtain your Slack API bot token, please refer to the official Slack guide: 125 | 126 | [How to quickly get and use a Slack API bot token | Slack](https://api.slack.com/tutorials/tracks/getting-a-token) 127 | 128 | ### (Advanced) Environment Variables 129 | 130 | Some settings for the Slack API can be provided using environment variables. 131 | 132 | ``` 133 | NOTIFY_SLACK_WEBHOOK_URL 134 | NOTIFY_SLACK_TOKEN 135 | NOTIFY_SLACK_CHANNEL 136 | NOTIFY_SLACK_CHANNEL_ID 137 | NOTIFY_SLACK_USERNAME 138 | NOTIFY_SLACK_ICON_EMOJI 139 | NOTIFY_SLACK_INTERVAL 140 | ``` 141 | 142 | Using environment variables to specify settings for the 'notify_slack' tool can be useful if you are deploying it in a containerized environment. It allows you to avoid the need for a configuration file and simplifies the process of managing and updating settings. 143 | -------------------------------------------------------------------------------- /bin/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/catatsuy/notify_slack/30c46ca8b5da109a206eb074c3135e16832da881/bin/.gitkeep -------------------------------------------------------------------------------- /cmd/notify_slack/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/catatsuy/notify_slack/internal/cli" 7 | "golang.org/x/term" 8 | ) 9 | 10 | func main() { 11 | c := cli.NewCLI(os.Stdout, os.Stderr, os.Stdin, term.IsTerminal(int(os.Stdin.Fd()))) 12 | os.Exit(c.Run(os.Args)) 13 | } 14 | -------------------------------------------------------------------------------- /cmd/output/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "math/rand" 7 | "time" 8 | ) 9 | 10 | func init() { 11 | flag.Parse() 12 | rand.Seed(time.Now().Unix()) 13 | } 14 | 15 | func main() { 16 | for i := 0; i < 1000; i++ { 17 | fmt.Printf("Welcome %d times\n", i) 18 | // sleep 10ms-30ms 19 | time.Sleep((time.Duration)(rand.Intn(3)+1) * 10 * time.Millisecond) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/catatsuy/notify_slack 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | github.com/google/go-cmp v0.7.0 7 | github.com/pelletier/go-toml/v2 v2.2.4 8 | golang.org/x/term v0.32.0 9 | ) 10 | 11 | require golang.org/x/sys v0.33.0 // indirect 12 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 2 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 3 | github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= 4 | github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= 5 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 6 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 7 | golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= 8 | golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= 9 | -------------------------------------------------------------------------------- /internal/cli/cli.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "io" 8 | "log/slog" 9 | "os" 10 | "os/signal" 11 | "runtime" 12 | "runtime/debug" 13 | "syscall" 14 | "time" 15 | 16 | "github.com/catatsuy/notify_slack/internal/config" 17 | "github.com/catatsuy/notify_slack/internal/slack" 18 | "github.com/catatsuy/notify_slack/internal/throttle" 19 | ) 20 | 21 | var ( 22 | Version string 23 | ) 24 | 25 | const ( 26 | ExitCodeOK = 0 27 | ExitCodeParseFlagError = 1 28 | ExitCodeFail = 1 29 | ) 30 | 31 | type CLI struct { 32 | outStream, errStream io.Writer 33 | inputStream io.Reader 34 | 35 | isStdinTerminal bool 36 | 37 | sClient slack.Slack 38 | conf *config.Config 39 | appVersion string 40 | } 41 | 42 | func NewCLI(outStream, errStream io.Writer, inputStream io.Reader, isStdinTerminal bool) *CLI { 43 | return &CLI{appVersion: version(), outStream: outStream, errStream: errStream, inputStream: inputStream, isStdinTerminal: isStdinTerminal} 44 | } 45 | 46 | func version() string { 47 | if Version != "" { 48 | return Version 49 | } 50 | 51 | info, ok := debug.ReadBuildInfo() 52 | if !ok { 53 | return "(devel)" 54 | } 55 | return info.Main.Version 56 | } 57 | 58 | func (c *CLI) Run(args []string) int { 59 | var ( 60 | version bool 61 | tomlFile string 62 | uploadFilename string 63 | filetype string 64 | snippetMode bool 65 | debugMode bool 66 | ) 67 | 68 | c.conf = config.NewConfig() 69 | 70 | flags := flag.NewFlagSet("notify_slack", flag.ContinueOnError) 71 | flags.SetOutput(c.errStream) 72 | 73 | flags.StringVar(&c.conf.Channel, "channel", "", "specify channel (unavailable for new Incoming Webhooks)") 74 | flags.StringVar(&c.conf.ChannelID, "channel-id", "", "specify channel id (for uploading a file)") 75 | flags.StringVar(&c.conf.SlackURL, "slack-url", "", "slack url (Incoming Webhooks URL)") 76 | flags.StringVar(&c.conf.Token, "token", "", "token (for uploading to snippet)") 77 | flags.StringVar(&c.conf.Username, "username", "", "specify username (unavailable for new Incoming Webhooks)") 78 | flags.StringVar(&c.conf.IconEmoji, "icon-emoji", "", "specify icon emoji (unavailable for new Incoming Webhooks)") 79 | flags.DurationVar(&c.conf.Duration, "interval", time.Second, "interval") 80 | flags.StringVar(&tomlFile, "c", "", "config file name") 81 | flags.StringVar(&uploadFilename, "filename", "", "specify a file name (for uploading to snippet)") 82 | flags.StringVar(&filetype, "filetype", "", "[compatible] specify a filetype for uploading to snippet. This option is maintained for compatibility. Please use -snippet-type instead.") 83 | flags.StringVar(&filetype, "snippet-type", "", "specify a snippet_type (for uploading to snippet)") 84 | 85 | flags.BoolVar(&snippetMode, "snippet", false, "switch to snippet uploading mode") 86 | 87 | flags.BoolVar(&debugMode, "debug", false, "debug mode (for developers)") 88 | 89 | flags.BoolVar(&version, "version", false, "Print version information and quit") 90 | 91 | err := flags.Parse(args[1:]) 92 | if err != nil { 93 | return ExitCodeParseFlagError 94 | } 95 | 96 | if version { 97 | fmt.Fprintf(c.errStream, "notify_slack version %s; %s\n", c.appVersion, runtime.Version()) 98 | return ExitCodeOK 99 | } 100 | 101 | argv := flags.Args() 102 | filename := "" 103 | if len(argv) == 1 { 104 | filename = argv[0] 105 | } else if len(argv) > 1 { 106 | filename = argv[0] 107 | err = flags.Parse(argv[1:]) 108 | if err != nil { 109 | return ExitCodeParseFlagError 110 | } 111 | 112 | argv = flags.Args() 113 | if len(argv) > 0 { 114 | fmt.Fprintln(c.errStream, "You cannot pass multiple files") 115 | return ExitCodeParseFlagError 116 | } 117 | } else if c.isStdinTerminal { 118 | fmt.Fprintln(c.errStream, "No input file specified") 119 | return ExitCodeFail 120 | } 121 | 122 | tomlFile = config.LoadTOMLFilename(tomlFile) 123 | 124 | if tomlFile != "" { 125 | err := c.conf.LoadTOML(tomlFile) 126 | if err != nil { 127 | fmt.Fprintln(c.errStream, err) 128 | return ExitCodeFail 129 | } 130 | } 131 | 132 | err = c.conf.LoadEnv() 133 | if err != nil { 134 | fmt.Fprintln(c.errStream, err) 135 | return ExitCodeFail 136 | } 137 | 138 | var logger *slog.Logger 139 | if debugMode { 140 | logger = slog.New(slog.NewTextHandler(c.errStream, &slog.HandlerOptions{AddSource: true, Level: slog.LevelDebug})) 141 | } else { 142 | logger = slog.New(slog.NewTextHandler(c.errStream, nil)) 143 | } 144 | 145 | ctx := context.Background() 146 | 147 | if filename != "" || snippetMode { 148 | if c.conf.Token == "" { 149 | fmt.Fprintln(c.errStream, "must specify Slack token for uploading to snippet") 150 | return ExitCodeFail 151 | } 152 | 153 | c.sClient, err = slack.NewClientForPostFile(c.conf.Token, logger) 154 | if err != nil { 155 | fmt.Fprintln(c.errStream, err) 156 | return ExitCodeFail 157 | } 158 | 159 | err := c.uploadSnippet(ctx, filename, uploadFilename, filetype) 160 | if err != nil { 161 | fmt.Fprintln(c.errStream, err) 162 | return ExitCodeFail 163 | } 164 | 165 | return ExitCodeOK 166 | } 167 | 168 | if c.conf.SlackURL == "" { 169 | fmt.Fprintln(c.errStream, "must specify Slack URL") 170 | return ExitCodeFail 171 | } 172 | 173 | c.sClient, err = slack.NewClient(c.conf.SlackURL, logger) 174 | if err != nil { 175 | fmt.Fprintln(c.errStream, err) 176 | return ExitCodeFail 177 | } 178 | 179 | copyStdin := io.TeeReader(c.inputStream, c.outStream) 180 | 181 | ex := throttle.NewExec(copyStdin) 182 | 183 | ctx, stop := signal.NotifyContext(ctx, syscall.SIGTERM, syscall.SIGINT) 184 | defer stop() 185 | 186 | channel := c.conf.Channel 187 | 188 | param := &slack.PostTextParam{ 189 | Channel: channel, 190 | Username: c.conf.Username, 191 | IconEmoji: c.conf.IconEmoji, 192 | } 193 | 194 | flushCallback := func(ctx context.Context, output string) error { 195 | param.Text = output 196 | return c.sClient.PostText(context.WithoutCancel(ctx), param) 197 | } 198 | 199 | done := make(chan struct{}) 200 | 201 | doneCallback := func(ctx context.Context, output string) error { 202 | defer func() { 203 | // If goroutine is not used, it will not exit when the pipe is closed 204 | go func() { 205 | done <- struct{}{} 206 | }() 207 | }() 208 | 209 | return flushCallback(context.WithoutCancel(ctx), output) 210 | } 211 | 212 | ticker := time.NewTicker(c.conf.Duration) 213 | defer ticker.Stop() 214 | 215 | ex.Start(ctx, ticker.C, flushCallback, doneCallback) 216 | <-done 217 | 218 | return ExitCodeOK 219 | } 220 | 221 | func (c *CLI) uploadSnippet(ctx context.Context, filename, uploadFilename, snippetType string) error { 222 | channelID := c.conf.ChannelID 223 | 224 | var reader io.ReadCloser 225 | if filename == "" { 226 | reader = os.Stdin 227 | } else { 228 | _, err := os.Stat(filename) 229 | if err != nil { 230 | return fmt.Errorf("%s does not exist: %w", filename, err) 231 | } 232 | reader, err = os.Open(filename) 233 | if err != nil { 234 | return fmt.Errorf("can't open %s: %w", filename, err) 235 | } 236 | } 237 | defer reader.Close() 238 | 239 | content, err := io.ReadAll(reader) 240 | if err != nil { 241 | return err 242 | } 243 | 244 | if uploadFilename == "" { 245 | uploadFilename = filename 246 | } 247 | 248 | param := &slack.PostFileParam{ 249 | ChannelID: channelID, 250 | Filename: uploadFilename, 251 | SnippetType: snippetType, 252 | } 253 | 254 | err = c.sClient.PostFile(ctx, param, content) 255 | if err != nil { 256 | return err 257 | } 258 | 259 | return nil 260 | } 261 | -------------------------------------------------------------------------------- /internal/cli/cli_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/catatsuy/notify_slack/internal/config" 11 | "github.com/catatsuy/notify_slack/internal/slack" 12 | "github.com/google/go-cmp/cmp" 13 | ) 14 | 15 | type fakeSlackClient struct { 16 | slack.Slack 17 | 18 | FakePostFile func(ctx context.Context, param *slack.PostFileParam, content []byte) error 19 | } 20 | 21 | func (c *fakeSlackClient) PostFile(ctx context.Context, param *slack.PostFileParam, content []byte) error { 22 | return c.FakePostFile(ctx, param, content) 23 | } 24 | 25 | func (c *fakeSlackClient) PostText(ctx context.Context, param *slack.PostTextParam) error { 26 | return nil 27 | } 28 | 29 | func TestRun_versionFlg(t *testing.T) { 30 | outStream, errStream, inputStream := new(bytes.Buffer), new(bytes.Buffer), new(bytes.Buffer) 31 | cl := NewCLI(outStream, errStream, inputStream, true) 32 | 33 | args := strings.Split("notify_slack -version", " ") 34 | status := cl.Run(args) 35 | 36 | if status != ExitCodeOK { 37 | t.Errorf("ExitStatus=%d, want %d", status, ExitCodeOK) 38 | } 39 | 40 | expected := fmt.Sprintf("notify_slack version %s", Version) 41 | if !strings.Contains(errStream.String(), expected) { 42 | t.Errorf("Output=%q, want %q", errStream.String(), expected) 43 | } 44 | } 45 | 46 | func TestUploadSnippet(t *testing.T) { 47 | cl := &CLI{ 48 | sClient: &fakeSlackClient{}, 49 | conf: config.NewConfig(), 50 | } 51 | 52 | cl.conf.ChannelID = "C12345678" 53 | err := cl.uploadSnippet(t.Context(), "testdata/nofile.txt", "", "") 54 | want := "no such file or directory" 55 | if err == nil || !strings.Contains(err.Error(), want) { 56 | t.Errorf("error = %v; want %q", err, want) 57 | } 58 | 59 | cl.sClient = &fakeSlackClient{ 60 | FakePostFile: func(ctx context.Context, param *slack.PostFileParam, content []byte) error { 61 | expectedFilename := "testdata/upload.txt" 62 | if param.Filename != expectedFilename { 63 | t.Errorf("expected %s; got %s", expectedFilename, param.Filename) 64 | } 65 | 66 | expectedContent := "upload_test\n" 67 | if diff := cmp.Diff(expectedContent, string(content)); diff != "" { 68 | t.Errorf("unexpected diff: (-want +got):\n%s", diff) 69 | } 70 | 71 | return nil 72 | }, 73 | } 74 | 75 | err = cl.uploadSnippet(t.Context(), "testdata/upload.txt", "", "") 76 | if err != nil { 77 | t.Errorf("expected nil; got %v", err) 78 | } 79 | 80 | cl.sClient = &fakeSlackClient{ 81 | FakePostFile: func(ctx context.Context, param *slack.PostFileParam, content []byte) error { 82 | expectedFilename := "overwrite.txt" 83 | if param.Filename != expectedFilename { 84 | t.Errorf("expected %s; got %s", expectedFilename, param.Filename) 85 | } 86 | 87 | expectedContent := "upload_test\n" 88 | if diff := cmp.Diff(expectedContent, string(content)); diff != "" { 89 | t.Errorf("unexpected diff: (-want +got):\n%s", diff) 90 | } 91 | 92 | return nil 93 | }, 94 | } 95 | 96 | err = cl.uploadSnippet(t.Context(), "testdata/upload.txt", "overwrite.txt", "") 97 | if err != nil { 98 | t.Errorf("expected nil; got %v", err) 99 | } 100 | 101 | cl.sClient = &fakeSlackClient{ 102 | FakePostFile: func(ctx context.Context, param *slack.PostFileParam, content []byte) error { 103 | if param.ChannelID != cl.conf.ChannelID { 104 | t.Errorf("expected %s; got %s", cl.conf.ChannelID, param.ChannelID) 105 | } 106 | 107 | expectedFilename := "overwrite.txt" 108 | if param.Filename != expectedFilename { 109 | t.Errorf("expected %s; got %s", expectedFilename, param.Filename) 110 | } 111 | 112 | expectedSnippetType := "diff" 113 | if param.SnippetType != expectedSnippetType { 114 | t.Errorf("expected %s; got %s", expectedSnippetType, param.SnippetType) 115 | } 116 | 117 | expectedContent := "upload_test\n" 118 | if diff := cmp.Diff(expectedContent, string(content)); diff != "" { 119 | t.Errorf("unexpected diff: (-want +got):\n%s", diff) 120 | } 121 | 122 | return nil 123 | }, 124 | } 125 | 126 | err = cl.uploadSnippet(t.Context(), "testdata/upload.txt", "overwrite.txt", "diff") 127 | if err != nil { 128 | t.Errorf("expected nil; got %v", err) 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /internal/cli/testdata/upload.txt: -------------------------------------------------------------------------------- 1 | upload_test 2 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "time" 8 | 9 | toml "github.com/pelletier/go-toml/v2" 10 | ) 11 | 12 | var ( 13 | userHomeDir = os.UserHomeDir 14 | ) 15 | 16 | type Config struct { 17 | SlackURL string 18 | Token string 19 | Channel string 20 | SnippetChannel string 21 | ChannelID string 22 | Username string 23 | IconEmoji string 24 | Duration time.Duration 25 | } 26 | 27 | func NewConfig() *Config { 28 | return &Config{} 29 | } 30 | 31 | func (c *Config) LoadEnv() error { 32 | if c.SlackURL == "" { 33 | c.SlackURL = os.Getenv("NOTIFY_SLACK_WEBHOOK_URL") 34 | } 35 | 36 | if c.Token == "" { 37 | c.Token = os.Getenv("NOTIFY_SLACK_TOKEN") 38 | } 39 | 40 | if c.Channel == "" { 41 | c.Channel = os.Getenv("NOTIFY_SLACK_CHANNEL") 42 | } 43 | 44 | if c.SnippetChannel == "" { 45 | if os.Getenv("NOTIFY_SLACK_SNIPPET_CHANNEL") != "" { 46 | return fmt.Errorf("the NOTIFY_SLACK_SNIPPET_CHANNEL option is deprecated") 47 | } 48 | } 49 | 50 | if c.ChannelID == "" { 51 | c.ChannelID = os.Getenv("NOTIFY_SLACK_CHANNEL_ID") 52 | } 53 | 54 | if c.Username == "" { 55 | c.Username = os.Getenv("NOTIFY_SLACK_USERNAME") 56 | } 57 | 58 | if c.IconEmoji == "" { 59 | c.IconEmoji = os.Getenv("NOTIFY_SLACK_ICON_EMOJI") 60 | } 61 | 62 | durationStr := os.Getenv("NOTIFY_SLACK_INTERVAL") 63 | if durationStr != "" { 64 | duration, err := time.ParseDuration(durationStr) 65 | if err != nil { 66 | return fmt.Errorf("incorrect value to inteval option from NOTIFY_SLACK_INTERVAL: %s: %w", durationStr, err) 67 | } 68 | c.Duration = duration 69 | } 70 | 71 | return nil 72 | } 73 | 74 | type slackConfig struct { 75 | URL string 76 | Token string 77 | Channel string 78 | SnippetChannel string `toml:"snippet_channel"` 79 | ChannelID string `toml:"channel_id"` 80 | Username string 81 | IconEmoji string `toml:"icon_emoji"` 82 | Interval string 83 | } 84 | 85 | type rootConfig struct { 86 | Slack slackConfig 87 | } 88 | 89 | func (c *Config) LoadTOML(filename string) error { 90 | f, err := os.Open(filename) 91 | if err != nil { 92 | return err 93 | } 94 | 95 | var cfg rootConfig 96 | 97 | err = toml.NewDecoder(f).Decode(&cfg) 98 | if err != nil { 99 | return err 100 | } 101 | 102 | slackConfig := cfg.Slack 103 | 104 | if c.SlackURL == "" { 105 | if slackConfig.URL != "" { 106 | c.SlackURL = slackConfig.URL 107 | } 108 | } 109 | if c.Token == "" { 110 | if slackConfig.Token != "" { 111 | c.Token = slackConfig.Token 112 | } 113 | } 114 | if c.Channel == "" { 115 | if slackConfig.Channel != "" { 116 | c.Channel = slackConfig.Channel 117 | } 118 | } 119 | if c.Username == "" { 120 | if slackConfig.Username != "" { 121 | c.Username = slackConfig.Username 122 | } 123 | } 124 | if slackConfig.SnippetChannel != "" { 125 | return fmt.Errorf("the snippet_channel option is deprecated") 126 | } 127 | if c.ChannelID == "" { 128 | if slackConfig.ChannelID != "" { 129 | c.ChannelID = slackConfig.ChannelID 130 | } 131 | } 132 | if c.IconEmoji == "" { 133 | if slackConfig.IconEmoji != "" { 134 | c.IconEmoji = slackConfig.IconEmoji 135 | } 136 | } 137 | 138 | if slackConfig.Interval != "" { 139 | duration, err := time.ParseDuration(slackConfig.Interval) 140 | if err != nil { 141 | return fmt.Errorf("incorrect value to interval option: %s: %w", slackConfig.Interval, err) 142 | } 143 | c.Duration = duration 144 | } 145 | 146 | return nil 147 | } 148 | 149 | func LoadTOMLFilename(filename string) string { 150 | if filename != "" { 151 | return filename 152 | } 153 | 154 | homeDir, err := userHomeDir() 155 | if err == nil { 156 | tomlFile := filepath.Join(homeDir, ".notify_slack.toml") 157 | if fileExists(tomlFile) { 158 | return tomlFile 159 | } 160 | 161 | tomlFile = filepath.Join(homeDir, "/etc/notify_slack.toml") 162 | if fileExists(tomlFile) { 163 | return tomlFile 164 | } 165 | } 166 | 167 | tomlFile := "/etc/notify_slack.toml" 168 | if fileExists(tomlFile) { 169 | return tomlFile 170 | } 171 | 172 | return "" 173 | } 174 | 175 | func fileExists(filename string) bool { 176 | _, err := os.Stat(filename) 177 | 178 | return err == nil 179 | } 180 | -------------------------------------------------------------------------------- /internal/config/config_test.go: -------------------------------------------------------------------------------- 1 | package config_test 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "strings" 7 | "testing" 8 | "time" 9 | 10 | . "github.com/catatsuy/notify_slack/internal/config" 11 | ) 12 | 13 | func TestLoadTOML(t *testing.T) { 14 | c := NewConfig() 15 | err := c.LoadTOML("./testdata/config.toml") 16 | if err != nil { 17 | t.Fatal(err) 18 | } 19 | expectedSlackURL := "https://hooks.slack.com/aaaaa" 20 | if c.SlackURL != expectedSlackURL { 21 | t.Errorf("got %s, want %s", c.SlackURL, expectedSlackURL) 22 | } 23 | expectedToken := "xoxp-token" 24 | if c.Token != expectedToken { 25 | t.Errorf("got %s, want %s", c.Token, expectedToken) 26 | } 27 | expectedChannel := "#test" 28 | if c.Channel != expectedChannel { 29 | t.Errorf("got %s, want %s", c.Channel, expectedChannel) 30 | } 31 | expectedChannelID := "C12345678" 32 | if c.ChannelID != expectedChannelID { 33 | t.Errorf("got %s, want %s", c.ChannelID, expectedChannelID) 34 | } 35 | expectedUsername := "deploy!" 36 | if c.Username != expectedUsername { 37 | t.Errorf("got %s, want %s", c.Username, expectedUsername) 38 | } 39 | expectedIconEmoji := ":rocket:" 40 | if c.IconEmoji != expectedIconEmoji { 41 | t.Errorf("got %s, want %s", c.IconEmoji, expectedIconEmoji) 42 | } 43 | expectedInterval := time.Duration(2 * time.Second) 44 | if c.Duration != expectedInterval { 45 | t.Errorf("got %+v, want %+v", c.Duration, expectedInterval) 46 | } 47 | } 48 | 49 | func TestLoadTOML_Deprecated(t *testing.T) { 50 | c := NewConfig() 51 | err := c.LoadTOML("./testdata/config_deprecated.toml") 52 | if err == nil { 53 | t.Fatal("expected error, but got nil") 54 | } 55 | 56 | expected := "the snippet_channel option is deprecated" 57 | if !strings.Contains(err.Error(), expected) { 58 | t.Errorf("got %s, want %s", err.Error(), expected) 59 | } 60 | } 61 | 62 | func TestLoadEnv(t *testing.T) { 63 | expectedSlackURL := "https://hooks.slack.com/aaaaa" 64 | expectedToken := "xoxp-token" 65 | expectedChannel := "#test" 66 | expectedChannelID := "C12345678" 67 | expectedUsername := "deploy!" 68 | expectedIconEmoji := ":rocket:" 69 | expectedIntervalStr := "2s" 70 | expectedInterval := time.Duration(2 * time.Second) 71 | 72 | t.Setenv("NOTIFY_SLACK_WEBHOOK_URL", expectedSlackURL) 73 | t.Setenv("NOTIFY_SLACK_TOKEN", expectedToken) 74 | t.Setenv("NOTIFY_SLACK_CHANNEL", expectedChannel) 75 | t.Setenv("NOTIFY_SLACK_CHANNEL_ID", expectedChannelID) 76 | t.Setenv("NOTIFY_SLACK_USERNAME", expectedUsername) 77 | t.Setenv("NOTIFY_SLACK_ICON_EMOJI", expectedIconEmoji) 78 | t.Setenv("NOTIFY_SLACK_INTERVAL", expectedIntervalStr) 79 | 80 | c := NewConfig() 81 | err := c.LoadEnv() 82 | if err != nil { 83 | t.Fatal(err) 84 | } 85 | 86 | if c.SlackURL != expectedSlackURL { 87 | t.Errorf("got %s, want %s", c.SlackURL, expectedSlackURL) 88 | } 89 | 90 | if c.Token != expectedToken { 91 | t.Errorf("got %s, want %s", c.Token, expectedToken) 92 | } 93 | 94 | if c.Channel != expectedChannel { 95 | t.Errorf("got %s, want %s", c.Channel, expectedChannel) 96 | } 97 | 98 | if c.ChannelID != expectedChannelID { 99 | t.Errorf("got %s, want %s", c.ChannelID, expectedChannelID) 100 | } 101 | 102 | if c.Username != expectedUsername { 103 | t.Errorf("got %s, want %s", c.Username, expectedUsername) 104 | } 105 | 106 | if c.IconEmoji != expectedIconEmoji { 107 | t.Errorf("got %s, want %s", c.IconEmoji, expectedIconEmoji) 108 | } 109 | 110 | if c.Duration != expectedInterval { 111 | t.Errorf("got %+v, want %+v", c.Duration, expectedInterval) 112 | } 113 | } 114 | 115 | func TestLoadEnv_Deprecated(t *testing.T) { 116 | expectedSlackURL := "https://hooks.slack.com/aaaaa" 117 | expectedToken := "xoxp-token" 118 | expectedChannel := "#test" 119 | expectedSnippetChannel := "#general" 120 | expectedUsername := "deploy!" 121 | expectedIconEmoji := ":rocket:" 122 | expectedIntervalStr := "2s" 123 | 124 | t.Setenv("NOTIFY_SLACK_WEBHOOK_URL", expectedSlackURL) 125 | t.Setenv("NOTIFY_SLACK_TOKEN", expectedToken) 126 | t.Setenv("NOTIFY_SLACK_CHANNEL", expectedChannel) 127 | t.Setenv("NOTIFY_SLACK_SNIPPET_CHANNEL", expectedSnippetChannel) 128 | t.Setenv("NOTIFY_SLACK_USERNAME", expectedUsername) 129 | t.Setenv("NOTIFY_SLACK_ICON_EMOJI", expectedIconEmoji) 130 | t.Setenv("NOTIFY_SLACK_INTERVAL", expectedIntervalStr) 131 | 132 | c := NewConfig() 133 | err := c.LoadEnv() 134 | if err == nil { 135 | t.Fatal("expected error, but got nil") 136 | } 137 | 138 | expected := "the NOTIFY_SLACK_SNIPPET_CHANNEL option is deprecated" 139 | if !strings.Contains(err.Error(), expected) { 140 | t.Errorf("got %s, want %s", err.Error(), expected) 141 | } 142 | } 143 | 144 | func TestLoadTOMLFilename(t *testing.T) { 145 | baseDir := "./testdata/" 146 | defer SetUserHomeDir(baseDir)() 147 | 148 | filename := "etc/notify_slack.toml" 149 | input := filepath.Join(baseDir, filename) 150 | _, err := os.Create(input) 151 | if err != nil { 152 | t.Fatal(err) 153 | } 154 | defer os.Remove(input) 155 | 156 | tomlFile := LoadTOMLFilename("") 157 | if !equalFilepath(tomlFile, input) { 158 | t.Errorf("got %s, want %s", tomlFile, input) 159 | } 160 | 161 | filename = ".notify_slack.toml" 162 | input = filepath.Join(baseDir, filename) 163 | _, err = os.Create(input) 164 | if err != nil { 165 | t.Fatal(err) 166 | } 167 | defer os.Remove(input) 168 | 169 | tomlFile = LoadTOMLFilename("") 170 | if !equalFilepath(tomlFile, input) { 171 | t.Errorf("got %s, want %s", tomlFile, input) 172 | } 173 | } 174 | 175 | func equalFilepath(input1, input2 string) bool { 176 | path1, _ := filepath.Abs(input1) 177 | path2, _ := filepath.Abs(input2) 178 | 179 | return path1 == path2 180 | } 181 | -------------------------------------------------------------------------------- /internal/config/export_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | func SetUserHomeDir(u string) (resetFunc func()) { 4 | tmp := userHomeDir 5 | userHomeDir = func() (string, error) { 6 | return u, nil 7 | } 8 | return func() { 9 | userHomeDir = tmp 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /internal/config/testdata/.gitignore: -------------------------------------------------------------------------------- 1 | .notify_slack.toml 2 | etc/notify_slack.toml 3 | -------------------------------------------------------------------------------- /internal/config/testdata/config.toml: -------------------------------------------------------------------------------- 1 | [slack] 2 | url = "https://hooks.slack.com/aaaaa" 3 | token = "xoxp-token" 4 | channel = "#test" 5 | channel_id = "C12345678" 6 | username = "deploy!" 7 | icon_emoji = ":rocket:" 8 | interval = "2s" 9 | -------------------------------------------------------------------------------- /internal/config/testdata/config_deprecated.toml: -------------------------------------------------------------------------------- 1 | [slack] 2 | url = "https://hooks.slack.com/aaaaa" 3 | token = "xoxp-token" 4 | channel = "#test" 5 | snippet_channel = "#general" 6 | username = "deploy!" 7 | icon_emoji = ":rocket:" 8 | interval = "2s" 9 | -------------------------------------------------------------------------------- /internal/config/testdata/etc/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/catatsuy/notify_slack/30c46ca8b5da109a206eb074c3135e16832da881/internal/config/testdata/etc/.gitkeep -------------------------------------------------------------------------------- /internal/slack/client.go: -------------------------------------------------------------------------------- 1 | package slack 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "log/slog" 10 | "mime/multipart" 11 | "net/http" 12 | "net/url" 13 | "strconv" 14 | "strings" 15 | ) 16 | 17 | var ( 18 | filesGetUploadURLExternalURL = "https://slack.com/api/files.getUploadURLExternal" 19 | filesCompleteUploadExternalURL = "https://slack.com/api/files.completeUploadExternal" 20 | ) 21 | 22 | type Client struct { 23 | Slack 24 | 25 | URL *url.URL 26 | HTTPClient *http.Client 27 | 28 | Token string 29 | 30 | Logger *slog.Logger 31 | } 32 | 33 | type GetUploadURLExternalRes struct { 34 | OK bool `json:"ok"` 35 | UploadURL string `json:"upload_url"` 36 | FileID string `json:"file_id"` 37 | } 38 | 39 | type PostTextParam struct { 40 | Channel string `json:"channel,omitempty"` 41 | Username string `json:"username,omitempty"` 42 | Text string `json:"text"` 43 | IconEmoji string `json:"icon_emoji,omitempty"` 44 | } 45 | 46 | type PostFileParam struct { 47 | ChannelID string 48 | Filename string 49 | AltText string 50 | Title string 51 | SnippetType string 52 | } 53 | 54 | type GetUploadURLExternalResParam struct { 55 | Filename string 56 | Length int 57 | SnippetType string 58 | AltText string 59 | } 60 | 61 | type Slack interface { 62 | PostText(ctx context.Context, param *PostTextParam) error 63 | PostFile(ctx context.Context, param *PostFileParam, content []byte) error 64 | } 65 | 66 | func NewClient(urlStr string, logger *slog.Logger) (*Client, error) { 67 | if len(urlStr) == 0 { 68 | return nil, fmt.Errorf("client: missing url") 69 | } 70 | 71 | parsedURL, err := url.ParseRequestURI(urlStr) 72 | if err != nil { 73 | return nil, fmt.Errorf("failed to parse url: %s: %w", urlStr, err) 74 | } 75 | 76 | client := &Client{ 77 | URL: parsedURL, 78 | HTTPClient: http.DefaultClient, 79 | Logger: logger, 80 | } 81 | 82 | return client, nil 83 | } 84 | 85 | func NewClientForPostFile(token string, logger *slog.Logger) (*Client, error) { 86 | if len(token) == 0 { 87 | return nil, fmt.Errorf("provide Slack token") 88 | } 89 | 90 | client := &Client{ 91 | HTTPClient: http.DefaultClient, 92 | Token: token, 93 | Logger: logger, 94 | } 95 | 96 | return client, nil 97 | } 98 | 99 | func (c *Client) newRequest(ctx context.Context, method string, body io.Reader) (*http.Request, error) { 100 | u := *c.URL 101 | 102 | req, err := http.NewRequest(method, u.String(), body) 103 | if err != nil { 104 | return nil, err 105 | } 106 | 107 | req = req.WithContext(ctx) 108 | 109 | return req, nil 110 | } 111 | 112 | func (c *Client) PostText(ctx context.Context, param *PostTextParam) error { 113 | if param.Text == "" { 114 | return nil 115 | } 116 | 117 | b, _ := json.Marshal(param) 118 | 119 | req, err := c.newRequest(ctx, "POST", bytes.NewBuffer(b)) 120 | if err != nil { 121 | return err 122 | } 123 | 124 | req.Header.Set("Content-Type", "application/json") 125 | 126 | c.Logger.Debug("request", "url", req.URL.String(), "method", req.Method, "header", req.Header) 127 | 128 | res, err := c.HTTPClient.Do(req) 129 | if err != nil { 130 | return err 131 | } 132 | defer res.Body.Close() 133 | 134 | body, err := io.ReadAll(res.Body) 135 | if err != nil { 136 | return fmt.Errorf("failed to read res.Body: %w", err) 137 | } 138 | 139 | c.Logger.Debug("request", "url", req.URL.String(), "method", req.Method, "header", req.Header, "status", res.StatusCode, "body", body) 140 | 141 | if res.StatusCode != http.StatusOK { 142 | return fmt.Errorf("status code: %d; body: %s", res.StatusCode, body) 143 | } 144 | 145 | return nil 146 | } 147 | 148 | func (c *Client) PostFile(ctx context.Context, param *PostFileParam, content []byte) error { 149 | uParam := &GetUploadURLExternalResParam{ 150 | Filename: param.Filename, 151 | Length: len(content), 152 | SnippetType: param.SnippetType, 153 | AltText: param.AltText, 154 | } 155 | 156 | uploadURL, fileID, err := c.GetUploadURLExternalURL(ctx, uParam) 157 | if err != nil { 158 | return fmt.Errorf("failed to get upload url: %w", err) 159 | } 160 | 161 | err = c.UploadToURL(ctx, param.Filename, uploadURL, content) 162 | if err != nil { 163 | return fmt.Errorf("failed to upload file: %w", err) 164 | } 165 | 166 | cParam := &CompleteUploadExternalParam{ 167 | FileID: fileID, 168 | Title: param.Title, 169 | ChannelID: param.ChannelID, 170 | } 171 | 172 | err = c.CompleteUploadExternal(ctx, cParam) 173 | if err != nil { 174 | return fmt.Errorf("failed to complete upload: %w", err) 175 | } 176 | 177 | return nil 178 | } 179 | 180 | func (c *Client) GetUploadURLExternalURL(ctx context.Context, param *GetUploadURLExternalResParam) (uploadURL string, fileID string, err error) { 181 | if param == nil { 182 | return "", "", fmt.Errorf("provide filename and length") 183 | } 184 | 185 | if param.Filename == "" { 186 | return "", "", fmt.Errorf("provide filename") 187 | } 188 | 189 | if param.Length == 0 { 190 | return "", "", fmt.Errorf("provide length") 191 | } 192 | 193 | v := url.Values{} 194 | v.Set("filename", param.Filename) 195 | v.Set("length", strconv.Itoa(param.Length)) 196 | 197 | if param.AltText != "" { 198 | v.Set("alt_text", param.AltText) 199 | } 200 | 201 | if param.SnippetType != "" { 202 | v.Set("snippet_type", param.SnippetType) 203 | } 204 | 205 | req, err := http.NewRequest(http.MethodPost, filesGetUploadURLExternalURL, strings.NewReader(v.Encode())) 206 | if err != nil { 207 | return "", "", err 208 | } 209 | 210 | req = req.WithContext(ctx) 211 | 212 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 213 | req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.Token)) 214 | 215 | res, err := c.HTTPClient.Do(req) 216 | if err != nil { 217 | return "", "", err 218 | } 219 | defer res.Body.Close() 220 | 221 | b, err := io.ReadAll(res.Body) 222 | if err != nil { 223 | return "", "", fmt.Errorf("failed to read res.Body: %w", err) 224 | } 225 | 226 | if res.StatusCode != http.StatusOK { 227 | return "", "", fmt.Errorf("failed to read res.Body and the status code: %d; body: %s", res.StatusCode, b) 228 | } 229 | 230 | apiRes := GetUploadURLExternalRes{} 231 | err = json.Unmarshal(b, &apiRes) 232 | if err != nil { 233 | return "", "", fmt.Errorf("response returned from slack is not json: body: %s: %w", b, err) 234 | } 235 | 236 | if !apiRes.OK { 237 | return "", "", fmt.Errorf("response has failed; body: %s", b) 238 | } 239 | 240 | return apiRes.UploadURL, apiRes.FileID, nil 241 | } 242 | 243 | func (c *Client) UploadToURL(ctx context.Context, filename, uploadURL string, content []byte) error { 244 | body := &bytes.Buffer{} 245 | writer := multipart.NewWriter(body) 246 | part, err := writer.CreateFormFile("file", filename) 247 | if err != nil { 248 | return fmt.Errorf("failed to create form file: %w", err) 249 | } 250 | 251 | _, err = part.Write(content) 252 | if err != nil { 253 | return fmt.Errorf("failed to write content: %w", err) 254 | } 255 | 256 | contentType := writer.FormDataContentType() 257 | 258 | err = writer.Close() 259 | if err != nil { 260 | return fmt.Errorf("failed to close writer: %w", err) 261 | } 262 | 263 | req, err := http.NewRequest(http.MethodPost, uploadURL, body) 264 | if err != nil { 265 | return fmt.Errorf("failed to create request: %w", err) 266 | } 267 | 268 | req = req.WithContext(ctx) 269 | 270 | req.Header.Set("Content-Type", contentType) 271 | 272 | res, err := c.HTTPClient.Do(req) 273 | if err != nil { 274 | return fmt.Errorf("failed to do request: %w", err) 275 | } 276 | defer res.Body.Close() 277 | 278 | b, err := io.ReadAll(res.Body) 279 | if err != nil { 280 | return fmt.Errorf("failed to read res.Body: %w", err) 281 | } 282 | 283 | if res.StatusCode != http.StatusOK { 284 | return fmt.Errorf("failed to read res.Body and the status code: %d; body: %s", res.StatusCode, b) 285 | } 286 | 287 | return nil 288 | } 289 | 290 | type FileSummary struct { 291 | ID string `json:"id"` 292 | Title string `json:"title,omitempty"` 293 | } 294 | 295 | type CompleteUploadExternalRes struct { 296 | OK bool `json:"ok"` 297 | Files []struct { 298 | ID string `json:"id"` 299 | Title string `json:"title"` 300 | } `json:"files"` 301 | } 302 | 303 | type CompleteUploadExternalParam struct { 304 | FileID string 305 | Title string 306 | ChannelID string 307 | } 308 | 309 | func (c *Client) CompleteUploadExternal(ctx context.Context, params *CompleteUploadExternalParam) error { 310 | request := []FileSummary{{ID: params.FileID, Title: params.Title}} 311 | requestBytes, err := json.Marshal(request) 312 | if err != nil { 313 | return err 314 | } 315 | 316 | v := url.Values{} 317 | v.Set("files", string(requestBytes)) 318 | if params.ChannelID != "" { 319 | v.Set("channel_id", params.ChannelID) 320 | } 321 | 322 | req, err := http.NewRequest(http.MethodPost, filesCompleteUploadExternalURL, strings.NewReader(v.Encode())) 323 | if err != nil { 324 | return err 325 | } 326 | 327 | req = req.WithContext(ctx) 328 | 329 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 330 | req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.Token)) 331 | 332 | res, err := c.HTTPClient.Do(req) 333 | if err != nil { 334 | return err 335 | } 336 | defer res.Body.Close() 337 | 338 | b, err := io.ReadAll(res.Body) 339 | if err != nil { 340 | return fmt.Errorf("failed to read res.Body: %w", err) 341 | } 342 | 343 | c.Logger.Debug("request", "url", req.URL.String(), "method", req.Method, "header", req.Header, "status", res.StatusCode, "body", b) 344 | 345 | if res.StatusCode != http.StatusOK { 346 | return fmt.Errorf("failed to read res.Body and the status code: %d; body: %s", res.StatusCode, b) 347 | } 348 | 349 | apiRes := CompleteUploadExternalRes{} 350 | err = json.Unmarshal(b, &apiRes) 351 | if err != nil { 352 | return fmt.Errorf("response returned from slack is not json: body: %s: %w", b, err) 353 | } 354 | 355 | if !apiRes.OK { 356 | return fmt.Errorf("response has failed; body: %s", b) 357 | } 358 | 359 | return nil 360 | } 361 | -------------------------------------------------------------------------------- /internal/slack/client_test.go: -------------------------------------------------------------------------------- 1 | package slack_test 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "log/slog" 7 | "net/http" 8 | "net/http/httptest" 9 | "net/url" 10 | "os" 11 | "reflect" 12 | "strconv" 13 | "strings" 14 | "testing" 15 | 16 | . "github.com/catatsuy/notify_slack/internal/slack" 17 | "github.com/google/go-cmp/cmp" 18 | ) 19 | 20 | func TestNewClient_badURL(t *testing.T) { 21 | _, err := NewClient("", slog.New(slog.NewTextHandler(io.Discard, nil))) 22 | if err == nil { 23 | t.Fatal("expected error, but nothing was returned") 24 | } 25 | 26 | expected := "client: missing url" 27 | if !strings.Contains(err.Error(), expected) { 28 | t.Fatalf("expected %q to contain %q", err.Error(), expected) 29 | } 30 | } 31 | 32 | func TestNewClient_parsesURL(t *testing.T) { 33 | client, err := NewClient("https://example.com/foo/bar", slog.New(slog.NewTextHandler(io.Discard, nil))) 34 | if err != nil { 35 | t.Fatal(err) 36 | } 37 | 38 | expected := &url.URL{ 39 | Scheme: "https", 40 | Host: "example.com", 41 | Path: "/foo/bar", 42 | } 43 | if !reflect.DeepEqual(client.URL, expected) { 44 | t.Fatalf("expected %q to equal %q", client.URL, expected) 45 | } 46 | } 47 | 48 | func TestPostText_Success(t *testing.T) { 49 | muxAPI := http.NewServeMux() 50 | testAPIServer := httptest.NewServer(muxAPI) 51 | defer testAPIServer.Close() 52 | 53 | param := &PostTextParam{ 54 | Channel: "test-channel", 55 | Username: "tester", 56 | Text: "testtesttest", 57 | IconEmoji: ":rocket:", 58 | } 59 | 60 | muxAPI.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 61 | contentType := r.Header.Get("Content-Type") 62 | expectedType := "application/json" 63 | if contentType != expectedType { 64 | t.Fatalf("Content-Type expected %s, but %s", expectedType, contentType) 65 | } 66 | 67 | bodyBytes, err := io.ReadAll(r.Body) 68 | if err != nil { 69 | t.Fatal(err) 70 | } 71 | defer r.Body.Close() 72 | 73 | actualBody := &PostTextParam{} 74 | err = json.Unmarshal(bodyBytes, actualBody) 75 | if err != nil { 76 | t.Fatal(err) 77 | } 78 | 79 | if !reflect.DeepEqual(actualBody, param) { 80 | t.Fatalf("expected %q to equal %q", actualBody, param) 81 | } 82 | 83 | http.ServeFile(w, r, "testdata/post_text_ok.html") 84 | }) 85 | 86 | c, err := NewClient(testAPIServer.URL, slog.New(slog.NewTextHandler(io.Discard, nil))) 87 | if err != nil { 88 | t.Fatal(err) 89 | } 90 | 91 | err = c.PostText(t.Context(), param) 92 | 93 | if err != nil { 94 | t.Fatal(err) 95 | } 96 | } 97 | 98 | func TestPostText_Fail(t *testing.T) { 99 | muxAPI := http.NewServeMux() 100 | testAPIServer := httptest.NewServer(muxAPI) 101 | defer testAPIServer.Close() 102 | 103 | param := &PostTextParam{ 104 | Channel: "test2-channel", 105 | Username: "tester", 106 | Text: "testtesttest", 107 | IconEmoji: ":rocket:", 108 | } 109 | 110 | muxAPI.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 111 | b, err := os.ReadFile("testdata/post_text_fail.html") 112 | if err != nil { 113 | t.Fatal(err) 114 | } 115 | 116 | w.WriteHeader(http.StatusNotFound) 117 | w.Write(b) 118 | }) 119 | 120 | c, err := NewClient(testAPIServer.URL, slog.New(slog.NewTextHandler(io.Discard, nil))) 121 | if err != nil { 122 | t.Fatal(err) 123 | } 124 | 125 | err = c.PostText(t.Context(), param) 126 | 127 | if err == nil { 128 | t.Fatal("expected error, but nothing was returned") 129 | } 130 | 131 | expected := "status code: 404" 132 | if !strings.Contains(err.Error(), expected) { 133 | t.Fatalf("expected %q to contain %q", err.Error(), expected) 134 | } 135 | } 136 | 137 | func TestPostFile_Success(t *testing.T) { 138 | muxAPI := http.NewServeMux() 139 | testAPIServer := httptest.NewServer(muxAPI) 140 | defer testAPIServer.Close() 141 | 142 | slackToken := "slack-token" 143 | 144 | param := &GetUploadURLExternalResParam{ 145 | Filename: "test.txt", 146 | Length: 100, 147 | } 148 | 149 | muxAPI.HandleFunc("/api/files.getUploadURLExternal", func(w http.ResponseWriter, r *http.Request) { 150 | contentType := r.Header.Get("Content-Type") 151 | expectedType := "application/x-www-form-urlencoded" 152 | if contentType != expectedType { 153 | t.Fatalf("Content-Type expected %s, but %s", expectedType, contentType) 154 | } 155 | 156 | authorization := r.Header.Get("Authorization") 157 | expectedAuth := "Bearer " + slackToken 158 | if authorization != expectedAuth { 159 | t.Fatalf("Authorization expected %s, but %s", expectedAuth, authorization) 160 | } 161 | 162 | bodyBytes, err := io.ReadAll(r.Body) 163 | if err != nil { 164 | t.Fatal(err) 165 | } 166 | defer r.Body.Close() 167 | 168 | actualV, err := url.ParseQuery(string(bodyBytes)) 169 | if err != nil { 170 | t.Fatal(err) 171 | } 172 | 173 | expectedV := url.Values{} 174 | expectedV.Set("filename", param.Filename) 175 | expectedV.Set("length", strconv.Itoa(param.Length)) 176 | 177 | if diff := cmp.Diff(expectedV, actualV); diff != "" { 178 | t.Errorf("unexpected diff: (-want +got):\n%s", diff) 179 | } 180 | 181 | http.ServeFile(w, r, "testdata/files_get_upload_url_external_ok.json") 182 | }) 183 | 184 | defer SetFilesGetUploadURLExternalURL(testAPIServer.URL + "/api/files.getUploadURLExternal")() 185 | 186 | c, err := NewClientForPostFile(slackToken, slog.New(slog.NewTextHandler(io.Discard, nil))) 187 | if err != nil { 188 | t.Fatal(err) 189 | } 190 | 191 | uploadURL, fileID, err := c.GetUploadURLExternalURL(t.Context(), param) 192 | if err != nil { 193 | t.Fatal(err) 194 | } 195 | 196 | expectedUploadURL := "https://files.slack.com/upload/v1/ABC123456" 197 | if uploadURL != expectedUploadURL { 198 | t.Fatalf("expected %q to equal %q", uploadURL, expectedUploadURL) 199 | } 200 | 201 | expectedFileID := "F123ABC456" 202 | if fileID != expectedFileID { 203 | t.Fatalf("expected %q to equal %q", fileID, expectedFileID) 204 | } 205 | } 206 | 207 | func TestPostFile_FailCallFunc(t *testing.T) { 208 | muxAPI := http.NewServeMux() 209 | testAPIServer := httptest.NewServer(muxAPI) 210 | defer testAPIServer.Close() 211 | 212 | slackToken := "slack-token" 213 | 214 | muxAPI.HandleFunc("/api/files.getUploadURLExternal", func(w http.ResponseWriter, r *http.Request) { 215 | panic("unexpected call") 216 | }) 217 | 218 | defer SetFilesGetUploadURLExternalURL(testAPIServer.URL + "/api/files.getUploadURLExternal")() 219 | 220 | _, err := NewClientForPostFile("", slog.New(slog.NewTextHandler(io.Discard, nil))) 221 | expectedErrorPart := "provide Slack token" 222 | if err == nil { 223 | t.Fatal("expected error, but nothing was returned") 224 | } else if !strings.Contains(err.Error(), expectedErrorPart) { 225 | t.Fatalf("expected %q to contain %q", err.Error(), expectedErrorPart) 226 | } 227 | 228 | c, err := NewClientForPostFile(slackToken, slog.New(slog.NewTextHandler(io.Discard, nil))) 229 | if err != nil { 230 | t.Fatal(err) 231 | } 232 | 233 | _, _, err = c.GetUploadURLExternalURL(t.Context(), nil) 234 | expectedErrorPart = "provide filename and length" 235 | if err == nil { 236 | t.Fatal("expected error, but nothing was returned") 237 | } else if !strings.Contains(err.Error(), expectedErrorPart) { 238 | t.Fatalf("expected %q to contain %q", err.Error(), expectedErrorPart) 239 | } 240 | 241 | _, _, err = c.GetUploadURLExternalURL(t.Context(), &GetUploadURLExternalResParam{}) 242 | expectedErrorPart = "provide filename" 243 | if err == nil { 244 | t.Fatal("expected error, but nothing was returned") 245 | } else if !strings.Contains(err.Error(), expectedErrorPart) { 246 | t.Fatalf("expected %q to contain %q", err.Error(), expectedErrorPart) 247 | } 248 | 249 | _, _, err = c.GetUploadURLExternalURL(t.Context(), &GetUploadURLExternalResParam{Filename: "test.txt"}) 250 | expectedErrorPart = "provide length" 251 | if err == nil { 252 | t.Fatal("expected error, but nothing was returned") 253 | } else if !strings.Contains(err.Error(), expectedErrorPart) { 254 | t.Fatalf("expected %q to contain %q", err.Error(), expectedErrorPart) 255 | } 256 | } 257 | 258 | func TestPostFile_FailAPINotOK(t *testing.T) { 259 | muxAPI := http.NewServeMux() 260 | testAPIServer := httptest.NewServer(muxAPI) 261 | defer testAPIServer.Close() 262 | 263 | slackToken := "slack-token" 264 | 265 | param := &GetUploadURLExternalResParam{ 266 | Filename: "test.txt", 267 | Length: 100, 268 | } 269 | 270 | muxAPI.HandleFunc("/api/files.getUploadURLExternal", func(w http.ResponseWriter, r *http.Request) { 271 | contentType := r.Header.Get("Content-Type") 272 | expectedType := "application/x-www-form-urlencoded" 273 | if contentType != expectedType { 274 | t.Fatalf("Content-Type expected %s, but %s", expectedType, contentType) 275 | } 276 | 277 | authorization := r.Header.Get("Authorization") 278 | expectedAuth := "Bearer " + slackToken 279 | if authorization != expectedAuth { 280 | t.Fatalf("Authorization expected %s, but %s", expectedAuth, authorization) 281 | } 282 | 283 | bodyBytes, err := io.ReadAll(r.Body) 284 | if err != nil { 285 | t.Fatal(err) 286 | } 287 | defer r.Body.Close() 288 | 289 | actualV, err := url.ParseQuery(string(bodyBytes)) 290 | if err != nil { 291 | t.Fatal(err) 292 | } 293 | 294 | expectedV := url.Values{} 295 | expectedV.Set("filename", param.Filename) 296 | expectedV.Set("length", strconv.Itoa(param.Length)) 297 | 298 | if diff := cmp.Diff(expectedV, actualV); diff != "" { 299 | t.Errorf("unexpected diff: (-want +got):\n%s", diff) 300 | } 301 | 302 | w.WriteHeader(http.StatusForbidden) 303 | w.Header().Set("Content-Type", "application/json") 304 | 305 | b, err := os.ReadFile("testdata/files_get_upload_url_external_fail.json") 306 | if err != nil { 307 | t.Fatal(err) 308 | } 309 | 310 | w.Write(b) 311 | }) 312 | 313 | defer SetFilesGetUploadURLExternalURL(testAPIServer.URL + "/api/files.getUploadURLExternal")() 314 | 315 | c, err := NewClientForPostFile(slackToken, slog.New(slog.NewTextHandler(io.Discard, nil))) 316 | if err != nil { 317 | t.Fatal(err) 318 | } 319 | 320 | _, _, err = c.GetUploadURLExternalURL(t.Context(), param) 321 | 322 | if err == nil { 323 | t.Fatal("expected error, but nothing was returned") 324 | } else { 325 | expected := "status code: 403" 326 | if !strings.Contains(err.Error(), expected) { 327 | t.Errorf("expected %q to contain %q", err.Error(), expected) 328 | } 329 | 330 | expectedBodyPart := `"invalid_auth"` 331 | if !strings.Contains(err.Error(), expectedBodyPart) { 332 | t.Errorf("expected %q to contain %q", err.Error(), expectedBodyPart) 333 | } 334 | } 335 | } 336 | 337 | func TestPostFile_FailAPIStatusOK(t *testing.T) { 338 | muxAPI := http.NewServeMux() 339 | testAPIServer := httptest.NewServer(muxAPI) 340 | defer testAPIServer.Close() 341 | 342 | slackToken := "slack-token" 343 | 344 | param := &GetUploadURLExternalResParam{ 345 | Filename: "test.txt", 346 | Length: 100, 347 | } 348 | 349 | muxAPI.HandleFunc("/api/files.getUploadURLExternal", func(w http.ResponseWriter, r *http.Request) { 350 | contentType := r.Header.Get("Content-Type") 351 | expectedType := "application/x-www-form-urlencoded" 352 | if contentType != expectedType { 353 | t.Fatalf("Content-Type expected %s, but %s", expectedType, contentType) 354 | } 355 | 356 | authorization := r.Header.Get("Authorization") 357 | expectedAuth := "Bearer " + slackToken 358 | if authorization != expectedAuth { 359 | t.Fatalf("Authorization expected %s, but %s", expectedAuth, authorization) 360 | } 361 | 362 | bodyBytes, err := io.ReadAll(r.Body) 363 | if err != nil { 364 | t.Fatal(err) 365 | } 366 | defer r.Body.Close() 367 | 368 | actualV, err := url.ParseQuery(string(bodyBytes)) 369 | if err != nil { 370 | t.Fatal(err) 371 | } 372 | 373 | expectedV := url.Values{} 374 | expectedV.Set("filename", param.Filename) 375 | expectedV.Set("length", strconv.Itoa(param.Length)) 376 | 377 | if diff := cmp.Diff(expectedV, actualV); diff != "" { 378 | t.Errorf("unexpected diff: (-want +got):\n%s", diff) 379 | } 380 | 381 | w.Header().Set("Content-Type", "application/json") 382 | 383 | b, err := os.ReadFile("testdata/files_get_upload_url_external_fail_invalid_arguments.json") 384 | if err != nil { 385 | t.Fatal(err) 386 | } 387 | 388 | w.Write(b) 389 | }) 390 | 391 | defer SetFilesGetUploadURLExternalURL(testAPIServer.URL + "/api/files.getUploadURLExternal")() 392 | 393 | c, err := NewClientForPostFile(slackToken, slog.New(slog.NewTextHandler(io.Discard, nil))) 394 | if err != nil { 395 | t.Fatal(err) 396 | } 397 | 398 | _, _, err = c.GetUploadURLExternalURL(t.Context(), param) 399 | 400 | if err == nil { 401 | t.Fatal("expected error, but nothing was returned") 402 | } else { 403 | expected := "response has failed" 404 | if !strings.Contains(err.Error(), expected) { 405 | t.Errorf("expected %q to contain %q", err.Error(), expected) 406 | } 407 | 408 | expectedBodyPart := `"invalid_arguments"` 409 | if !strings.Contains(err.Error(), expectedBodyPart) { 410 | t.Errorf("expected %q to contain %q", err.Error(), expectedBodyPart) 411 | } 412 | } 413 | } 414 | 415 | func TestPostFile_FailBrokenJSON(t *testing.T) { 416 | muxAPI := http.NewServeMux() 417 | testAPIServer := httptest.NewServer(muxAPI) 418 | defer testAPIServer.Close() 419 | 420 | slackToken := "slack-token" 421 | 422 | param := &GetUploadURLExternalResParam{ 423 | Filename: "test.txt", 424 | Length: 100, 425 | } 426 | 427 | muxAPI.HandleFunc("/api/files.getUploadURLExternal", func(w http.ResponseWriter, r *http.Request) { 428 | contentType := r.Header.Get("Content-Type") 429 | expectedType := "application/x-www-form-urlencoded" 430 | if contentType != expectedType { 431 | t.Fatalf("Content-Type expected %s, but %s", expectedType, contentType) 432 | } 433 | 434 | authorization := r.Header.Get("Authorization") 435 | expectedAuth := "Bearer " + slackToken 436 | if authorization != expectedAuth { 437 | t.Fatalf("Authorization expected %s, but %s", expectedAuth, authorization) 438 | } 439 | 440 | bodyBytes, err := io.ReadAll(r.Body) 441 | if err != nil { 442 | t.Fatal(err) 443 | } 444 | defer r.Body.Close() 445 | 446 | actualV, err := url.ParseQuery(string(bodyBytes)) 447 | if err != nil { 448 | t.Fatal(err) 449 | } 450 | 451 | expectedV := url.Values{} 452 | expectedV.Set("filename", param.Filename) 453 | expectedV.Set("length", strconv.Itoa(param.Length)) 454 | 455 | if diff := cmp.Diff(expectedV, actualV); diff != "" { 456 | t.Errorf("unexpected diff: (-want +got):\n%s", diff) 457 | } 458 | 459 | w.Header().Set("Content-Type", "text/plain") 460 | 461 | w.Write([]byte("this is not json")) 462 | }) 463 | 464 | defer SetFilesGetUploadURLExternalURL(testAPIServer.URL + "/api/files.getUploadURLExternal")() 465 | 466 | c, err := NewClientForPostFile(slackToken, slog.New(slog.NewTextHandler(io.Discard, nil))) 467 | if err != nil { 468 | t.Fatal(err) 469 | } 470 | 471 | _, _, err = c.GetUploadURLExternalURL(t.Context(), param) 472 | 473 | if err == nil { 474 | t.Fatal("expected error, but nothing was returned") 475 | } else { 476 | expectedBodyPart := `this is not json` 477 | if !strings.Contains(err.Error(), expectedBodyPart) { 478 | t.Errorf("expected %q to contain %q", err.Error(), expectedBodyPart) 479 | } 480 | } 481 | } 482 | 483 | func TestUploadToURL_success(t *testing.T) { 484 | muxAPI := http.NewServeMux() 485 | testAPIServer := httptest.NewServer(muxAPI) 486 | defer testAPIServer.Close() 487 | 488 | muxAPI.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 489 | if !strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data") { 490 | t.Errorf("Expected multipart/form-data content type, got '%s'", r.Header.Get("Content-Type")) 491 | } 492 | 493 | err := r.ParseMultipartForm(32 << 10) // 32 KB 494 | if err != nil { 495 | t.Errorf("Error parsing multipart form: %v", err) 496 | } 497 | 498 | f, fh, err := r.FormFile("file") 499 | if err != nil { 500 | t.Errorf("Error retrieving file from form: %v", err) 501 | } 502 | 503 | if fh.Filename != "upload.txt" { 504 | t.Errorf("Expected filename 'testdata/upload_to_url_ok.txt', got '%s'", fh.Filename) 505 | } 506 | 507 | b, err := io.ReadAll(f) 508 | if err != nil { 509 | t.Errorf("Error reading file: %v", err) 510 | } 511 | 512 | expectedBody := []byte("this is test.\n") 513 | if !reflect.DeepEqual(b, expectedBody) { 514 | t.Errorf("expected %q to equal %q", b, expectedBody) 515 | } 516 | 517 | http.ServeFile(w, r, "testdata/upload_to_url_ok.txt") 518 | }) 519 | 520 | c, err := NewClientForPostFile("abcd", slog.New(slog.NewTextHandler(io.Discard, nil))) 521 | if err != nil { 522 | t.Fatal(err) 523 | } 524 | 525 | b, err := os.ReadFile("testdata/upload.txt") 526 | if err != nil { 527 | t.Fatal(err) 528 | } 529 | 530 | err = c.UploadToURL(t.Context(), "testdata/upload.txt", testAPIServer.URL, b) 531 | if err != nil { 532 | t.Fatal(err) 533 | } 534 | } 535 | 536 | func TestUploadToURL_fail(t *testing.T) { 537 | muxAPI := http.NewServeMux() 538 | testAPIServer := httptest.NewServer(muxAPI) 539 | defer testAPIServer.Close() 540 | 541 | muxAPI.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 542 | w.WriteHeader(http.StatusBadRequest) 543 | }) 544 | 545 | c, err := NewClientForPostFile("abcd", slog.New(slog.NewTextHandler(io.Discard, nil))) 546 | if err != nil { 547 | t.Fatal(err) 548 | } 549 | 550 | b, err := os.ReadFile("testdata/upload.txt") 551 | if err != nil { 552 | t.Fatal(err) 553 | } 554 | 555 | err = c.UploadToURL(t.Context(), "upload.txt", testAPIServer.URL, b) 556 | if err == nil { 557 | t.Fatal("expected error, but nothing was returned") 558 | } 559 | 560 | expected := "status code: 400" 561 | if !strings.Contains(err.Error(), expected) { 562 | t.Fatalf("expected %q to contain %q", err.Error(), expected) 563 | } 564 | } 565 | 566 | func TestCompleteUploadExternal_Success(t *testing.T) { 567 | muxAPI := http.NewServeMux() 568 | testAPIServer := httptest.NewServer(muxAPI) 569 | defer testAPIServer.Close() 570 | 571 | slackToken := "slack-token" 572 | 573 | muxAPI.HandleFunc("/api/files.completeUploadExternal", func(w http.ResponseWriter, r *http.Request) { 574 | contentType := r.Header.Get("Content-Type") 575 | expectedType := "application/x-www-form-urlencoded" 576 | if contentType != expectedType { 577 | t.Fatalf("Content-Type expected %s, but %s", expectedType, contentType) 578 | } 579 | 580 | authorization := r.Header.Get("Authorization") 581 | expectedAuth := "Bearer " + slackToken 582 | if authorization != expectedAuth { 583 | t.Fatalf("Authorization expected %s, but %s", expectedAuth, authorization) 584 | } 585 | 586 | bodyBytes, err := io.ReadAll(r.Body) 587 | if err != nil { 588 | t.Fatal(err) 589 | } 590 | defer r.Body.Close() 591 | 592 | actualV, err := url.ParseQuery(string(bodyBytes)) 593 | if err != nil { 594 | t.Fatal(err) 595 | } 596 | 597 | expectedV := url.Values{} 598 | expectedV.Set("files", `[{"id":"file-id","title":"file-title"}]`) 599 | expectedV.Set("channel_id", "C0NF841BK") 600 | 601 | if diff := cmp.Diff(expectedV, actualV); diff != "" { 602 | t.Errorf("unexpected diff: (-want +got):\n%s", diff) 603 | } 604 | 605 | http.ServeFile(w, r, "testdata/files_complete_upload_external_ok.json") 606 | }) 607 | 608 | defer SetFilesCompleteUploadExternalURL(testAPIServer.URL + "/api/files.completeUploadExternal")() 609 | 610 | c, err := NewClientForPostFile(slackToken, slog.New(slog.NewTextHandler(io.Discard, nil))) 611 | if err != nil { 612 | t.Fatal(err) 613 | } 614 | 615 | param := &CompleteUploadExternalParam{ 616 | FileID: "file-id", 617 | Title: "file-title", 618 | ChannelID: "C0NF841BK", 619 | } 620 | err = c.CompleteUploadExternal(t.Context(), param) 621 | if err != nil { 622 | t.Fatal(err) 623 | } 624 | } 625 | -------------------------------------------------------------------------------- /internal/slack/export_test.go: -------------------------------------------------------------------------------- 1 | package slack 2 | 3 | func SetFilesGetUploadURLExternalURL(u string) (resetFunc func()) { 4 | var tmp string 5 | tmp, filesGetUploadURLExternalURL = filesGetUploadURLExternalURL, u 6 | return func() { 7 | filesGetUploadURLExternalURL = tmp 8 | } 9 | } 10 | 11 | func SetFilesCompleteUploadExternalURL(u string) (resetFunc func()) { 12 | var tmp string 13 | tmp, filesCompleteUploadExternalURL = filesCompleteUploadExternalURL, u 14 | return func() { 15 | filesCompleteUploadExternalURL = tmp 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /internal/slack/testdata/files_complete_upload_external_fail.json: -------------------------------------------------------------------------------- 1 | { 2 | "ok": false, 3 | "error": "invalid_auth" 4 | } 5 | -------------------------------------------------------------------------------- /internal/slack/testdata/files_complete_upload_external_ok.json: -------------------------------------------------------------------------------- 1 | { 2 | "ok": true, 3 | "files": [ 4 | { 5 | "id": "F123ABC456", 6 | "title": "slack-test" 7 | } 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /internal/slack/testdata/files_get_upload_url_external_fail.json: -------------------------------------------------------------------------------- 1 | { 2 | "ok": false, 3 | "error": "invalid_auth" 4 | } 5 | -------------------------------------------------------------------------------- /internal/slack/testdata/files_get_upload_url_external_fail_invalid_arguments.json: -------------------------------------------------------------------------------- 1 | { 2 | "ok": false, 3 | "error": "invalid_arguments", 4 | "response_metadata": { 5 | "messages": [ 6 | "[ERROR] must be greater than 1 [json-pointer:/length]" 7 | ] 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /internal/slack/testdata/files_get_upload_url_external_ok.json: -------------------------------------------------------------------------------- 1 | { 2 | "ok": true, 3 | "upload_url": "https://files.slack.com/upload/v1/ABC123456", 4 | "file_id": "F123ABC456" 5 | } 6 | -------------------------------------------------------------------------------- /internal/slack/testdata/post_files_upload_fail.json: -------------------------------------------------------------------------------- 1 | {"ok":false,"error":"invalid_auth"} 2 | -------------------------------------------------------------------------------- /internal/slack/testdata/post_files_upload_ok.json: -------------------------------------------------------------------------------- 1 | { 2 | "ok": true, 3 | "file": { 4 | "id": "xxxxxxx", 5 | "created": 1535265940, 6 | "timestamp": 1535265940, 7 | "name": "tmp.txt", 8 | "title": "tmp", 9 | "mimetype": "text/plain", 10 | "filetype": "text", 11 | "pretty_type": "Plain Text", 12 | "user": "xxxxxxx", 13 | "editable": true, 14 | "size": 10, 15 | "mode": "snippet", 16 | "is_external": false, 17 | "external_type": "", 18 | "is_public": true, 19 | "public_url_shared": false, 20 | "display_as_bot": false, 21 | "username": "", 22 | "url_private": "https://files.slack.com/files-pri/xxxxxx/tmp.txt", 23 | "url_private_download": "https://files.slack.com/files-pri/xxxx/download/tmp.txt", 24 | "permalink": "https://xxxx.slack.com/files/xxxxx/tmp.txt", 25 | "permalink_public": "https://slack-files.com/xxxx", 26 | "edit_link": "https://xxxx.slack.com/files/xxxxxxx/tmp.txt/edit", 27 | "preview": "aaaaafhaaa", 28 | "preview_highlight": "