diff --git a/__tests__/NowPlaying_test.re b/__tests__/NowPlaying_test.re index 76b4a4e..7db4e9d 100644 --- a/__tests__/NowPlaying_test.re +++ b/__tests__/NowPlaying_test.re @@ -3,7 +3,7 @@ open Expect; describe("#nowPlayingData", () => { test("nothing is playing", () => { - let track: Sonos.Decode.currentTrackResponse = { + let sonos: Sonos.Decode.currentTrackResponse = { album: Some("30 Seconds to Mars"), albumArtURI: "", albumArtURL: "", @@ -15,11 +15,11 @@ describe("#nowPlayingData", () => { position: 1200.0, }; - expect(NowPlaying.message(track)) |> toMatchSnapshot; + expect(NowPlaying.message(~sonos, ~cover="img")) |> toMatchSnapshot; }); test("current track", () => { - let track: Sonos.Decode.currentTrackResponse = { + let sonos: Sonos.Decode.currentTrackResponse = { album: Some("30 Seconds to Mars"), albumArtURI: "", albumArtURL: "", @@ -31,6 +31,6 @@ describe("#nowPlayingData", () => { position: 120.0, }; - expect(NowPlaying.message(track)) |> toMatchSnapshot; + expect(NowPlaying.message(~sonos, ~cover="img")) |> toMatchSnapshot; }); }); diff --git a/lib/js/__tests__/__snapshots__/NowPlaying_test.bs.js.snap b/lib/js/__tests__/__snapshots__/NowPlaying_test.bs.js.snap index 6ffc9f1..746a227 100644 --- a/lib/js/__tests__/__snapshots__/NowPlaying_test.bs.js.snap +++ b/lib/js/__tests__/__snapshots__/NowPlaying_test.bs.js.snap @@ -1,9 +1,81 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`#nowPlayingData current track 1`] = ` -"*Currently playing* -30 Seconds to Mars - Echelon (30 Seconds to Mars) -Position in queue 1 - 2:00/6:00" +Array [ + Object { + "accessory": Object { + "action_id": undefined, + "alt_text": "Album cover", + "image_url": "img", + "text": undefined, + "type": "image", + "value": undefined, + }, + "elements": undefined, + "fields": Array [ + Object { + "text": "*Artist* +30 Seconds to Mars", + "type": "mrkdwn", + }, + Object { + "text": "*Track name* +Echelon", + "type": "mrkdwn", + }, + Object { + "text": "*Album* +30 Seconds to Mars", + "type": "mrkdwn", + }, + Object { + "text": "*Current position* +2:00 / 6:00", + "type": "mrkdwn", + }, + ], + "text": undefined, + "type": "section", + }, +] `; -exports[`#nowPlayingData nothing is playing 1`] = `"Nothing is currently playing, add a track using \`search \`"`; +exports[`#nowPlayingData nothing is playing 1`] = ` +Array [ + Object { + "accessory": Object { + "action_id": undefined, + "alt_text": "Album cover", + "image_url": "img", + "text": undefined, + "type": "image", + "value": undefined, + }, + "elements": undefined, + "fields": Array [ + Object { + "text": "*Artist* +30 Seconds to Mars", + "type": "mrkdwn", + }, + Object { + "text": "*Track name* +Echelon", + "type": "mrkdwn", + }, + Object { + "text": "*Album* +30 Seconds to Mars", + "type": "mrkdwn", + }, + Object { + "text": "*Current position* +20:00 / 1:00:00", + "type": "mrkdwn", + }, + ], + "text": undefined, + "type": "section", + }, +] +`; diff --git a/src/Commands.re b/src/Commands.re index 62e3773..c581a76 100644 --- a/src/Commands.re +++ b/src/Commands.re @@ -83,7 +83,7 @@ let make = text => { | text => UnknownCommand(text) } } - | None => UnknownCommand("no text") + | None => UnhandledCommand }; }; diff --git a/src/Decode.re b/src/Decode.re index cc71e28..c083970 100644 --- a/src/Decode.re +++ b/src/Decode.re @@ -132,6 +132,7 @@ module Action = { [@decco] type t = { actions, + response_url: string, channel, user, }; diff --git a/src/Event.re b/src/Event.re index a11c666..2cc1fe0 100644 --- a/src/Event.re +++ b/src/Event.re @@ -6,12 +6,13 @@ let logCommand = (command, args, user) => { }; }; -let makeWithAttachment = +let makeWithBlocks = (~command, ~args, ~user, ~subtype=Decode.Requester.Human, ()) => { logCommand(command, args, user); Js.Promise.( switch (subtype, command) { + | (Human, NowPlaying) => NowPlaying.run() | (Human, Search) => Spotify.search(args) | (Human, _) => resolve(`Failed("This is not the command you are looking for")) @@ -28,7 +29,6 @@ let make = (~command, ~args, ~user, ~subtype=Decode.Requester.Human, ()) => { | Human => switch (command) { | Blame => Blame.run() - | NowPlaying => NowPlaying.run() /* Queue control */ | Clear => Queue.clear() diff --git a/src/Routes.re b/src/Routes.re index ea04083..1176c25 100644 --- a/src/Routes.re +++ b/src/Routes.re @@ -27,18 +27,18 @@ let message = request => { ); }; -let messageWithAttachment = request => { +let messageWithBlocks = request => { let {subtype, channel, command, text: args, user}: Decode.Event.t = request; - let sendSearchResponse = Slack.Message.withAttachments(channel); + let sendSearchResponse = Slack.Message.withBlocks(channel); Js.Promise.( - Event.makeWithAttachment(~command, ~args, ~user, ~subtype, ()) + Event.makeWithBlocks(~command, ~args, ~user, ~subtype, ()) |> then_(response => { switch (response) { - | `Ok(message, attachments) => - sendSearchResponse(message, attachments) + | `Ok(message, blocks) => sendSearchResponse(message, blocks) | `Failed(_) => () }; + resolve(); }) |> ignore @@ -49,7 +49,8 @@ let eventCallback = (event: option(Decode.Event.t), res) => { switch (event) { | Some(e) => switch (e.command) { - | Search => messageWithAttachment(e) + | Search + | NowPlaying => messageWithBlocks(e) | _ => message(e) }; @@ -78,12 +79,15 @@ let event = ) ); -let action = - PromiseMiddleware.from((_next, req, res) => - Js.Promise.( +module Action = { + open Js.Promise; + + let make = + PromiseMiddleware.from((_next, req, res) => switch (Request.bodyJSON(req)) { | Some(body) => - let {actions, user}: Decode.Action.t = body |> Decode.Action.make; + let {actions, response_url, user}: Decode.Action.t = + body |> Decode.Action.make; let track = actions[0].value; Queue.last(track) @@ -95,14 +99,22 @@ let action = ); switch (message) { - | `Ok(m) => res |> Response.sendString(m) |> resolve + | `Ok(m) => + API.createRequest( + ~url=response_url, + ~_method="POST", + ~data=Some({"text": m}), + (), + ) + |> then_(_ => res |> Response.sendString(m) |> resolve) + | `Failed(_) => res |> failed |> resolve }; }); | None => res |> failed |> resolve } - ) - ); + ); +}; let slackAuth = Middleware.from((_next, _req, res) => diff --git a/src/Server.re b/src/Server.re index a9bc03d..ce0fdfd 100644 --- a/src/Server.re +++ b/src/Server.re @@ -8,7 +8,7 @@ App.use(app, Middleware.urlencoded(~extended=false, ())); App.get(app, ~path="/") @@ Routes.index; App.post(app, ~path="/event") @@ Routes.event; -App.post(app, ~path="/action") @@ Routes.action; +App.post(app, ~path="/action") @@ Routes.Action.make; App.post(app, ~path="/cli") @@ CLI.route; App.get(app, ~path="/slack/auth") @@ Routes.slackAuth; App.get(app, ~path="/slack/token") @@ Routes.slackToken; diff --git a/src/adapters/Slack.re b/src/adapters/Slack.re index 8d9742d..137e106 100644 --- a/src/adapters/Slack.re +++ b/src/adapters/Slack.re @@ -1,31 +1,105 @@ -module Attachment = { - [@bs.obj] - external field: (~title: string, ~value: string, ~short: bool) => _ = ""; +module Block = { + [@bs.deriving {jsConverter: newType}] + type text = { + _type: string, + text: string, + }; - [@bs.obj] - external action: - ( - ~name: [@bs.as "track"] _, - ~text: [@bs.as "Queue"] _, - ~_type: [@bs.as "button"] _, - ~value: string, - unit - ) => - _ = - ""; + [@bs.deriving {jsConverter: newType}] + type accessory = { + _type: string, + action_id: option(string), + alt_text: option(string), + image_url: option(string), + text: option(abs_text), + value: option(string), + }; - [@bs.obj] - external make: - ( - ~color: [@bs.as "#efb560"] _, - ~callback_id: [@bs.as "queue"] _, - ~thumb_url: string, - ~fields: array(Js.t('a)), - ~actions: array(Js.t('b)), - unit - ) => - _ = - ""; + [@bs.deriving jsConverter] + type section = { + _type: string, + elements: option(array(option(abs_accessory))), + fields: option(array(abs_text)), + accessory: option(abs_accessory), + text: option(abs_text), + }; + + let base = + ( + ~_type="section", + ~fields=None, + ~text=None, + ~accessory=None, + ~elements=None, + (), + ) => { + sectionToJs({_type, fields, text, accessory, elements}); + }; + + let baseAccessory = + ( + ~_type, + ~image_url=None, + ~alt_text=None, + ~text=None, + ~value=None, + ~action_id=None, + (), + ) => { + accessoryToJs({_type, image_url, alt_text, text, value, action_id}); + }; + + module Text = { + let make = (~text, ~_type="mrkdwn", ()) => { + textToJs({_type, text}); + }; + }; + + module Image = { + let make = (~image_url, ~alt_text, ~_type="image", ()) => { + Some( + baseAccessory( + ~_type, + ~image_url=Some(image_url), + ~alt_text=Some(alt_text), + (), + ), + ); + }; + }; + + module Button = { + let make = (~text, ~value, ~action_id, ~_type="button", ()) => { + Some( + baseAccessory( + ~_type, + ~value=Some(value), + ~action_id=Some(action_id), + ~text=Some(Text.make(~text, ~_type="plain_text", ())), + (), + ), + ); + }; + }; + + module Divider = { + let make = () => base(~_type="divider", ()); + }; + + module Fields = { + let make = (~fields, ~accessory=None, ()) => + base(~fields=Some(fields), ~accessory, ()); + }; + + module Actions = { + let make = (~elements) => + base(~_type="actions", ~elements=Some(elements), ()); + }; + + module Section = { + let make = (~text, ~_type="mrkdwn", ~accessory=None, ()) => + base(~accessory, ~text=Some(Text.make(~_type, ~text, ())), ()); + }; }; let userId = id => "<@" ++ id ++ ">"; @@ -79,14 +153,15 @@ module Message = { ~username: [@bs.as "Wejay"] _, ~text: string, ~attachments: array(Js.t('a))=?, + ~blocks: array(Js.t('a))=?, ~mrkdwn: bool, unit ) => _ = ""; - let withAttachments = (channel, message, attachments) => - slackMessage(~channel, ~text=message, ~attachments, ~mrkdwn=true, ()) + let withBlocks = (channel, message, blocks) => + slackMessage(~channel, ~text=message, ~blocks, ~mrkdwn=true, ()) |> sendPayload; let regular = (channel: string, message: string) => diff --git a/src/adapters/Spotify.re b/src/adapters/Spotify.re index 5cba878..6fe5eb3 100644 --- a/src/adapters/Spotify.re +++ b/src/adapters/Spotify.re @@ -33,22 +33,32 @@ let getSpotifyTrack = id => { let createSearchAttachment = ({albumName, artist, cover, duration, name, uri}: WejayTrack.t) => { - Slack.Attachment.( - make( - ~thumb_url=cover, - ~fields=[| - field(~title="Artist", ~value=artist, ~short=true), - field(~title="Title", ~value=name, ~short=true), - field(~title="Album", ~value=albumName, ~short=true), - field( - ~title="Duration", - ~value=Utils.parseDuration(duration /. 1000.), - ~short=true, - ), - |], - ~actions=[|action(~value=uri, ())|], - (), - ) + let trackDuration = Utils.parseDuration(duration /. 1000.0); + + Slack.Block.( + [| + Divider.make(), + Fields.make( + ~accessory=Image.make(~image_url=cover, ~alt_text="Album cover", ()), + ~fields=[| + Text.make(~text={j|*Artist*\n$artist|j}, ()), + Text.make(~text={j|*Track name*\n$name|j}, ()), + Text.make(~text={j|*Album*\n$albumName|j}, ()), + Text.make(~text={j|*Current position*\n$trackDuration|j}, ()), + |], + (), + ), + Actions.make( + ~elements=[| + Button.make( + ~text="Queue track", + ~value=uri, + ~action_id="queue_new_track", + (), + ), + |], + ), + |] ); }; @@ -67,9 +77,22 @@ let search = query => { let attachments = tracks ->Belt.Array.slice(~offset=0, ~len=5) - ->Belt.Array.map(createSearchAttachment); + ->Belt.Array.reduce([||], (acc, curr) => + acc->Belt.Array.concat(createSearchAttachment(curr)) + ); - resolve(`Ok((message, attachments))); + resolve( + `Ok(( + message, + [| + Slack.Block.Section.make( + ~text={j|Here are the results for *$query*|j}, + (), + ), + |] + ->Belt.Array.concat(attachments), + )), + ); }) ); }; diff --git a/src/commands/Blame.re b/src/commands/Blame.re index 1b3e622..d423183 100644 --- a/src/commands/Blame.re +++ b/src/commands/Blame.re @@ -14,20 +14,27 @@ let message = (hits: Elastic.Search.t) => ->Utils.joinWithNewline }; +module Request = { + let make = uri => { + Js.Promise.( + API.createRequest( + ~url=Config.blameUrl, + ~_method="POST", + ~data=Some({"uri": uri}), + (), + ) + |> then_(response => response##data->Elastic.Search.make->resolve) + ); + }; +}; + let run = () => Js.Promise.( Services.getCurrentTrack() |> then_(({uri}: Sonos.Decode.currentTrackResponse) => { let uri = Utils.sonosUriToSpotifyUri(uri); - API.createRequest( - ~url=Config.blameUrl, - ~_method="POST", - ~data=Some({"uri": uri}), - (), - ) - |> then_(response => - `Ok(response##data->Elastic.Search.make->message)->resolve - ); + Request.make(uri) + |> then_(response => `Ok(message(response))->resolve); }) ); diff --git a/src/commands/NowPlaying.re b/src/commands/NowPlaying.re index 3760576..8053308 100644 --- a/src/commands/NowPlaying.re +++ b/src/commands/NowPlaying.re @@ -1,22 +1,41 @@ -let message = - ( - {artist, title, album, position, duration, queuePosition}: Sonos.Decode.currentTrackResponse, - ) => - switch (queuePosition) { - | 0. => Messages.nothingIsPlaying - | _ => - let q = queuePosition |> Utils.cleanFloat; - let track = Utils.artistAndTitle(~artist, ~title); - let album = Belt.Option.getWithDefault(album, "N/A"); - let p = position |> Utils.parseDuration; - let d = duration |> Utils.parseDuration; +let message = (~sonos, ~cover) => { + let {artist, title, album, position, duration}: Sonos.Decode.currentTrackResponse = sonos; - {j|*Currently playing*\n$track ($album)\nPosition in queue $q - $p/$d|j}; - }; + let trackDuration = Utils.parseDuration(duration); + let currentPosition = Utils.parseDuration(position); + + Slack.Block.( + [| + Fields.make( + ~fields=[| + Text.make(~text={j|*Artist*\n$artist|j}, ()), + Text.make(~text={j|*Track name*\n$title|j}, ()), + Text.make(~text={j|*Album*\n$album|j}, ()), + Text.make( + ~text={j|*Current position*\n$currentPosition / $trackDuration|j}, + (), + ), + |], + ~accessory=Image.make(~image_url=cover, ~alt_text="Album cover", ()), + (), + ), + |] + ); +}; let run = () => Js.Promise.( Services.getCurrentTrack() - |> then_(track => `Ok(track |> message) |> resolve) + |> then_((sonos: Sonos.Decode.currentTrackResponse) => { + let uri = Utils.sonosUriToSpotifyUri(sonos.uri); + let id = SpotifyUtils.trackId(uri); + + Spotify.getSpotifyTrack(id) + |> then_(spotifyTrack => { + let {cover}: Spotify.WejayTrack.t = spotifyTrack; + + `Ok(("", message(~cover, ~sonos))) |> resolve; + }); + }) |> catch(_ => `Failed("Now playing failed") |> resolve) );