Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions apps/chat/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,16 @@ TELEGRAM_BOT_USERNAME=CalcomBot
# Optional: self-hosted Telegram API gateway (default: https://api.telegram.org)
# TELEGRAM_API_BASE_URL=

# ─── Sendblue iMessage (optional) ────────────────────────────────────────────
# Enables iMessage inbound/outbound support via https://sendblue.co
# SENDBLUE_API_KEY=your-sendblue-api-key
# SENDBLUE_API_SECRET=your-sendblue-api-secret
# SENDBLUE_FROM_NUMBER=+14155551234
# Optional: webhook secret checked from the sb-signing-secret header
# SENDBLUE_WEBHOOK_SECRET=
# Optional: outbound delivery status callback URL
# SENDBLUE_STATUS_CALLBACK_URL=

# ─── Redis ────────────────────────────────────────────────────────────────────
# Upstash Redis (serverless) or any Redis-compatible URL
# Required for production. Omit for local dev (uses in-memory state adapter).
Expand Down
70 changes: 67 additions & 3 deletions apps/chat/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Cal.com Chat Bot

A multi-platform chat bot for Cal.com built with [Chat SDK](https://chat-sdk.dev) and Next.js. Supports **Slack** and **Telegram**.
A multi-platform chat bot for Cal.com built with [Chat SDK](https://chat-sdk.dev) and Next.js. Supports **Slack**, **Telegram**, and **Sendblue iMessage**.

## Features

Expand All @@ -19,12 +19,20 @@ A multi-platform chat bot for Cal.com built with [Chat SDK](https://chat-sdk.dev
- **`/unlink`** — disconnect your Cal.com account
- **@mention** — ask anything in natural language (AI-powered)

### Sendblue iMessage
- **`/bookings`** — view upcoming bookings
- **`/availability`** — check your availability
- **`/book <username>`** — book a public Cal.com event with numbered text prompts
- **`/cancel` / `/reschedule`** — manage upcoming bookings with text confirmations
- **`/link`** — connect your Cal.com account via OAuth
- **Freeform message** — ask anything in natural language (AI-powered)

## Architecture

```
app/
api/
webhooks/[platform]/route.ts # Chat SDK webhook handler (Slack + Telegram events)
webhooks/[platform]/route.ts # Chat SDK webhook handler (Slack + Telegram + Sendblue events)
webhooks/calcom/route.ts # Cal.com webhook receiver (booking notifications)
auth/slack/callback/route.ts # Slack OAuth callback (workspace install)
auth/calcom/callback/route.ts # Cal.com OAuth callback (user account linking)
Expand All @@ -37,6 +45,7 @@ lib/
notifications.ts # Booking notification card builders
user-linking.ts # Redis: platform user <-> Cal.com account linking + token refresh
format-for-telegram.ts # Converts markdown/cards to Telegram-safe HTML
format-for-sendblue.ts # Plain-text formatting for iMessage/SMS
redis.ts # Redis client (Upstash / ioredis)
logger.ts # Structured logger
env.ts # Startup environment variable validation
Expand All @@ -48,6 +57,7 @@ lib/
handlers/
slack.ts # Slack-specific slash command + action handlers
telegram.ts # Telegram-specific slash command handlers
sendblue.ts # Sendblue text command + numbered flow handlers
slack-manifest.yml # Slack app manifest template
vercel.json # Vercel deployment config (region: iad1)
```
Expand All @@ -57,6 +67,7 @@ vercel.json # Vercel deployment config (region: iad1)
- Node.js 20.9+ / Bun
- A Slack workspace (for Slack bot)
- A Telegram account and BotFather access (for Telegram bot, optional)
- A Sendblue account and registered iMessage-enabled phone number (for Sendblue, optional)
- A Redis instance — [Upstash](https://upstash.com) recommended for Vercel (serverless-compatible)
- A Cal.com account with OAuth client access

Expand Down Expand Up @@ -113,6 +124,11 @@ cp .env.example .env
| `TELEGRAM_BOT_USERNAME` | — | Your bot's username (e.g. `CalcomBot`) — required when `TELEGRAM_BOT_TOKEN` is set |
| `TELEGRAM_WEBHOOK_SECRET_TOKEN` | — | Optional secret to verify incoming Telegram webhook requests |
| `TELEGRAM_API_BASE_URL` | — | Override Telegram API gateway (default: `https://api.telegram.org`) |
| `SENDBLUE_API_KEY` | — | Sendblue API key — required to enable Sendblue iMessage |
| `SENDBLUE_API_SECRET` | — | Sendblue API secret — required when `SENDBLUE_API_KEY` is set |
| `SENDBLUE_FROM_NUMBER` | — | Registered Sendblue phone number in E.164 format |
| `SENDBLUE_WEBHOOK_SECRET` | — | Optional secret checked from the `sb-signing-secret` webhook header |
| `SENDBLUE_STATUS_CALLBACK_URL` | — | Optional outbound delivery status callback URL |
| `REDIS_KEY_PREFIX` | — | Key prefix for Chat SDK state (default: `chat-sdk`). Changing this requires reinstalling the Slack app |
| `REDIS_USE_IOREDIS` | — | Set `true` to use ioredis adapter (Redis Cluster / Sentinel support) |
| `LOG_LEVEL` | — | `debug` \| `info` \| `warn` \| `error` \| `silent` (default: `info` in prod, `debug` in dev) |
Expand Down Expand Up @@ -141,7 +157,10 @@ docker run -p 6379:6379 redis
2. Create a new webhook pointing to `https://your-domain.com/api/webhooks/calcom`
3. Enable events: `BOOKING_CREATED`, `BOOKING_RESCHEDULED`, `BOOKING_CANCELLED`, `BOOKING_CONFIRMED`
4. Add a signing secret and set it as `CALCOM_WEBHOOK_SECRET`
5. (Optional) In the webhook metadata, include `slack_team_id` and `slack_user_id` to route notifications to specific users
5. (Optional) In the webhook metadata, include routing fields:
- Slack: `slack_team_id` and `slack_user_id`
- Telegram: `telegram_chat_id`
- Sendblue: `sendblue_phone` or `sendblue_thread_id`

### Telegram (optional)

Expand All @@ -165,6 +184,26 @@ To enable the Telegram bot alongside Slack:

**Limitations:** Streaming uses post+edit fallback (no native streaming). Modals are not supported. Button callback data is limited to 64 bytes — keep action IDs short.

### Sendblue iMessage (optional)

To enable Sendblue alongside Slack and Telegram:

1. Create a Sendblue account and register an iMessage-capable sending number
2. Add to `.env`:
- `SENDBLUE_API_KEY`
- `SENDBLUE_API_SECRET`
- `SENDBLUE_FROM_NUMBER`
- `SENDBLUE_WEBHOOK_SECRET` if you configure webhook verification
3. Point Sendblue inbound webhooks to:
```text
https://your-domain.com/api/webhooks/sendblue
```
4. If using webhook verification, configure Sendblue to send the same secret in the `sb-signing-secret` header.

**Services:** The adapter accepts iMessage inbound messages by default. SMS/RCS are intentionally not enabled here.

**Limitations:** Sendblue/iMessage has no message editing, modals, dropdowns, or buttons. The bot uses numbered text prompts for booking, cancellation, and rescheduling flows. Group iMessage threads are intentionally ignored.

### 7. Run locally

```bash
Expand Down Expand Up @@ -192,6 +231,12 @@ Remember to restore the production webhook URL (including the secret token if yo
curl "https://api.telegram.org/bot<YOUR_BOT_TOKEN>/setWebhook?url=https://your-production-domain.com/api/webhooks/telegram&secret_token=<YOUR_SECRET>"
```

If you are testing Sendblue locally, point the Sendblue inbound webhook at your tunnel:

```text
https://YOUR_NGROK_URL/api/webhooks/sendblue
```

### 8. Install the app to a workspace

Visit `http://localhost:3000` and click **Add to Slack**.
Expand Down Expand Up @@ -221,6 +266,7 @@ After deploy, complete this checklist:
3. **Update Cal.com OAuth** — Set redirect URI to `https://your-vercel-url.vercel.app/api/auth/calcom/callback`
4. **Update Cal.com webhook** — Set webhook URL to `https://your-vercel-url.vercel.app/api/webhooks/calcom`
5. **Update Telegram webhook** (if enabled) — Point to `https://your-vercel-url.vercel.app/api/webhooks/telegram`
6. **Update Sendblue webhook** (if enabled) — Point to `https://your-vercel-url.vercel.app/api/webhooks/sendblue`

> **Region:** `vercel.json` defaults to `iad1` (US East). Change the `regions` field to deploy closer to your users or your Upstash database region.

Expand Down Expand Up @@ -248,8 +294,26 @@ After deploy, complete this checklist:
| `/help` | Show help |
| `@mention` | Ask anything in natural language |

### Sendblue

| Command | Description |
| -------------------- | ------------------------------- |
| `/link` | Connect your Cal.com account |
| `/unlink` | Disconnect your Cal.com account |
| `/availability` | Check your availability |
| `/book <username>` | Book a meeting |
| `/bookings` | View upcoming bookings |
| `/cancel` | Cancel a booking |
| `/reschedule` | Reschedule a booking |
| `/eventtypes` | List your event types |
| `/schedules` | Show your working hours |
| `/profile` | Show your linked profile |
| `/help` | Show help |
| Freeform text | Ask anything in natural language |

## Next steps

- [Chat SDK docs](https://chat-sdk.dev/docs) — Cards, Modals, Streaming, Actions
- [Slack adapter](https://chat-sdk.dev/docs/adapters/slack) — Multi-workspace OAuth, token encryption
- [Telegram adapter](https://chat-sdk.dev/docs/adapters/telegram) — Webhook setup, group bots
- [Sendblue adapter](https://github.com/midday-ai/chat-adapter-sendblue) — iMessage adapter used here with SMS/RCS disabled
2 changes: 1 addition & 1 deletion apps/chat/app/api/auth/calcom/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export async function GET(request: Request) {
if (!payload) {
return redirectWithError(
request,
"Invalid or expired authorization link. Please try /cal link again."
"Invalid or expired authorization link. Please return to your chat app and run the link command again."
);
}

Expand Down
67 changes: 56 additions & 11 deletions apps/chat/app/api/webhooks/calcom/route.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import { slackAdapter } from "@/lib/bot";
import { getLogger } from "@/lib/logger";

const logger = getLogger("calcom-webhook");

import { getSendblueAdapter, slackAdapter } from "@/lib/bot";
import type { CalcomWebhookMetadata } from "@/lib/calcom/types";
import { parseCalcomWebhook, verifyCalcomWebhook } from "@/lib/calcom/webhooks";
import { formatCalcomWebhookForSendblue } from "@/lib/format-for-sendblue";
import { getLogger } from "@/lib/logger";
import {
bookingCancelledCard,
bookingConfirmedCard,
Expand All @@ -14,6 +12,8 @@ import {
} from "@/lib/notifications";
import { getLinkedUserByEmail, getWorkspaceNotificationConfig } from "@/lib/user-linking";

const logger = getLogger("calcom-webhook");

export async function POST(request: Request) {
const body = await request.text();
const signature = request.headers.get("X-Cal-Signature-256");
Expand All @@ -39,26 +39,44 @@ export async function POST(request: Request) {
const teamId = metadata?.slack_team_id;
const slackUserId = metadata?.slack_user_id;
let telegramChatId = metadata?.telegram_chat_id;

if (!telegramChatId && process.env.TELEGRAM_BOT_TOKEN) {
let sendbluePhone = metadata?.sendblue_phone;
const sendblueThreadId = metadata?.sendblue_thread_id;
const hasExplicitRouting = !!(
teamId ||
slackUserId ||
telegramChatId ||
sendbluePhone ||
sendblueThreadId
);
const needsLinkedUserRouting =
!hasExplicitRouting &&
((!telegramChatId && !!process.env.TELEGRAM_BOT_TOKEN) ||
(!sendbluePhone && !sendblueThreadId && !!process.env.SENDBLUE_API_KEY));

if (needsLinkedUserRouting) {
const linkedByEmail = await getLinkedUserByEmail(webhook.payload.organizer.email);
if (linkedByEmail?.teamId === "telegram") {
if (!telegramChatId && linkedByEmail?.teamId === "telegram") {
telegramChatId = linkedByEmail.userId;
} else if (!sendbluePhone && !sendblueThreadId && linkedByEmail?.teamId === "sendblue") {
sendbluePhone = linkedByEmail.userId;
}
}

const sendblueAdapter = getSendblueAdapter();
const workspaceConfig = teamId ? await getWorkspaceNotificationConfig(teamId) : null;
const hasSlackTarget = !!(teamId && (slackUserId || workspaceConfig?.defaultChannelId));
const hasTelegramTarget = !!(telegramChatId && process.env.TELEGRAM_BOT_TOKEN);
const hasSendblueTarget = !!(sendblueAdapter && (sendbluePhone || sendblueThreadId));

logger.info("Cal.com webhook", {
event: webhook.triggerEvent,
organizerEmail: webhook.payload.organizer.email,
hasSlackTarget,
hasTelegramTarget,
hasSendblueTarget,
});

if (!hasSlackTarget && !hasTelegramTarget) {
if (!hasSlackTarget && !hasTelegramTarget && !hasSendblueTarget) {
logger.info("Cal.com webhook skipped", { reason: "no_target", event: webhook.triggerEvent });
return new Response("OK", { status: 200 });
}
Expand All @@ -70,7 +88,8 @@ export async function POST(request: Request) {
return true;
};

if (!shouldNotify(webhook.triggerEvent)) {
const shouldNotifySlack = shouldNotify(webhook.triggerEvent);
if (!shouldNotifySlack && !hasTelegramTarget && !hasSendblueTarget) {
logger.info("Cal.com webhook skipped", {
reason: "workspace_config",
event: webhook.triggerEvent,
Expand Down Expand Up @@ -101,7 +120,7 @@ export async function POST(request: Request) {

const { bot } = await import("@/lib/bot");

if (hasSlackTarget && teamId) {
if (hasSlackTarget && teamId && shouldNotifySlack) {
const targetChannelId = workspaceConfig?.defaultChannelId ?? null;
// Channel ID format: Slack channel ID (C...) or user ID (U...) for DMs
const channelId = targetChannelId ?? slackUserId ?? "";
Expand Down Expand Up @@ -148,5 +167,31 @@ export async function POST(request: Request) {
}
}

if (hasSendblueTarget && sendblueAdapter) {
try {
const threadId =
sendblueThreadId ||
sendblueAdapter.encodeThreadId({
fromNumber: process.env.SENDBLUE_FROM_NUMBER ?? "",
contactNumber: sendbluePhone,
});
Comment thread
dhairyashiil marked this conversation as resolved.
const channel = bot.channel(threadId);
await channel.post(formatCalcomWebhookForSendblue(webhook));
logger.info("Cal.com notification sent", {
target: "sendblue",
threadId,
event: webhook.triggerEvent,
});
} catch (err) {
logger.error("Cal.com notification failed", {
err,
target: "sendblue",
phone: sendbluePhone,
threadId: sendblueThreadId,
event: webhook.triggerEvent,
});
}
}

return new Response("OK", { status: 200 });
}
13 changes: 11 additions & 2 deletions apps/chat/app/auth/calcom/complete/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ function CompletePage() {

const isSlack = platform === "slack";
const isTelegram = platform === "telegram";
const isSendblue = platform === "sendblue";
const telegramBot = searchParams.get("telegram_bot");
const slackWebUrl = `https://app.slack.com/client/${teamId}`;
const tmeFallback = telegramBot ? `https://t.me/${telegramBot}?start=link_success` : "";
Expand Down Expand Up @@ -71,7 +72,10 @@ function CompletePage() {
)}
</>
)}
{success && !isSlack && !(isTelegram && telegramBot) && (
{success && isSendblue && (
<p style={styles.hint}>You can close this tab and return to Messages.</p>
)}
{success && !isSlack && !isSendblue && !(isTelegram && telegramBot) && (
<p style={styles.hint}>
You can close this tab and return to{" "}
{platform.charAt(0).toUpperCase() + platform.slice(1)}.
Expand All @@ -87,7 +91,12 @@ function CompletePage() {
Go back to Telegram and send <code style={styles.code}>/link</code> to try again.
</p>
)}
{!success && !isSlack && !isTelegram && (
{!success && isSendblue && (
<p style={styles.hint}>
Go back to Messages and send <code style={styles.code}>/link</code> to try again.
</p>
)}
{!success && !isSlack && !isTelegram && !isSendblue && (
<p style={styles.hint}>
{platform
? `Go back to ${platform.charAt(0).toUpperCase() + platform.slice(1)} and use the link command to try again.`
Expand Down
Loading
Loading