diff --git a/.env.sample b/.env.sample index bda81256..77c6a593 100644 --- a/.env.sample +++ b/.env.sample @@ -28,6 +28,7 @@ GOOGLE_ALLOWED_DOMAINS= # Third party credentials (optional) SLACK_VERIFICATION_TOKEN=PLxk6OlXXXXXVj3YYYY SLACK_APP_ID=A0XXXXXXX +SLACK_MESSAGE_ACTIONS=true GOOGLE_ANALYTICS_ID= BUGSNAG_KEY= GITHUB_ACCESS_TOKEN= diff --git a/server/api/hooks.js b/server/api/hooks.js index 86fb4f23..45c1f0fb 100644 --- a/server/api/hooks.js +++ b/server/api/hooks.js @@ -7,6 +7,7 @@ import { presentSlackAttachment } from '../presenters'; import * as Slack from '../slack'; const router = new Router(); +// triggered by a user posting a getoutline.com link in Slack router.post('hooks.unfurl', async ctx => { const { challenge, token, event } = ctx.body; if (challenge) return (ctx.body = ctx.body.challenge); @@ -46,6 +47,50 @@ router.post('hooks.unfurl', async ctx => { }); }); +// triggered by interactions with actions, dialogs, message buttons in Slack +router.post('hooks.interactive', async ctx => { + const { payload } = ctx.body; + ctx.assertPresent(payload, 'payload is required'); + + const data = JSON.parse(payload); + const { callback_id, token } = data; + ctx.assertPresent(token, 'token is required'); + ctx.assertPresent(callback_id, 'callback_id is required'); + + if (token !== process.env.SLACK_VERIFICATION_TOKEN) + throw new AuthenticationError('Invalid verification token'); + + const user = await User.find({ + where: { service: 'slack', serviceId: data.user.id }, + }); + if (!user) { + ctx.body = { + text: 'Sorry, we couldn’t find your user on this team in Outline.', + response_type: 'ephemeral', + replace_original: false, + }; + return; + } + + // we find the document based on the users teamId to ensure access + const document = await Document.find({ + where: { id: data.callback_id, teamId: user.teamId }, + }); + if (!document) throw new InvalidRequestError('Invalid document'); + + const team = await Team.findById(user.teamId); + + // respond with a public message that will be posted in the original channel + ctx.body = { + response_type: 'in_channel', + replace_original: false, + attachments: [ + presentSlackAttachment(document, team, document.getSummary()), + ], + }; +}); + +// triggered by the /outline command in Slack router.post('hooks.slack', async ctx => { const { token, user_id, text } = ctx.body; ctx.assertPresent(token, 'token is required'); @@ -53,7 +98,7 @@ router.post('hooks.slack', async ctx => { ctx.assertPresent(text, 'text is required'); if (token !== process.env.SLACK_VERIFICATION_TOKEN) - throw new AuthenticationError('Invalid token'); + throw new AuthenticationError('Invalid verification token'); const user = await User.find({ where: { @@ -61,11 +106,14 @@ router.post('hooks.slack', async ctx => { serviceId: user_id, }, }); - - if (!user) throw new InvalidRequestError('Invalid user'); + if (!user) { + ctx.body = { + text: 'Sorry, we couldn’t find your user on this team in Outline.', + }; + return; + } const team = await Team.findById(user.teamId); - const results = await Document.searchForUser(user, text, { limit: 5, }); @@ -81,7 +129,17 @@ router.post('hooks.slack', async ctx => { presentSlackAttachment( result.document, team, - queryIsInTitle ? undefined : result.context + queryIsInTitle ? undefined : result.context, + process.env.SLACK_MESSAGE_ACTIONS + ? [ + { + name: 'post', + text: 'Post to Channel', + type: 'button', + value: result.document.id, + }, + ] + : undefined ) ); } diff --git a/server/api/hooks.test.js b/server/api/hooks.test.js index 68031e92..e51b3803 100644 --- a/server/api/hooks.test.js +++ b/server/api/hooks.test.js @@ -128,7 +128,7 @@ describe('#hooks.slack', async () => { ); }); - it('should error if unknown user', async () => { + it('should respond with error if unknown user', async () => { const res = await server.post('/api/hooks.slack', { body: { token: process.env.SLACK_VERIFICATION_TOKEN, @@ -136,7 +136,10 @@ describe('#hooks.slack', async () => { text: 'Welcome', }, }); - expect(res.status).toEqual(400); + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.text.includes('Sorry')).toEqual(true); + expect(body.attachments).toEqual(undefined); }); it('should error if incorrect verification token', async () => { @@ -152,3 +155,56 @@ describe('#hooks.slack', async () => { expect(res.status).toEqual(401); }); }); + +describe('#hooks.interactive', async () => { + it('should respond with replacement message', async () => { + const user = await buildUser(); + const document = await buildDocument({ + title: 'This title contains a search term', + userId: user.id, + teamId: user.teamId, + }); + + const payload = JSON.stringify({ + token: process.env.SLACK_VERIFICATION_TOKEN, + user: { id: user.serviceId, name: user.name }, + callback_id: document.id, + }); + const res = await server.post('/api/hooks.interactive', { + body: { payload }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.response_type).toEqual('in_channel'); + expect(body.attachments.length).toEqual(1); + expect(body.attachments[0].title).toEqual(document.title); + }); + + it('should respond with error if unknown user', async () => { + const payload = JSON.stringify({ + token: process.env.SLACK_VERIFICATION_TOKEN, + user: { id: 'not-a-user-id', name: 'unknown' }, + callback_id: 'doesnt-matter', + }); + const res = await server.post('/api/hooks.interactive', { + body: { payload }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.text.includes('Sorry')).toEqual(true); + expect(body.attachments).toEqual(undefined); + }); + + it('should error if incorrect verification token', async () => { + const { user } = await seed(); + const payload = JSON.stringify({ + token: 'wrong-verification-token', + user: { id: user.serviceId, name: user.name }, + callback_id: 'doesnt-matter', + }); + const res = await server.post('/api/hooks.interactive', { + body: { payload }, + }); + expect(res.status).toEqual(401); + }); +}); diff --git a/server/pages/integrations/slack.md b/server/pages/integrations/slack.md index e01d1de8..e7512189 100644 --- a/server/pages/integrations/slack.md +++ b/server/pages/integrations/slack.md @@ -7,7 +7,7 @@ Sign In with Slack means your team doesn't have to worry about invites, password ## Search your Knowledgebase -Optionally [Connect to Slack](https://www.getoutline.com/settings/integrations/slack) to enable the `/outline` slack command. Once enabled team members can easily search your wiki from within Slack by typing `/outline search term`. +Optionally [Connect to Slack](https://www.getoutline.com/settings/integrations/slack) to enable the `/outline` slack command. Once enabled, team members can easily search your wiki from within Slack by typing `/outline search term`, and post results directly back to the Slack channel. ![Slack Search Integration](/images/screenshots/slack-search.png) diff --git a/server/presenters/slackAttachment.js b/server/presenters/slackAttachment.js index af7ec9d7..26502490 100644 --- a/server/presenters/slackAttachment.js +++ b/server/presenters/slackAttachment.js @@ -1,7 +1,19 @@ // @flow import { Document, Team } from '../models'; -function present(document: Document, team: Team, context?: string) { +type Action = { + type: string, + text: string, + name: string, + value: string, +}; + +function present( + document: Document, + team: Team, + context?: string, + actions?: Action[] +) { // the context contains tags around search terms, we convert them here // to the markdown format that slack expects to receive. const text = context @@ -13,8 +25,10 @@ function present(document: Document, team: Team, context?: string) { title: document.title, title_link: `${team.url}${document.url}`, footer: document.collection.name, + callback_id: document.id, text, ts: document.getTimestamp(), + actions, }; }