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:
@@ -18,7 +18,6 @@ import presentProviderConfig from "./providerConfig";
|
||||
import presentRevision from "./revision";
|
||||
import presentSearchQuery from "./searchQuery";
|
||||
import presentShare from "./share";
|
||||
import presentSlackAttachment from "./slackAttachment";
|
||||
import presentStar from "./star";
|
||||
import presentSubscription from "./subscription";
|
||||
import presentTeam from "./team";
|
||||
@@ -48,7 +47,6 @@ export {
|
||||
presentRevision,
|
||||
presentSearchQuery,
|
||||
presentShare,
|
||||
presentSlackAttachment,
|
||||
presentStar,
|
||||
presentSubscription,
|
||||
presentTeam,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { signin } from "@shared/utils/urlHelpers";
|
||||
import { signin } from "@shared/utils/routeHelpers";
|
||||
import { AuthenticationProviderConfig } from "@server/routes/auth/providers";
|
||||
|
||||
export default function presentProviderConfig(
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { traceFunction } from "@server/logging/tracing";
|
||||
import { Document, Collection, Team } from "@server/models";
|
||||
|
||||
type Action = {
|
||||
type: string;
|
||||
text: string;
|
||||
name: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
function presentSlackAttachment(
|
||||
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",
|
||||
})(presentSlackAttachment);
|
||||
@@ -1,139 +0,0 @@
|
||||
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 { presentSlackAttachment } from "@server/presenters";
|
||||
import {
|
||||
DocumentEvent,
|
||||
IntegrationEvent,
|
||||
RevisionEvent,
|
||||
Event,
|
||||
} from "@server/types";
|
||||
import BaseProcessor from "./BaseProcessor";
|
||||
|
||||
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: [
|
||||
presentSlackAttachment(document, team, document.collection),
|
||||
],
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,28 @@
|
||||
import path from "path";
|
||||
import { glob } from "glob";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { requireDirectory } from "@server/utils/fs";
|
||||
import BaseProcessor from "./BaseProcessor";
|
||||
|
||||
const processors = {};
|
||||
|
||||
requireDirectory(__dirname).forEach(([module, id]) => {
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'default' does not exist on type 'unknown'
|
||||
const { default: Processor } = module;
|
||||
|
||||
if (id === "index") {
|
||||
return;
|
||||
requireDirectory<{ default: BaseProcessor }>(__dirname).forEach(
|
||||
([module, id]) => {
|
||||
if (id === "index") {
|
||||
return;
|
||||
}
|
||||
processors[id] = module.default;
|
||||
}
|
||||
);
|
||||
|
||||
processors[id] = Processor;
|
||||
});
|
||||
glob
|
||||
.sync("build/plugins/*/server/processors/!(*.test).js")
|
||||
.forEach((filePath: string) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const processor = require(path.join(process.cwd(), filePath)).default;
|
||||
const name = path.basename(filePath, ".js");
|
||||
processors[name] = processor;
|
||||
Logger.debug("processor", `Registered processor ${name}`);
|
||||
});
|
||||
|
||||
export default processors;
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
import path from "path";
|
||||
import { glob } from "glob";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { requireDirectory } from "@server/utils/fs";
|
||||
import BaseTask from "./BaseTask";
|
||||
|
||||
const tasks = {};
|
||||
|
||||
requireDirectory(__dirname).forEach(([module, id]) => {
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'default' does not exist on type 'unknown'
|
||||
const { default: Task } = module;
|
||||
|
||||
if (id === "index") {
|
||||
return;
|
||||
requireDirectory<{ default: BaseTask<any> }>(__dirname).forEach(
|
||||
([module, id]) => {
|
||||
if (id === "index") {
|
||||
return;
|
||||
}
|
||||
tasks[id] = module.default;
|
||||
}
|
||||
);
|
||||
|
||||
tasks[id] = Task;
|
||||
});
|
||||
glob
|
||||
.sync("build/plugins/*/server/tasks/!(*.test).js")
|
||||
.forEach((filePath: string) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const task = require(path.join(process.cwd(), filePath)).default;
|
||||
const name = path.basename(filePath, ".js");
|
||||
tasks[name] = task;
|
||||
Logger.debug("task", `Registered task ${name}`);
|
||||
});
|
||||
|
||||
export default tasks;
|
||||
|
||||
@@ -1,341 +0,0 @@
|
||||
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 "@server/utils/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);
|
||||
});
|
||||
});
|
||||
@@ -1,375 +0,0 @@
|
||||
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 { presentSlackAttachment } from "@server/presenters";
|
||||
import { APIContext } from "@server/types";
|
||||
import { opts } from "@server/utils/i18n";
|
||||
import * as Slack from "@server/utils/slack";
|
||||
import { assertPresent } from "@server/validation";
|
||||
|
||||
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: [
|
||||
presentSlackAttachment(
|
||||
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(
|
||||
presentSlackAttachment(
|
||||
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;
|
||||
@@ -1,9 +1,12 @@
|
||||
import path from "path";
|
||||
import glob from "glob";
|
||||
import Koa, { BaseContext } from "koa";
|
||||
import bodyParser from "koa-body";
|
||||
import Router from "koa-router";
|
||||
import userAgent, { UserAgentContext } from "koa-useragent";
|
||||
import env from "@server/env";
|
||||
import { NotFoundError } from "@server/errors";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { AppState, AppContext } from "@server/types";
|
||||
import apiKeys from "./apiKeys";
|
||||
import attachments from "./attachments";
|
||||
@@ -16,7 +19,6 @@ import documents from "./documents";
|
||||
import events from "./events";
|
||||
import fileOperationsRoute from "./fileOperations";
|
||||
import groups from "./groups";
|
||||
import hooks from "./hooks";
|
||||
import integrations from "./integrations";
|
||||
import apiWrapper from "./middlewares/apiWrapper";
|
||||
import editor from "./middlewares/editor";
|
||||
@@ -48,6 +50,16 @@ api.use<BaseContext, UserAgentContext>(userAgent);
|
||||
api.use(apiWrapper());
|
||||
api.use(editor());
|
||||
|
||||
// register package API routes before others to allow for overrides
|
||||
glob
|
||||
.sync("build/plugins/*/server/api/!(*.test).js")
|
||||
.forEach((filePath: string) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const pkg: Router = require(path.join(process.cwd(), filePath)).default;
|
||||
router.use("/", pkg.routes());
|
||||
Logger.debug("lifecycle", `Registered API routes for ${filePath}`);
|
||||
});
|
||||
|
||||
// routes
|
||||
router.use("/", auth.routes());
|
||||
router.use("/", authenticationProviders.routes());
|
||||
@@ -58,7 +70,6 @@ router.use("/", documents.routes());
|
||||
router.use("/", pins.routes());
|
||||
router.use("/", revisions.routes());
|
||||
router.use("/", views.routes());
|
||||
router.use("/", hooks.routes());
|
||||
router.use("/", apiKeys.routes());
|
||||
router.use("/", searches.routes());
|
||||
router.use("/", shares.routes());
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
import path from "path";
|
||||
import { glob } from "glob";
|
||||
import Router from "koa-router";
|
||||
import { sortBy } from "lodash";
|
||||
import env from "@server/env";
|
||||
import { requireDirectory } from "@server/utils/fs";
|
||||
|
||||
export type AuthenticationProviderConfig = {
|
||||
@@ -11,8 +15,10 @@ export type AuthenticationProviderConfig = {
|
||||
|
||||
const authenticationProviderConfigs: AuthenticationProviderConfig[] = [];
|
||||
|
||||
requireDirectory(__dirname).forEach(([module, id]) => {
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'config' does not exist on type 'unknown'... Remove this comment to see the full error message
|
||||
requireDirectory<{
|
||||
default: Router;
|
||||
config: { name: string; enabled: boolean };
|
||||
}>(__dirname).forEach(([module, id]) => {
|
||||
const { config, default: router } = module;
|
||||
|
||||
if (id === "index") {
|
||||
@@ -41,4 +47,34 @@ requireDirectory(__dirname).forEach(([module, id]) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Temporarily also include plugins here until all auth methods are moved over.
|
||||
glob
|
||||
.sync(
|
||||
(env.ENVIRONMENT === "test" ? "" : "build/") +
|
||||
"plugins/*/server/auth/!(*.test).[jt]s"
|
||||
)
|
||||
.forEach((filePath: string) => {
|
||||
const authProvider = require(path.join(process.cwd(), filePath)).default;
|
||||
const id = filePath.replace("build/", "").split("/")[1];
|
||||
const config = require(path.join(
|
||||
process.cwd(),
|
||||
env.ENVIRONMENT === "test" ? "" : "build",
|
||||
"plugins",
|
||||
id,
|
||||
"plugin.json"
|
||||
));
|
||||
|
||||
// Test the all required env vars are set for the auth provider
|
||||
const enabled = (config.requiredEnvVars ?? []).every(
|
||||
(name: string) => !!env[name]
|
||||
);
|
||||
|
||||
authenticationProviderConfigs.push({
|
||||
id,
|
||||
name: config.name,
|
||||
enabled,
|
||||
router: authProvider,
|
||||
});
|
||||
});
|
||||
|
||||
export default sortBy(authenticationProviderConfigs, "id");
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
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 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 * as Slack from "@server/utils/slack";
|
||||
import { assertPresent, assertUuid } from "@server/validation";
|
||||
|
||||
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>`;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
name: "Slack",
|
||||
enabled: !!env.SLACK_CLIENT_ID,
|
||||
};
|
||||
|
||||
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(`/settings/integrations/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(
|
||||
`/settings/integrations/slack?error=unauthenticated`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return ctx.redirect(
|
||||
`/settings/integrations/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("/settings/integrations/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(`/settings/integrations/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(
|
||||
`/settings/integrations/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("/settings/integrations/slack");
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default router;
|
||||
@@ -9,7 +9,7 @@ export function deserializeFilename(text: string): string {
|
||||
return text.replace(/%2F/g, "/").replace(/%5C/g, "\\");
|
||||
}
|
||||
|
||||
export function requireDirectory<T>(dirName: string): [T, string][] {
|
||||
export function getFilenamesInDirectory(dirName: string): string[] {
|
||||
return fs
|
||||
.readdirSync(dirName)
|
||||
.filter(
|
||||
@@ -18,10 +18,13 @@ export function requireDirectory<T>(dirName: string): [T, string][] {
|
||||
file.match(/\.[jt]s$/) &&
|
||||
file !== path.basename(__filename) &&
|
||||
!file.includes(".test")
|
||||
)
|
||||
.map((fileName) => {
|
||||
const filePath = path.join(dirName, fileName);
|
||||
const name = path.basename(filePath.replace(/\.[jt]s$/, ""));
|
||||
return [require(filePath), name];
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
export function requireDirectory<T>(dirName: string): [T, string][] {
|
||||
return getFilenamesInDirectory(dirName).map((fileName) => {
|
||||
const filePath = path.join(dirName, fileName);
|
||||
const name = path.basename(filePath.replace(/\.[jt]s$/, ""));
|
||||
return [require(filePath), name];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import querystring from "querystring";
|
||||
import fetch from "fetch-with-proxy";
|
||||
import env from "@server/env";
|
||||
import { InvalidRequestError } from "../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