Files
outline/plugins/slack/server/api/hooks.ts

407 lines
9.5 KiB
TypeScript

import { t } from "i18next";
import Router from "koa-router";
import escapeRegExp from "lodash/escapeRegExp";
import { z } from "zod";
import { IntegrationService, IntegrationType } from "@shared/types";
import {
AuthenticationError,
InvalidRequestError,
ValidationError,
} from "@server/errors";
import Logger from "@server/logging/Logger";
import validate from "@server/middlewares/validate";
import {
UserAuthentication,
Document,
User,
Team,
SearchQuery,
Integration,
IntegrationAuthentication,
AuthenticationProvider,
} from "@server/models";
import SearchHelper from "@server/models/helpers/SearchHelper";
import { APIContext } from "@server/types";
import { safeEqual } from "@server/utils/crypto";
import { opts } from "@server/utils/i18n";
import env from "../env";
import { presentMessageAttachment } from "../presenters/messageAttachment";
import { presentUserNotLinkedBlocks } from "../presenters/userNotLinkedBlocks";
import * as Slack from "../slack";
import * as T from "./schema";
const router = new Router();
// triggered by a user posting a getoutline.com link in Slack
router.post(
"hooks.unfurl",
validate(T.HooksUnfurlSchema),
async (ctx: APIContext<T.HooksUnfurlReq>) => {
// See URL verification handshake documentation on this page:
// https://api.slack.com/apis/connections/events-api
if ("challenge" in ctx.input.body) {
ctx.body = {
challenge: ctx.input.body.challenge,
};
return;
}
const { token, team_id, event } = ctx.input.body;
verifySlackToken(token);
const user = await findUserForRequest(team_id, event.user);
if (!user) {
Logger.debug("plugins", "No user found for Slack user ID", {
providerId: event.user,
});
return;
}
const auth = await IntegrationAuthentication.findOne({
where: {
service: IntegrationService.Slack,
teamId: user.teamId,
},
});
if (!auth) {
Logger.debug(
"plugins",
"No Slack integration authentication found for team",
{
teamId: user.teamId,
}
);
return;
}
// get content for unfurled links
const unfurls = {};
for (const link of event.links) {
const id = link.url.slice(link.url.lastIndexOf("/") + 1);
const doc = await Document.findByPk(id);
if (!doc || doc.teamId !== user.teamId) {
continue;
}
unfurls[link.url] = {
title: doc.title,
text: doc.getSummary(),
color: doc.collection?.color,
};
}
await Slack.post("chat.unfurl", {
token: auth.token,
channel: event.channel,
ts: event.message_ts,
unfurls,
});
ctx.body = {
success: true,
};
}
);
// triggered by interactions with actions, dialogs, message buttons in Slack
router.post(
"hooks.interactive",
validate(T.HooksInteractiveSchema),
async (ctx: APIContext<T.HooksInteractiveReq>) => {
const { payload } = ctx.input.body;
let callback_id, token;
try {
// https://api.slack.com/interactivity/handling#payloads
const data = JSON.parse(payload);
const parsed = z
.object({
type: z.string(),
callback_id: z.string(),
token: z.string(),
})
.parse(data);
callback_id = parsed.callback_id;
token = parsed.token;
} catch (err) {
Logger.error("Failed to parse Slack interactive payload", err, {
payload,
});
throw ValidationError("Invalid payload");
}
verifySlackToken(token);
// we find the document based on the users teamId to ensure access
const document = await Document.scope("withCollection").findByPk(
callback_id
);
if (!document) {
throw InvalidRequestError("Invalid callback_id");
}
const team = await Team.findByPk(document.teamId, { rejectOnEmpty: true });
// respond with a public message that will be posted in the original channel
ctx.body = {
response_type: "in_channel",
replace_original: false,
attachments: [
presentMessageAttachment(
document,
team,
document.collection,
document.getSummary()
),
],
};
}
);
// triggered by the /outline command in Slack
router.post(
"hooks.slack",
validate(T.HooksSlackCommandSchema),
async (ctx: APIContext<T.HooksSlackCommandReq>) => {
const { token, team_id, user_id, text } = ctx.input.body;
verifySlackToken(token);
const user = await findUserForRequest(team_id, user_id);
// Handle "help" command or no input
if (text.trim() === "help" || !text.trim()) {
ctx.body = {
response_type: "ephemeral",
text: t("How to use {{ command }}", {
command: "/outline",
...opts(user),
}),
attachments: [
{
text: t(
"To search your workspace use {{ command }}. \nType {{ command2 }} help to display this help text.",
{
command: `/outline keyword`,
command2: `/outline`,
...opts(user),
}
),
},
],
};
return;
}
const options = {
limit: 5,
};
if (!user) {
const team = await findTeamForRequest(team_id);
ctx.body = {
response_type: "ephemeral",
blocks: presentUserNotLinkedBlocks(team),
};
return;
}
const { results, totalCount } = await SearchHelper.searchForUser(
user,
text,
options
);
await SearchQuery.create({
userId: user ? user.id : null,
teamId: user.teamId,
source: "slack",
query: text,
results: totalCount,
});
// Map search results to the format expected by the Slack API
if (results.length) {
const attachments = [];
for (const result of results) {
const queryIsInTitle = !!result.document.title
.toLowerCase()
.match(escapeRegExp(text.toLowerCase()));
attachments.push(
presentMessageAttachment(
result.document,
user.team,
result.document.collection,
queryIsInTitle ? undefined : result.context,
env.SLACK_MESSAGE_ACTIONS
? [
{
name: "post",
text: t("Post to Channel", opts(user)),
type: "button",
value: result.document.id,
},
]
: undefined
)
);
}
ctx.body = {
text: t(`This is what we found for "{{ term }}"`, {
...opts(user),
term: text,
}),
attachments,
};
} else {
ctx.body = {
text: t(`No results for "{{ term }}"`, {
...opts(user),
term: text,
}),
};
}
}
);
function verifySlackToken(token: string) {
if (!env.SLACK_VERIFICATION_TOKEN) {
throw AuthenticationError(
"SLACK_VERIFICATION_TOKEN is not present in environment"
);
}
if (!safeEqual(env.SLACK_VERIFICATION_TOKEN, token)) {
throw AuthenticationError("Invalid token");
}
}
/**
* Find a matching team for the given Slack team ID
*
* @param serviceTeamId The Slack team ID
* @returns A promise resolving to a matching team, if found
*/
async function findTeamForRequest(
serviceTeamId: string
): Promise<Team | undefined> {
const authenticationProvider = await AuthenticationProvider.findOne({
where: {
name: "slack",
providerId: serviceTeamId,
},
include: [
{
required: true,
model: Team,
as: "team",
},
],
});
if (authenticationProvider) {
return authenticationProvider.team;
}
const integration = await Integration.findOne({
where: {
service: IntegrationService.Slack,
type: IntegrationType.LinkedAccount,
settings: {
slack: {
serviceTeamId,
},
},
},
include: [
{
model: Team,
as: "team",
required: true,
},
],
});
if (integration) {
return integration.team;
}
return;
}
/**
* Find a matching user for the given Slack team and user ID
*
* @param serviceTeamId The Slack team ID
* @param serviceUserId The Slack user ID
* @returns A promise resolving to a matching user, if found
*/
async function findUserForRequest(
serviceTeamId: string,
serviceUserId: string
): Promise<User | undefined> {
// Prefer explicit linked account
const integration = await Integration.findOne({
where: {
service: IntegrationService.Slack,
type: IntegrationType.LinkedAccount,
settings: {
slack: {
serviceTeamId,
serviceUserId,
},
},
},
include: [
{
model: User,
as: "user",
required: true,
},
{
model: Team,
as: "team",
required: true,
},
],
order: [["createdAt", "DESC"]],
});
if (integration) {
integration.user.team = integration.team;
return integration.user;
}
// Fallback to authentication provider if the user has Slack sign-in
const user = await User.findOne({
include: [
{
where: {
providerId: serviceUserId,
},
order: [["createdAt", "DESC"]],
model: UserAuthentication,
as: "authentications",
required: true,
},
{
model: Team,
as: "team",
},
],
});
if (user) {
return user;
}
return;
}
export default router;