Plugin architecture (#4861)
* wip * Refactor, tasks, processors, routes loading * Move Slack settings config to plugin * Fix translations in plugins * Move Slack auth to plugin * test * Move other slack-related files into plugin * Forgot to save * refactor
This commit is contained in:
22
plugins/slack/client/Icon.tsx
Normal file
22
plugins/slack/client/Icon.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
/** The size of the icon, 24px is default to match standard icons */
|
||||
size?: number;
|
||||
/** The color of the icon, defaults to the current text color */
|
||||
color?: string;
|
||||
};
|
||||
|
||||
export default function Icon({ size = 24, color = "currentColor" }: Props) {
|
||||
return (
|
||||
<svg
|
||||
fill={color}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
version="1.1"
|
||||
>
|
||||
<path d="M7.36156352,14.1107492 C7.36156352,15.0358306 6.60586319,15.7915309 5.68078176,15.7915309 C4.75570033,15.7915309 4,15.0358306 4,14.1107492 C4,13.1856678 4.75570033,12.4299674 5.68078176,12.4299674 L7.36156352,12.4299674 L7.36156352,14.1107492 Z M8.20846906,14.1107492 C8.20846906,13.1856678 8.96416938,12.4299674 9.88925081,12.4299674 C10.8143322,12.4299674 11.5700326,13.1856678 11.5700326,14.1107492 L11.5700326,18.3192182 C11.5700326,19.2442997 10.8143322,20 9.88925081,20 C8.96416938,20 8.20846906,19.2442997 8.20846906,18.3192182 C8.20846906,18.3192182 8.20846906,14.1107492 8.20846906,14.1107492 Z M9.88925081,7.36156352 C8.96416938,7.36156352 8.20846906,6.60586319 8.20846906,5.68078176 C8.20846906,4.75570033 8.96416938,4 9.88925081,4 C10.8143322,4 11.5700326,4.75570033 11.5700326,5.68078176 L11.5700326,7.36156352 L9.88925081,7.36156352 Z M9.88925081,8.20846906 C10.8143322,8.20846906 11.5700326,8.96416938 11.5700326,9.88925081 C11.5700326,10.8143322 10.8143322,11.5700326 9.88925081,11.5700326 L5.68078176,11.5700326 C4.75570033,11.5700326 4,10.8143322 4,9.88925081 C4,8.96416938 4.75570033,8.20846906 5.68078176,8.20846906 C5.68078176,8.20846906 9.88925081,8.20846906 9.88925081,8.20846906 Z M16.6384365,9.88925081 C16.6384365,8.96416938 17.3941368,8.20846906 18.3192182,8.20846906 C19.2442997,8.20846906 20,8.96416938 20,9.88925081 C20,10.8143322 19.2442997,11.5700326 18.3192182,11.5700326 L16.6384365,11.5700326 L16.6384365,9.88925081 Z M15.7915309,9.88925081 C15.7915309,10.8143322 15.0358306,11.5700326 14.1107492,11.5700326 C13.1856678,11.5700326 12.4299674,10.8143322 12.4299674,9.88925081 L12.4299674,5.68078176 C12.4299674,4.75570033 13.1856678,4 14.1107492,4 C15.0358306,4 15.7915309,4.75570033 15.7915309,5.68078176 L15.7915309,9.88925081 Z M14.1107492,16.6384365 C15.0358306,16.6384365 15.7915309,17.3941368 15.7915309,18.3192182 C15.7915309,19.2442997 15.0358306,20 14.1107492,20 C13.1856678,20 12.4299674,19.2442997 12.4299674,18.3192182 L12.4299674,16.6384365 L14.1107492,16.6384365 Z M14.1107492,15.7915309 C13.1856678,15.7915309 12.4299674,15.0358306 12.4299674,14.1107492 C12.4299674,13.1856678 13.1856678,12.4299674 14.1107492,12.4299674 L18.3192182,12.4299674 C19.2442997,12.4299674 20,13.1856678 20,14.1107492 C20,15.0358306 19.2442997,15.7915309 18.3192182,15.7915309 L14.1107492,15.7915309 Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
176
plugins/slack/client/Settings.tsx
Normal file
176
plugins/slack/client/Settings.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { find } from "lodash";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { IntegrationType } from "@shared/types";
|
||||
import Collection from "~/models/Collection";
|
||||
import Integration from "~/models/Integration";
|
||||
import Button from "~/components/Button";
|
||||
import Heading from "~/components/Heading";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import List from "~/components/List";
|
||||
import ListItem from "~/components/List/Item";
|
||||
import Notice from "~/components/Notice";
|
||||
import Scene from "~/components/Scene";
|
||||
import Text from "~/components/Text";
|
||||
import env from "~/env";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import SlackIcon from "./Icon";
|
||||
import SlackButton from "./components/SlackButton";
|
||||
import SlackListItem from "./components/SlackListItem";
|
||||
|
||||
function Slack() {
|
||||
const team = useCurrentTeam();
|
||||
const { collections, integrations } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const query = useQuery();
|
||||
const error = query.get("error");
|
||||
|
||||
React.useEffect(() => {
|
||||
collections.fetchPage({
|
||||
limit: 100,
|
||||
});
|
||||
integrations.fetchPage({
|
||||
limit: 100,
|
||||
});
|
||||
}, [collections, integrations]);
|
||||
|
||||
const commandIntegration = find(
|
||||
integrations.slackIntegrations,
|
||||
(i) => i.type === IntegrationType.Command
|
||||
);
|
||||
|
||||
const groupedCollections = collections.orderedData
|
||||
.map<[Collection, Integration | undefined]>((collection) => {
|
||||
const integration = find(integrations.slackIntegrations, {
|
||||
collectionId: collection.id,
|
||||
});
|
||||
|
||||
return [collection, integration];
|
||||
})
|
||||
.sort((a) => (a[1] ? -1 : 1));
|
||||
|
||||
const appName = env.APP_NAME;
|
||||
|
||||
return (
|
||||
<Scene title="Slack" icon={<SlackIcon color="currentColor" />}>
|
||||
<Heading>Slack</Heading>
|
||||
|
||||
{error === "access_denied" && (
|
||||
<Notice>
|
||||
<Trans>
|
||||
Whoops, you need to accept the permissions in Slack to connect
|
||||
{{ appName }} to your team. Try again?
|
||||
</Trans>
|
||||
</Notice>
|
||||
)}
|
||||
{error === "unauthenticated" && (
|
||||
<Notice>
|
||||
<Trans>
|
||||
Something went wrong while authenticating your request. Please try
|
||||
logging in again?
|
||||
</Trans>
|
||||
</Notice>
|
||||
)}
|
||||
<Text type="secondary">
|
||||
<Trans
|
||||
defaults="Get rich previews of {{ appName }} links shared in Slack and use the <em>{{ command }}</em> slash command to search for documents without leaving your chat."
|
||||
values={{
|
||||
command: "/outline",
|
||||
appName,
|
||||
}}
|
||||
components={{
|
||||
em: <Code />,
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
{env.SLACK_CLIENT_ID ? (
|
||||
<>
|
||||
<p>
|
||||
{commandIntegration ? (
|
||||
<Button onClick={() => commandIntegration.delete()}>
|
||||
{t("Disconnect")}
|
||||
</Button>
|
||||
) : (
|
||||
<SlackButton
|
||||
scopes={[
|
||||
"commands",
|
||||
"links:read",
|
||||
"links:write",
|
||||
// TODO: Wait forever for Slack to approve these scopes.
|
||||
//"users:read",
|
||||
//"users:read.email",
|
||||
]}
|
||||
redirectUri={`${env.URL}/auth/slack.commands`}
|
||||
state={team.id}
|
||||
icon={<SlackIcon color="currentColor" />}
|
||||
/>
|
||||
)}
|
||||
</p>
|
||||
<p> </p>
|
||||
|
||||
<h2>{t("Collections")}</h2>
|
||||
<Text type="secondary">
|
||||
<Trans>
|
||||
Connect {{ appName }} collections to Slack channels. Messages will
|
||||
be automatically posted to Slack when documents are published or
|
||||
updated.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<List>
|
||||
{groupedCollections.map(([collection, integration]) => {
|
||||
if (integration) {
|
||||
return (
|
||||
<SlackListItem
|
||||
key={integration.id}
|
||||
collection={collection}
|
||||
integration={
|
||||
integration as Integration<IntegrationType.Post>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={collection.id}
|
||||
title={collection.name}
|
||||
image={<CollectionIcon collection={collection} />}
|
||||
actions={
|
||||
<SlackButton
|
||||
scopes={["incoming-webhook"]}
|
||||
redirectUri={`${env.URL}/auth/slack.post`}
|
||||
state={collection.id}
|
||||
label={t("Connect")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
</>
|
||||
) : (
|
||||
<Notice>
|
||||
<Trans>
|
||||
The Slack integration is currently disabled. Please set the
|
||||
associated environment variables and restart the server to enable
|
||||
the integration.
|
||||
</Trans>
|
||||
</Notice>
|
||||
)}
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
const Code = styled.code`
|
||||
padding: 4px 6px;
|
||||
margin: 0 2px;
|
||||
background: ${(props) => props.theme.codeBackground};
|
||||
border-radius: 4px;
|
||||
`;
|
||||
|
||||
export default observer(Slack);
|
||||
38
plugins/slack/client/components/SlackButton.tsx
Normal file
38
plugins/slack/client/components/SlackButton.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { slackAuth } from "@shared/utils/urlHelpers";
|
||||
import Button from "~/components/Button";
|
||||
import env from "~/env";
|
||||
|
||||
type Props = {
|
||||
scopes?: string[];
|
||||
redirectUri: string;
|
||||
icon?: React.ReactNode;
|
||||
state?: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
function SlackButton({ state = "", scopes, redirectUri, label, icon }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleClick = () => {
|
||||
if (!env.SLACK_CLIENT_ID) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = slackAuth(
|
||||
state,
|
||||
scopes,
|
||||
env.SLACK_CLIENT_ID,
|
||||
redirectUri
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button onClick={handleClick} icon={icon} neutral>
|
||||
{label || t("Add to Slack")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default SlackButton;
|
||||
118
plugins/slack/client/components/SlackListItem.tsx
Normal file
118
plugins/slack/client/components/SlackListItem.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { uniq } from "lodash";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
|
||||
import styled from "styled-components";
|
||||
import { IntegrationType } from "@shared/types";
|
||||
import Collection from "~/models/Collection";
|
||||
import Integration from "~/models/Integration";
|
||||
import Button from "~/components/Button";
|
||||
import ButtonLink from "~/components/ButtonLink";
|
||||
import Flex from "~/components/Flex";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import ListItem from "~/components/List/Item";
|
||||
import Popover from "~/components/Popover";
|
||||
import Switch from "~/components/Switch";
|
||||
import Text from "~/components/Text";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
|
||||
type Props = {
|
||||
integration: Integration<IntegrationType.Post>;
|
||||
collection: Collection;
|
||||
};
|
||||
|
||||
function SlackListItem({ integration, collection }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { showToast } = useToasts();
|
||||
|
||||
const handleChange = async (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (ev.target.checked) {
|
||||
integration.events = uniq([...integration.events, ev.target.name]);
|
||||
} else {
|
||||
integration.events = integration.events.filter(
|
||||
(n) => n !== ev.target.name
|
||||
);
|
||||
}
|
||||
|
||||
await integration.save();
|
||||
|
||||
showToast(t("Settings saved"), {
|
||||
type: "success",
|
||||
});
|
||||
};
|
||||
|
||||
const mapping = {
|
||||
"documents.publish": t("document published"),
|
||||
"documents.update": t("document updated"),
|
||||
};
|
||||
|
||||
const popover = usePopoverState({
|
||||
gutter: 0,
|
||||
placement: "bottom-start",
|
||||
});
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={integration.id}
|
||||
title={
|
||||
<Flex align="center" gap={6}>
|
||||
<CollectionIcon collection={collection} /> {collection.name}
|
||||
</Flex>
|
||||
}
|
||||
subtitle={
|
||||
<>
|
||||
<Trans
|
||||
defaults={`Posting to the <em>{{ channelName }}</em> channel on`}
|
||||
values={{
|
||||
channelName: integration.settings.channel,
|
||||
events: integration.events.map((ev) => mapping[ev]).join(", "),
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>{" "}
|
||||
<PopoverDisclosure {...popover}>
|
||||
{(props) => (
|
||||
<ButtonLink {...props}>
|
||||
{integration.events.map((ev) => mapping[ev]).join(", ")}
|
||||
</ButtonLink>
|
||||
)}
|
||||
</PopoverDisclosure>
|
||||
<Popover {...popover} aria-label={t("Settings")}>
|
||||
<Events>
|
||||
<h3>{t("Notifications")}</h3>
|
||||
<Text type="secondary">
|
||||
{t("These events should be posted to Slack")}
|
||||
</Text>
|
||||
<Switch
|
||||
label={t("Document published")}
|
||||
name="documents.publish"
|
||||
checked={integration.events.includes("documents.publish")}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<Switch
|
||||
label={t("Document updated")}
|
||||
name="documents.update"
|
||||
checked={integration.events.includes("documents.update")}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</Events>
|
||||
</Popover>
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<Button onClick={integration.delete} neutral>
|
||||
{t("Disconnect")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const Events = styled.div`
|
||||
color: ${(props) => props.theme.text};
|
||||
margin-top: -12px;
|
||||
`;
|
||||
|
||||
export default observer(SlackListItem);
|
||||
5
plugins/slack/plugin.json
Normal file
5
plugins/slack/plugin.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "Slack",
|
||||
"description": "Adds a Slack authentication provider, support for the /outline slash command, and link unfurling.",
|
||||
"requiredEnvVars": ["SLACK_CLIENT_ID", "SLACK_CLIENT_SECRET"]
|
||||
}
|
||||
3
plugins/slack/server/.babelrc
Normal file
3
plugins/slack/server/.babelrc
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "../../../server/.babelrc"
|
||||
}
|
||||
341
plugins/slack/server/api/hooks.test.ts
Normal file
341
plugins/slack/server/api/hooks.test.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
import { IntegrationService } from "@shared/types";
|
||||
import env from "@server/env";
|
||||
import { IntegrationAuthentication, SearchQuery } from "@server/models";
|
||||
import { buildDocument, buildIntegration } from "@server/test/factories";
|
||||
import { seed, getTestServer } from "@server/test/support";
|
||||
import * as Slack from "../slack";
|
||||
|
||||
jest.mock("@server/utils/slack", () => ({
|
||||
post: jest.fn(),
|
||||
}));
|
||||
|
||||
const server = getTestServer();
|
||||
|
||||
describe("#hooks.unfurl", () => {
|
||||
it("should return documents", async () => {
|
||||
const { user, document } = await seed();
|
||||
await IntegrationAuthentication.create({
|
||||
service: IntegrationService.Slack,
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
token: "",
|
||||
});
|
||||
const res = await server.post("/api/hooks.unfurl", {
|
||||
body: {
|
||||
token: env.SLACK_VERIFICATION_TOKEN,
|
||||
team_id: "TXXXXXXXX",
|
||||
api_app_id: "AXXXXXXXXX",
|
||||
event: {
|
||||
type: "link_shared",
|
||||
channel: "Cxxxxxx",
|
||||
user: user.authentications[0].providerId,
|
||||
message_ts: "123456789.9875",
|
||||
links: [
|
||||
{
|
||||
domain: "getoutline.com",
|
||||
url: document.url,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(200);
|
||||
expect(Slack.post).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("#hooks.slack", () => {
|
||||
it("should return no matches", async () => {
|
||||
const { user, team } = await seed();
|
||||
const res = await server.post("/api/hooks.slack", {
|
||||
body: {
|
||||
token: env.SLACK_VERIFICATION_TOKEN,
|
||||
user_id: user.authentications[0].providerId,
|
||||
team_id: team.authenticationProviders[0].providerId,
|
||||
text: "dsfkndfskndsfkn",
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.attachments).toEqual(undefined);
|
||||
});
|
||||
|
||||
it("should return search results with summary if query is in title", async () => {
|
||||
const { user, team } = await seed();
|
||||
const document = await buildDocument({
|
||||
title: "This title contains a search term",
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const res = await server.post("/api/hooks.slack", {
|
||||
body: {
|
||||
token: env.SLACK_VERIFICATION_TOKEN,
|
||||
user_id: user.authentications[0].providerId,
|
||||
team_id: team.authenticationProviders[0].providerId,
|
||||
text: "contains",
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.attachments.length).toEqual(1);
|
||||
expect(body.attachments[0].title).toEqual(document.title);
|
||||
expect(body.attachments[0].text).toEqual(document.getSummary());
|
||||
});
|
||||
|
||||
it("should return search results if query is regex-like", async () => {
|
||||
const { user, team } = await seed();
|
||||
await buildDocument({
|
||||
title: "This title contains a search term",
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const res = await server.post("/api/hooks.slack", {
|
||||
body: {
|
||||
token: env.SLACK_VERIFICATION_TOKEN,
|
||||
user_id: user.authentications[0].providerId,
|
||||
team_id: team.authenticationProviders[0].providerId,
|
||||
text: "*contains",
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.attachments.length).toEqual(1);
|
||||
});
|
||||
|
||||
it("should return search results with snippet if query is in text", async () => {
|
||||
const { user, team } = await seed();
|
||||
const document = await buildDocument({
|
||||
text: "This title contains a search term",
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const res = await server.post("/api/hooks.slack", {
|
||||
body: {
|
||||
token: env.SLACK_VERIFICATION_TOKEN,
|
||||
user_id: user.authentications[0].providerId,
|
||||
team_id: team.authenticationProviders[0].providerId,
|
||||
text: "contains",
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.attachments.length).toEqual(1);
|
||||
expect(body.attachments[0].title).toEqual(document.title);
|
||||
expect(body.attachments[0].text).toEqual(
|
||||
"This title *contains* a search term"
|
||||
);
|
||||
});
|
||||
|
||||
it("should save search term, hits and source", async () => {
|
||||
const { user, team } = await seed();
|
||||
await server.post("/api/hooks.slack", {
|
||||
body: {
|
||||
token: env.SLACK_VERIFICATION_TOKEN,
|
||||
user_id: user.authentications[0].providerId,
|
||||
team_id: team.authenticationProviders[0].providerId,
|
||||
text: "contains",
|
||||
},
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
// setTimeout is needed here because SearchQuery is saved asynchronously
|
||||
// in order to not slow down the response time.
|
||||
setTimeout(async () => {
|
||||
const searchQuery = await SearchQuery.findAll({
|
||||
where: {
|
||||
query: "contains",
|
||||
},
|
||||
});
|
||||
expect(searchQuery.length).toBe(1);
|
||||
expect(searchQuery[0].results).toBe(0);
|
||||
expect(searchQuery[0].source).toBe("slack");
|
||||
resolve(undefined);
|
||||
}, 100);
|
||||
});
|
||||
});
|
||||
|
||||
it("should respond with help content for help keyword", async () => {
|
||||
const { user, team } = await seed();
|
||||
const res = await server.post("/api/hooks.slack", {
|
||||
body: {
|
||||
token: env.SLACK_VERIFICATION_TOKEN,
|
||||
user_id: user.authentications[0].providerId,
|
||||
team_id: team.authenticationProviders[0].providerId,
|
||||
text: "help",
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.text.includes("How to use")).toEqual(true);
|
||||
});
|
||||
|
||||
it("should respond with help content for no keyword", async () => {
|
||||
const { user, team } = await seed();
|
||||
const res = await server.post("/api/hooks.slack", {
|
||||
body: {
|
||||
token: env.SLACK_VERIFICATION_TOKEN,
|
||||
user_id: user.authentications[0].providerId,
|
||||
team_id: team.authenticationProviders[0].providerId,
|
||||
text: "",
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.text.includes("How to use")).toEqual(true);
|
||||
});
|
||||
|
||||
it("should return search results with snippet for unknown user", async () => {
|
||||
const { user, team } = await seed();
|
||||
// unpublished document will not be returned
|
||||
await buildDocument({
|
||||
text: "This title contains a search term",
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
publishedAt: null,
|
||||
});
|
||||
const document = await buildDocument({
|
||||
text: "This title contains a search term",
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const res = await server.post("/api/hooks.slack", {
|
||||
body: {
|
||||
token: env.SLACK_VERIFICATION_TOKEN,
|
||||
user_id: "unknown-slack-user-id",
|
||||
team_id: team.authenticationProviders[0].providerId,
|
||||
text: "contains",
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.text).toContain("you haven’t signed in to Outline yet");
|
||||
expect(body.attachments.length).toEqual(1);
|
||||
expect(body.attachments[0].title).toEqual(document.title);
|
||||
expect(body.attachments[0].text).toEqual(
|
||||
"This title *contains* a search term"
|
||||
);
|
||||
});
|
||||
|
||||
it("should return search results with snippet for user through integration mapping", async () => {
|
||||
const { user } = await seed();
|
||||
const serviceTeamId = "slack_team_id";
|
||||
await buildIntegration({
|
||||
teamId: user.teamId,
|
||||
settings: {
|
||||
serviceTeamId,
|
||||
},
|
||||
});
|
||||
const document = await buildDocument({
|
||||
text: "This title contains a search term",
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const res = await server.post("/api/hooks.slack", {
|
||||
body: {
|
||||
token: env.SLACK_VERIFICATION_TOKEN,
|
||||
user_id: "unknown-slack-user-id",
|
||||
team_id: serviceTeamId,
|
||||
text: "contains",
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.text).toContain("you haven’t signed in to Outline yet");
|
||||
expect(body.attachments.length).toEqual(1);
|
||||
expect(body.attachments[0].title).toEqual(document.title);
|
||||
expect(body.attachments[0].text).toEqual(
|
||||
"This title *contains* a search term"
|
||||
);
|
||||
});
|
||||
|
||||
it("should error if incorrect verification token", async () => {
|
||||
const { user, team } = await seed();
|
||||
const res = await server.post("/api/hooks.slack", {
|
||||
body: {
|
||||
token: "wrong-verification-token",
|
||||
user_id: user.authentications[0].providerId,
|
||||
team_id: team.authenticationProviders[0].providerId,
|
||||
text: "Welcome",
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#hooks.interactive", () => {
|
||||
it("should respond with replacement message", async () => {
|
||||
const { user, team } = await seed();
|
||||
const document = await buildDocument({
|
||||
title: "This title contains a search term",
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const payload = JSON.stringify({
|
||||
token: env.SLACK_VERIFICATION_TOKEN,
|
||||
user: {
|
||||
id: user.authentications[0].providerId,
|
||||
},
|
||||
team: {
|
||||
id: team.authenticationProviders[0].providerId,
|
||||
},
|
||||
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 replacement message if unknown user", async () => {
|
||||
const { user, team } = await seed();
|
||||
const document = await buildDocument({
|
||||
title: "This title contains a search term",
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const payload = JSON.stringify({
|
||||
token: env.SLACK_VERIFICATION_TOKEN,
|
||||
user: {
|
||||
id: "unknown-slack-user-id",
|
||||
},
|
||||
team: {
|
||||
id: team.authenticationProviders[0].providerId,
|
||||
},
|
||||
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 error if incorrect verification token", async () => {
|
||||
const { user } = await seed();
|
||||
const payload = JSON.stringify({
|
||||
token: "wrong-verification-token",
|
||||
user: {
|
||||
id: user.authentications[0].providerId,
|
||||
name: user.name,
|
||||
},
|
||||
callback_id: "doesnt-matter",
|
||||
});
|
||||
const res = await server.post("/api/hooks.interactive", {
|
||||
body: {
|
||||
payload,
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(401);
|
||||
});
|
||||
});
|
||||
375
plugins/slack/server/api/hooks.ts
Normal file
375
plugins/slack/server/api/hooks.ts
Normal file
@@ -0,0 +1,375 @@
|
||||
import crypto from "crypto";
|
||||
import { t } from "i18next";
|
||||
import Router from "koa-router";
|
||||
import { escapeRegExp } from "lodash";
|
||||
import { IntegrationService } from "@shared/types";
|
||||
import env from "@server/env";
|
||||
import { AuthenticationError, InvalidRequestError } from "@server/errors";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import {
|
||||
UserAuthentication,
|
||||
AuthenticationProvider,
|
||||
Document,
|
||||
User,
|
||||
Team,
|
||||
SearchQuery,
|
||||
Integration,
|
||||
IntegrationAuthentication,
|
||||
} from "@server/models";
|
||||
import SearchHelper from "@server/models/helpers/SearchHelper";
|
||||
import { APIContext } from "@server/types";
|
||||
import { opts } from "@server/utils/i18n";
|
||||
import { assertPresent } from "@server/validation";
|
||||
import presentMessageAttachment from "../presenters/messageAttachment";
|
||||
import * as Slack from "../slack";
|
||||
|
||||
const router = new Router();
|
||||
|
||||
function verifySlackToken(token: string) {
|
||||
if (!env.SLACK_VERIFICATION_TOKEN) {
|
||||
throw AuthenticationError(
|
||||
"SLACK_VERIFICATION_TOKEN is not present in environment"
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
token.length !== env.SLACK_VERIFICATION_TOKEN.length ||
|
||||
!crypto.timingSafeEqual(
|
||||
Buffer.from(env.SLACK_VERIFICATION_TOKEN),
|
||||
Buffer.from(token)
|
||||
)
|
||||
) {
|
||||
throw AuthenticationError("Invalid token");
|
||||
}
|
||||
}
|
||||
|
||||
// triggered by a user posting a getoutline.com link in Slack
|
||||
router.post("hooks.unfurl", async (ctx: APIContext) => {
|
||||
const { challenge, token, event } = ctx.request.body;
|
||||
|
||||
// See URL verification handshake documentation on this page:
|
||||
// https://api.slack.com/apis/connections/events-api
|
||||
if (challenge) {
|
||||
ctx.body = {
|
||||
challenge,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
assertPresent(token, "token is required");
|
||||
verifySlackToken(token);
|
||||
|
||||
const user = await User.findOne({
|
||||
include: [
|
||||
{
|
||||
where: {
|
||||
providerId: event.user,
|
||||
},
|
||||
model: UserAuthentication,
|
||||
as: "authentications",
|
||||
required: true,
|
||||
separate: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
const auth = await IntegrationAuthentication.findOne({
|
||||
where: {
|
||||
service: IntegrationService.Slack,
|
||||
teamId: user.teamId,
|
||||
},
|
||||
});
|
||||
if (!auth) {
|
||||
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", async (ctx: APIContext) => {
|
||||
const { payload } = ctx.request.body;
|
||||
assertPresent(payload, "payload is required");
|
||||
|
||||
const data = JSON.parse(payload);
|
||||
const { callback_id, token } = data;
|
||||
|
||||
assertPresent(token, "token is required");
|
||||
assertPresent(callback_id, "callback_id is required");
|
||||
verifySlackToken(token);
|
||||
|
||||
// we find the document based on the users teamId to ensure access
|
||||
const document = await Document.scope("withCollection").findByPk(
|
||||
data.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", async (ctx: APIContext) => {
|
||||
const { token, team_id, user_id, text = "" } = ctx.request.body;
|
||||
assertPresent(token, "token is required");
|
||||
assertPresent(team_id, "team_id is required");
|
||||
assertPresent(user_id, "user_id is required");
|
||||
verifySlackToken(token);
|
||||
|
||||
let user, team;
|
||||
// attempt to find the corresponding team for this request based on the team_id
|
||||
team = await Team.findOne({
|
||||
include: [
|
||||
{
|
||||
where: {
|
||||
name: "slack",
|
||||
providerId: team_id,
|
||||
enabled: true,
|
||||
},
|
||||
as: "authenticationProviders",
|
||||
model: AuthenticationProvider,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (team) {
|
||||
const authentication = await UserAuthentication.findOne({
|
||||
where: {
|
||||
providerId: user_id,
|
||||
},
|
||||
include: [
|
||||
{
|
||||
where: {
|
||||
teamId: team.id,
|
||||
},
|
||||
model: User,
|
||||
as: "user",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (authentication) {
|
||||
user = authentication.user;
|
||||
}
|
||||
} else {
|
||||
// If we couldn't find a team it's still possible that the request is from
|
||||
// a team that authenticated with a different service, but connected Slack
|
||||
// via integration
|
||||
const integration = await Integration.findOne({
|
||||
where: {
|
||||
settings: {
|
||||
serviceTeamId: team_id,
|
||||
},
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: Team,
|
||||
as: "team",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (integration) {
|
||||
team = integration.team;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle "help" command or no input
|
||||
if (text.trim() === "help" || !text.trim()) {
|
||||
ctx.body = {
|
||||
response_type: "ephemeral",
|
||||
text: "How to use /outline",
|
||||
attachments: [
|
||||
{
|
||||
text: t(
|
||||
"To search your knowledgebase use {{ command }}. \nYou’ve already learned how to get help with {{ command2 }}.",
|
||||
{
|
||||
command: `/outline keyword`,
|
||||
command2: `/outline help`,
|
||||
...opts(user),
|
||||
}
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
// This should be super rare, how does someone end up being able to make a valid
|
||||
// request from Slack that connects to no teams in Outline.
|
||||
if (!team) {
|
||||
ctx.body = {
|
||||
response_type: "ephemeral",
|
||||
text: t(
|
||||
`Sorry, we couldn’t find an integration for your team. Head to your {{ appName }} settings to set one up.`,
|
||||
{
|
||||
...opts(user),
|
||||
appName: env.APP_NAME,
|
||||
}
|
||||
),
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to find the user by matching the email address if it is confirmed on
|
||||
// Slack's side. It's always trusted on our side as it is only updatable
|
||||
// through the authentication provider.
|
||||
if (!user) {
|
||||
const auth = await IntegrationAuthentication.findOne({
|
||||
where: {
|
||||
service: IntegrationService.Slack,
|
||||
teamId: team.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (auth) {
|
||||
try {
|
||||
const response = await Slack.request("users.info", {
|
||||
token: auth.token,
|
||||
user: user_id,
|
||||
});
|
||||
|
||||
if (response.user.is_email_confirmed && response.user.profile.email) {
|
||||
user = await User.findOne({
|
||||
where: {
|
||||
email: response.user.profile.email,
|
||||
teamId: team.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
// Old connections do not have the correct permissions to access user info
|
||||
// so errors here are expected.
|
||||
Logger.info(
|
||||
"utils",
|
||||
"Failed requesting users.info from Slack, the Slack integration should be reconnected.",
|
||||
{
|
||||
teamId: auth.teamId,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const options = {
|
||||
limit: 5,
|
||||
};
|
||||
|
||||
// If we were able to map the request to a user then we can use their permissions
|
||||
// to load more documents based on the collections they have access to. Otherwise
|
||||
// just a generic search against team-visible documents is allowed.
|
||||
const { results, totalCount } = user
|
||||
? await SearchHelper.searchForUser(user, text, options)
|
||||
: await SearchHelper.searchForTeam(team, text, options);
|
||||
SearchQuery.create({
|
||||
userId: user ? user.id : null,
|
||||
teamId: team.id,
|
||||
source: "slack",
|
||||
query: text,
|
||||
results: totalCount,
|
||||
});
|
||||
const haventSignedIn = t(
|
||||
`It looks like you haven’t signed in to {{ appName }} yet, so results may be limited`,
|
||||
{
|
||||
...opts(user),
|
||||
appName: env.APP_NAME,
|
||||
}
|
||||
);
|
||||
|
||||
// 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,
|
||||
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: user
|
||||
? t(`This is what we found for "{{ term }}"`, {
|
||||
...opts(user),
|
||||
term: text,
|
||||
})
|
||||
: t(`This is what we found for "{{ term }}"`, {
|
||||
term: text,
|
||||
}) + ` (${haventSignedIn})…`,
|
||||
attachments,
|
||||
};
|
||||
} else {
|
||||
ctx.body = {
|
||||
text: user
|
||||
? t(`No results for "{{ term }}"`, {
|
||||
...opts(user),
|
||||
term: text,
|
||||
})
|
||||
: t(`No results for "{{ term }}"`, { term: text }) +
|
||||
` (${haventSignedIn})…`,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
265
plugins/slack/server/auth/slack.ts
Normal file
265
plugins/slack/server/auth/slack.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
import passport from "@outlinewiki/koa-passport";
|
||||
import type { Context } from "koa";
|
||||
import Router from "koa-router";
|
||||
import { Profile } from "passport";
|
||||
import { Strategy as SlackStrategy } from "passport-slack-oauth2";
|
||||
import { IntegrationService, IntegrationType } from "@shared/types";
|
||||
import { integrationSettingsPath } from "@shared/utils/routeHelpers";
|
||||
import accountProvisioner from "@server/commands/accountProvisioner";
|
||||
import env from "@server/env";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import passportMiddleware from "@server/middlewares/passport";
|
||||
import {
|
||||
IntegrationAuthentication,
|
||||
Collection,
|
||||
Integration,
|
||||
Team,
|
||||
User,
|
||||
} from "@server/models";
|
||||
import { AuthenticationResult } from "@server/types";
|
||||
import {
|
||||
getClientFromContext,
|
||||
getTeamFromContext,
|
||||
StateStore,
|
||||
} from "@server/utils/passport";
|
||||
import { assertPresent, assertUuid } from "@server/validation";
|
||||
import * as Slack from "../slack";
|
||||
|
||||
type SlackProfile = Profile & {
|
||||
team: {
|
||||
id: string;
|
||||
name: string;
|
||||
domain: string;
|
||||
image_192: string;
|
||||
image_230: string;
|
||||
};
|
||||
user: {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
image_192: string;
|
||||
image_230: string;
|
||||
};
|
||||
};
|
||||
|
||||
const router = new Router();
|
||||
const providerName = "slack";
|
||||
const scopes = [
|
||||
"identity.email",
|
||||
"identity.basic",
|
||||
"identity.avatar",
|
||||
"identity.team",
|
||||
];
|
||||
|
||||
function redirectOnClient(ctx: Context, url: string) {
|
||||
ctx.type = "text/html";
|
||||
ctx.body = `
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv="refresh" content="0;URL='${url}'"/>
|
||||
</head>`;
|
||||
}
|
||||
|
||||
if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
|
||||
const strategy = new SlackStrategy(
|
||||
{
|
||||
clientID: env.SLACK_CLIENT_ID,
|
||||
clientSecret: env.SLACK_CLIENT_SECRET,
|
||||
callbackURL: `${env.URL}/auth/slack.callback`,
|
||||
passReqToCallback: true,
|
||||
// @ts-expect-error StateStore
|
||||
store: new StateStore(),
|
||||
scope: scopes,
|
||||
},
|
||||
async function (
|
||||
ctx: Context,
|
||||
accessToken: string,
|
||||
refreshToken: string,
|
||||
params: { expires_in: number },
|
||||
profile: SlackProfile,
|
||||
done: (
|
||||
err: Error | null,
|
||||
user: User | null,
|
||||
result?: AuthenticationResult
|
||||
) => void
|
||||
) {
|
||||
try {
|
||||
const team = await getTeamFromContext(ctx);
|
||||
const client = getClientFromContext(ctx);
|
||||
|
||||
const result = await accountProvisioner({
|
||||
ip: ctx.ip,
|
||||
team: {
|
||||
teamId: team?.id,
|
||||
name: profile.team.name,
|
||||
subdomain: profile.team.domain,
|
||||
avatarUrl: profile.team.image_230,
|
||||
},
|
||||
user: {
|
||||
name: profile.user.name,
|
||||
email: profile.user.email,
|
||||
avatarUrl: profile.user.image_192,
|
||||
},
|
||||
authenticationProvider: {
|
||||
name: providerName,
|
||||
providerId: profile.team.id,
|
||||
},
|
||||
authentication: {
|
||||
providerId: profile.user.id,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiresIn: params.expires_in,
|
||||
scopes,
|
||||
},
|
||||
});
|
||||
return done(null, result.user, { ...result, client });
|
||||
} catch (err) {
|
||||
return done(err, null);
|
||||
}
|
||||
}
|
||||
);
|
||||
// For some reason the author made the strategy name capatilised, I don't know
|
||||
// why but we need everything lowercase so we just monkey-patch it here.
|
||||
strategy.name = providerName;
|
||||
passport.use(strategy);
|
||||
|
||||
router.get("slack", passport.authenticate(providerName));
|
||||
|
||||
router.get("slack.callback", passportMiddleware(providerName));
|
||||
|
||||
router.get(
|
||||
"slack.commands",
|
||||
auth({
|
||||
optional: true,
|
||||
}),
|
||||
async (ctx) => {
|
||||
const { code, state, error } = ctx.request.query;
|
||||
const { user } = ctx.state;
|
||||
assertPresent(code || error, "code is required");
|
||||
|
||||
if (error) {
|
||||
ctx.redirect(integrationSettingsPath(`slack?error=${error}`));
|
||||
return;
|
||||
}
|
||||
|
||||
// this code block accounts for the root domain being unable to
|
||||
// access authentication for subdomains. We must forward to the appropriate
|
||||
// subdomain to complete the oauth flow
|
||||
if (!user) {
|
||||
if (state) {
|
||||
try {
|
||||
const team = await Team.findByPk(String(state), {
|
||||
rejectOnEmpty: true,
|
||||
});
|
||||
return redirectOnClient(
|
||||
ctx,
|
||||
`${team.url}/auth/slack.commands?${ctx.request.querystring}`
|
||||
);
|
||||
} catch (err) {
|
||||
return ctx.redirect(
|
||||
integrationSettingsPath(`slack?error=unauthenticated`)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return ctx.redirect(
|
||||
integrationSettingsPath(`slack?error=unauthenticated`)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const endpoint = `${env.URL}/auth/slack.commands`;
|
||||
const data = await Slack.oauthAccess(String(code), endpoint);
|
||||
const authentication = await IntegrationAuthentication.create({
|
||||
service: IntegrationService.Slack,
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
token: data.access_token,
|
||||
scopes: data.scope.split(","),
|
||||
});
|
||||
await Integration.create({
|
||||
service: IntegrationService.Slack,
|
||||
type: IntegrationType.Command,
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
authenticationId: authentication.id,
|
||||
settings: {
|
||||
serviceTeamId: data.team_id,
|
||||
},
|
||||
});
|
||||
ctx.redirect(integrationSettingsPath("slack"));
|
||||
}
|
||||
);
|
||||
|
||||
router.get(
|
||||
"slack.post",
|
||||
auth({
|
||||
optional: true,
|
||||
}),
|
||||
async (ctx) => {
|
||||
const { code, error, state } = ctx.request.query;
|
||||
const { user } = ctx.state;
|
||||
assertPresent(code || error, "code is required");
|
||||
|
||||
const collectionId = state;
|
||||
assertUuid(collectionId, "collectionId must be an uuid");
|
||||
|
||||
if (error) {
|
||||
ctx.redirect(integrationSettingsPath(`slack?error=${error}`));
|
||||
return;
|
||||
}
|
||||
|
||||
// this code block accounts for the root domain being unable to
|
||||
// access authentication for subdomains. We must forward to the
|
||||
// appropriate subdomain to complete the oauth flow
|
||||
if (!user) {
|
||||
try {
|
||||
const collection = await Collection.findOne({
|
||||
where: {
|
||||
id: String(state),
|
||||
},
|
||||
rejectOnEmpty: true,
|
||||
});
|
||||
const team = await Team.findByPk(collection.teamId, {
|
||||
rejectOnEmpty: true,
|
||||
});
|
||||
return redirectOnClient(
|
||||
ctx,
|
||||
`${team.url}/auth/slack.post?${ctx.request.querystring}`
|
||||
);
|
||||
} catch (err) {
|
||||
return ctx.redirect(
|
||||
integrationSettingsPath(`slack?error=unauthenticated`)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const endpoint = `${env.URL}/auth/slack.post`;
|
||||
const data = await Slack.oauthAccess(code as string, endpoint);
|
||||
const authentication = await IntegrationAuthentication.create({
|
||||
service: IntegrationService.Slack,
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
token: data.access_token,
|
||||
scopes: data.scope.split(","),
|
||||
});
|
||||
|
||||
await Integration.create({
|
||||
service: IntegrationService.Slack,
|
||||
type: IntegrationType.Post,
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
authenticationId: authentication.id,
|
||||
collectionId,
|
||||
events: ["documents.update", "documents.publish"],
|
||||
settings: {
|
||||
url: data.incoming_webhook.url,
|
||||
channel: data.incoming_webhook.channel,
|
||||
channelId: data.incoming_webhook.channel_id,
|
||||
},
|
||||
});
|
||||
ctx.redirect(integrationSettingsPath("slack"));
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default router;
|
||||
38
plugins/slack/server/presenters/messageAttachment.ts
Normal file
38
plugins/slack/server/presenters/messageAttachment.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { traceFunction } from "@server/logging/tracing";
|
||||
import { Document, Collection, Team } from "@server/models";
|
||||
|
||||
type Action = {
|
||||
type: string;
|
||||
text: string;
|
||||
name: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
function presentMessageAttachment(
|
||||
document: Document,
|
||||
team: Team,
|
||||
collection?: Collection | null,
|
||||
context?: string,
|
||||
actions?: Action[]
|
||||
) {
|
||||
// the context contains <b> tags around search terms, we convert them here
|
||||
// to the markdown format that slack expects to receive.
|
||||
const text = context
|
||||
? context.replace(/<\/?b>/g, "*").replace(/\n/g, "")
|
||||
: document.getSummary();
|
||||
|
||||
return {
|
||||
color: collection?.color,
|
||||
title: document.title,
|
||||
title_link: `${team.url}${document.url}`,
|
||||
footer: collection?.name,
|
||||
callback_id: document.id,
|
||||
text,
|
||||
ts: document.getTimestamp(),
|
||||
actions,
|
||||
};
|
||||
}
|
||||
|
||||
export default traceFunction({
|
||||
spanName: "presenters",
|
||||
})(presentMessageAttachment);
|
||||
139
plugins/slack/server/processors/SlackProcessor.ts
Normal file
139
plugins/slack/server/processors/SlackProcessor.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import fetch from "fetch-with-proxy";
|
||||
import { Op } from "sequelize";
|
||||
import { IntegrationService, IntegrationType } from "@shared/types";
|
||||
import env from "@server/env";
|
||||
import { Document, Integration, Collection, Team } from "@server/models";
|
||||
import BaseProcessor from "@server/queues/processors/BaseProcessor";
|
||||
import {
|
||||
DocumentEvent,
|
||||
IntegrationEvent,
|
||||
RevisionEvent,
|
||||
Event,
|
||||
} from "@server/types";
|
||||
import presentMessageAttachment from "../presenters/messageAttachment";
|
||||
|
||||
export default class SlackProcessor extends BaseProcessor {
|
||||
static applicableEvents: Event["name"][] = [
|
||||
"documents.publish",
|
||||
"revisions.create",
|
||||
"integrations.create",
|
||||
];
|
||||
|
||||
async perform(event: Event) {
|
||||
switch (event.name) {
|
||||
case "documents.publish":
|
||||
case "revisions.create":
|
||||
return this.documentUpdated(event);
|
||||
|
||||
case "integrations.create":
|
||||
return this.integrationCreated(event);
|
||||
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
async integrationCreated(event: IntegrationEvent) {
|
||||
const integration = (await Integration.findOne({
|
||||
where: {
|
||||
id: event.modelId,
|
||||
service: IntegrationService.Slack,
|
||||
type: IntegrationType.Post,
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: Collection,
|
||||
required: true,
|
||||
as: "collection",
|
||||
},
|
||||
],
|
||||
})) as Integration<IntegrationType.Post>;
|
||||
if (!integration) {
|
||||
return;
|
||||
}
|
||||
|
||||
const collection = integration.collection;
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
|
||||
await fetch(integration.settings.url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
text: `👋 Hey there! When documents are published or updated in the *${collection.name}* collection on ${env.APP_NAME} they will be posted to this channel!`,
|
||||
attachments: [
|
||||
{
|
||||
color: collection.color,
|
||||
title: collection.name,
|
||||
title_link: `${env.URL}${collection.url}`,
|
||||
text: collection.description,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async documentUpdated(event: DocumentEvent | RevisionEvent) {
|
||||
// never send notifications when batch importing documents
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'data' does not exist on type 'DocumentEv... Remove this comment to see the full error message
|
||||
if (event.data && event.data.source === "import") {
|
||||
return;
|
||||
}
|
||||
const [document, team] = await Promise.all([
|
||||
Document.findByPk(event.documentId),
|
||||
Team.findByPk(event.teamId),
|
||||
]);
|
||||
if (!document || !team) {
|
||||
return;
|
||||
}
|
||||
|
||||
// never send notifications for draft documents
|
||||
if (!document.publishedAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
event.name === "revisions.create" &&
|
||||
document.updatedAt === document.publishedAt
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const integration = (await Integration.findOne({
|
||||
where: {
|
||||
teamId: document.teamId,
|
||||
collectionId: document.collectionId,
|
||||
service: IntegrationService.Slack,
|
||||
type: IntegrationType.Post,
|
||||
events: {
|
||||
[Op.contains]: [
|
||||
event.name === "revisions.create" ? "documents.update" : event.name,
|
||||
],
|
||||
},
|
||||
},
|
||||
})) as Integration<IntegrationType.Post>;
|
||||
if (!integration) {
|
||||
return;
|
||||
}
|
||||
let text = `${document.updatedBy.name} updated a document`;
|
||||
|
||||
if (event.name === "documents.publish") {
|
||||
text = `${document.createdBy.name} published a new document`;
|
||||
}
|
||||
|
||||
await fetch(integration.settings.url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
text,
|
||||
attachments: [
|
||||
presentMessageAttachment(document, team, document.collection),
|
||||
],
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
60
plugins/slack/server/slack.ts
Normal file
60
plugins/slack/server/slack.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import querystring from "querystring";
|
||||
import fetch from "fetch-with-proxy";
|
||||
import env from "@server/env";
|
||||
import { InvalidRequestError } from "@server/errors";
|
||||
|
||||
const SLACK_API_URL = "https://slack.com/api";
|
||||
|
||||
export async function post(endpoint: string, body: Record<string, any>) {
|
||||
let data;
|
||||
const token = body.token;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${SLACK_API_URL}/${endpoint}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
data = await response.json();
|
||||
} catch (err) {
|
||||
throw InvalidRequestError(err.message);
|
||||
}
|
||||
|
||||
if (!data.ok) {
|
||||
throw InvalidRequestError(data.error);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function request(endpoint: string, body: Record<string, any>) {
|
||||
let data;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${SLACK_API_URL}/${endpoint}?${querystring.stringify(body)}`
|
||||
);
|
||||
data = await response.json();
|
||||
} catch (err) {
|
||||
throw InvalidRequestError(err.message);
|
||||
}
|
||||
|
||||
if (!data.ok) {
|
||||
throw InvalidRequestError(data.error);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function oauthAccess(
|
||||
code: string,
|
||||
redirect_uri = `${env.URL}/auth/slack.callback`
|
||||
) {
|
||||
return request("oauth.access", {
|
||||
client_id: env.SLACK_CLIENT_ID,
|
||||
client_secret: env.SLACK_CLIENT_SECRET,
|
||||
redirect_uri,
|
||||
code,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user