Skip to content

Commit

Permalink
Schedule Looks to a Slack Channel (looker#53)
Browse files Browse the repository at this point in the history
* working scheduler

* show look title when receiving scheduled look

* verify token and docs

* Scheduled messages don't have a loading indicator, why distract everything?

* fix linkText

* allow posting to dms and groups

* making expanding urls optional

* update readme for new slack urls

* don't expand urls by default

* enable loading messages by default

* just one look query runner
  • Loading branch information
wilg authored Dec 22, 2016
1 parent b85d814 commit 1684c51
Show file tree
Hide file tree
Showing 6 changed files with 107 additions and 25 deletions.
24 changes: 22 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Detailed information on how to interact with Lookerbot [can be found on Looker D
- [Amazon S3](https://aws.amazon.com/s3/) account, bucket, and access keys
- [Documentation](http://docs.aws.amazon.com/AmazonS3/latest/gsg/CreatingABucket.html)
- [Microsoft Azure Storage](https://azure.microsoft.com/en-us/services/storage/) account and access key
- [Documentation](https://azure.microsoft.com/en-us/documentation/articles/storage-introduction/)
- [Documentation](https://azure.microsoft.com/en-us/documentation/articles/storage-introduction/)

### Deployment

Expand Down Expand Up @@ -57,6 +57,8 @@ The bot is configured entirely via environment variables. You'll want to set up

- `LOOKER_CUSTOM_COMMAND_SPACE_ID` (optional) – The ID of a Space that you would like the bot to use to define custom commands. [Read about using custom commands on Looker Discourse](https://discourse.looker.com/t/2302).

- `LOOKER_WEBHOOK_TOKEN` (optional) – The webhook validation token found in Looker's admin panel. This is only required if you're using the bot to send scheduled webhooks.

- `SLACK_SLASH_COMMAND_TOKEN` (optional) – If you want to use slash commands with the Slack bot, provide the verification token from the slash command setup page so that the bot can verify the integrity of incoming slash commands.

- `PORT` (optional) – The port that the bot web server will run on to accept slash commands. Defaults to `3333`.
Expand Down Expand Up @@ -100,14 +102,15 @@ The JSON objects should have the following keys:
- `clientID` should be the API 3.0 client ID for the user you want the bot to run as
- `clientSecret` should be the secret for that API 3.0 key
- `customCommandSpaceId` is an optional parameter, representing a Space that you would like the bot to use to define custom commands.
- `webhookToken` is an optional parameter. It's the webhook validation token found in Looker's admin panel. This is only required if you're using the bot to send scheduled webhooks.

Here's an example JSON that connects to two Looker instances:

```json
[{"url": "https://me.looker.com", "apiBaseUrl": "https://me.looker.com:19999/api/3.0", "clientId": "abcdefghjkl", "clientSecret": "abcdefghjkl"},{"url": "https://me-staging.looker.com", "apiBaseUrl": "https://me-staging.looker.com:19999/api/3.0", "clientId": "abcdefghjkl", "clientSecret": "abcdefghjkl"}]
```

The `LOOKER_URL`, `LOOKER_API_BASE_URL`, `LOOKER_API_3_CLIENT_ID`, `LOOKER_API_3_CLIENT_SECRET`, and `LOOKER_CUSTOM_COMMAND_SPACE_ID` variables are ignored when `LOOKERS` is set.
The `LOOKER_URL`, `LOOKER_API_BASE_URL`, `LOOKER_API_3_CLIENT_ID`, `LOOKER_API_3_CLIENT_SECRET`, `LOOKER_WEBHOOK_TOKEN`, and `LOOKER_CUSTOM_COMMAND_SPACE_ID` variables are ignored when `LOOKERS` is set.

##### Running the Server

Expand Down Expand Up @@ -138,6 +141,23 @@ However, Slash commands are a bit friendlier to use and allow Slack to auto-comp

Directions for creating slash commands [can be found in Looker Discourse](https://discourse.looker.com/t/using-lookerbot-for-slack/2302)

### Scheduling Data to Slack

You can use the bot to send scheduled Looks to Slack.

1. Click "Schedule" on a Look
2. Set "Destination" to "Webhook"
3. Leave "Format" set to "HTML Attachment". The format selection is ignored.
4. Enter the webhook URL.

- Post to public channels `/slack/post/channel/my-channel-name`
- Post to private groups `/slack/post/group/my-channel-name`
- To direct message a user `/slack/post/dm/myusername`

These URLs are prefixed with the URL your bot. So, if yoru bot is hosted at `https://example.com` and you want to post to a channel called `data-science`, the URL would be `https://example.com/slack/post/channel/data-science`.

5. You'll need to make sure that the `LOOKER_WEBHOOK_TOKEN` environment variable is properly set to the same verification token found in the Looker admin panel.

### Data Access

We suggest creating a Looker API user specifically for the Slack bot, and using that user's API credentials. It's worth remembering that _everyone who can talk to your Slack bot has the permissions of this user_. If there's data you don't want people to access via Slack, ensure that user cannot access it using Looker's permissioning mechanisms.
Expand Down
9 changes: 6 additions & 3 deletions lib/bot.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ QueryRunner = require('./repliers/query_runner')
LookQueryRunner = require('./repliers/look_query_runner')

versionChecker = require('./version_checker')
ScheduleReceiver = require('./schedule_receiver')

if process.env.DEV == "true"
# Allow communicating with Lookers running on localhost with self-signed certificates
Expand All @@ -36,6 +37,7 @@ else
clientId: process.env.LOOKER_API_3_CLIENT_ID
clientSecret: process.env.LOOKER_API_3_CLIENT_SECRET
customCommandSpaceId: process.env.LOOKER_CUSTOM_COMMAND_SPACE_ID
webhookToken: process.env.LOOKER_WEBHOOK_TOKEN
}]

lookers = lookerConfig.map((looker) ->
Expand Down Expand Up @@ -162,9 +164,6 @@ controller = Botkit.slackbot(
debug: process.env.DEBUG_MODE == "true"
)

controller.setupWebserver process.env.PORT || 3333, (err, expressWebserver) ->
controller.createWebhookEndpoints(expressWebserver)

defaultBot = controller.spawn({
token: process.env.SLACK_API_KEY,
retry: 10,
Expand All @@ -180,6 +179,10 @@ defaultBot.api.team.info {}, (err, response) ->
else
throw new Error("Could not connect to the Slack API.")

controller.setupWebserver process.env.PORT || 3333, (err, expressWebserver) ->
controller.createWebhookEndpoints(expressWebserver)
ScheduleReceiver.listen(expressWebserver, defaultBot, lookers)

controller.on 'rtm_reconnect_failed', ->
throw new Error("Failed to reconnect to the Slack RTM API.")

Expand Down
5 changes: 5 additions & 0 deletions lib/repliers/fancy_replier.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ module.exports = class FancyReplier

startLoading: (cb) ->

# Scheduled messages don't have a loading indicator, why distract everything?
if @replyContext.scheduled
cb()
return

sass = if @replyContext.isSlashCommand()
""
else
Expand Down
29 changes: 13 additions & 16 deletions lib/repliers/look_query_runner.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,20 @@ module.exports = class LookQueryRunner extends QueryRunner
constructor: (@replyContext, @lookId) ->
super @replyContext, null

showShareUrl: -> false
showShareUrl: -> true

work: ->
@replyContext.looker.client.get("looks/#{@lookId}", (look) =>
message =
attachments: [
fallback: look.title
title: look.title
text: look.description
color: "#64518A"
title_link: "#{@replyContext.looker.url}#{look.short_url}"
image_url: if look.public then "#{look.image_embed_url}?width=606" else null
]

@reply(message)
linkText: (shareUrl) ->
@loadedLook?.title || super(shareUrl)

if !look.public
@runQuery(look.query, message.attachments[0])
linkUrl: (shareUrl) ->
if @loadedLook
"#{@replyContext.looker.url}#{@loadedLook.short_url}"
else
super(shareUrl)

work: ->
@replyContext.looker.client.get("looks/#{@lookId}", (look) =>
@querySlug = look.query.slug
@loadedLook = look
super
(r) => @replyError(r))
19 changes: 15 additions & 4 deletions lib/repliers/query_runner.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,27 @@ module.exports = class QueryRunner extends FancyReplier

showShareUrl: -> false

linkText: (shareUrl) ->
shareUrl

linkUrl: (shareUrl) ->
shareUrl

shareUrlContent: (shareUrl) ->
if @linkText(shareUrl) == @linkUrl(shareUrl)
"<#{@linkUrl(shareUrl)}>"
else
"<#{@linkUrl(shareUrl)}|#{@linkText(shareUrl)}>"

postImage: (query, imageData, options = {}) ->
if @replyContext.looker.storeBlob
success = (url) =>
share = if @showShareUrl() then query.share_url else ""
@reply(
attachments: [
_.extend({}, options, {
image_url: url
title: share
title_link: share
title: if @showShareUrl() then @linkText(query.share_url) else ""
title_link: if @showShareUrl() then @linkUrl(query.share_url) else ""
color: "#64518A"
})
]
Expand Down Expand Up @@ -45,7 +56,7 @@ module.exports = class QueryRunner extends FancyReplier

renderableFields = dimension_like.concat(measure_like)

shareUrl = "<#{query.share_url}>"
shareUrl = @shareUrlContent(query.share_url)

renderString = (d) ->
d.rendered || d.value
Expand Down
46 changes: 46 additions & 0 deletions lib/schedule_receiver.coffee
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
ReplyContext = require('./reply_context')
LookQueryRunner = require('./repliers/look_query_runner')

module.exports =

listen: (server, bot, lookers) ->
server.post("/slack/post/:post_type/:channel_name", (req, res) =>

reply = (json) ->
res.setHeader 'Content-Type', 'application/json'
res.send JSON.stringify(json)
console.log("Replied to scheduled plan webhook.", json)

if req.body.scheduled_plan
if req.body.scheduled_plan.type == "Look"
if matches = req.body.scheduled_plan.url.match(/\/looks\/([0-9]+)$/)
lookId = matches[1]

channelName = req.params.channel_name
channelType = req.params.post_type
if channelType == "dm"
channelName = "@#{channelName}"
else if channelType == "channel"
channelName = "##{channelName}"

for looker in lookers
if req.body.scheduled_plan.url.lastIndexOf(looker.url, 0) == 0
if req.headers['x-looker-webhook-token'] == looker.webhookToken
context = new ReplyContext(bot, bot, {
channel: channelName
})
context.looker = looker
context.scheduled = true
runner = new LookQueryRunner(context, lookId)
runner.start()
reply {success: true, reason: "Sending Look #{lookId} to channel #{channelName}."}
else
reply {success: false, reason: "Invalid webhook token."}

else
reply {success: false, reason: "Unknown scheduled plan URL."}
else
reply {success: false, reason: "Scheduled plan type #{req.body.scheduled_plan.type} not supported."}
else
reply {success: false, reason: "No scheduled plan in payload."}
)

0 comments on commit 1684c51

Please sign in to comment.