Skip to content

Commit d536fd8

Browse files
committed
feat(chat): add Sendblue support
1 parent 8953fbd commit d536fd8

15 files changed

Lines changed: 1536 additions & 207 deletions

File tree

apps/chat/.env.example

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,16 @@ TELEGRAM_BOT_USERNAME=CalcomBot
1616
# Optional: self-hosted Telegram API gateway (default: https://api.telegram.org)
1717
# TELEGRAM_API_BASE_URL=
1818

19+
# ─── Sendblue iMessage (optional) ────────────────────────────────────────────
20+
# Enables iMessage inbound/outbound support via https://sendblue.co
21+
# SENDBLUE_API_KEY=your-sendblue-api-key
22+
# SENDBLUE_API_SECRET=your-sendblue-api-secret
23+
# SENDBLUE_FROM_NUMBER=+14155551234
24+
# Optional: webhook secret checked from the sb-signing-secret header
25+
# SENDBLUE_WEBHOOK_SECRET=
26+
# Optional: outbound delivery status callback URL
27+
# SENDBLUE_STATUS_CALLBACK_URL=
28+
1929
# ─── Redis ────────────────────────────────────────────────────────────────────
2030
# Upstash Redis (serverless) or any Redis-compatible URL
2131
# Required for production. Omit for local dev (uses in-memory state adapter).

apps/chat/README.md

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Cal.com Chat Bot
22

