Slack App Integration (#2041)

* patch applied

* patch applied

* We shouldn't pollute global css

* Build fixes

* Updates typings

* WIP extracting zoom to package

* Revert "Upgrades next to 12.1 (#1895)" (#1903)

This reverts commit ede0e98e1f.

* Tweak/gitignore prisma zod (#1905)

* Extracts ignored createEventTypeBaseInput

* Adds postinstall script

* Revert "Tweak/gitignore prisma zod (#1905)" (#1906)

This reverts commit 15bfeb30d7.

* Eslint fixes (#1898)

* Eslint fixes

* Docs build fixes

* Upgrade to next 12.1 (#1904)

* Upgrades next to 12.1

* Fixes build

* Updaters e2e test pipelines

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>

* Fix URL by removing slash and backslash (#1733)

* Fix URl by removing slash and backslash

* Implement slugify

* Add data type

* Fixing folder structure

* Solve zod-utils conflict

* Build fixes (#1929)

* Build fixes

* Fixes type error

* WIP

* Conflict fixes

* Removes unused file

* TODO

* WIP

* Type fixes

* Linting

* WIP

* Moved App definition to types

* WIP

* WIP

* WIP

* WIP WIP

* Renamed zoomvideo app

* Import fix

* Daily.co app (#2022)

* Daily.co app

* Update packages/app-store/dailyvideo/lib/VideoApiAdapter.ts

Co-authored-by: Omar López <zomars@me.com>

* Update packages/app-store/dailyvideo/lib/VideoApiAdapter.ts

Co-authored-by: Omar López <zomars@me.com>

* Missing deps for newly added contants to lib

Co-authored-by: Omar López <zomars@me.com>

* WIP

* WIP

* WIP

* Daily fixes

* Updated type info

* Slack Oauth integration - api route ideas

* Adds getLocationOptions

* Type fixes

* Adds location option for daily video

* Revert "Slack Oauth integration - api route ideas"

This reverts commit 35ffa78e92.

* Slack Oauth + verify sig

* Slack Oauth + verify sig

Implementing connect slack with workspace OAuth

Implemented the ability for slack to send requests on events (commands etc) - This only works if slacks signature matches with our signature

* Revert "Slack Oauth + verify sig"

This reverts commit ee95795e0f.

* WIP - Signature verifiaction failure

* Huddle01 migration to app store (#2038)

* Jitsi Video App migration

* Removing uneeded dependencies

* Missed unused reference

* Missing dependency

`@calcom/lib` is needed in the `locationOption.ts` file

* Huddle01 migration to app store

* WIP: PostData for creating event

* Optimising Query

Vital as we only have 3 seconds max to return the response to slack.

* Jitsi Video App migration (#2027)

* Jitsi Video App migration

* Removing uneeded dependencies

* Missed unused reference

* Missing dependency

`@calcom/lib` is needed in the `locationOption.ts` file

Co-authored-by: Omar López <zomars@me.com>

* Monorepo/app store MS Teams Integration (#2080)

* Create teamsvideo package

* Remove zoom specific refrences

* Add teams video files

* Rename to office365_video

* Add call back to add crednetial type office365_teams

* Rename to office_video to match type

* Add MS Teams as a location option

* Rename files

* Add teams reponse interface and create meeting

* Comment out Daily imports

* Add check for Teams integration

* Add token checking functions

* Change template to create event rather than meeting

* Add comment to test between create link and event

* Add teams URL to booking

* Ask for just onlineMeeting permission

* Add MS Teams logo

* Add message to have an enterprise account

* Remove comments

* Comment back hasDailyIntegration

* Comment back daily credentials

* Update link to MS Graph section of README

* Move API calls to package

Co-authored-by: Omar López <zomars@me.com>

* Re-adds missing module for transpiling

* Adding connect button if there is on user

* Adds email as required field for app store metadata

* WIP: migrates tandem to app store

* Cleanup

* Migrates tandem api routes to app store

* Fixes tandem api handlers

* Big WIP WIP

* Show todays bookings.

* No booking message to json

* Transition into modals

Better UX for submitting forms.

* Create Bookings - Working

* Fixing /today to show today and not all upcoming

* Fixing message

* Build fixes

* WIP

* Fixes annoying circular dependency bug

I've spent a whole day on this....

* Location option cleanup

* Type fixes

* Update EventManager.ts

* Update CalendarManager.ts

* Merge branch 'monorepo/app-store' into sean-monorepo-slack-oauth

* Moves CalendarService back to lib

* Moves apple calendar to App Store

* Cleanup

* Booking Success

* Merge branch 'main' into sean-monorepo-slack-oauth

* Restored moved file

* Delete TeamRole.tsx

* Undoing unrelated changes

* Cleanup

* Cleanup

* Updates website

* Delete .env.example

* Update yarn.lock

* Adds instructions to README

* Build fixes

* Uses generic app store api handler

* Adds install button and cleanup

* Updates .env.example

* Update README.md

* Renames slackapp to slackmessaing

* Update InstallAppButton.tsx

* Delete locationOption.ts

* Type fixes

* Build fixes

* Links + Fixing connection issue

* fixed merge conflict

* fixed merge conflict

* Type fixes

* Update index.ts

Co-authored-by: zomars <zomars@me.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
Co-authored-by: Juan Esteban Nieto Cifuentes <89233604+Jenietoc@users.noreply.github.com>
Co-authored-by: Leo Giovanetti <hello@leog.me>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peer@cal.com>
This commit is contained in:
sean-brydon 2022-04-06 13:37:06 +01:00 committed by GitHub
parent 41755c8c90
commit 02dbb88e6b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 1659 additions and 971 deletions

View File

@ -9,6 +9,7 @@
# - DAILY.CO VIDEO
# - GOOGLE CALENDAR/MEET/LOGIN
# - OFFICE 365
# - SLACK
# - STRIPE
# - TANDEM
# - ZOOM
@ -129,6 +130,12 @@ GOOGLE_LOGIN_ENABLED=false
MS_GRAPH_CLIENT_ID=
MS_GRAPH_CLIENT_SECRET=
# - SLACK
# @see https://github.com/calcom/cal.com/#obtaining-slack-client-id-and-secret-and-signing-secret
SLACK_SIGNING_SECRET=
SLACK_CLIENT_ID=
SLACK_CLIENT_SECRET=
# - STRIPE
NEXT_PUBLIC_STRIPE_PUBLIC_KEY= # pk_test_...
STRIPE_PRIVATE_KEY= # sk_test_...

View File

@ -317,6 +317,56 @@ We have a list of [good first issues](https://github.com/calcom/cal.com/labels/
5. Use **Application (client) ID** as the **MS_GRAPH_CLIENT_ID** attribute value in .env
6. Click **Certificates & secrets** create a new client secret and use the value as the **MS_GRAPH_CLIENT_SECRET** attribute
### Obtaining Slack Client ID and Secret and Signing Secret
To test this you will need to create a Slack app for yourself on [their apps website](https://api.slack.com/apps).
Copy and paste the app manifest below into the setting on your slack app. Be sure to replace `YOUR_DOMAIN` with your own domain or your proxy host if you're testing locally.
<details>
<summary>App Manifest</summary>
```yaml
display_information:
name: Cal.com Slack
features:
bot_user:
display_name: Cal.com Slack
always_online: false
slash_commands:
- command: /create-event
url: https://YOUR_DOMAIN/api/integrations/slackmessaging/commandHandler
description: Create an event within Cal!
should_escape: false
- command: /today
url: https://YOUR_DOMAIN/api/integrations/slackmessaging/commandHandler
description: View all your bookings for today
should_escape: false
oauth_config:
redirect_urls:
- https://YOUR_DOMAIN/api/integrations/slackmessaging/callback
scopes:
bot:
- chat:write
- commands
settings:
interactivity:
is_enabled: true
request_url: https://YOUR_DOMAIN/api/integrations/slackmessaging/interactiveHandler
message_menu_options_url: https://YOUR_DOMAIN/api/integrations/slackmessaging/interactiveHandler
org_deploy_enabled: false
socket_mode_enabled: false
token_rotation_enabled: false
```
</details>
Add the integration as normal - slack app - add. Follow the oauth flow to add it to a server.
Next make sure you have your app running `yarn dx`. Then in the slack chat type one of these commands: `/create-event` or `/today`
> NOTE: Next you will need to setup a proxy server like [ngrok](https://ngrok.com/) to allow your local host machine to be hosted on a public https server.
### Obtaining Zoom Client ID and Secret
1. Open [Zoom Marketplace](https://marketplace.zoom.us/) and sign in with your Zoom account.

View File

@ -31,12 +31,15 @@ import { detectBrowserTimeFormat } from "@lib/timeFormat";
import CustomBranding from "@components/CustomBranding";
import AvatarGroup from "@components/ui/AvatarGroup";
import type PhoneInputType from "@components/ui/form/PhoneInput";
import { BookPageProps } from "../../../pages/[user]/book";
import { TeamBookingPageProps } from "../../../pages/team/[slug]/book";
/** These are like 40kb that not every user needs */
const PhoneInput = dynamic(() => import("@components/ui/form/PhoneInput"));
const PhoneInput = dynamic(
() => import("@components/ui/form/PhoneInput")
) as unknown as typeof PhoneInputType;
type BookingPageProps = BookPageProps | TeamBookingPageProps;
@ -383,8 +386,7 @@ const BookingPage = ({ eventType, booking, profile, locationLabels }: BookingPag
{t("phone_number")}
</label>
<div className="mt-1">
<PhoneInput
// @ts-expect-error
<PhoneInput<BookingFormValues>
control={bookingForm.control}
name="phone"
placeholder={t("enter_phone_number")}

View File

@ -1,28 +1,29 @@
import React from "react";
import BasePhoneInput, { Props } from "react-phone-number-input/react-hook-form";
import "react-phone-number-input/style.css";
import classNames from "@lib/classNames";
type PhoneInputProps = {
value: string;
id: string;
placeholder: string;
required: boolean;
};
export type PhoneInputProps<FormValues> = Props<
{
value: string;
id: string;
placeholder: string;
required: boolean;
},
FormValues
>;
export const PhoneInput = ({ control, name, ...rest }: Props<PhoneInputProps>) => (
<BasePhoneInput
{...rest}
name={name}
control={control}
className={classNames(
"border-1 focus-within:border-brand block w-full rounded-sm border border-gray-300 py-px px-3 shadow-sm ring-black focus-within:ring-1 dark:border-black dark:bg-black dark:text-white"
)}
onChange={() => {
/* DO NOT REMOVE: Callback required by PhoneInput, comment added to satisfy eslint:no-empty-function */
}}
/>
);
function PhoneInput<FormValues>({ control, name, ...rest }: PhoneInputProps<FormValues>) {
return (
<BasePhoneInput
{...rest}
name={name}
control={control}
className={classNames(
"border-1 focus-within:border-brand block w-full rounded-sm border border-gray-300 py-px px-3 shadow-sm ring-black focus-within:ring-1 dark:border-black dark:bg-black dark:text-white"
)}
/>
);
}
export default PhoneInput;

View File

@ -9,11 +9,6 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
// Check that user is authenticated
req.session = await getSession({ req });
if (!req.session?.user?.id) {
res.status(401).json({ message: "You must be logged in to do this" });
return;
}
const { args } = req.query;
if (!Array.isArray(args)) {
@ -38,7 +33,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const response = await handler(req, res);
console.log("response", response);
res.status(200);
return res.status(200);
} catch (error) {
console.error(error);
if (error instanceof HttpError) {

View File

@ -0,0 +1,31 @@
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 270 270" style="enable-background:new 0 0 270 270;" xml:space="preserve">
<style type="text/css">
.st0{fill:#E01E5A;}
.st1{fill:#36C5F0;}
.st2{fill:#2EB67D;}
.st3{fill:#ECB22E;}
</style>
<g>
<g>
<path class="st0" d="M99.4,151.2c0,7.1-5.8,12.9-12.9,12.9c-7.1,0-12.9-5.8-12.9-12.9c0-7.1,5.8-12.9,12.9-12.9h12.9V151.2z"/>
<path class="st0" d="M105.9,151.2c0-7.1,5.8-12.9,12.9-12.9s12.9,5.8,12.9,12.9v32.3c0,7.1-5.8,12.9-12.9,12.9
s-12.9-5.8-12.9-12.9V151.2z"/>
</g>
<g>
<path class="st1" d="M118.8,99.4c-7.1,0-12.9-5.8-12.9-12.9c0-7.1,5.8-12.9,12.9-12.9s12.9,5.8,12.9,12.9v12.9H118.8z"/>
<path class="st1" d="M118.8,105.9c7.1,0,12.9,5.8,12.9,12.9s-5.8,12.9-12.9,12.9H86.5c-7.1,0-12.9-5.8-12.9-12.9
s5.8-12.9,12.9-12.9H118.8z"/>
</g>
<g>
<path class="st2" d="M170.6,118.8c0-7.1,5.8-12.9,12.9-12.9c7.1,0,12.9,5.8,12.9,12.9s-5.8,12.9-12.9,12.9h-12.9V118.8z"/>
<path class="st2" d="M164.1,118.8c0,7.1-5.8,12.9-12.9,12.9c-7.1,0-12.9-5.8-12.9-12.9V86.5c0-7.1,5.8-12.9,12.9-12.9
c7.1,0,12.9,5.8,12.9,12.9V118.8z"/>
</g>
<g>
<path class="st3" d="M151.2,170.6c7.1,0,12.9,5.8,12.9,12.9c0,7.1-5.8,12.9-12.9,12.9c-7.1,0-12.9-5.8-12.9-12.9v-12.9H151.2z"/>
<path class="st3" d="M151.2,164.1c-7.1,0-12.9-5.8-12.9-12.9c0-7.1,5.8-12.9,12.9-12.9h32.3c7.1,0,12.9,5.8,12.9,12.9
c0,7.1-5.8,12.9-12.9,12.9H151.2z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -1,7 +1,7 @@
import { useSession } from "next-auth/react";
import dynamic from "next/dynamic";
import { NEXT_PUBLIC_BASE_URL } from "@calcom/lib/constants";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { App } from "@calcom/types/App";
import Button from "@calcom/ui/Button";
@ -14,6 +14,7 @@ export const InstallAppButtonMap = {
caldavcalendar: dynamic(() => import("./caldavcalendar/components/InstallAppButton")),
googlecalendar: dynamic(() => import("./googlecalendar/components/InstallAppButton")),
office365calendar: dynamic(() => import("./office365calendar/components/InstallAppButton")),
slackmessaging: dynamic(() => import("./slackmessaging/components/InstallAppButton")),
stripepayment: dynamic(() => import("./stripepayment/components/InstallAppButton")),
tandemvideo: dynamic(() => import("./tandemvideo/components/InstallAppButton")),
zoomvideo: dynamic(() => import("./zoomvideo/components/InstallAppButton")),
@ -36,9 +37,7 @@ export const InstallAppButton = (
render={() => (
<Button
color="primary"
href={`${NEXT_PUBLIC_BASE_URL}/auth/login?callbackUrl=${
NEXT_PUBLIC_BASE_URL + location.pathname + location.search
}`}>
href={`${WEBAPP_URL}/auth/login?callbackUrl=${WEBAPP_URL + location.pathname + location.search}`}>
{t("install_app")}
</Button>
)}

View File

@ -8,6 +8,7 @@ import * as huddle01video from "./huddle01video";
import * as jitsivideo from "./jitsivideo";
import * as office365calendar from "./office365calendar";
import * as office365video from "./office365video";
import * as slackmessaging from "./slackmessaging";
import * as stripepayment from "./stripepayment";
import * as tandemvideo from "./tandemvideo";
import * as zoomvideo from "./zoomvideo";
@ -23,6 +24,7 @@ const appStore = {
jitsivideo,
office365calendar,
office365video,
slackmessaging,
stripepayment,
tandemvideo,
zoomvideo,

View File

@ -0,0 +1,37 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { stringify } from "querystring";
import prisma from "@calcom/prisma";
const client_id = process.env.SLACK_CLIENT_ID;
const scopes = ["commands", "users:read", "users:read.email"];
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!req.session?.user?.id) {
res.status(401).json({ message: "You must be logged in to do this" });
return;
}
if (req.method === "GET") {
// Get user
await prisma.user.findFirst({
rejectOnNotFound: true,
where: {
id: req.session.user.id,
},
select: {
id: true,
},
});
const params = {
client_id,
scope: scopes.join(","),
};
const query = stringify(params);
const url = `https://slack.com/oauth/v2/authorize?${query}&user_`;
// const url =
// "https://slack.com/oauth/v2/authorize?client_id=3194129032064.3178385871204&scope=chat:write,commands&user_scope=";
res.status(200).json({ url });
}
res.status(404).json({ error: "Not Found" });
}

View File

@ -0,0 +1,51 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { stringify } from "querystring";
import prisma from "@calcom/prisma";
const client_id = process.env.SLACK_CLIENT_ID;
const client_secret = process.env.SLACK_CLIENT_SECRET;
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!req.session?.user?.id) {
res.status(401).json({ message: "You must be logged in to do this" });
return;
}
if (req.method === "GET") {
// Get user
const { code } = req.query;
console.log(req.query);
if (!code) {
res.redirect("/apps/installed"); // Redirect to where the user was if they cancel the signup or if the oauth fails
}
const query = {
client_secret,
client_id,
code,
};
const params = stringify(query);
console.log("params", params);
const url = `https://slack.com/api/oauth.v2.access?${params}`;
const result = await fetch(url);
const responseBody = await result.json();
await prisma.user.update({
where: {
id: req.session.user.id,
},
data: {
credentials: {
create: {
type: "slack_app",
key: responseBody,
},
},
},
});
res.redirect("/apps/installed");
}
}

View File

@ -0,0 +1,28 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { showCreateEventMessage, showTodayMessage } from "../lib";
import showLinksMessage from "../lib/showLinksMessage";
export enum SlackAppCommands {
CREATE_EVENT = "create-event",
TODAY = "today",
LINKS = "links",
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "POST") {
const command = req.body.command.split("/").pop();
switch (command) {
case SlackAppCommands.CREATE_EVENT:
return await showCreateEventMessage(req, res);
case SlackAppCommands.TODAY:
return await showTodayMessage(req, res);
case SlackAppCommands.LINKS:
return await showLinksMessage(req, res);
default:
return res.status(404).json({ message: `Command not found` });
}
}
res.status(400).json({ message: "Invalid request" });
}

View File

@ -0,0 +1,4 @@
export { default as add } from "./add";
export { default as callback } from "./callback";
export { default as commandHandler } from "./commandHandler";
export { default as interactiveHandler } from "./interactiveHandler";

View File

@ -0,0 +1,23 @@
import { NextApiRequest, NextApiResponse } from "next";
import createEvent from "../lib/actions/createEvent";
enum InteractionEvents {
CREATE_EVENT = "cal.event.create",
}
export default async function interactiveHandler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "POST") {
const payload = JSON.parse(req.body.payload);
const actions = payload.view.callback_id;
// I've not found a case where actions is ever > than 1 when this function is called.
switch (actions) {
case InteractionEvents.CREATE_EVENT:
return await createEvent(req, res);
default:
res.status(200).end(); // Techincally an invalid request but we don't want to return an throw an error to slack - 200 just does nothing
}
}
res.status(200).end(); // Send 200 if we dont have a case for the action_id
}

View File

@ -0,0 +1,18 @@
import type { InstallAppButtonProps } from "@calcom/app-store/types";
import useAddAppMutation from "../../_utils/useAddAppMutation";
export default function InstallAppButton(props: InstallAppButtonProps) {
const mutation = useAddAppMutation("slack_messaging");
return (
<>
{props.render({
onClick() {
mutation.mutate("");
},
loading: mutation.isLoading,
})}
</>
);
}

View File

@ -0,0 +1 @@
export { default as InstallAppButton } from "./InstallAppButton";

View File

@ -0,0 +1,29 @@
import type { App } from "@calcom/types/App";
import _package from "./package.json";
export const metadata = {
name: "Slack App",
description: _package.description,
installed: !!(
process.env.SLACK_CLIENT_ID &&
process.env.SLACK_CLIENT_SECRET &&
process.env.SLACK_SIGNING_SECRET
),
category: "messaging",
imageSrc: "/apps/slack.svg",
logo: "/apps/slack.svg",
publisher: "Cal.com",
rating: 5,
reviews: 69,
slug: "slack",
title: "Slack App",
trending: true,
type: "slack_messaging",
url: "https://slack.com/",
variant: "conferencing",
verified: true,
email: "help@cal.com",
} as App;
export * as api from "./api";

View File

@ -0,0 +1,9 @@
export const WhereCredsEqualsId = (userId: string) => ({
where: {
type: "slack_app",
key: {
path: ["authed_user", "id"],
equals: userId,
},
},
});

View File

@ -0,0 +1,142 @@
import { WebClient } from "@slack/web-api";
import dayjs from "dayjs";
import { NextApiRequest, NextApiResponse } from "next";
import { WEBAPP_URL } from "@calcom/lib/constants";
import db from "@calcom/prisma";
import { WhereCredsEqualsId } from "../WhereCredsEqualsID";
import { getUserEmail } from "../utils";
import BookingSuccess from "../views/BookingSuccess";
// TODO: Move this type to a shared location - being used in more than one package.
export type BookingCreateBody = {
email: string;
end: string;
web3Details?: {
userWallet: string;
userSignature: unknown;
};
eventTypeId: number;
guests?: string[];
location: string;
name: string;
notes?: string;
rescheduleUid?: string;
start: string;
timeZone: string;
user?: string | string[];
language: string;
customInputs: { label: string; value: string }[];
metadata: {
[key: string]: string;
};
};
export default async function createEvent(req: NextApiRequest, res: NextApiResponse) {
const {
user,
view: {
state: { values },
},
} = JSON.parse(req.body.payload);
// This is a mess I have no idea why slack makes getting infomation this hard.
const {
eventName: {
event_name: { value: selected_name },
},
eventType: {
"create.event.type": {
selected_option: { value: selected_event_id },
},
},
selectedUsers: {
invite_users: { selected_users },
},
eventDate: {
event_date: { selected_date },
},
eventTime: {
event_start_time: { selected_time },
},
} = values;
// Im sure this query can be made more efficient... The JSON filtering wouldnt work when doing it directly on user.
const foundUser = await db.credential
.findFirst({
rejectOnNotFound: true,
...WhereCredsEqualsId(user.id),
})
.user({
select: {
username: true,
email: true,
timeZone: true,
locale: true,
eventTypes: {
where: {
id: parseInt(selected_event_id),
},
select: {
id: true,
length: true,
locations: true,
},
},
credentials: {
...WhereCredsEqualsId(user.id),
},
},
});
const slackCredentials = foundUser?.credentials[0].key; // Only one slack credential for user
// @ts-ignore access_token must exist on slackCredentials otherwise we have wouldnt have reached this endpoint
const access_token = slackCredentials?.access_token;
// https://api.slack.com/authentication/best-practices#verifying since we verify the request is coming from slack we can store the access_token in the DB.
const client = new WebClient(access_token);
// This could get a bit weird as there is a 3 second limit until the post times ou
// Compute all users that have been selected and get their email.
const invitedGuestsEmails = selected_users.map(
async (userId: string) => await getUserEmail(client, userId)
);
const startDate = dayjs(`${selected_date} ${selected_time}`, "YYYY-MM-DD HH:mm");
const PostData: BookingCreateBody = {
start: dayjs(startDate).format(),
end: dayjs(startDate)
.add(foundUser?.eventTypes[0]?.length ?? 0, "minute")
.format(),
eventTypeId: foundUser?.eventTypes[0]?.id ?? 0,
user: foundUser?.username ?? "",
email: foundUser?.email ?? "",
name: foundUser?.username ?? "",
guests: await Promise.all(invitedGuestsEmails),
location: "inPerson", // TODO: Make this pickable in the future - defaulting to in person as any video provider that does not exist within the monorepo will crash the app.
timeZone: foundUser?.timeZone ?? "",
language: foundUser?.locale ?? "en",
customInputs: [{ label: "", value: "" }],
metadata: {},
notes: "This event was created with slack.",
};
fetch(`${WEBAPP_URL}/api/book/event`, {
method: "POST",
body: JSON.stringify(PostData),
headers: {
"Content-Type": "application/json",
},
})
.then(() => {
return res.status(200).send(""); // Slack requires a 200 to be sent to clear the modal. This makes it massive pain to update the user that the event has been created.
})
.catch(() => {
return res
.status(200)
.json({ text: "Event creation failed. Please try again", response_action: "update" });
});
}

View File

@ -0,0 +1,3 @@
export { default as showCreateEventMessage } from "./showCreateEventMessage";
export { default as showTodayMessage } from "./showTodayMessage";
export * as utils from "./utils";

View File

@ -0,0 +1,39 @@
import { Prisma } from "@prisma/client";
import { WebClient } from "@slack/web-api";
import { NextApiRequest, NextApiResponse } from "next";
import prisma from "@calcom/prisma";
import { WhereCredsEqualsId } from "./WhereCredsEqualsID";
import { CreateEventModal, NoUserMessage } from "./views";
export default async function showCreateEventMessage(req: NextApiRequest, res: NextApiResponse) {
const body = req.body;
const data = await prisma.credential.findFirst({
...WhereCredsEqualsId(body.user_id),
include: {
user: {
select: {
username: true,
eventTypes: {
select: {
id: true,
title: true,
},
},
},
},
},
});
if (!data) return res.status(200).json(NoUserMessage);
const slackCredentials = data?.key; // Only one slack credential for user
const access_token = (slackCredentials as Prisma.JsonObject)?.access_token as string;
const slackClient = new WebClient(access_token);
await slackClient.views.open({
trigger_id: body.trigger_id,
view: CreateEventModal(data),
});
res.status(200).end();
}

View File

@ -0,0 +1,48 @@
import { Prisma } from "@prisma/client";
import { KnownBlock, WebClient } from "@slack/web-api";
import { NextApiRequest, NextApiResponse } from "next";
import prisma from "@calcom/prisma";
import { WhereCredsEqualsId } from "./WhereCredsEqualsID";
import { CreateEventModal, NoUserMessage } from "./views";
import ShowLinks from "./views/ShowLinks";
export default async function showLinksMessage(req: NextApiRequest, res: NextApiResponse) {
const body = req.body;
const data = await prisma.credential.findFirst({
...WhereCredsEqualsId(body.user_id),
include: {
user: {
select: {
username: true,
eventTypes: {
where: {
hidden: false,
},
select: {
slug: true,
title: true,
},
},
},
},
},
});
if (!data) return res.status(200).json(NoUserMessage);
const slackCredentials = data?.key; // Only one slack credential for user
const access_token = (slackCredentials as Prisma.JsonObject)?.access_token as string;
const slackClient = new WebClient(access_token);
const blocks = JSON.parse(ShowLinks(data.user?.eventTypes, data.user?.username ?? "")).blocks;
slackClient.chat.postMessage({
channel: body.channel_id,
text: `${data.user?.username}'s Cal.com Links`,
//@ts-ignore this doesnt need to be of type Block[] - an object works completely fine
blocks,
});
res.status(200).end();
}

View File

@ -0,0 +1,54 @@
import { BookingStatus } from "@prisma/client";
import dayjs from "dayjs";
import { NextApiRequest, NextApiResponse } from "next";
import prisma from "@calcom/prisma";
import { WhereCredsEqualsId } from "./WhereCredsEqualsID";
import { NoUserMessage, TodayMessage } from "./views";
export default async function showCreateEventMessage(req: NextApiRequest, res: NextApiResponse) {
const body = req.body;
const foundUser = await prisma.credential.findFirst({
...WhereCredsEqualsId(body.user_id),
include: {
user: {
select: {
id: true,
email: true,
},
},
},
});
if (!foundUser) res.status(200).json(NoUserMessage);
const bookings = await prisma.booking.findMany({
where: {
OR: [
{
userId: foundUser?.userId,
},
{
attendees: {
some: {
email: foundUser?.user?.email,
},
},
},
],
AND: [
{
endTime: { gte: dayjs().startOf("day").toDate(), lte: dayjs().endOf("day").toDate() },
AND: [
{ NOT: { status: { equals: BookingStatus.CANCELLED } } },
{ NOT: { status: { equals: BookingStatus.REJECTED } } },
],
},
],
},
});
res.status(200).json(TodayMessage(bookings));
}

View File

@ -0,0 +1,8 @@
import { WebClient } from "@slack/web-api";
const getUserEmail = async (client: WebClient, userId: string) =>
await (
await client.users.info({ user: userId })
).user?.profile?.email;
export { getUserEmail };

View File

@ -0,0 +1,9 @@
import { Blocks, Message } from "slack-block-builder";
const BookingSuccess = () => {
return Message()
.blocks(Blocks.Section({ text: `Your booking has been created!` }))
.buildToObject();
};
export default BookingSuccess;

View File

@ -0,0 +1,53 @@
import { Credential } from "@prisma/client";
import { Bits, Blocks, Elements, Modal, setIfTruthy } from "slack-block-builder";
const CreateEventModal = (
data:
| (Credential & {
user: {
username: string | null;
eventTypes: {
id: number;
title: string;
}[];
} | null;
})
| null,
invalidInput: boolean = false
) => {
return Modal({ title: "Create Booking", submit: "Create", callbackId: "cal.event.create" })
.blocks(
Blocks.Section({ text: `Hey there, *${data?.user?.username}!*` }),
Blocks.Divider(),
Blocks.Input({ label: "Your Name", blockId: "eventName" }).element(
Elements.TextInput({ placeholder: "Event Name" }).actionId("event_name")
),
Blocks.Input({ label: "Which event would you like to create?", blockId: "eventType" }).element(
Elements.StaticSelect({ placeholder: "Which event would you like to create?" })
.actionId("create.event.type")
.options(
data?.user?.eventTypes.map((item: any) =>
Bits.Option({ text: item.title ?? "No Name", value: item.id.toString() })
)
)
), // This doesnt need to reach out to the server when the user changes the selection
Blocks.Input({
label: "Who would you like to invite to your event?",
blockId: "selectedUsers",
}).element(
Elements.UserMultiSelect({ placeholder: "Who would you like to invite to your event?" }).actionId(
"invite_users"
)
),
Blocks.Input({ label: "When would this event be?", blockId: "eventDate" }).element(
Elements.DatePicker({ placeholder: "Select Date" }).actionId("event_date")
),
Blocks.Input({ label: "What time would you like to start?", blockId: "eventTime" }).element(
Elements.TimePicker({ placeholder: "Select Time" }).actionId("event_start_time")
), // TODO: We could in future validate if the time is in the future or if busy at point - Didnt see much point as this gets validated when you submit. Could be better UX
setIfTruthy(invalidInput, [Blocks.Section({ text: "Please fill in all the fields" })])
)
.buildToObject();
};
export default CreateEventModal;

View File

@ -0,0 +1,20 @@
import { Message, Blocks, Elements } from "slack-block-builder";
import { BASE_URL } from "@calcom/lib/constants";
const NoUserMessage = () => {
return Message()
.blocks(
Blocks.Section({ text: "This slack account is not linked with a cal.com account" }),
Blocks.Actions().elements(
Elements.Button({ text: "Cancel", actionId: "cancel" }).danger(),
Elements.Button({
text: "Connect",
actionId: "open.connect.link",
url: `${BASE_URL}/apps/installed`,
}).primary()
)
)
.buildToJSON();
};
export default NoUserMessage;

View File

@ -0,0 +1,31 @@
import dayjs from "dayjs";
import { Blocks, Elements, Message } from "slack-block-builder";
import { WEBAPP_URL } from "@calcom/lib/constants";
interface IEventTypes {
slug: string;
title: string;
}
const ShowLinks = (eventLinks: IEventTypes[] | undefined, username: string) => {
if (eventLinks?.length === 0 || !eventLinks) {
return Message()
.blocks(Blocks.Section({ text: "You do not have any links." }))
.asUser()
.buildToJSON();
}
return Message()
.blocks(
Blocks.Section({ text: `${username}'s Cal.com Links` }),
Blocks.Divider(),
eventLinks.map((links) =>
Blocks.Section({
text: `${links.title} | ${WEBAPP_URL}/${links.slug}`,
}).accessory(Elements.Button({ text: "Open", url: `${WEBAPP_URL}/${links.slug}` }))
)
)
.buildToJSON();
};
export default ShowLinks;

View File

@ -0,0 +1,27 @@
import { Booking } from "@prisma/client";
import dayjs from "dayjs";
import { Modal, Blocks, Elements, Bits, Message } from "slack-block-builder";
import { BASE_URL } from "@calcom/lib/constants";
const TodayMessage = (bookings: Booking[]) => {
if (bookings.length === 0) {
return Message()
.blocks(Blocks.Section({ text: "You do not have any bookings for today." }))
.asUser()
.buildToJSON();
}
return Message()
.blocks(
Blocks.Section({ text: `Todays Bookings.` }),
Blocks.Divider(),
bookings.map((booking) =>
Blocks.Section({
text: `${booking.title} | ${dayjs(booking.startTime).format("HH:mm")}`,
}).accessory(Elements.Button({ text: "Cancel", url: `${BASE_URL}/cancel/${booking.uid}` }))
)
)
.buildToObject();
};
export default TodayMessage;

View File

@ -0,0 +1,3 @@
export { default as CreateEventModal } from "./CreateEventModal";
export { default as TodayMessage } from "./TodayMessage";
export { default as NoUserMessage } from "./NoUser";

View File

@ -0,0 +1,16 @@
{
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"name": "@calcom/slackmessaging",
"version": "0.0.0",
"main": "./index.ts",
"description": "This is a package for the intergration of slack into the app-store",
"dependencies": {
"@calcom/prisma": "*",
"@slack/web-api": "^6.7.0",
"slack-block-builder": "^2.5.0"
},
"devDependencies": {
"@calcom/types": "*"
}
}

View File

@ -0,0 +1,31 @@
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 270 270" style="enable-background:new 0 0 270 270;" xml:space="preserve">
<style type="text/css">
.st0{fill:#E01E5A;}
.st1{fill:#36C5F0;}
.st2{fill:#2EB67D;}
.st3{fill:#ECB22E;}
</style>
<g>
<g>
<path class="st0" d="M99.4,151.2c0,7.1-5.8,12.9-12.9,12.9c-7.1,0-12.9-5.8-12.9-12.9c0-7.1,5.8-12.9,12.9-12.9h12.9V151.2z"/>
<path class="st0" d="M105.9,151.2c0-7.1,5.8-12.9,12.9-12.9s12.9,5.8,12.9,12.9v32.3c0,7.1-5.8,12.9-12.9,12.9
s-12.9-5.8-12.9-12.9V151.2z"/>
</g>
<g>
<path class="st1" d="M118.8,99.4c-7.1,0-12.9-5.8-12.9-12.9c0-7.1,5.8-12.9,12.9-12.9s12.9,5.8,12.9,12.9v12.9H118.8z"/>
<path class="st1" d="M118.8,105.9c7.1,0,12.9,5.8,12.9,12.9s-5.8,12.9-12.9,12.9H86.5c-7.1,0-12.9-5.8-12.9-12.9
s5.8-12.9,12.9-12.9H118.8z"/>
</g>
<g>
<path class="st2" d="M170.6,118.8c0-7.1,5.8-12.9,12.9-12.9c7.1,0,12.9,5.8,12.9,12.9s-5.8,12.9-12.9,12.9h-12.9V118.8z"/>
<path class="st2" d="M164.1,118.8c0,7.1-5.8,12.9-12.9,12.9c-7.1,0-12.9-5.8-12.9-12.9V86.5c0-7.1,5.8-12.9,12.9-12.9
c7.1,0,12.9,5.8,12.9,12.9V118.8z"/>
</g>
<g>
<path class="st3" d="M151.2,170.6c7.1,0,12.9,5.8,12.9,12.9c0,7.1-5.8,12.9-12.9,12.9c-7.1,0-12.9-5.8-12.9-12.9v-12.9H151.2z"/>
<path class="st3" d="M151.2,164.1c-7.1,0-12.9-5.8-12.9-12.9c0-7.1,5.8-12.9,12.9-12.9h32.3c7.1,0,12.9,5.8,12.9,12.9
c0,7.1-5.8,12.9-12.9,12.9H151.2z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -7,7 +7,8 @@ datasource db {
}
generator client {
provider = "prisma-client-js"
provider = "prisma-client-js"
previewFeatures = ["filterJson"]
}
generator zod {

View File

@ -13,7 +13,13 @@ export interface App {
* */
installed: boolean;
/** The app type */
type: `${string}_calendar` | `${string}_payment` | `${string}_video` | `${string}_web3` | `${string}_other`;
type:
| `${string}_calendar`
| `${string}_messaging`
| `${string}_payment`
| `${string}_video`
| `${string}_web3`
| `${string}_other`;
/** The display name for the app, TODO settle between this or name */
title: string;
/** The display name for the app */

1782
yarn.lock

File diff suppressed because it is too large Load Diff