Skip to content

Commit 8a7d4d3

Browse files
committed
Merge branch 'yasu/slack-webhook-to-api'
* yasu/slack-webhook-to-api: update docs with slack access token setup and secrets file changes tests: promote w/ new `channel` field in notification payload enforce presence of either webhooks or access token in secrets file use the new notification sending interface throughout the codebase send slack notifications with web api if token is configured rename slack req/res atd types so they're not webhook-specific
2 parents f44d9cd + 7404d4a commit 8a7d4d3

File tree

14 files changed

+203
-159
lines changed

14 files changed

+203
-159
lines changed

README.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,23 @@ Run the `_build/default/src/monorobot.exe` binary. The following commands are su
2222

2323
- `run`: Launch the HTTP server
2424
- `check_gh <GH_PAYLOAD>`: read a Github notification from a file and display the actions that will be taken (used for testing)
25-
- `check_slack <SLACK_PAYLOAD> <SLACK_WEBHOOK>`: read a Slack notification from a file and send it to a webhook (used for testing)
25+
- `check_slack <SLACK_PAYLOAD>`: read a Slack notification from a file and send it to a channel (used for testing)
26+
27+
## Getting Started
28+
29+
1. Commit a **repository configuration** file to the root of your target repository.
30+
2. Place a **secrets** file locally on the server.
31+
3. Configure GitHub
32+
1. If targeting a private repository, set up a [personal access token](https://docs.github.com/en/free-pro-team@latest/github/authenticating-to-github/creating-a-personal-access-token) with `repo` scope and store it in the `gh_token` field of the secrets file.
33+
2. [Create a webhook](https://docs.github.com/en/free-pro-team@latest/developers/webhooks-and-events/creating-webhooks#setting-up-a-webhook) for the repository you are targeting. Set the *Payload URL* to be `<server_domain>/github`.
34+
3. You can optionally [secure the webhook](https://docs.github.com/en/free-pro-team@latest/developers/webhooks-and-events/securing-your-webhooks) with a token, and store it in the `gh_hook_token` field of the secrets file.
35+
4. Configure Slack
36+
1. [Create a Slack app](https://api.slack.com/apps?new_app=1).
37+
2. Click "Install to Workspace", and when prompted to grant permissions to your workspace, click "Allow".
38+
3. Set up notifications with one of the following methods:
39+
- **Web API (recommended):** To use Slack's [Web API](https://api.slack.com/web), click on "OAuth & Permissions" in your app dashboard's sidebar. Give your bot a *Bot Token Scope* of `chat:write`. Copy the generated OAuth access token (`xoxb-XXXX`) to the `slack_access_token` field of your secrets file. This token is used by the bot to authenticate to the workspace, and remains valid until the token is revoked or the app is uninstalled.
40+
- **Incoming Webhooks:** To use [incoming webhooks](https://api.slack.com/messaging/webhooks), enable them in your app dashboard and create one for each channel you want to notify. Store them in the `slack_hooks` field of your secrets file. If you decide to notify additional channels later, you will need to update the secrets file with the new webhooks and restart the server.
41+
2642

2743
### Documentation
2844

documentation/config_docs.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ A **label rule** specifies whether or not a Slack channel should be notified, ba
8787
|-|-|-|-|
8888
| `match` | if notifications have any label in this list, they should be routed to the channel | Yes | all labels matched if no list provided |
8989
| `ignore` | if notifications have any label in this list, they shouldn't be routed to the channel (even if they have any `match` labels) | Yes | - |
90-
| `channel` | channel to use as webhook if the rule is matched | No | - |
90+
| `channel` | channel to notify if the rule is matched | No | - |
9191

9292
## Prefix Options
9393

@@ -126,7 +126,7 @@ A **prefix rule** specifies whether or not a Slack channel should be notified, b
126126
|-|-|-|-|
127127
| `match` | if commit files have any prefix in this list, they should be routed to the channel | Yes | all prefixes matched if no list provided |
128128
| `ignore` | if commit files have any prefix in this list, they shouldn't be routed to the channel (even if they have any `match` prefixes) | Yes | - |
129-
| `channel` | channel to use as webhook if the rule is matched | No | - |
129+
| `channel` | channel to notify if the rule is matched | No | - |
130130

131131
## Status Options
132132

documentation/secret_docs.md

Lines changed: 32 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -8,44 +8,19 @@ A secrets file stores sensitive information. Unlike the repository configuration
88

99
```json
1010
{
11-
"slack_hooks": [
12-
{
13-
"url": "https://slack_webhook_url",
14-
"channel": "default"
15-
},
16-
{
17-
"url": "https://slack_webhook_url",
18-
"channel": "aa"
19-
},
20-
{
21-
"url": "https://slack_webhook_url",
22-
"channel": "backend"
23-
},
24-
{
25-
"url": "https://slack_webhook_url",
26-
"channel": "all-push-events"
27-
},
28-
{
29-
"url": "https://slack_webhook_url",
30-
"channel": "frontend-bot"
31-
},
32-
{
33-
"url": "https://slack_webhook_url",
34-
"channel": "aa-git"
35-
},
36-
{
37-
"url": "https://slack_webhook_url",
38-
"channel": "siren"
39-
}
40-
]
11+
"gh_token": "",
12+
"slack_access_token": ""
4113
}
4214
```
4315

4416
| value | description | optional | default |
4517
|-|-|-|-|
46-
| `slack_hooks` | list of channel names (`channel`) and their corresponding webhook endpoint (`url`) | No | - |
4718
| `gh_token` | specify to grant the bot access to private repositories; omit for public repositories | Yes | - |
4819
| `gh_hook_token` | specify to ensure the bot only receives GitHub notifications from pre-approved repositories | Yes | - |
20+
| `slack_access_token` | slack bot access token to enable message posting to the workspace | Yes | try to use webhooks defined in `slack_hooks` instead |
21+
| `slack_hooks` | list of channel names and their corresponding webhook endpoint | Yes | try to use token defined in `slack_access_token` instead |
22+
23+
Note that either `slack_access_token` or `slack_hooks` must be defined.
4924

5025
## `gh_token`
5126

@@ -54,3 +29,29 @@ Some operations, such as fetching a config file from a private repository, or th
5429
## `gh_hook_token`
5530

5631
Refer [here](https://docs.github.com/en/free-pro-team@latest/developers/webhooks-and-events/securing-your-webhooks) for more information on securing webhooks with a token.
32+
33+
## `slack_access_token`
34+
35+
Refer [here](https://api.slack.com/authentication/oauth-v2) for obtaining an access token via OAuth.
36+
37+
## `slack_hooks`
38+
39+
*Note: If `slack_access_token` is also defined, the bot will authenticate over Slack's Web API and this option will not be used.*
40+
41+
Expected format:
42+
43+
```json
44+
[
45+
{
46+
"channel": "channel name",
47+
"url": "webhook url"
48+
},
49+
{
50+
"channel": "channel name",
51+
"url": "webhook url"
52+
},
53+
...
54+
]
55+
```
56+
57+
Refer [here](https://api.slack.com/messaging/webhooks) for obtaining a webhook for a channel.

lib/action.ml

Lines changed: 14 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -152,41 +152,29 @@ module Action (Github_api : Api.Github) (Slack_api : Api.Slack) = struct
152152
let cfg = Context.get_config_exn ctx in
153153
match req with
154154
| Github.Push n ->
155-
partition_push cfg n |> List.map ~f:(fun (webhook, n) -> webhook, generate_push_notification n) |> Lwt.return
156-
| Pull_request n ->
157-
partition_pr cfg n |> List.map ~f:(fun webhook -> webhook, generate_pull_request_notification n) |> Lwt.return
158-
| PR_review n ->
159-
partition_pr_review cfg n |> List.map ~f:(fun webhook -> webhook, generate_pr_review_notification n) |> Lwt.return
155+
partition_push cfg n |> List.map ~f:(fun (channel, n) -> generate_push_notification n channel) |> Lwt.return
156+
| Pull_request n -> partition_pr cfg n |> List.map ~f:(generate_pull_request_notification n) |> Lwt.return
157+
| PR_review n -> partition_pr_review cfg n |> List.map ~f:(generate_pr_review_notification n) |> Lwt.return
160158
| PR_review_comment n ->
161-
partition_pr_review_comment cfg n
162-
|> List.map ~f:(fun webhook -> webhook, generate_pr_review_comment_notification n)
163-
|> Lwt.return
164-
| Issue n ->
165-
partition_issue cfg n |> List.map ~f:(fun webhook -> webhook, generate_issue_notification n) |> Lwt.return
159+
partition_pr_review_comment cfg n |> List.map ~f:(generate_pr_review_comment_notification n) |> Lwt.return
160+
| Issue n -> partition_issue cfg n |> List.map ~f:(generate_issue_notification n) |> Lwt.return
166161
| Issue_comment n ->
167-
partition_issue_comment cfg n
168-
|> List.map ~f:(fun webhook -> webhook, generate_issue_comment_notification n)
169-
|> Lwt.return
162+
partition_issue_comment cfg n |> List.map ~f:(generate_issue_comment_notification n) |> Lwt.return
170163
| Commit_comment n ->
171-
let%lwt webhooks, api_commit = partition_commit_comment ctx n in
172-
let%lwt notif = generate_commit_comment_notification api_commit n in
173-
let notifs = List.map ~f:(fun webhook -> webhook, notif) webhooks in
164+
let%lwt channels, api_commit = partition_commit_comment ctx n in
165+
let notifs = List.map ~f:(generate_commit_comment_notification api_commit n) channels in
174166
Lwt.return notifs
175167
| Status n ->
176-
let%lwt webhooks = partition_status ctx n in
177-
let notifs = List.map ~f:(fun webhook -> webhook, generate_status_notification cfg n) webhooks in
168+
let%lwt channels = partition_status ctx n in
169+
let notifs = List.map ~f:(generate_status_notification cfg n) channels in
178170
Lwt.return notifs
179171
| _ -> Lwt.return []
180172

181173
let send_notifications (ctx : Context.t) notifications =
182-
let notify (chan, msg) =
183-
match Context.hook_of_channel ctx chan with
184-
| None -> Printf.ksprintf action_error "webhook not defined for Slack channel '%s'" chan
185-
| Some url ->
186-
( match%lwt Slack_api.send_notification ~chan ~msg ~url with
187-
| Ok () -> Lwt.return_unit
188-
| Error e -> action_error e
189-
)
174+
let notify (msg : Slack_t.post_message_req) =
175+
match%lwt Slack_api.send_notification ~ctx ~msg with
176+
| Ok () -> Lwt.return_unit
177+
| Error e -> action_error e
190178
in
191179
Lwt_list.iter_s notify notifications
192180

lib/api.ml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,5 @@ module type Github = sig
99
end
1010

1111
module type Slack = sig
12-
val send_notification : chan:string -> msg:webhook_notification -> url:string -> (unit, string) Result.t Lwt.t
12+
val send_notification : ctx:Context.t -> msg:post_message_req -> (unit, string) Result.t Lwt.t
1313
end

lib/api_local.ml

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,20 +21,18 @@ module Github : Api.Github = struct
2121
end
2222

2323
module Slack : Api.Slack = struct
24-
let send_notification ~chan ~msg ~url:_ =
25-
let json =
26-
msg |> Slack_j.string_of_webhook_notification |> Yojson.Basic.from_string |> Yojson.Basic.pretty_to_string
27-
in
28-
Stdio.printf "will notify #%s\n" chan;
24+
let send_notification ~ctx:_ ~msg =
25+
let json = msg |> Slack_j.string_of_post_message_req |> Yojson.Basic.from_string |> Yojson.Basic.pretty_to_string in
26+
Stdio.printf "will notify #%s\n" msg.channel;
2927
Stdio.printf "%s\n" json;
3028
Lwt.return @@ Ok ()
3129
end
3230

3331
module Slack_simple : Api.Slack = struct
3432
let log = Log.from "slack"
3533

36-
let send_notification ~chan ~msg ~url:_ =
37-
log#info "will notify %s%s" chan
34+
let send_notification ~ctx:_ ~(msg : Slack_t.post_message_req) =
35+
log#info "will notify %s%s" msg.channel
3836
( match msg.Slack_t.text with
3937
| None -> ""
4038
| Some s -> Printf.sprintf " with %S" s
@@ -45,9 +43,9 @@ end
4543
module Slack_json : Api.Slack = struct
4644
let log = Log.from "slack"
4745

48-
let send_notification ~chan ~msg ~url:_ =
49-
let json = Slack_j.string_of_webhook_notification msg in
50-
log#info "will notify %s" chan;
46+
let send_notification ~ctx:_ ~(msg : Slack_t.post_message_req) =
47+
log#info "will notify %s" msg.channel;
48+
let json = Slack_j.string_of_post_message_req msg in
5149
let url = Uri.of_string "https://api.slack.com/docs/messages/builder" in
5250
let url = Uri.add_query_param url ("msg", [ json ]) in
5351
log#info "%s" (Uri.to_string url);

lib/api_remote.ml

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,35 @@ end
5050
module Slack : Api.Slack = struct
5151
let log = Log.from "slack"
5252

53-
let send_notification ~chan ~msg ~url =
54-
let data = Slack_j.string_of_webhook_notification msg in
55-
let body = `Raw ("application/json", data) in
56-
log#info "sending to %s : %s" chan data;
57-
match%lwt http_request ~body `POST url with
58-
| Ok _ -> Lwt.return @@ Ok ()
59-
| Error e ->
60-
Lwt.return @@ fmt_error "error while querying remote: %s\nfailed to send Slack notification to %s" e url
53+
let bearer_token_header access_token = sprintf "Authorization: Bearer %s" (Uri.pct_decode access_token)
54+
55+
(** `send_notification ctx msg` notifies `msg.channel` with the payload `msg`;
56+
uses web API with access token if available, or with webhook otherwise *)
57+
let send_notification ~(ctx : Context.t) ~(msg : Slack_t.post_message_req) =
58+
log#info "sending to %s" msg.channel;
59+
let build_error e = fmt_error "%s\nfailed to send Slack notification" e in
60+
let build_query_error url e = build_error @@ sprintf "error while querying %s: %s" url e in
61+
let secrets = Context.get_secrets_exn ctx in
62+
let headers, url, webhook_mode =
63+
match secrets.slack_access_token with
64+
| Some access_token -> [ bearer_token_header access_token ], Some "https://slack.com/api/chat.postMessage", false
65+
| None -> [], Context.hook_of_channel ctx msg.channel, true
66+
in
67+
match url with
68+
| None -> Lwt.return @@ build_error @@ sprintf "no token or webhook configured to notify channel %s" msg.channel
69+
| Some url ->
70+
let data = Slack_j.string_of_post_message_req msg in
71+
let body = `Raw ("application/json", data) in
72+
log#info "data: %s" data;
73+
( match%lwt http_request ~body ~headers `POST url with
74+
(* error detection in response: slack uses status codes for webhooks versus a 200 code w/ `error` field for web api *)
75+
| Ok s ->
76+
if webhook_mode then Lwt.return @@ Ok ()
77+
else (
78+
let res = Slack_j.post_message_res_of_string s in
79+
if res.ok then Lwt.return @@ Ok ()
80+
else Lwt.return @@ build_query_error url (Option.value ~default:"an unknown error occurred" res.error)
81+
)
82+
| Error e -> Lwt.return @@ build_query_error url e
83+
)
6184
end

lib/config.atd

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,12 @@ type webhook = {
3939
(* This is the structure of the secrets file which stores sensitive information, and
4040
shouldn't be checked into version control. *)
4141
type secrets = {
42-
slack_hooks : webhook list;
43-
?gh_token : string option; (* GitHub personal access token, if repo access requires it *)
44-
?gh_hook_token : string option; (* GitHub webhook token to secure the webhook *)
42+
(* GitHub personal access token, if repo access requires it *)
43+
?gh_token : string nullable;
44+
(* GitHub webhook token to secure the webhook *)
45+
?gh_hook_token : string nullable;
46+
(* list of Slack webhook & channel name pairs *)
47+
~slack_hooks <ocaml default="[]"> : webhook list;
48+
(* Slack bot token obtained via OAuth, enabling message posting to the workspace *)
49+
?slack_access_token : string nullable;
4550
}

lib/context.ml

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,14 @@ let refresh_secrets ctx =
6767
match get_local_file path with
6868
| Error e -> fmt_error "error while getting local file: %s\nfailed to get secrets from file %s" e path
6969
| Ok file ->
70-
ctx.secrets <- Some (Config_j.secrets_of_string file);
71-
Ok ctx
70+
let secrets = Config_j.secrets_of_string file in
71+
begin
72+
match secrets.slack_access_token, secrets.slack_hooks with
73+
| None, [] -> fmt_error "either slack_access_token or slack_hooks must be defined in file '%s'" path
74+
| _ ->
75+
ctx.secrets <- Some secrets;
76+
Ok ctx
77+
end
7278

7379
let refresh_state ctx =
7480
match ctx.state_filepath with

lib/slack.atd

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
type webhook_notification_field = {
1+
type message_field = {
22
?title: string nullable;
33
value: string;
44
}
55

6-
type webhook_notification_attachment = {
6+
type message_attachment = {
77
fallback: string nullable;
88
?mrkdwn_in: string list nullable;
99
?color: string nullable;
@@ -14,18 +14,18 @@ type webhook_notification_attachment = {
1414
?title: string nullable;
1515
?title_link: string nullable;
1616
?text: string nullable;
17-
?fields: webhook_notification_field list nullable;
17+
?fields: message_field list nullable;
1818
?image_url: string nullable;
1919
?thumb_url: string nullable;
2020
?ts: int nullable;
2121
?footer: string nullable;
2222
}
2323

24-
type notification_section_block_type = [
24+
type message_section_block_type = [
2525
Section <json name="section">
2626
] <ocaml repr="classic">
2727

28-
type notification_divider_block_type = [
28+
type message_divider_block_type = [
2929
Divider <json name="divider">
3030
] <ocaml repr="classic">
3131

@@ -39,22 +39,29 @@ type text_object = {
3939
text: string;
4040
}
4141

42-
type webhook_notification_text_block = {
43-
notification_type <json name="type"> : notification_section_block_type;
42+
type message_text_block = {
43+
message_type <json name="type"> : message_section_block_type;
4444
text: text_object;
4545
}
4646

47-
type webhook_notification_divider_block = {
48-
notification_type <json name="type"> : notification_divider_block_type;
47+
type message_divider_block = {
48+
message_type <json name="type"> : message_divider_block_type;
4949
}
5050

51-
type webhook_notification_block = [
52-
Text of webhook_notification_text_block
53-
| Divider of webhook_notification_divider_block
51+
type message_block = [
52+
Text of message_text_block
53+
| Divider of message_divider_block
5454
] <json adapter.ocaml="Atdgen_runtime.Json_adapter.Type_field">
5555

56-
type webhook_notification = {
56+
type post_message_req = {
57+
channel: string;
5758
?text: string nullable;
58-
?attachments: webhook_notification_attachment list nullable;
59-
?blocks: webhook_notification_block list nullable;
59+
?attachments: message_attachment list nullable;
60+
?blocks: message_block list nullable;
61+
}
62+
63+
type post_message_res = {
64+
ok: bool;
65+
?channel: string nullable;
66+
?error: string nullable;
6067
}

0 commit comments

Comments
 (0)