Skip to content

Commit f287135

Browse files
committed
Merge branch 'yasu/slack-unfurl-links'
* yasu/slack-unfurl-links: show number of changed files in longest shared prefix escape and only show first line of commit msg update docs with link unfurl tests: add unit tests for parsing of GH links add route to handle incoming slack event notifications parse incoming unfurl links and try to construct supported GH types add api route to send slack unfurl request add slack atd bindings for url verification and chat unfurl add logic for building message body of unfurled commit links add slack incoming request signature validation
2 parents d753eb4 + 723711b commit f287135

17 files changed

+350
-5
lines changed

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,14 @@ Run the `_build/default/src/monorobot.exe` binary. The following commands are su
4040
- **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.
4141

4242

43+
### Link Unfurling
44+
45+
You can configure Monorobot to [unfurl GitHub links](https://api.slack.com/reference/messaging/link-unfurling) in Slack messages. Currently, commit links are supported.
46+
47+
1. Give your app `links:read` and `links:write` [permissions](https://api.slack.com/apps).
48+
1. Configure your app to [support the Events API](https://api.slack.com/events-api#prepare). During the [url verification handshake](https://api.slack.com/events-api#the-events-api__subscribing-to-event-types__events-api-request-urls__request-url-configuration--verification__url-verification-handshake), you should tell Slack to direct event notifications to `<server_domain>/slack/events`. Ensure the server is running before triggering the handshake.
49+
1. [Register the GitHub domains](https://api.slack.com/reference/messaging/link-unfurling#configuring_domains) you want to support.
50+
4351
### Documentation
4452

4553
The bot expects two configuration files to be present.

documentation/secret_docs.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ A secrets file stores sensitive information. Unlike the repository configuration
1919
| `gh_hook_token` | specify to ensure the bot only receives GitHub notifications from pre-approved repositories | Yes | - |
2020
| `slack_access_token` | slack bot access token to enable message posting to the workspace | Yes | try to use webhooks defined in `slack_hooks` instead |
2121
| `slack_hooks` | list of channel names and their corresponding webhook endpoint | Yes | try to use token defined in `slack_access_token` instead |
22+
| `slack_signing_secret` | specify to verify incoming slack requests | Yes | - |
2223

2324
Note that either `slack_access_token` or `slack_hooks` must be defined.
2425

lib/action.ml

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,4 +232,42 @@ module Action (Github_api : Api.Github) (Slack_api : Api.Slack) = struct
232232
| Context.Context_error msg ->
233233
log#error "%s" msg;
234234
Lwt.return_unit
235+
236+
let process_link_shared_event (ctx : Context.t) (event : Slack_t.link_shared_event) =
237+
let process link =
238+
match Github.gh_link_of_string link with
239+
| None -> Lwt.return_none
240+
| Some gh_link ->
241+
match gh_link with
242+
| Commit (repo, sha) ->
243+
( match%lwt Github_api.get_api_commit ~ctx ~repo ~sha with
244+
| Error _ -> Lwt.return_none
245+
| Ok commit -> Lwt.return_some @@ (link, Slack_message.populate_commit repo commit)
246+
)
247+
in
248+
if List.length event.links > 2 then Lwt.return "ignored: more than two links present"
249+
else begin
250+
let links = List.map event.links ~f:(fun l -> l.url) in
251+
let%lwt unfurls = List.map links ~f:process |> Lwt.all |> Lwt.map List.filter_opt |> Lwt.map StringMap.of_list in
252+
if Map.is_empty unfurls then Lwt.return "ignored: no links to unfurl"
253+
else begin
254+
let req : Slack_j.chat_unfurl_req = { channel = event.channel; ts = event.message_ts; unfurls } in
255+
match%lwt Slack_api.send_chat_unfurl ~ctx req with
256+
| Ok () -> Lwt.return "ok"
257+
| Error e ->
258+
log#error "%s" e;
259+
Lwt.return "ignored: failed to unfurl links"
260+
end
261+
end
262+
263+
let process_slack_event (ctx : Context.t) headers body =
264+
let secrets = Context.get_secrets_exn ctx in
265+
match Slack_j.event_notification_of_string body with
266+
| Url_verification payload -> Lwt.return payload.challenge
267+
| Event_callback notification ->
268+
match Slack.validate_signature ?signing_key:secrets.slack_signing_secret ~headers body with
269+
| Error e -> action_error e
270+
| Ok () ->
271+
match notification.event with
272+
| Link_shared event -> process_link_shared_event ctx event
235273
end

lib/api.ml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,6 @@ end
1010

1111
module type Slack = sig
1212
val send_notification : ctx:Context.t -> msg:post_message_req -> (unit, string) Result.t Lwt.t
13+
14+
val send_chat_unfurl : ctx:Context.t -> chat_unfurl_req -> (unit, string) Result.t Lwt.t
1315
end

lib/api_local.ml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,15 @@ module Github : Api.Github = struct
2020
| Ok file -> Lwt.return @@ Ok (Github_j.api_commit_of_string file)
2121
end
2222

23+
module Slack_base : Api.Slack = struct
24+
let send_notification ~ctx:_ ~msg:_ = Lwt.return @@ Error "undefined for local setup"
25+
26+
let send_chat_unfurl ~ctx:_ _ = Lwt.return @@ Error "undefined for local setup"
27+
end
28+
2329
module Slack : Api.Slack = struct
30+
include Slack_base
31+
2432
let send_notification ~ctx:_ ~msg =
2533
let json = msg |> Slack_j.string_of_post_message_req |> Yojson.Basic.from_string |> Yojson.Basic.pretty_to_string in
2634
Stdio.printf "will notify #%s\n" msg.channel;
@@ -29,6 +37,8 @@ module Slack : Api.Slack = struct
2937
end
3038

3139
module Slack_simple : Api.Slack = struct
40+
include Slack_base
41+
3242
let log = Log.from "slack"
3343

3444
let send_notification ~ctx:_ ~(msg : Slack_t.post_message_req) =
@@ -41,6 +51,8 @@ module Slack_simple : Api.Slack = struct
4151
end
4252

4353
module Slack_json : Api.Slack = struct
54+
include Slack_base
55+
4456
let log = Log.from "slack"
4557

4658
let send_notification ~ctx:_ ~(msg : Slack_t.post_message_req) =

lib/api_remote.ml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,4 +81,26 @@ module Slack : Api.Slack = struct
8181
)
8282
| Error e -> Lwt.return @@ build_query_error url e
8383
)
84+
85+
let send_chat_unfurl ~(ctx : Context.t) req =
86+
log#info "unfurling Slack links";
87+
let secrets = Context.get_secrets_exn ctx in
88+
match secrets.slack_access_token with
89+
| None -> Lwt.return @@ fmt_error "failed to retrieve Slack access token"
90+
| Some access_token ->
91+
let data = Slack_j.string_of_chat_unfurl_req req in
92+
log#info "%s" data;
93+
let url = "https://slack.com/api/chat.unfurl" in
94+
let headers = [ bearer_token_header access_token ] in
95+
let body = `Raw ("application/json", data) in
96+
( match%lwt http_request ~body ~headers `POST url with
97+
| Ok s ->
98+
let res = Slack_j.chat_unfurl_res_of_string s in
99+
if res.ok then Lwt.return @@ Ok ()
100+
else (
101+
let msg = Option.value ~default:"an unknown error occurred" res.error in
102+
Lwt.return @@ fmt_error "%s\nfailed to unfurl Slack links" msg
103+
)
104+
| Error e -> Lwt.return @@ fmt_error "error while querying %s: %s\nfailed to unfurl Slack links" url e
105+
)
84106
end

lib/colors.ml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
(* https://styleguide.github.com/primer/utilities/colors/#background-colors *)
2+
3+
let gray = "#f6f8fa"
4+
5+
let blue = "#0366d6"
6+
7+
let yellow = "#ffd33d"
8+
9+
let red = "#d73a49"
10+
11+
let green = "#28a745"
12+
13+
let purple = "#6f42c1"

lib/common.ml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,12 @@ let get_local_file path = try Ok (Std.input_file path) with exn -> fmt_error "%s
3535
let write_to_local_file ~data path =
3636
try Ok (Devkit.Files.save_as path (fun oc -> Stdio.Out_channel.fprintf oc "%s" data))
3737
with exn -> fmt_error "%s" (Exn.to_string exn)
38+
39+
let longest_common_prefix xs =
40+
match xs with
41+
| [] -> ""
42+
| [ x ] -> x
43+
| x :: _ -> String.sub x ~pos:0 ~len:(Stre.common_prefix x (List.sort xs ~compare:String.compare |> List.last_exn))
44+
45+
let sign_string_sha256 ~key ~basestring =
46+
Cstruct.of_string basestring |> Nocrypto.Hash.SHA256.hmac ~key:(Cstruct.of_string key) |> Hex.of_cstruct |> Hex.show

lib/config.atd

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,6 @@ type secrets = {
4747
~slack_hooks <ocaml default="[]"> : webhook list;
4848
(* Slack bot token obtained via OAuth, enabling message posting to the workspace *)
4949
?slack_access_token : string nullable;
50+
(* Slack uses this secret to sign requests; provide to verify incoming Slack requests *)
51+
?slack_signing_secret : string nullable;
5052
}

lib/github.atd

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ type commit = {
2424
type github_user = {
2525
login: string;
2626
id: int;
27+
url: string;
28+
html_url: string;
29+
avatar_url: string;
2730
}
2831

2932
type repository = {
@@ -218,12 +221,19 @@ type file = {
218221
blob_url <ocaml name="url"> : string;
219222
}
220223

224+
type api_commit_stats = {
225+
total: int;
226+
additions: int;
227+
deletions: int;
228+
}
229+
221230
type api_commit = {
222231
sha: commit_hash;
223232
commit: inner_commit;
224233
html_url <ocaml name="url"> : string;
225234
author: github_user;
226235
files: file list;
236+
stats: api_commit_stats;
227237
}
228238

229239
type commit_comment_notification = {

0 commit comments

Comments
 (0)