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 commitede0e98e1f
. * Tweak/gitignore prisma zod (#1905) * Extracts ignored createEventTypeBaseInput * Adds postinstall script * Revert "Tweak/gitignore prisma zod (#1905)" (#1906) This reverts commit15bfeb30d7
. * 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 35ffa78e929339c4badb98cdab4e4b953ecc7cca. * 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 ee95795e0f0ae6d06be4e0a423afb8c315d9af7d. * 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:
parent
41755c8c90
commit
02dbb88e6b
34 changed files with 1659 additions and 971 deletions
|
@ -9,6 +9,7 @@
|
||||||
# - DAILY.CO VIDEO
|
# - DAILY.CO VIDEO
|
||||||
# - GOOGLE CALENDAR/MEET/LOGIN
|
# - GOOGLE CALENDAR/MEET/LOGIN
|
||||||
# - OFFICE 365
|
# - OFFICE 365
|
||||||
|
# - SLACK
|
||||||
# - STRIPE
|
# - STRIPE
|
||||||
# - TANDEM
|
# - TANDEM
|
||||||
# - ZOOM
|
# - ZOOM
|
||||||
|
@ -129,6 +130,12 @@ GOOGLE_LOGIN_ENABLED=false
|
||||||
MS_GRAPH_CLIENT_ID=
|
MS_GRAPH_CLIENT_ID=
|
||||||
MS_GRAPH_CLIENT_SECRET=
|
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
|
# - STRIPE
|
||||||
NEXT_PUBLIC_STRIPE_PUBLIC_KEY= # pk_test_...
|
NEXT_PUBLIC_STRIPE_PUBLIC_KEY= # pk_test_...
|
||||||
STRIPE_PRIVATE_KEY= # sk_test_...
|
STRIPE_PRIVATE_KEY= # sk_test_...
|
||||||
|
|
50
README.md
50
README.md
|
@ -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
|
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
|
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
|
### Obtaining Zoom Client ID and Secret
|
||||||
|
|
||||||
1. Open [Zoom Marketplace](https://marketplace.zoom.us/) and sign in with your Zoom account.
|
1. Open [Zoom Marketplace](https://marketplace.zoom.us/) and sign in with your Zoom account.
|
||||||
|
|
|
@ -31,12 +31,15 @@ import { detectBrowserTimeFormat } from "@lib/timeFormat";
|
||||||
|
|
||||||
import CustomBranding from "@components/CustomBranding";
|
import CustomBranding from "@components/CustomBranding";
|
||||||
import AvatarGroup from "@components/ui/AvatarGroup";
|
import AvatarGroup from "@components/ui/AvatarGroup";
|
||||||
|
import type PhoneInputType from "@components/ui/form/PhoneInput";
|
||||||
|
|
||||||
import { BookPageProps } from "../../../pages/[user]/book";
|
import { BookPageProps } from "../../../pages/[user]/book";
|
||||||
import { TeamBookingPageProps } from "../../../pages/team/[slug]/book";
|
import { TeamBookingPageProps } from "../../../pages/team/[slug]/book";
|
||||||
|
|
||||||
/** These are like 40kb that not every user needs */
|
/** 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;
|
type BookingPageProps = BookPageProps | TeamBookingPageProps;
|
||||||
|
|
||||||
|
@ -383,8 +386,7 @@ const BookingPage = ({ eventType, booking, profile, locationLabels }: BookingPag
|
||||||
{t("phone_number")}
|
{t("phone_number")}
|
||||||
</label>
|
</label>
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<PhoneInput
|
<PhoneInput<BookingFormValues>
|
||||||
// @ts-expect-error
|
|
||||||
control={bookingForm.control}
|
control={bookingForm.control}
|
||||||
name="phone"
|
name="phone"
|
||||||
placeholder={t("enter_phone_number")}
|
placeholder={t("enter_phone_number")}
|
||||||
|
|
|
@ -1,17 +1,20 @@
|
||||||
import React from "react";
|
|
||||||
import BasePhoneInput, { Props } from "react-phone-number-input/react-hook-form";
|
import BasePhoneInput, { Props } from "react-phone-number-input/react-hook-form";
|
||||||
import "react-phone-number-input/style.css";
|
import "react-phone-number-input/style.css";
|
||||||
|
|
||||||
import classNames from "@lib/classNames";
|
import classNames from "@lib/classNames";
|
||||||
|
|
||||||
type PhoneInputProps = {
|
export type PhoneInputProps<FormValues> = Props<
|
||||||
|
{
|
||||||
value: string;
|
value: string;
|
||||||
id: string;
|
id: string;
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
required: boolean;
|
required: boolean;
|
||||||
};
|
},
|
||||||
|
FormValues
|
||||||
|
>;
|
||||||
|
|
||||||
export const PhoneInput = ({ control, name, ...rest }: Props<PhoneInputProps>) => (
|
function PhoneInput<FormValues>({ control, name, ...rest }: PhoneInputProps<FormValues>) {
|
||||||
|
return (
|
||||||
<BasePhoneInput
|
<BasePhoneInput
|
||||||
{...rest}
|
{...rest}
|
||||||
name={name}
|
name={name}
|
||||||
|
@ -19,10 +22,8 @@ export const PhoneInput = ({ control, name, ...rest }: Props<PhoneInputProps>) =
|
||||||
className={classNames(
|
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"
|
"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 */
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default PhoneInput;
|
export default PhoneInput;
|
||||||
|
|
|
@ -9,11 +9,6 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
// Check that user is authenticated
|
// Check that user is authenticated
|
||||||
req.session = await getSession({ req });
|
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;
|
const { args } = req.query;
|
||||||
|
|
||||||
if (!Array.isArray(args)) {
|
if (!Array.isArray(args)) {
|
||||||
|
@ -38,7 +33,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
const response = await handler(req, res);
|
const response = await handler(req, res);
|
||||||
console.log("response", response);
|
console.log("response", response);
|
||||||
|
|
||||||
res.status(200);
|
return res.status(200);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
if (error instanceof HttpError) {
|
if (error instanceof HttpError) {
|
||||||
|
|
31
apps/web/public/apps/slack.svg
Normal file
31
apps/web/public/apps/slack.svg
Normal 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 |
|
@ -1,7 +1,7 @@
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
import dynamic from "next/dynamic";
|
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 { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import type { App } from "@calcom/types/App";
|
import type { App } from "@calcom/types/App";
|
||||||
import Button from "@calcom/ui/Button";
|
import Button from "@calcom/ui/Button";
|
||||||
|
@ -14,6 +14,7 @@ export const InstallAppButtonMap = {
|
||||||
caldavcalendar: dynamic(() => import("./caldavcalendar/components/InstallAppButton")),
|
caldavcalendar: dynamic(() => import("./caldavcalendar/components/InstallAppButton")),
|
||||||
googlecalendar: dynamic(() => import("./googlecalendar/components/InstallAppButton")),
|
googlecalendar: dynamic(() => import("./googlecalendar/components/InstallAppButton")),
|
||||||
office365calendar: dynamic(() => import("./office365calendar/components/InstallAppButton")),
|
office365calendar: dynamic(() => import("./office365calendar/components/InstallAppButton")),
|
||||||
|
slackmessaging: dynamic(() => import("./slackmessaging/components/InstallAppButton")),
|
||||||
stripepayment: dynamic(() => import("./stripepayment/components/InstallAppButton")),
|
stripepayment: dynamic(() => import("./stripepayment/components/InstallAppButton")),
|
||||||
tandemvideo: dynamic(() => import("./tandemvideo/components/InstallAppButton")),
|
tandemvideo: dynamic(() => import("./tandemvideo/components/InstallAppButton")),
|
||||||
zoomvideo: dynamic(() => import("./zoomvideo/components/InstallAppButton")),
|
zoomvideo: dynamic(() => import("./zoomvideo/components/InstallAppButton")),
|
||||||
|
@ -36,9 +37,7 @@ export const InstallAppButton = (
|
||||||
render={() => (
|
render={() => (
|
||||||
<Button
|
<Button
|
||||||
color="primary"
|
color="primary"
|
||||||
href={`${NEXT_PUBLIC_BASE_URL}/auth/login?callbackUrl=${
|
href={`${WEBAPP_URL}/auth/login?callbackUrl=${WEBAPP_URL + location.pathname + location.search}`}>
|
||||||
NEXT_PUBLIC_BASE_URL + location.pathname + location.search
|
|
||||||
}`}>
|
|
||||||
{t("install_app")}
|
{t("install_app")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -8,6 +8,7 @@ import * as huddle01video from "./huddle01video";
|
||||||
import * as jitsivideo from "./jitsivideo";
|
import * as jitsivideo from "./jitsivideo";
|
||||||
import * as office365calendar from "./office365calendar";
|
import * as office365calendar from "./office365calendar";
|
||||||
import * as office365video from "./office365video";
|
import * as office365video from "./office365video";
|
||||||
|
import * as slackmessaging from "./slackmessaging";
|
||||||
import * as stripepayment from "./stripepayment";
|
import * as stripepayment from "./stripepayment";
|
||||||
import * as tandemvideo from "./tandemvideo";
|
import * as tandemvideo from "./tandemvideo";
|
||||||
import * as zoomvideo from "./zoomvideo";
|
import * as zoomvideo from "./zoomvideo";
|
||||||
|
@ -23,6 +24,7 @@ const appStore = {
|
||||||
jitsivideo,
|
jitsivideo,
|
||||||
office365calendar,
|
office365calendar,
|
||||||
office365video,
|
office365video,
|
||||||
|
slackmessaging,
|
||||||
stripepayment,
|
stripepayment,
|
||||||
tandemvideo,
|
tandemvideo,
|
||||||
zoomvideo,
|
zoomvideo,
|
||||||
|
|
37
packages/app-store/slackmessaging/api/add.ts
Normal file
37
packages/app-store/slackmessaging/api/add.ts
Normal 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" });
|
||||||
|
}
|
51
packages/app-store/slackmessaging/api/callback.ts
Normal file
51
packages/app-store/slackmessaging/api/callback.ts
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
28
packages/app-store/slackmessaging/api/commandHandler.ts
Normal file
28
packages/app-store/slackmessaging/api/commandHandler.ts
Normal 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" });
|
||||||
|
}
|
4
packages/app-store/slackmessaging/api/index.ts
Normal file
4
packages/app-store/slackmessaging/api/index.ts
Normal 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";
|
23
packages/app-store/slackmessaging/api/interactiveHandler.ts
Normal file
23
packages/app-store/slackmessaging/api/interactiveHandler.ts
Normal 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
|
||||||
|
}
|
|
@ -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,
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
1
packages/app-store/slackmessaging/components/index.ts
Normal file
1
packages/app-store/slackmessaging/components/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export { default as InstallAppButton } from "./InstallAppButton";
|
29
packages/app-store/slackmessaging/index.ts
Normal file
29
packages/app-store/slackmessaging/index.ts
Normal 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";
|
|
@ -0,0 +1,9 @@
|
||||||
|
export const WhereCredsEqualsId = (userId: string) => ({
|
||||||
|
where: {
|
||||||
|
type: "slack_app",
|
||||||
|
key: {
|
||||||
|
path: ["authed_user", "id"],
|
||||||
|
equals: userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
142
packages/app-store/slackmessaging/lib/actions/createEvent.ts
Normal file
142
packages/app-store/slackmessaging/lib/actions/createEvent.ts
Normal 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" });
|
||||||
|
});
|
||||||
|
}
|
3
packages/app-store/slackmessaging/lib/index.ts
Normal file
3
packages/app-store/slackmessaging/lib/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
export { default as showCreateEventMessage } from "./showCreateEventMessage";
|
||||||
|
export { default as showTodayMessage } from "./showTodayMessage";
|
||||||
|
export * as utils from "./utils";
|
|
@ -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();
|
||||||
|
}
|
48
packages/app-store/slackmessaging/lib/showLinksMessage.ts
Normal file
48
packages/app-store/slackmessaging/lib/showLinksMessage.ts
Normal 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();
|
||||||
|
}
|
54
packages/app-store/slackmessaging/lib/showTodayMessage.ts
Normal file
54
packages/app-store/slackmessaging/lib/showTodayMessage.ts
Normal 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));
|
||||||
|
}
|
8
packages/app-store/slackmessaging/lib/utils.ts
Normal file
8
packages/app-store/slackmessaging/lib/utils.ts
Normal 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 };
|
|
@ -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;
|
|
@ -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;
|
20
packages/app-store/slackmessaging/lib/views/NoUser.ts
Normal file
20
packages/app-store/slackmessaging/lib/views/NoUser.ts
Normal 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;
|
31
packages/app-store/slackmessaging/lib/views/ShowLinks.ts
Normal file
31
packages/app-store/slackmessaging/lib/views/ShowLinks.ts
Normal 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;
|
27
packages/app-store/slackmessaging/lib/views/TodayMessage.ts
Normal file
27
packages/app-store/slackmessaging/lib/views/TodayMessage.ts
Normal 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;
|
3
packages/app-store/slackmessaging/lib/views/index.ts
Normal file
3
packages/app-store/slackmessaging/lib/views/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
export { default as CreateEventModal } from "./CreateEventModal";
|
||||||
|
export { default as TodayMessage } from "./TodayMessage";
|
||||||
|
export { default as NoUserMessage } from "./NoUser";
|
16
packages/app-store/slackmessaging/package.json
Normal file
16
packages/app-store/slackmessaging/package.json
Normal 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": "*"
|
||||||
|
}
|
||||||
|
}
|
31
packages/app-store/slackmessaging/static/icon.svg
Normal file
31
packages/app-store/slackmessaging/static/icon.svg
Normal 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 |
|
@ -8,6 +8,7 @@ datasource db {
|
||||||
|
|
||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
|
previewFeatures = ["filterJson"]
|
||||||
}
|
}
|
||||||
|
|
||||||
generator zod {
|
generator zod {
|
||||||
|
|
8
packages/types/App.d.ts
vendored
8
packages/types/App.d.ts
vendored
|
@ -13,7 +13,13 @@ export interface App {
|
||||||
* */
|
* */
|
||||||
installed: boolean;
|
installed: boolean;
|
||||||
/** The app type */
|
/** 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 */
|
/** The display name for the app, TODO settle between this or name */
|
||||||
title: string;
|
title: string;
|
||||||
/** The display name for the app */
|
/** The display name for the app */
|
||||||
|
|
Loading…
Reference in a new issue