├── .eslintrc ├── .github └── CODE_OF_CONDUCT.md ├── .gitignore ├── .nvmrc ├── README.md ├── basic.js ├── interactive.js ├── lib ├── common.js └── flickr.js ├── package.json └── support └── demo.gif /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb-base", 3 | "env": { 4 | "node": true 5 | }, 6 | "rules": { 7 | "no-console": ["off"], 8 | "import/no-extraneous-dependencies": [ 9 | "error", 10 | { 11 | "devDependencies": ["**/*.js", "!scripts/**/*.js"], 12 | "optionalDependencies": true, 13 | "peerDependencies": true 14 | } 15 | ] 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | ## Introduction 4 | 5 | Diversity and inclusion make our community strong. We encourage participation from the most varied and diverse backgrounds possible and want to be very clear about where we stand. 6 | 7 | Our goal is to maintain a safe, helpful and friendly community for everyone, regardless of experience, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, religion, nationality, or other defining characteristic. 8 | 9 | This code and related procedures also apply to unacceptable behavior occurring outside the scope of community activities, in all community venues (online and in-person) as well as in all one-on-one communications, and anywhere such behavior has the potential to adversely affect the safety and well-being of community members. 10 | 11 | For more information on our code of conduct, please visit [https://slackhq.github.io/code-of-conduct](https://slackhq.github.io/code-of-conduct) -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | tmp/ 4 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v6 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # App Unfurls API Sample for Node 2 | 3 | [App Unfurls](https://api.slack.com/docs/message-link-unfurling) are a feature of the Slack Platform 4 | that allow your Slack app customize the presentation of links that belong to a certain domain or 5 | set of domains. 6 | 7 | This sample demonstrates building an app that can unfurl links from the popular photo sharing site 8 | [Flickr](https://www.flickr.com/). You are welcome to use this as a starting point or a guide in 9 | building your own app which unfurls links. This sample uses Slack's own SDKs and tools. Even if you 10 | choose to use another programming language or another set of tools, reading through the code will 11 | help you gain an understanding of how to make use of unfurls. 12 | 13 | ![Demo](support/demo.gif "Demo") 14 | 15 | ## Set Up 16 | 17 | You should start by [creating a Slack app](https://api.slack.com/slack-apps) and configuring it 18 | to use the Events API. This sample app uses the 19 | [Slack Event Adapter](https://github.com/slackapi/node-slack-events-api), where you can find some 20 | configuration steps to get the Events API ready to use in your app. 21 | 22 | 23 | ### Event Subscription 24 | 25 | Turn on Event Subscriptions for the Slack app. You must input and verify a Request URL, and the 26 | easiest way to do this is to 27 | [use a development proxy as described in the Events API module](https://github.com/slackapi/node-slack-events-api#configuration). 28 | The application listens for events at the path `/slack/events`. For example, the Request URL may 29 | look like `https://myappunfurlsample.ngrok.io/slack/events`. 30 | Create a subscription to the team event `link_shared`. Add an app unfurl domain for "flickr.com". 31 | Lastly, install the app on a development team (you should have the `links:read` and `links:write` 32 | scopes). Once the installation is complete, note the OAuth Access Token. 33 | 34 | ### Flickr 35 | 36 | Create a Flickr app at the [Flickr developer site](https://www.flickr.com/services/apps/create/). 37 | Once you create an app, note the API Key. 38 | 39 | ### Environment 40 | 41 | You should now have a Slack verification token and access token, as well as a Flickr API key. Clone 42 | this application locally. Create a new file named `.env` within the directory and place these values 43 | as shown: 44 | 45 | ``` 46 | SLACK_VERIFICATION_TOKEN=xxxxxxxxxxxxxxxxxxx 47 | SLACK_CLIENT_TOKEN=xoxp-0000000000-0000000000-0000000000-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 48 | 49 | FLICKR_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 50 | ``` 51 | 52 | Lastly, download the dependencies for the application by running `npm install`. Note that this 53 | example assumes you are using a currently supported LTS version of Node (at this time, v6 or above). 54 | 55 | ## Part 1: The basic unfurl 56 | 57 | The example of a basic unfurl is contained in `basic.js`. 58 | 59 | This example gives users a more pleasant way to view links to photos in Flickr. 60 | 61 | ### Understanding the code 62 | 63 | In the code you'll find a the Slack Event Adapter being set up and used to subscribe to the 64 | `link_shared` event. 65 | 66 | ```javascript 67 | slackEvents.on('link_shared', (event) => { 68 | // Call a helper that transforms the URL into a promise for an attachment suitable for Slack 69 | Promise.all(event.links.map(messageAttachmentFromLink)) 70 | // Transform the array of attachments to an unfurls object keyed by URL 71 | .then(attachments => keyBy(attachments, 'url')) 72 | .then(unfurls => mapValues(unfurls, attachment => omit(attachment, 'url'))) 73 | // Invoke the Slack Web API to append the attachment 74 | .then(unfurls => slack.chat.unfurl(event.message_ts, event.channel, unfurls)) 75 | .catch(console.error); 76 | }); 77 | ``` 78 | 79 | The event contains an array of links, which are each run through the function 80 | `messageAttachmentFromLink()` to fetch data about the link from Flickr, and transform the link into 81 | a message attachment. Message attachments have 82 | [rich formatting capabilities](https://api.slack.com/docs/message-attachments), and this app uses 83 | fields, author details, and an image to make Flickr links awesome to view in Slack. 84 | 85 | Once the set of attachments is built, we build a new structure called `unfurls` which is a map of 86 | link URLs to attachments. That unfurls structure is passed to the Web API method `chat.unfurl` to 87 | finally let Slack know how that this app has a prettier way to unfurl those particular links. 88 | 89 | ## Part 2: Interactivity with unfurls 90 | 91 | The example of adding interactivity to unfurls is in `interactive.js`. 92 | 93 | This example builds off of `basic.js` but adds interactive message buttons to each of the unfurls. 94 | This is an extremely powerful feature of unfurls, since buttons can be used to make updates and 95 | *act* rather than just display information to a user. In our simple example, we use buttons to help 96 | the user drill into more detailed information about a photo. 97 | 98 | ### Additional set up 99 | 100 | The Slack app needs additional configuration to be able to use interactive messages (buttons). 101 | Return to the app's configuration page from [your list of apps](https://api.slack.com/apps). 102 | Navigate to the interactive messages section using the menu. Input a Request URL based on the 103 | development proxy's base URL that you set up earlier. The path that the application listens for 104 | interactive messages is `/slack/messages`. For example, the Request URL may look like 105 | `https://myappunfurlsample.ngrok.io/slack/messages`. 106 | 107 | ### Understanding the code 108 | 109 | The main change in this version is that the `messageAttachmentFromLink()` function now adds 110 | an array of `actions` to each attachment it produces. The attachment itself also gets a new 111 | `callback_id` parameter to identify the interaction. In this case we call the interaction 112 | `"photo_details"`. 113 | 114 | Handling interactive messages requires setting up a new endpoint for our server with a listener that 115 | can dispatch to handlers for the specific interaction. 116 | 117 | ```javascript 118 | function handleInteractiveMessages(req, res) { 119 | // Parse the `payload` body parameter as JSON, otherwise abort and respond with client erorr 120 | let payload; 121 | try { 122 | payload = JSON.parse(req.body.payload); 123 | } catch (parseError) { 124 | res.sendStatus(400); 125 | return; 126 | } 127 | 128 | // Verify token to prove that the request originates from Slack 129 | if (!payload.token || payload.token !== process.env.SLACK_VERIFICATION_TOKEN) { 130 | res.sendStatus(404); 131 | return; 132 | } 133 | 134 | // Define a completion handler that is bound to the response for this request. Note that 135 | // this function must be invoked by the handling code within 3 seconds. A more sophisticated 136 | // implementation may choose to timeout before 3 seconds and send an HTTP response anyway, and 137 | // then use the `payload.response_url` to send a request once the completion handler is invoked. 138 | function callback(error, body) { 139 | if (error) { 140 | res.sendStatus(500); 141 | } else { 142 | res.send(body); 143 | } 144 | } 145 | 146 | // This switch statement should have a case for the exhaustive set of callback identifiers 147 | // this application may handle. In this sample, we only have one: `photo_details`. 148 | switch (payload.callback_id) { 149 | case 'photo_details': 150 | handlePhotoDetailsInteraction(payload, callback); 151 | break; 152 | default: 153 | // As long as the above list of cases is exhaustive, there shouldn't be anything here 154 | callback(new Error('Unhandled callack ID')); 155 | break; 156 | } 157 | } 158 | ``` 159 | 160 | Our listener does some basic validation and processing of the interactive message payload, and then 161 | dispatches the `photo_details` interactions from our previous attachment to a new function 162 | `handlePhotoDetailsInteraction()`. This is a very simple function that augments the original 163 | attachment with a new field for either the photo's groups or albums. Once the new attachment is 164 | built, the server responds to Slack with a new attachment payload. 165 | 166 | Now we have beautiful interactive unfurls that allow users to drill deeper into content that 167 | was shared in a channel. 168 | -------------------------------------------------------------------------------- /basic.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | 3 | const slackEventsAPI = require('@slack/events-api'); 4 | const { WebClient } = require('@slack/client'); 5 | const { getFlickrUrlData } = require('./lib/flickr'); 6 | const keyBy = require('lodash.keyby'); 7 | const omit = require('lodash.omit'); 8 | const mapValues = require('lodash.mapvalues'); 9 | const normalizePort = require('normalize-port'); 10 | 11 | /** 12 | * Transform a Slack link into a Slack message attachment. 13 | * 14 | * @param {Object} link - Slack link 15 | * @param {string} link.url - The URL of the link 16 | * 17 | * @returns {Promise.} An object described by the Slack message attachment structure. In 18 | * addition to the properties described in the API documentation, an additional `url` property is 19 | * defined so the source of the attachment is captured. 20 | * See: https://api.slack.com/docs/message-attachments 21 | */ 22 | function messageAttachmentFromLink(link) { 23 | return getFlickrUrlData(link.url) 24 | .then((photo) => { 25 | // The basic attachment 26 | const attachment = { 27 | fallback: photo.title + (photo.description ? `: ${photo.description}` : ''), 28 | color: '#ff0084', // Flickr logo pink 29 | title: photo.title, 30 | title_link: photo.url, 31 | image_url: photo.imageUrl, 32 | url: link.url, 33 | }; 34 | 35 | // Slack only renders the author information if the `author_name` property is defined 36 | // Doesn't always have a value. see: https://github.com/npm-flickr/flickr-photo-info/pull/3 37 | const authorName = photo.owner.name || photo.owner.username; 38 | if (authorName) { 39 | attachment.author_name = authorName; 40 | attachment.author_icon = photo.owner.icons.small; 41 | attachment.author_link = photo.owner.url; 42 | } 43 | 44 | // Conditionally add fields as long as the data is available 45 | const fields = []; 46 | 47 | if (photo.description) { 48 | fields.push({ 49 | title: 'Description', 50 | value: photo.description, 51 | }); 52 | } 53 | 54 | if (photo.tags.length > 0) { 55 | fields.push({ 56 | title: 'Tags', 57 | value: photo.tags.map(t => t.raw).join(', '), 58 | }); 59 | } 60 | 61 | if (photo.takenTS) { 62 | fields.push({ 63 | title: 'Taken', 64 | value: (new Date(photo.takenTS)).toUTCString(), 65 | }); 66 | } 67 | 68 | if (photo.postTS) { 69 | fields.push({ 70 | title: 'Posted', 71 | value: (new Date(photo.postTS)).toUTCString(), 72 | }); 73 | } 74 | 75 | if (fields.length > 0) { 76 | attachment.fields = fields; 77 | } 78 | 79 | return attachment; 80 | }); 81 | } 82 | 83 | // Initialize a Slack Event Adapter for easy use of the Events API 84 | // See: https://github.com/slackapi/node-slack-events-api 85 | const slackEvents = slackEventsAPI.createSlackEventAdapter(process.env.SLACK_VERIFICATION_TOKEN); 86 | 87 | // Initialize a Web Client 88 | const slack = new WebClient(process.env.SLACK_CLIENT_TOKEN); 89 | 90 | // Handle the event from the Slack Events API 91 | slackEvents.on('link_shared', (event) => { 92 | // Call a helper that transforms the URL into a promise for an attachment suitable for Slack 93 | Promise.all(event.links.map(messageAttachmentFromLink)) 94 | // Transform the array of attachments to an unfurls object keyed by URL 95 | .then(attachments => keyBy(attachments, 'url')) 96 | .then(unfurls => mapValues(unfurls, attachment => omit(attachment, 'url'))) 97 | // Invoke the Slack Web API to append the attachment 98 | .then(unfurls => slack.chat.unfurl(event.message_ts, event.channel, unfurls)) 99 | .catch(console.error); 100 | }); 101 | 102 | // Handle errors 103 | const slackEventsErrorCodes = slackEventsAPI.errorCodes; 104 | slackEvents.on('error', (error) => { 105 | if (error.code === slackEventsErrorCodes.TOKEN_VERIFICATION_FAILURE) { 106 | console.warn(`An unverified request was sent to the Slack events request URL: ${error.body}`); 107 | } else { 108 | console.error(error); 109 | } 110 | }); 111 | 112 | // Start the server 113 | const port = normalizePort(process.env.PORT || '3000'); 114 | slackEvents.start(port).then(() => { 115 | console.log(`server listening on port ${port}`); 116 | }); 117 | -------------------------------------------------------------------------------- /interactive.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | 3 | const http = require('http'); 4 | const express = require('express'); 5 | const bodyParser = require('body-parser'); 6 | const slackEventsAPI = require('@slack/events-api'); 7 | const { WebClient } = require('@slack/client'); 8 | const { getFlickrUrlData, getFlickrPhotoSets, getFlickrPhotoPools } = require('./lib/flickr'); 9 | const { cloneAndCleanAttachment } = require('./lib/common'); 10 | const keyBy = require('lodash.keyby'); 11 | const omit = require('lodash.omit'); 12 | const mapValues = require('lodash.mapvalues'); 13 | const normalizePort = require('normalize-port'); 14 | 15 | /** 16 | * Transform a Slack link into a Slack message attachment. 17 | * 18 | * @param {Object} link - Slack link 19 | * @param {string} link.url - The URL of the link 20 | * 21 | * @returns {Promise.} An object described by the Slack message attachment structure. In 22 | * addition to the properties described in the API documentation, an additional `url` property is 23 | * defined so the source of the attachment is captured. 24 | * See: https://api.slack.com/docs/message-attachments 25 | */ 26 | function messageAttachmentFromLink(link) { 27 | return getFlickrUrlData(link.url) 28 | .then((photo) => { 29 | // The basic attachment 30 | const attachment = { 31 | fallback: photo.title + (photo.description ? `: ${photo.description}` : ''), 32 | color: '#ff0084', // Flickr logo pink 33 | title: photo.title, 34 | title_link: photo.url, 35 | image_url: photo.imageUrl, 36 | url: link.url, 37 | }; 38 | 39 | // Slack only renders the author information if the `author_name` property is defined 40 | // Doesn't always have a value. see: https://github.com/npm-flickr/flickr-photo-info/pull/3 41 | const authorName = photo.owner.name || photo.owner.username; 42 | if (authorName) { 43 | attachment.author_name = authorName; 44 | attachment.author_icon = photo.owner.icons.small; 45 | attachment.author_link = photo.owner.url; 46 | } 47 | 48 | // Conditionally add fields as long as the data is available 49 | const fields = []; 50 | 51 | if (photo.description) { 52 | fields.push({ 53 | title: 'Description', 54 | value: photo.description, 55 | }); 56 | } 57 | 58 | if (photo.tags.length > 0) { 59 | fields.push({ 60 | title: 'Tags', 61 | value: photo.tags.map(t => t.raw).join(', '), 62 | }); 63 | } 64 | 65 | if (photo.takenTS) { 66 | fields.push({ 67 | title: 'Taken', 68 | value: (new Date(photo.takenTS)).toUTCString(), 69 | }); 70 | } 71 | 72 | if (photo.postTS) { 73 | fields.push({ 74 | title: 'Posted', 75 | value: (new Date(photo.postTS)).toUTCString(), 76 | }); 77 | } 78 | 79 | if (fields.length > 0) { 80 | attachment.fields = fields; 81 | } 82 | 83 | // Add buttons for interactivity 84 | attachment.callback_id = 'photo_details'; 85 | attachment.actions = [ 86 | { 87 | text: 'Albums', 88 | name: 'list_photosets', 89 | type: 'button', 90 | value: photo.id, 91 | }, 92 | { 93 | text: 'Groups', 94 | name: 'list_pools', 95 | type: 'button', 96 | value: photo.id, 97 | }, 98 | ]; 99 | 100 | return attachment; 101 | }); 102 | } 103 | 104 | /** 105 | * Handle Slack interactive messages from `photo_details` interaction types 106 | */ 107 | function handlePhotoDetailsInteraction(payload, done) { 108 | // Clone the originalAttachment so that we can send back a replacement with our own modifications 109 | const originalAttachment = payload.original_message.attachments[0]; 110 | const attachment = cloneAndCleanAttachment(originalAttachment); 111 | 112 | // Find the relevant action 113 | const action = payload.actions[0]; 114 | 115 | // Since many buttons could have triggered a `photo_details` interaction, we choose to use another 116 | // switch statement to deal with each kind of button separately. 117 | let attachmentPromise; 118 | switch (action.name) { 119 | case 'list_photosets': 120 | // Make modifications to the attachment to include the photo set details 121 | // In general, this is an opportunity to fetch more data, perform updates, or communicate 122 | // with other systems to build a new attachment. 123 | attachmentPromise = getFlickrPhotoSets(action.value) 124 | .then((photoSets) => { 125 | // If this isn't the first time the button was pressed, the field might already exist, 126 | // so here we remove it so the content is essentially refreshed. 127 | attachment.fields = attachment.fields ? attachment.fields.filter(f => f.title !== 'Albums') : []; 128 | const field = { 129 | title: 'Albums', 130 | }; 131 | if (photoSets.length > 0) { 132 | field.value = photoSets.map(set => `:small_blue_diamond: <${set.url}|${set.title}>`).join('\n'); 133 | } else { 134 | field.value = 'This photo is not in any albums'; 135 | } 136 | attachment.fields.push(field); 137 | return attachment; 138 | }); 139 | break; 140 | case 'list_pools': 141 | // As described above, the attachment is augmented to Group data 142 | attachmentPromise = getFlickrPhotoPools(action.value) 143 | .then((photoPools) => { 144 | attachment.fields = attachment.fields ? attachment.fields.filter(f => f.title !== 'Groups') : []; 145 | const field = { 146 | title: 'Groups', 147 | }; 148 | if (photoPools.length > 0) { 149 | field.value = photoPools.map(pool => `:small_blue_diamond: <${pool.url}|${pool.title}>`).join('\n'); 150 | } else { 151 | field.value = 'This photo is not in any groups'; 152 | } 153 | attachment.fields.push(field); 154 | return attachment; 155 | }); 156 | break; 157 | default: 158 | // As long as the above list of cases is exhaustive, there shouldn't be anything here 159 | attachmentPromise = Promise.reject(new Error('Unhandled action')); 160 | break; 161 | } 162 | attachmentPromise.then(a => done(null, a)).catch(done); 163 | } 164 | 165 | /** 166 | * Handle requests from Slack interactive messages 167 | * 168 | * @param {http.IncomingMessage} req 169 | * @param {http.ServerResponse} res 170 | */ 171 | function handleInteractiveMessages(req, res) { 172 | // Parse the `payload` body parameter as JSON, otherwise abort and respond with client erorr 173 | let payload; 174 | try { 175 | payload = JSON.parse(req.body.payload); 176 | } catch (parseError) { 177 | res.sendStatus(400); 178 | return; 179 | } 180 | 181 | // Verify token to prove that the request originates from Slack 182 | if (!payload.token || payload.token !== process.env.SLACK_VERIFICATION_TOKEN) { 183 | res.sendStatus(404); 184 | return; 185 | } 186 | 187 | // Define a completion handler that is bound to the response for this request. Note that 188 | // this function must be invoked by the handling code within 3 seconds. A more sophisticated 189 | // implementation may choose to timeout before 3 seconds and send an HTTP response anyway, and 190 | // then use the `payload.response_url` to send a request once the completion handler is invoked. 191 | function callback(error, body) { 192 | if (error) { 193 | res.sendStatus(500); 194 | } else { 195 | res.send(body); 196 | } 197 | } 198 | 199 | // This switch statement should have a case for the exhaustive set of callback identifiers 200 | // this application may handle. In this sample, we only have one: `photo_details`. 201 | switch (payload.callback_id) { 202 | case 'photo_details': 203 | handlePhotoDetailsInteraction(payload, callback); 204 | break; 205 | default: 206 | // As long as the above list of cases is exhaustive, there shouldn't be anything here 207 | callback(new Error('Unhandled callack ID')); 208 | break; 209 | } 210 | } 211 | 212 | // Initialize a Slack Event Adapter for easy use of the Events API 213 | // See: https://github.com/slackapi/node-slack-events-api 214 | const slackEvents = slackEventsAPI.createSlackEventAdapter(process.env.SLACK_VERIFICATION_TOKEN); 215 | 216 | // Initialize a Web Client 217 | const slack = new WebClient(process.env.SLACK_CLIENT_TOKEN); 218 | 219 | // Handle the event from the Slack Events API 220 | slackEvents.on('link_shared', (event) => { 221 | // Call a helper that transforms the URL into a promise for an attachment suitable for Slack 222 | Promise.all(event.links.map(messageAttachmentFromLink)) 223 | // Transform the array of attachments to an unfurls object keyed by URL 224 | .then(attachments => keyBy(attachments, 'url')) 225 | .then(unfurls => mapValues(unfurls, attachment => omit(attachment, 'url'))) 226 | // Invoke the Slack Web API to append the attachment 227 | .then(unfurls => slack.chat.unfurl(event.message_ts, event.channel, unfurls)) 228 | .catch(console.error); 229 | }); 230 | 231 | // Handle Events API errors 232 | const slackEventsErrorCodes = slackEventsAPI.errorCodes; 233 | slackEvents.on('error', (error) => { 234 | if (error.code === slackEventsErrorCodes.TOKEN_VERIFICATION_FAILURE) { 235 | console.warn(`An unverified request was sent to the Slack events request URL: ${error.body}`); 236 | } else { 237 | console.error(error); 238 | } 239 | }); 240 | 241 | // Create the server 242 | const port = normalizePort(process.env.PORT || '3000'); 243 | const app = express(); 244 | // Mount JSON body parser before the Events API middleware 245 | app.use(bodyParser.json()); 246 | app.use('/slack/events', slackEvents.expressMiddleware()); 247 | // Mount the `application/x-www-form-urlencoded` body parser before handling Slack interactive 248 | // messages 249 | app.use(bodyParser.urlencoded({ extended: false })); 250 | app.use('/slack/messages', handleInteractiveMessages); 251 | // Start the server 252 | http.createServer(app).listen(port, () => { 253 | console.log(`server listening on port ${port}`); 254 | }); 255 | -------------------------------------------------------------------------------- /lib/common.js: -------------------------------------------------------------------------------- 1 | const pick = require('lodash.pick'); 2 | 3 | /** 4 | * Common helpers 5 | * @module lib/common 6 | */ 7 | 8 | // These dimensions are dictated by message attachment specifications from Slack 9 | // see: https://api.slack.com/docs/message-attachments 10 | 11 | /** 12 | * @constant {number} 13 | * @alias module:lib/common.maxWidth 14 | * @default 15 | */ 16 | const maxWidth = 400; 17 | 18 | /** 19 | * @constant {number} 20 | * @alias module:lib/common.maxHeight 21 | * @default 22 | */ 23 | const maxHeight = 500; 24 | 25 | /** 26 | * @constant {number} 27 | * @alias module:lib/common.idealAspectRatio 28 | * @default 29 | */ 30 | const idealAspectRatio = maxWidth / maxHeight; 31 | 32 | /** 33 | * Clone an attachment object while ensuring it doesn't have any server-assigned properties present 34 | * @param {Object} attachment - the attachment to be cloned and cleaned 35 | * 36 | * @returns {Object} The clean clone 37 | */ 38 | function cloneAndCleanAttachment(attachment) { 39 | const clone = pick(attachment, [ 40 | 'fallback', 41 | 'color', 42 | 'title', 43 | 'title_link', 44 | 'image_url', 45 | 'url', 46 | 'author_name', 47 | 'author_icon', 48 | 'author_link', 49 | 'url', 50 | 'fields', 51 | 'actions', 52 | 'callback_id', 53 | ]); 54 | if (clone.actions) { 55 | clone.actions = clone.actions.map(c => pick(c, ['text', 'name', 'type', 'value'])); 56 | } 57 | return clone; 58 | } 59 | 60 | exports.maxWidth = maxWidth; 61 | exports.maxHeight = maxHeight; 62 | exports.idealAspectRatio = idealAspectRatio; 63 | exports.cloneAndCleanAttachment = cloneAndCleanAttachment; 64 | -------------------------------------------------------------------------------- /lib/flickr.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Flickr Helper module 3 | * @module lib/flickr 4 | */ 5 | const promisify = require('es6-promisify'); 6 | const sample = require('lodash.sample'); 7 | const sortBy = require('lodash.sortby'); 8 | const find = require('lodash.find'); 9 | const fc = require('flickr-client'); 10 | const fpi = require('flickr-photo-info'); 11 | const fpu = require('flickr-photo-urls'); 12 | const { parseURL } = require('whatwg-url'); 13 | const { maxWidth, maxHeight, idealAspectRatio } = require('./common'); 14 | 15 | const flickrClient = fc({ key: process.env.FLICKR_API_KEY }); 16 | const fetchPhotoInfo = promisify(fpi(flickrClient)); 17 | const fetchPhotoUrls = promisify(fpu(flickrClient)); 18 | const flickr = promisify(flickrClient); 19 | 20 | /** 21 | * Find the photo URL oject that best suits the dimensions of a Slack message attachment. 22 | * 23 | * @param {Object} photoUrls - A collection of objects which describe a photoUrl that has specific 24 | * dimensions 25 | * @returns {Object} The chosen object from the collection 26 | */ 27 | function findBestImage(photoUrls) { 28 | const anyPhoto = sample(photoUrls); 29 | const aspectRatio = anyPhoto.width / anyPhoto.height; 30 | const prioritizedDimension = (idealAspectRatio > aspectRatio) ? 'width' : 'height'; 31 | const comparedDimensionValue = (prioritizedDimension === 'height') ? maxHeight : maxWidth; 32 | const sortedPhotos = sortBy(photoUrls, prioritizedDimension); 33 | return find(sortedPhotos, photo => photo[prioritizedDimension] > comparedDimensionValue); 34 | } 35 | 36 | /** 37 | * Retreive structured data about a Flickr image from its URL. This method encapsulates getting 38 | * any information about the URL. The goal is to aggregate all possibly required data. 39 | * 40 | * @alias module:lib/flickr.getFlickrUrlData 41 | * @param {string} inputUrl - An image URL 42 | * @returns {Promise.} An object which contains data about the photo at the URL 43 | */ 44 | function getFlickrUrlData(inputUrl) { 45 | const url = parseURL(inputUrl); 46 | const photoId = url.path[2]; 47 | return Promise.all([ 48 | fetchPhotoInfo(photoId), 49 | fetchPhotoUrls(photoId), 50 | ]) 51 | .then((results) => { 52 | const photoInfo = results[0]; 53 | const photoUrls = results[1]; 54 | const image = findBestImage(photoUrls); 55 | return Object.assign(photoInfo, { 56 | imageUrl: image.source, 57 | }); 58 | }); 59 | } 60 | 61 | /** 62 | * Retreive structured data about the Flickr photo sets (also known as an Album) that a certain 63 | * photo appears in. 64 | * 65 | * @alias module:lib/flickr.getFlickrPhotoSets 66 | * @param {string} photoId - The photo whose albums are to be found 67 | * @returns {Promise.} An array of objects containing data about the photo sets 68 | */ 69 | function getFlickrPhotoSets(photoId) { 70 | return flickr('photos.getAllContexts', { photo_id: photoId }) 71 | .then((photoContexts) => { 72 | if (photoContexts.set) { 73 | return Promise.all(photoContexts.set.map(set => flickr('photosets.getInfo', { 74 | photoset_id: set.id, 75 | }) 76 | .then((setInfo) => { 77 | const setData = Object.assign({}, setInfo.photoset); 78 | /* eslint-disable no-underscore-dangle */ 79 | setData.title = setData.title._content || ''; 80 | setData.description = setData.description._content || ''; 81 | /* eslint-enable no-underscore-dangle */ 82 | setData.url = `https://www.flickr.com/photos/${setData.owner}/sets/${setData.id}/`; 83 | return setData; 84 | }))); 85 | } 86 | return []; 87 | }); 88 | } 89 | 90 | function getFlickrPhotoPools(photoId) { 91 | return flickr('photos.getAllContexts', { photo_id: photoId }) 92 | .then((photoContexts) => { 93 | if (photoContexts.pool) { 94 | return photoContexts.pool.map((pool) => { 95 | const poolData = Object.assign({}, pool); 96 | poolData.url = `https://www.flickr.com${pool.url}`; 97 | return poolData; 98 | }); 99 | } 100 | return []; 101 | }); 102 | } 103 | 104 | exports.getFlickrUrlData = getFlickrUrlData; 105 | exports.getFlickrPhotoSets = getFlickrPhotoSets; 106 | exports.getFlickrPhotoPools = getFlickrPhotoPools; 107 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@slack/sample-app-unfurls", 3 | "version": "1.0.0", 4 | "private": true, 5 | "description": "An example Slack app that demonstrates use of App Unfurls", 6 | "scripts": { 7 | "start": "node basic.js", 8 | "basic": "node basic.js", 9 | "interactive": "node interactive.js", 10 | "test": "echo \"Error: no test specified\" && exit 1" 11 | }, 12 | "author": "Ankur Oberoi ", 13 | "license": "MIT", 14 | "dependencies": { 15 | "@slack/client": "^3.9.0", 16 | "@slack/events-api": "^1.0.1", 17 | "body-parser": "^1.17.1", 18 | "dotenv": "^4.0.0", 19 | "es6-promisify": "^5.0.0", 20 | "express": "^4.15.2", 21 | "flickr-client": "0.0.4", 22 | "flickr-photo-info": "0.0.1", 23 | "flickr-photo-urls": "0.0.2", 24 | "lodash.find": "^4.6.0", 25 | "lodash.keyby": "^4.6.0", 26 | "lodash.mapvalues": "^4.6.0", 27 | "lodash.omit": "^4.5.0", 28 | "lodash.pick": "^4.4.0", 29 | "lodash.sample": "^4.2.1", 30 | "lodash.sortby": "^4.7.0", 31 | "normalize-port": "^1.0.0", 32 | "whatwg-url": "^4.5.0" 33 | }, 34 | "devDependencies": { 35 | "eslint": "^3.16.1", 36 | "eslint-config-airbnb-base": "^11.1.0", 37 | "eslint-plugin-import": "^2.2.0" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /support/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slack-samples/javascript-link-unfurls/8b2b610e4ad5a2e64d9dd0fae64deac43d9a1c26/support/demo.gif --------------------------------------------------------------------------------