3-
A multi-platform chat bot for Cal.com built with [Chat SDK](https://chat-sdk.dev) and Next.js. Supports **Slack** and **Telegram**.
3+
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**.
44

55
## Features
66

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

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

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

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

146165
### Telegram (optional)
147166

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

166185
**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.
167186

187+
### Sendblue iMessage (optional)
188+
189+
To enable Sendblue alongside Slack and Telegram:
190+
191+
1. Create a Sendblue account and register an iMessage-capable sending number
192+
2. Add to `.env`:
193+
- `SENDBLUE_API_KEY`
194+
- `SENDBLUE_API_SECRET`
195+
- `SENDBLUE_FROM_NUMBER`
196+
- `SENDBLUE_WEBHOOK_SECRET` if you configure webhook verification
197+
3. Point Sendblue inbound webhooks to:
198+
```text
199+
https://your-domain.com/api/webhooks/sendblue
200+
```
201+
4. If using webhook verification, configure Sendblue to send the same secret in the `sb-signing-secret` header.
202+
203+
**Services:** The adapter accepts iMessage inbound messages by default. SMS/RCS are intentionally not enabled here.
204+
205+
**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.
206+
168207
### 7. Run locally
169208

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

234+
If you are testing Sendblue locally, point the Sendblue inbound webhook at your tunnel:
235+
236+
```text
237+
https://YOUR_NGROK_URL/api/webhooks/sendblue
238+
```
239+
195240
### 8. Install the app to a workspace
196241

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

225271
> **Region:** `vercel.json` defaults to `iad1` (US East). Change the `regions` field to deploy closer to your users or your Upstash database region.
226272
@@ -248,8 +294,26 @@ After deploy, complete this checklist:
248294
| `/help` | Show help |
249295
| `@mention` | Ask anything in natural language |
250296

297+
### Sendblue
298+
299+
| Command | Description |
300+
| -------------------- | ------------------------------- |
301+
| `/link` | Connect your Cal.com account |
302+
| `/unlink` | Disconnect your Cal.com account |
303+
| `/availability` | Check your availability |
304+
| `/book <username>` | Book a meeting |
305+
| `/bookings` | View upcoming bookings |
306+
| `/cancel` | Cancel a booking |
307+
| `/reschedule` | Reschedule a booking |
308+
| `/eventtypes` | List your event types |
309+
| `/schedules` | Show your working hours |
310+
| `/profile` | Show your linked profile |
311+
| `/help` | Show help |
312+
| Freeform text | Ask anything in natural language |
313+
251314
## Next steps
252315

253316
- [Chat SDK docs](https://chat-sdk.dev/docs) — Cards, Modals, Streaming, Actions
254317
- [Slack adapter](https://chat-sdk.dev/docs/adapters/slack) — Multi-workspace OAuth, token encryption
255318
- [Telegram adapter](https://chat-sdk.dev/docs/adapters/telegram) — Webhook setup, group bots
319+
- [Sendblue adapter](https://github.com/midday-ai/chat-adapter-sendblue) — iMessage adapter used here with SMS/RCS disabled

apps/chat/app/api/auth/calcom/callback/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export async function GET(request: Request) {
4646
if (!payload) {
4747
return redirectWithError(
4848
request,
49-
"Invalid or expired authorization link. Please try /cal link again."
49+
"Invalid or expired authorization link. Please return to your chat app and run the link command again."
5050
);
5151
}
5252

apps/chat/app/api/webhooks/calcom/route.ts

Lines changed: 51 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
1-
import { slackAdapter } from "@/lib/bot";
2-
import { getLogger } from "@/lib/logger";
3-
4-
const logger = getLogger("calcom-webhook");
5-
1+
import { getSendblueAdapter, slackAdapter } from "@/lib/bot";
62
import type { CalcomWebhookMetadata } from "@/lib/calcom/types";
73
import { parseCalcomWebhook, verifyCalcomWebhook } from "@/lib/calcom/webhooks";
4+
import { formatCalcomWebhookForSendblue } from "@/lib/format-for-sendblue";
5+
import { getLogger } from "@/lib/logger";
86
import {
97
bookingCancelledCard,
108
bookingConfirmedCard,
@@ -14,6 +12,8 @@ import {
1412
} from "@/lib/notifications";
1513
import { getLinkedUserByEmail, getWorkspaceNotificationConfig } from "@/lib/user-linking";
1614

15+
const logger = getLogger("calcom-webhook");
16+
1717
export async function POST(request: Request) {
1818
const body = await request.text();
1919
const signature = request.headers.get("X-Cal-Signature-256");
@@ -39,26 +39,40 @@ export async function POST(request: Request) {
3939
const teamId = metadata?.slack_team_id;
4040
const slackUserId = metadata?.slack_user_id;
4141
let telegramChatId = metadata?.telegram_chat_id;
42-
43-
if (!telegramChatId && process.env.TELEGRAM_BOT_TOKEN) {
42+
let sendbluePhone = metadata?.sendblue_phone;
43+
const sendblueThreadId = metadata?.sendblue_thread_id;
44+
const hasExplicitRouting = !!(
45+
teamId ||
46+
slackUserId ||
47+
telegramChatId ||
48+
sendbluePhone ||
49+
sendblueThreadId
50+
);
51+
52+
if (!hasExplicitRouting && (process.env.TELEGRAM_BOT_TOKEN || process.env.SENDBLUE_API_KEY)) {
4453
const linkedByEmail = await getLinkedUserByEmail(webhook.payload.organizer.email);
4554
if (linkedByEmail?.teamId === "telegram") {
4655
telegramChatId = linkedByEmail.userId;
56+
} else if (linkedByEmail?.teamId === "sendblue") {
57+
sendbluePhone = linkedByEmail.userId;
4758
}
4859
}
4960

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

5467
logger.info("Cal.com webhook", {
5568
event: webhook.triggerEvent,
5669
organizerEmail: webhook.payload.organizer.email,
5770
hasSlackTarget,
5871
hasTelegramTarget,
72+
hasSendblueTarget,
5973
});
6074

61-
if (!hasSlackTarget && !hasTelegramTarget) {
75+
if (!hasSlackTarget && !hasTelegramTarget && !hasSendblueTarget) {
6276
logger.info("Cal.com webhook skipped", { reason: "no_target", event: webhook.triggerEvent });
6377
return new Response("OK", { status: 200 });
6478
}
@@ -70,7 +84,8 @@ export async function POST(request: Request) {
7084
return true;
7185
};
7286

73-
if (!shouldNotify(webhook.triggerEvent)) {
87+
const shouldNotifySlack = shouldNotify(webhook.triggerEvent);
88+
if (!shouldNotifySlack && !hasTelegramTarget && !hasSendblueTarget) {
7489
logger.info("Cal.com webhook skipped", {
7590
reason: "workspace_config",
7691
event: webhook.triggerEvent,
@@ -101,7 +116,7 @@ export async function POST(request: Request) {
101116

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

104-
if (hasSlackTarget && teamId) {
119+
if (hasSlackTarget && teamId && shouldNotifySlack) {
105120
const targetChannelId = workspaceConfig?.defaultChannelId ?? null;
106121
// Channel ID format: Slack channel ID (C...) or user ID (U...) for DMs
107122
const channelId = targetChannelId ?? slackUserId ?? "";
@@ -148,5 +163,31 @@ export async function POST(request: Request) {
148163
}
149164
}
150165

166+
if (hasSendblueTarget && sendblueAdapter) {
167+
try {
168+
const threadId =
169+
sendblueThreadId ??
170+
sendblueAdapter.encodeThreadId({
171+
fromNumber: process.env.SENDBLUE_FROM_NUMBER ?? "",
172+
contactNumber: sendbluePhone,
173+
});
174+
const channel = bot.channel(threadId);
175+
await channel.post(formatCalcomWebhookForSendblue(webhook));
176+
logger.info("Cal.com notification sent", {
177+
target: "sendblue",
178+
threadId,
179+
event: webhook.triggerEvent,
180+
});
181+
} catch (err) {
182+
logger.error("Cal.com notification failed", {
183+
err,
184+
target: "sendblue",
185+
phone: sendbluePhone,
186+
threadId: sendblueThreadId,
187+
event: webhook.triggerEvent,
188+
});
189+
}
190+
}
191+
151192
return new Response("OK", { status: 200 });
152193
}

apps/chat/app/auth/calcom/complete/page.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ function CompletePage() {
1313

1414
const isSlack = platform === "slack";
1515
const isTelegram = platform === "telegram";
16+
const isSendblue = platform === "sendblue";
1617
const telegramBot = searchParams.get("telegram_bot");
1718
const slackWebUrl = `https://app.slack.com/client/${teamId}`;
1819
const tmeFallback = telegramBot ? `https://t.me/${telegramBot}?start=link_success` : "";
@@ -71,7 +72,10 @@ function CompletePage() {
7172
)}
7273
</>
7374
)}
74-
{success && !isSlack && !(isTelegram && telegramBot) && (
75+
{success && isSendblue && (
76+
<p style={styles.hint}>You can close this tab and return to Messages.</p>
77+
)}
78+
{success && !isSlack && !isSendblue && !(isTelegram && telegramBot) && (
7579
<p style={styles.hint}>
7680
You can close this tab and return to{" "}
7781
{platform.charAt(0).toUpperCase() + platform.slice(1)}.
@@ -87,7 +91,12 @@ function CompletePage() {
8791
Go back to Telegram and send <code style={styles.code}>/link</code> to try again.
8892
</p>
8993
)}
90-
{!success && !isSlack && !isTelegram && (
94+
{!success && isSendblue && (
95+
<p style={styles.hint}>
96+
Go back to Messages and send <code style={styles.code}>/link</code> to try again.
97+
</p>
98+
)}
99+
{!success && !isSlack && !isTelegram && !isSendblue && (
91100
<p style={styles.hint}>
92101
{platform
93102
? `Go back to ${platform.charAt(0).toUpperCase() + platform.slice(1)} and use the link command to try again.`

0 commit comments

Comments
 (0)