diff --git a/apps/chat/.env.example b/apps/chat/.env.example index 4de42e9e..7512830d 100644 --- a/apps/chat/.env.example +++ b/apps/chat/.env.example @@ -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). diff --git a/apps/chat/README.md b/apps/chat/README.md index d20c2514..5ad08b1d 100644 --- a/apps/chat/README.md +++ b/apps/chat/README.md @@ -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 @@ -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 `** — 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) @@ -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 @@ -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) ``` @@ -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 @@ -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) | @@ -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) @@ -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 @@ -192,6 +231,12 @@ Remember to restore the production webhook URL (including the secret token if yo curl "https://api.telegram.org/bot/setWebhook?url=https://your-production-domain.com/api/webhooks/telegram&secret_token=" ``` +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**. @@ -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. @@ -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 ` | 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 diff --git a/apps/chat/app/api/auth/calcom/callback/route.ts b/apps/chat/app/api/auth/calcom/callback/route.ts index ff74e15c..07ca26b9 100644 --- a/apps/chat/app/api/auth/calcom/callback/route.ts +++ b/apps/chat/app/api/auth/calcom/callback/route.ts @@ -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." ); } diff --git a/apps/chat/app/api/webhooks/calcom/route.ts b/apps/chat/app/api/webhooks/calcom/route.ts index 7dc49633..d703f1dc 100644 --- a/apps/chat/app/api/webhooks/calcom/route.ts +++ b/apps/chat/app/api/webhooks/calcom/route.ts @@ -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, @@ -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"); @@ -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 }); } @@ -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, @@ -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 ?? ""; @@ -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, + }); + 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 }); } diff --git a/apps/chat/app/auth/calcom/complete/page.tsx b/apps/chat/app/auth/calcom/complete/page.tsx index b2f7959e..22a3078d 100644 --- a/apps/chat/app/auth/calcom/complete/page.tsx +++ b/apps/chat/app/auth/calcom/complete/page.tsx @@ -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` : ""; @@ -71,7 +72,10 @@ function CompletePage() { )} )} - {success && !isSlack && !(isTelegram && telegramBot) && ( + {success && isSendblue && ( +

You can close this tab and return to Messages.

+ )} + {success && !isSlack && !isSendblue && !(isTelegram && telegramBot) && (

You can close this tab and return to{" "} {platform.charAt(0).toUpperCase() + platform.slice(1)}. @@ -87,7 +91,12 @@ function CompletePage() { Go back to Telegram and send /link to try again.

)} - {!success && !isSlack && !isTelegram && ( + {!success && isSendblue && ( +

+ Go back to Messages and send /link to try again. +

+ )} + {!success && !isSlack && !isTelegram && !isSendblue && (

{platform ? `Go back to ${platform.charAt(0).toUpperCase() + platform.slice(1)} and use the link command to try again.` diff --git a/apps/chat/lib/bot.ts b/apps/chat/lib/bot.ts index 8fcc635b..1a2f1e2b 100644 --- a/apps/chat/lib/bot.ts +++ b/apps/chat/lib/bot.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { AdapterRateLimitError, AuthenticationError, @@ -12,7 +13,7 @@ import { createMemoryState } from "@chat-adapter/state-memory"; import { createRedisState } from "@chat-adapter/state-redis"; import { createTelegramAdapter, type TelegramAdapter } from "@chat-adapter/telegram"; import type { ModelMessage } from "ai"; -import type { Thread } from "chat"; +import type { Adapter, Thread } from "chat"; import { Actions, Button, @@ -25,18 +26,34 @@ import { NotImplementedError, RateLimitError, } from "chat"; -import { createHash } from "node:crypto"; +import { createSendblueAdapter, type SendblueAdapter } from "chat-adapter-sendblue"; import type { LookupPlatformUserFn, UserContext } from "./agent"; import { isAIRateLimitError, isAIToolCallError, runAgentStream } from "./agent"; -import { CalcomApiError, checkCredits, chargeCredits } from "./calcom/client"; +import { CalcomApiError, chargeCredits, checkCredits } from "./calcom/client"; import { generateAuthUrl } from "./calcom/oauth"; import { validateRequiredEnv } from "./env"; +import { truncateForSendblue } from "./format-for-sendblue"; import { formatForTelegram } from "./format-for-telegram"; +import { + handleSendblueCommand, + handleSendblueFlowInput, + registerSendblueHandlers, +} from "./handlers/sendblue"; import { registerSlackHandlers } from "./handlers/slack"; -import { handleTelegramCommand, registerTelegramHandlers, TELEGRAM_COMMAND_RE } from "./handlers/telegram"; +import { + handleTelegramCommand, + registerTelegramHandlers, + TELEGRAM_COMMAND_RE, +} from "./handlers/telegram"; import { logger as botLogger } from "./logger"; import { helpCard, telegramHelpCard } from "./notifications"; -import { getLinkedUser, getValidAccessToken, getToolContext, setToolContext, type ToolContextEntry } from "./user-linking"; +import { + getLinkedUser, + getToolContext, + getValidAccessToken, + setToolContext, + type ToolContextEntry, +} from "./user-linking"; validateRequiredEnv(); @@ -46,6 +63,14 @@ const MAX_TOOL_CONTEXT_ENTRIES = 10; const STREAMING_UPDATE_INTERVAL_MS = 400; const TELEGRAM_TYPING_REFRESH_MS = 4000; +function isSendblueConfigured(): boolean { + return !!( + process.env.SENDBLUE_API_KEY && + process.env.SENDBLUE_API_SECRET && + process.env.SENDBLUE_FROM_NUMBER + ); +} + // ─── Slack user lookup via users.info API ──────────────────────────────────── function makeLookupSlackUser(teamId: string): LookupPlatformUserFn { @@ -118,6 +143,24 @@ if (!globalForBot._chatBot) { }), }), }), + ...(isSendblueConfigured() && { + // Published chat-adapter-sendblue@0.2.0 bundles newer Chat SDK types. + // Keep that version skew contained at the adapter boundary until upstream + // ships a peer-only Chat SDK dependency. + sendblue: createSendblueAdapter({ + apiKey: process.env.SENDBLUE_API_KEY ?? "", + apiSecret: process.env.SENDBLUE_API_SECRET ?? "", + defaultFromNumber: process.env.SENDBLUE_FROM_NUMBER ?? "", + logger: botLogger, + ...(process.env.SENDBLUE_WEBHOOK_SECRET && { + webhookSecret: process.env.SENDBLUE_WEBHOOK_SECRET, + webhookSecretHeader: "sb-signing-secret", + }), + ...(process.env.SENDBLUE_STATUS_CALLBACK_URL && { + statusCallbackUrl: process.env.SENDBLUE_STATUS_CALLBACK_URL, + }), + }) as unknown as Adapter, + }), } as const; const logger = botLogger; @@ -276,7 +319,10 @@ function reconstructSlackMentions(raw: unknown): string | null { const parts: string[] = []; for (const block of blocks) { if (block.type !== "rich_text") continue; - const richBlock = block as { type: "rich_text"; elements: Array<{ type: string; elements?: SlackRichTextElement[] }> }; + const richBlock = block as { + type: "rich_text"; + elements: Array<{ type: string; elements?: SlackRichTextElement[] }>; + }; for (const section of richBlock.elements) { if (!Array.isArray(section.elements)) continue; for (const el of section.elements) { @@ -308,7 +354,10 @@ async function preResolveMentions( userMessage: string, teamId: string, logger: ReturnType -): Promise<{ text: string; attendees: Array<{ uid: string; name: string; email: string | null }> }> { +): Promise<{ + text: string; + attendees: Array<{ uid: string; name: string; email: string | null }>; +}> { const mentionPattern = /<@([A-Z0-9]+)>/g; const matches = [...userMessage.matchAll(mentionPattern)]; if (matches.length === 0) return { text: userMessage, attendees: [] }; @@ -402,10 +451,7 @@ async function persistBookingContext( } // ─── Helper: build enriched user message with cached tool data ─────────────── -async function buildEnrichedMessage( - userMessage: string, - threadId: string -): Promise { +async function buildEnrichedMessage(userMessage: string, threadId: string): Promise { const entries = await getToolContext(threadId); if (entries.length === 0) return userMessage; @@ -434,9 +480,8 @@ function isSlackAuthError(err: unknown): boolean { } function friendlyCalcomError(err: CalcomApiError, context?: string): string { - const apiDetail = err.message && !err.message.startsWith("Cal.com API error:") - ? ` (${err.message})` - : ""; + const apiDetail = + err.message && !err.message.startsWith("Cal.com API error:") ? ` (${err.message})` : ""; switch (err.statusCode) { case 400: @@ -464,9 +509,9 @@ function friendlyCalcomError(err: CalcomApiError, context?: string): string { } } -function agentStreamErrorMessage( - lastStreamErrorRef: { current: Error | null } -): (err: unknown) => string | undefined { +function agentStreamErrorMessage(lastStreamErrorRef: { + current: Error | null; +}): (err: unknown) => string | undefined { return (err) => { if (!lastStreamErrorRef.current) return undefined; if (isAIRateLimitError(lastStreamErrorRef.current)) @@ -580,9 +625,7 @@ async function withBotErrorHandling( // callers like the booking modal can provide context-aware messages first) if (err instanceof CalcomApiError) { botLogger.warn("Cal.com API error", { statusCode: err.statusCode, message: err.message }); - await options - .postError(friendlyCalcomError(err)) - .catch(() => {}); + await options.postError(friendlyCalcomError(err)).catch(() => {}); return; } if (isSlackAuthError(err)) { @@ -595,21 +638,23 @@ async function withBotErrorHandling( return; } botLogger.error(options.logContext ? `Error in ${options.logContext}` : "Error", err); - await options - .postError("Sorry, something went wrong. Please try again.") - .catch((postErr) => { - botLogger.error("Failed to post error message to user", { postErr, originalErr: err }); - }); + await options.postError("Sorry, something went wrong. Please try again.").catch((postErr) => { + botLogger.error("Failed to post error message to user", { postErr, originalErr: err }); + }); } } // ─── Helper: post OAuth link prompt ───────────────────────────────────────── -function oauthLinkMessage(platform: string, teamId: string, userId: string, reason?: "expired" | "not_linked") { +function oauthLinkMessage( + platform: string, + teamId: string, + userId: string, + reason?: "expired" | "not_linked" +) { const authUrl = generateAuthUrl(platform, teamId, userId); - const title = reason === "expired" - ? "Your Cal.com Session Has Expired" - : "Connect Your Cal.com Account"; + const title = + reason === "expired" ? "Your Cal.com Session Has Expired" : "Connect Your Cal.com Account"; return Card({ title, children: [ @@ -638,6 +683,13 @@ async function promptUserToLink( } else { await thread.post(card); } + } else if (ctx.platform === "sendblue") { + const authUrl = generateAuthUrl(ctx.platform, ctx.teamId, ctx.userId); + const title = + reason === "expired" + ? "Your Cal.com session has expired. Reconnect here:" + : "Connect your Cal.com account here:"; + await thread.post(`${title}\n${authUrl}`); } else { // Ephemeral messages are unreliable in Slack assistant threads, so post as a // regular thread message. The OAuth URL is user-specific and safe to show. @@ -656,6 +708,22 @@ export function getTelegramAdapter(): TelegramAdapter | null { return adapter ? (adapter as TelegramAdapter) : null; } +export function getSendblueAdapter(): SendblueAdapter | null { + if (!isSendblueConfigured()) return null; + const adapter = bot.getAdapter("sendblue"); + return adapter ? (adapter as SendblueAdapter) : null; +} + +function isSendblueGroupThread(threadId: string): boolean { + const adapter = getSendblueAdapter(); + if (!adapter) return false; + try { + return !!adapter.decodeThreadId(threadId).groupId; + } catch { + return false; + } +} + /** Telegram typing indicator lasts ~5s. Refresh it every 4s during long agent runs. */ async function withTelegramTypingRefresh( thread: Thread, @@ -665,7 +733,10 @@ async function withTelegramTypingRefresh( await thread.startTyping(); if (platform !== "telegram") return fn(); return (async () => { - const interval = setInterval(() => thread.startTyping().catch(() => {}), TELEGRAM_TYPING_REFRESH_MS); + const interval = setInterval( + () => thread.startTyping().catch(() => {}), + TELEGRAM_TYPING_REFRESH_MS + ); try { await fn(); } finally { @@ -774,13 +845,29 @@ async function postAgentStream( Card({ children: [ CardText(formatted), - ...(showRetry - ? [Actions([Button({ id: "retry_response", label: "Retry" })])] - : []), + ...(showRetry ? [Actions([Button({ id: "retry_response", label: "Retry" })])] : []), ], }) ); } + } else if (ctx.platform === "sendblue") { + const fullText = await agentResult.text; + const steps = agentResult.steps ? await agentResult.steps : undefined; + const showRetry = shouldShowRetry(options?.onErrorRef, steps); + const formatted = truncateForSendblue((fullText ?? "").trim()); + if (!formatted) { + const fallbackMsg = + options?.onErrorRef?.current && isAIToolCallError(options.onErrorRef.current) + ? "I had trouble processing that request. Please try again, or be more specific." + : "Sorry, I couldn't generate a response. Please try again."; + await thread.post(`${fallbackMsg}\n\nSend the request again to retry.`); + } else { + await thread.post( + showRetry + ? `${formatted}\n\nIf this looks wrong, send the request again to retry.` + : formatted + ); + } } else { await thread.post(agentResult.textStream as unknown as string); } @@ -838,9 +925,7 @@ async function postAgentStream( log.warn("Slack auth error is secondary to stream error, re-throwing as generic", { streamError: options.onErrorRef.current.message, }); - throw new Error( - `Agent stream failed: ${options.onErrorRef.current.message}` - ); + throw new Error(`Agent stream failed: ${options.onErrorRef.current.message}`); } throw err; } @@ -864,7 +949,11 @@ async function runAgentHandler(opts: AgentHandlerOptions): Promise { const log = bot.getLogger(opts.loggerName); let linked = await getLinkedUser(opts.ctx.teamId, opts.ctx.userId); - log.info("User link check", { userId: opts.ctx.userId, teamId: opts.ctx.teamId, linked: !!linked }); + log.info("User link check", { + userId: opts.ctx.userId, + teamId: opts.ctx.teamId, + linked: !!linked, + }); if (!linked) { await opts.promptIfNotLinked(); return; @@ -878,15 +967,18 @@ async function runAgentHandler(opts: AgentHandlerOptions): Promise { } // Re-read in case token refresh synced org fields from /v2/me - linked = await getLinkedUser(opts.ctx.teamId, opts.ctx.userId) ?? linked; + linked = (await getLinkedUser(opts.ctx.teamId, opts.ctx.userId)) ?? linked; try { const credits = await checkCredits(token); if (!credits.hasCredits) { log.info("Agentic blocked — no credits", { userId: opts.ctx.userId }); - const noCreditsMsg = opts.ctx.platform === "slack" - ? "You've run out of AI credits. Use `/cal help` to see available slash commands, or purchase more credits at ." - : "You've run out of AI credits. Use /help to see available commands, or purchase more credits at [cal.com/pricing](https://cal.com/pricing)."; + const noCreditsMsg = + opts.ctx.platform === "slack" + ? "You've run out of AI credits. Use `/cal help` to see available slash commands, or purchase more credits at ." + : opts.ctx.platform === "sendblue" + ? "You've run out of AI credits. Send /help to see available commands, or purchase more credits at https://cal.com/pricing." + : "You've run out of AI credits. Use /help to see available commands, or purchase more credits at [cal.com/pricing](https://cal.com/pricing)."; if (opts.ctx.platform === "slack") { await withSlackToken(opts.ctx.teamId, () => opts.thread.post(noCreditsMsg)); } else { @@ -897,7 +989,10 @@ async function runAgentHandler(opts: AgentHandlerOptions): Promise { } catch (err) { // If credits endpoint is unavailable, allow the request through // so existing org-plan users aren't blocked during rollout. - log.warn("Credits check failed, allowing request", { userId: opts.ctx.userId, error: String(err) }); + log.warn("Credits check failed, allowing request", { + userId: opts.ctx.userId, + error: String(err), + }); } if (await opts.shouldSubscribe()) { @@ -910,9 +1005,7 @@ async function runAgentHandler(opts: AgentHandlerOptions): Promise { let resolvedMessage = opts.userMessage; if (opts.resolveMentions && opts.ctx.platform === "slack") { - const { text, attendees } = await preResolveMentions( - opts.userMessage, opts.ctx.teamId, log - ); + const { text, attendees } = await preResolveMentions(opts.userMessage, opts.ctx.teamId, log); resolvedMessage = text; if (opts.persistContext) { await persistBookingContext(opts.thread.id, opts.userMessage, attendees); @@ -947,15 +1040,23 @@ async function runAgentHandler(opts: AgentHandlerOptions): Promise { const externalRef = `agent-${opts.ctx.platform}-${opts.thread.id}-${msgHash}`; try { const chargeResult = await chargeCredits(token, { externalRef }); - log.info("Credits charged", { userId: opts.ctx.userId, externalRef, remaining: chargeResult.remainingBalance }); + log.info("Credits charged", { + userId: opts.ctx.userId, + externalRef, + remaining: chargeResult.remainingBalance, + }); // Warn if balance is running low if (chargeResult.remainingBalance) { - const remaining = chargeResult.remainingBalance.monthlyRemaining + chargeResult.remainingBalance.additional; + const remaining = + chargeResult.remainingBalance.monthlyRemaining + chargeResult.remainingBalance.additional; if (remaining < 10) { - const lowBalanceMsg = opts.ctx.platform === "slack" - ? `_Your AI credits are running low (${remaining} remaining). Visit to purchase more._` - : `_Your AI credits are running low (${remaining} remaining). Visit [cal.com/settings/billing](https://cal.com/settings/billing) to purchase more._`; + const lowBalanceMsg = + opts.ctx.platform === "slack" + ? `_Your AI credits are running low (${remaining} remaining). Visit to purchase more._` + : opts.ctx.platform === "sendblue" + ? `Your AI credits are running low (${remaining} remaining). Visit https://cal.com/settings/billing to purchase more.` + : `_Your AI credits are running low (${remaining} remaining). Visit [cal.com/settings/billing](https://cal.com/settings/billing) to purchase more._`; if (opts.ctx.platform === "slack") { await withSlackToken(opts.ctx.teamId, () => opts.thread.post(lowBalanceMsg)); } else { @@ -965,7 +1066,11 @@ async function runAgentHandler(opts: AgentHandlerOptions): Promise { } } catch (err) { // Don't fail the interaction if charging fails — log and continue - log.warn("Failed to charge credits", { userId: opts.ctx.userId, externalRef, error: String(err) }); + log.warn("Failed to charge credits", { + userId: opts.ctx.userId, + externalRef, + error: String(err), + }); } }); } @@ -977,49 +1082,64 @@ registerTelegramHandlers(bot, { extractContext, }); +registerSendblueHandlers(bot, { + withBotErrorHandling, + extractContext, +}); + // ─── Telegram freeform messages (1:1 DM only) ─────────────────────────────── // Only handles DMs. Group messages are handled by onNewMention (unsubscribed) // or onSubscribedMessage (subscribed). Even if the bot is a group admin and // receives all messages, we only want to respond to @mentions in groups. bot.onNewMessage(/[\s\S]+/, async (thread, message) => { - if (thread.adapter.name !== "telegram") return; + if (thread.adapter.name !== "telegram" && thread.adapter.name !== "sendblue") return; if (message.author.isBot || message.author.isMe) return; - if (TELEGRAM_COMMAND_RE.test(message.text.trim())) - return; + if (thread.adapter.name === "telegram" && TELEGRAM_COMMAND_RE.test(message.text.trim())) return; if (isAsideMessage(message.text)) return; + if (thread.adapter.name === "sendblue") { + if (isSendblueGroupThread(thread.id)) return; + if (message.text.trim().startsWith("/")) return; + if (await handleSendblueFlowInput(thread, message, { withBotErrorHandling, extractContext })) + return; + } + // Restrict to DMs only — group messages (subscribed or not) are handled elsewhere. - const isGroupThread = thread.id !== `telegram:${message.author.userId}`; + const isGroupThread = + thread.adapter.name === "telegram" && thread.id !== `telegram:${message.author.userId}`; if (isGroupThread) return; const ctx = extractContext(thread, message); bot - .getLogger("telegram-freeform") - .info("Telegram DM message", { userId: ctx.userId, text: message.text }); + .getLogger(`${ctx.platform}-freeform`) + .info("DM message", { platform: ctx.platform, userId: ctx.userId, text: message.text }); // Empty text means the user sent only "@botname" with no additional text. Show help. if (!message.text.trim()) { - await thread.post(telegramHelpCard()); + await thread.post( + ctx.platform === "telegram" ? telegramHelpCard() : "Send /help to see available commands." + ); return; } const lastStreamErrorRef = { current: null as Error | null }; await withBotErrorHandling( - () => runAgentHandler({ - thread, - ctx, - userMessage: message.text, - loggerName: "telegram-freeform", - lastStreamErrorRef, - promptIfNotLinked: async (reason) => { await thread.post(oauthLinkMessage(ctx.platform, ctx.teamId, ctx.userId, reason)); }, - shouldSubscribe: () => true, - resolveMentions: false, - persistContext: true, - }), + () => + runAgentHandler({ + thread, + ctx, + userMessage: message.text, + loggerName: `${ctx.platform}-freeform`, + lastStreamErrorRef, + promptIfNotLinked: (reason) => promptUserToLink(thread, message.author, ctx, reason), + shouldSubscribe: () => true, + resolveMentions: false, + persistContext: true, + }), { postError: (msg) => thread.post(msg).catch(() => {}), - logContext: "telegram freeform", + logContext: `${ctx.platform} freeform`, getCustomErrorMessage: agentStreamErrorMessage(lastStreamErrorRef), } ); @@ -1037,6 +1157,14 @@ bot.onNewMention(async (thread, message) => { const ctx = extractContext(thread, message); + if (ctx.platform === "sendblue") { + if (isSendblueGroupThread(thread.id)) return; + if (await handleSendblueCommand(thread, message, { withBotErrorHandling, extractContext })) + return; + if (await handleSendblueFlowInput(thread, message, { withBotErrorHandling, extractContext })) + return; + } + // For Slack: reconstruct <@USER_ID> mentions from raw blocks (the SDK resolves // them to display names in message.text, losing the user ID lookup_platform_user needs). // Also handles mailto: email links. Falls back to normalizeSlackText if no blocks. @@ -1044,7 +1172,9 @@ bot.onNewMention(async (thread, message) => { const userMessage = ctx.platform === "telegram" ? message.text.replace(/^@\S+\s*/, "").trim() - : (reconstructSlackMentions(message.raw) ?? normalizeSlackText(message.text)); + : ctx.platform === "sendblue" + ? message.text.trim() + : (reconstructSlackMentions(message.raw) ?? normalizeSlackText(message.text)); bot.getLogger("mention").info("New mention", { platform: ctx.platform, @@ -1062,15 +1192,17 @@ bot.onNewMention(async (thread, message) => { await withBotErrorHandling( async () => { // Telegram commands handled by onNewMessage (slash command handler) - if ( - ctx.platform === "telegram" && - TELEGRAM_COMMAND_RE.test(userMessage) - ) - return; + if (ctx.platform === "telegram" && TELEGRAM_COMMAND_RE.test(userMessage)) return; // Empty text means user only sent "@botname" with no additional message — show help. if (!userMessage) { - await thread.post(ctx.platform === "telegram" ? telegramHelpCard() : helpCard()); + await thread.post( + ctx.platform === "telegram" + ? telegramHelpCard() + : ctx.platform === "sendblue" + ? "Send /help to see available commands." + : helpCard() + ); return; } @@ -1119,6 +1251,14 @@ bot.onSubscribedMessage(async (thread, message) => { if (handled) return; } + if (ctx.platform === "sendblue") { + if (isSendblueGroupThread(thread.id)) return; + if (await handleSendblueCommand(thread, message, { withBotErrorHandling, extractContext })) + return; + if (await handleSendblueFlowInput(thread, message, { withBotErrorHandling, extractContext })) + return; + } + // For Slack: reconstruct <@USER_ID> mentions from raw blocks (the SDK resolves // them to display names in message.text, losing the user ID lookup_platform_user needs). // Also handles mailto: email links. Falls back to normalizeSlackText if no blocks. @@ -1126,7 +1266,9 @@ bot.onSubscribedMessage(async (thread, message) => { const userMessage = ctx.platform === "telegram" ? message.text.replace(/^@\S+\s*/, "").trim() - : (reconstructSlackMentions(message.raw) ?? normalizeSlackText(message.text)); + : ctx.platform === "sendblue" + ? message.text.trim() + : (reconstructSlackMentions(message.raw) ?? normalizeSlackText(message.text)); bot.getLogger("thread-follow-up").info("Thread follow-up", { platform: ctx.platform, @@ -1143,17 +1285,18 @@ bot.onSubscribedMessage(async (thread, message) => { : thread.post(msg).catch(() => {}); await withBotErrorHandling( - () => runAgentHandler({ - thread, - ctx, - userMessage, - loggerName: "thread-follow-up", - lastStreamErrorRef, - promptIfNotLinked: (reason) => promptUserToLink(thread, message.author, ctx, reason), - shouldSubscribe: () => !!(message.isMention), - resolveMentions: true, - persistContext: false, - }), + () => + runAgentHandler({ + thread, + ctx, + userMessage, + loggerName: "thread-follow-up", + lastStreamErrorRef, + promptIfNotLinked: (reason) => promptUserToLink(thread, message.author, ctx, reason), + shouldSubscribe: () => !!message.isMention, + resolveMentions: true, + persistContext: false, + }), { postError: safePost, logContext: "thread follow-up", diff --git a/apps/chat/lib/calcom/types.ts b/apps/chat/lib/calcom/types.ts index 1496e1e1..57647fcb 100644 --- a/apps/chat/lib/calcom/types.ts +++ b/apps/chat/lib/calcom/types.ts @@ -14,8 +14,8 @@ export interface CalcomEventType { } export interface BookingField { - name: string; // slug used as key in bookingFieldsResponses - type: string; // "text" | "phone" | "select" | "multiselect" | "radio" | "checkbox" | "boolean" | "number" | "address" | "url" | "textarea" | "multiemail" | ... + name: string; // slug used as key in bookingFieldsResponses + type: string; // "text" | "phone" | "select" | "multiselect" | "radio" | "checkbox" | "boolean" | "number" | "address" | "url" | "textarea" | "multiemail" | ... label?: string; required?: boolean; options?: string[]; // present for select, multiselect, radio, checkbox field types @@ -107,6 +107,10 @@ export interface CalcomWebhookMetadata { slack_user_id?: string; /** Telegram chat ID for routing to Telegram */ telegram_chat_id?: string; + /** Sendblue contact phone number in E.164 format for iMessage routing */ + sendblue_phone?: string; + /** Fully encoded Sendblue thread ID for advanced routing */ + sendblue_thread_id?: string; } export interface CalcomWebhookPayload { diff --git a/apps/chat/lib/env.ts b/apps/chat/lib/env.ts index 343a9712..2703cb80 100644 --- a/apps/chat/lib/env.ts +++ b/apps/chat/lib/env.ts @@ -16,6 +16,23 @@ export function validateRequiredEnv(): void { missing.push("TELEGRAM_BOT_USERNAME (required when TELEGRAM_BOT_TOKEN is set)"); } + const sendblueVars = [ + process.env.SENDBLUE_API_KEY, + process.env.SENDBLUE_API_SECRET, + process.env.SENDBLUE_FROM_NUMBER, + ]; + if (sendblueVars.some(Boolean)) { + if (!process.env.SENDBLUE_API_KEY) { + missing.push("SENDBLUE_API_KEY (required when Sendblue is enabled)"); + } + if (!process.env.SENDBLUE_API_SECRET) { + missing.push("SENDBLUE_API_SECRET (required when Sendblue is enabled)"); + } + if (!process.env.SENDBLUE_FROM_NUMBER) { + missing.push("SENDBLUE_FROM_NUMBER (required when Sendblue is enabled)"); + } + } + if (process.env.NODE_ENV === "production" && !process.env.REDIS_URL) { throw new Error( "REDIS_URL is required in production. The in-memory state adapter is not suitable for production (state is lost on restart, locks don't work across instances)." diff --git a/apps/chat/lib/format-availability-summary.ts b/apps/chat/lib/format-availability-summary.ts new file mode 100644 index 00000000..03bc7cc7 --- /dev/null +++ b/apps/chat/lib/format-availability-summary.ts @@ -0,0 +1,60 @@ +import type { ScheduleAvailability } from "./calcom/types"; + +const DAY_ORDER = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]; +const DAY_SHORT: Record = { + Monday: "Mon", + Tuesday: "Tue", + Wednesday: "Wed", + Thursday: "Thu", + Friday: "Fri", + Saturday: "Sat", + Sunday: "Sun", +}; + +export function formatAvailabilitySummary(availability: ScheduleAvailability[]): string { + if (availability.length === 0) return "No hours set"; + + const rangeMap = new Map(); + for (const entry of availability) { + const range = `${entry.startTime}-${entry.endTime}`; + for (const day of entry.days) { + const existing = rangeMap.get(range) ?? []; + existing.push(day); + rangeMap.set(range, existing); + } + } + + return [...rangeMap.entries()] + .map(([range, days]) => { + const sorted = days.sort((a, b) => DAY_ORDER.indexOf(a) - DAY_ORDER.indexOf(b)); + return `${compressDays(sorted)} ${range}`; + }) + .join(", "); +} + +function compressDays(sortedDays: string[]): string { + if (sortedDays.length === 0) return ""; + if (sortedDays.length === 1) return DAY_SHORT[sortedDays[0]] ?? sortedDays[0]; + + const runs: string[][] = []; + let currentRun = [sortedDays[0]]; + + for (let i = 1; i < sortedDays.length; i++) { + const prevIdx = DAY_ORDER.indexOf(sortedDays[i - 1]); + const currIdx = DAY_ORDER.indexOf(sortedDays[i]); + if (currIdx === prevIdx + 1) { + currentRun.push(sortedDays[i]); + } else { + runs.push(currentRun); + currentRun = [sortedDays[i]]; + } + } + runs.push(currentRun); + + return runs + .map((run) => { + if (run.length <= 2) return run.map((day) => DAY_SHORT[day] ?? day).join(", "); + return `${DAY_SHORT[run[0]] ?? run[0]}-${DAY_SHORT[run[run.length - 1]] ?? run[run.length - 1]}`; + }) + .join(", "); +} diff --git a/apps/chat/lib/format-for-sendblue.ts b/apps/chat/lib/format-for-sendblue.ts new file mode 100644 index 00000000..cab334d3 --- /dev/null +++ b/apps/chat/lib/format-for-sendblue.ts @@ -0,0 +1,107 @@ +import type { + CalcomBooking, + CalcomEventType, + CalcomSchedule, + CalcomWebhookPayload, +} from "./calcom/types"; +import { formatBookingTime } from "./calcom/webhooks"; +import { formatAvailabilitySummary } from "./format-availability-summary"; + +const MAX_TEXT_LENGTH = 3_500; +const CALCOM_APP_URL = process.env.CALCOM_APP_URL ?? "https://app.cal.com"; + +export function formatSlotLabel(time: string, timeZone: string): string { + return new Intl.DateTimeFormat("en-US", { + timeZone, + weekday: "short", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + hour12: true, + }).format(new Date(time)); +} + +export function truncateForSendblue(text: string): string { + if (text.length <= MAX_TEXT_LENGTH) return text; + return `${text.slice(0, MAX_TEXT_LENGTH - 32).trimEnd()}\n\n[truncated]`; +} + +export function formatNumberedList( + items: T[], + formatItem: (item: T, index: number) => string +): string { + return items.map((item, index) => `${index + 1}. ${formatItem(item, index)}`).join("\n"); +} + +export function formatBookingsForSendblue(bookings: CalcomBooking[], timeZone: string): string { + if (bookings.length === 0) return "You have no upcoming bookings."; + + const lines = bookings.slice(0, 10).map((booking, index) => { + const attendees = booking.attendees + .map((attendee) => attendee.name || attendee.email) + .join(", "); + const meeting = booking.meetingUrl ? `\n Join: ${booking.meetingUrl}` : ""; + return `${index + 1}. ${booking.title}\n ${formatBookingTime( + booking.start, + booking.end, + timeZone + )}\n With: ${attendees || "No attendees"}${meeting}`; + }); + + const suffix = bookings.length > 10 ? `\n\nShowing 10 of ${bookings.length} bookings.` : ""; + return truncateForSendblue(`Upcoming bookings:\n\n${lines.join("\n\n")}${suffix}`); +} + +export function formatEventTypesForSendblue(eventTypes: CalcomEventType[]): string { + if (eventTypes.length === 0) return "You have no event types."; + + const body = formatNumberedList(eventTypes.slice(0, 20), (eventType) => { + const hidden = eventType.hidden ? " hidden" : ""; + const url = eventType.bookingUrl ? ` - ${eventType.bookingUrl}` : ""; + return `${eventType.title} (${eventType.length} min${hidden})${url}`; + }); + const suffix = + eventTypes.length > 20 ? `\n\nShowing 20 of ${eventTypes.length} event types.` : ""; + return truncateForSendblue(`Your event types:\n\n${body}${suffix}`); +} + +export function formatSchedulesForSendblue(schedules: CalcomSchedule[]): string { + if (schedules.length === 0) return "You have no schedules."; + + const body = formatNumberedList(schedules.slice(0, 10), (schedule) => { + const defaultLabel = schedule.isDefault ? " (default)" : ""; + return `${schedule.name}${defaultLabel}\n Time zone: ${schedule.timeZone}\n Hours: ${formatAvailabilitySummary(schedule.availability)}`; + }); + const suffix = schedules.length > 10 ? `\n\nShowing 10 of ${schedules.length} schedules.` : ""; + return truncateForSendblue(`Your schedules:\n\n${body}${suffix}`); +} + +export function formatCalcomWebhookForSendblue(webhook: CalcomWebhookPayload): string { + const { payload } = webhook; + const time = formatBookingTime(payload.startTime, payload.endTime, payload.organizer.timeZone); + const attendees = payload.attendees.map((attendee) => attendee.name || attendee.email).join(", "); + const meetingUrl = payload.videoCallData?.url; + + const titleByEvent: Record = { + BOOKING_CREATED: "New booking", + BOOKING_RESCHEDULED: "Booking rescheduled", + BOOKING_CANCELLED: "Booking cancelled", + BOOKING_CONFIRMED: "Booking confirmed", + BOOKING_REMINDER: "Upcoming meeting reminder", + }; + + return truncateForSendblue( + [ + titleByEvent[webhook.triggerEvent] ?? "Booking update", + payload.title, + `When: ${time}`, + `With: ${attendees || "No attendees"}`, + ...(payload.location ? [`Location: ${payload.location}`] : []), + ...(payload.cancellationReason ? [`Reason: ${payload.cancellationReason}`] : []), + ...(payload.rescheduleReason ? [`Reason: ${payload.rescheduleReason}`] : []), + ...(meetingUrl ? [`Join: ${meetingUrl}`] : []), + `View bookings: ${CALCOM_APP_URL}/bookings`, + ].join("\n") + ); +} diff --git a/apps/chat/lib/handlers/sendblue.ts b/apps/chat/lib/handlers/sendblue.ts new file mode 100644 index 00000000..15a24e8b --- /dev/null +++ b/apps/chat/lib/handlers/sendblue.ts @@ -0,0 +1,860 @@ +import type { Chat, Message, Thread } from "chat"; +import { + cancelBooking, + createBookingPublic, + getAvailableSlotsPublic, + getBookings, + getEventTypesByUsername, + getSchedules, + rescheduleBooking, +} from "../calcom/client"; +import { generateAuthUrl } from "../calcom/oauth"; +import { formatBookingTime } from "../calcom/webhooks"; +import { + formatBookingsForSendblue, + formatEventTypesForSendblue, + formatNumberedList, + formatSchedulesForSendblue, + formatSlotLabel, +} from "../format-for-sendblue"; +import { getLogger } from "../logger"; +import { + clearSendblueFlow, + getLinkedUser, + getSendblueFlow, + getValidAccessToken, + type LinkedUser, + type SendblueFlowState, + setSendblueFlow, + unlinkUser, +} from "../user-linking"; + +const logger = getLogger("sendblue-handlers"); +const CALCOM_APP_URL = process.env.CALCOM_APP_URL ?? "https://app.cal.com"; + +export const SENDBLUE_COMMANDS = [ + "start", + "help", + "link", + "unlink", + "bookings", + "availability", + "profile", + "eventtypes", + "schedules", + "book", + "cancel", + "reschedule", +]; + +export const SENDBLUE_COMMAND_RE = new RegExp( + `^\\/(cal\\s+)?(${SENDBLUE_COMMANDS.join("|")})\\b`, + "i" +); + +export interface RegisterSendblueHandlersDeps { + withBotErrorHandling: ( + fn: () => Promise, + options: { + postError: (message: string) => Promise; + logContext?: string; + } + ) => Promise; + extractContext: ( + thread: { adapter: { name: string } }, + message: { author: { userId: string }; raw: unknown } + ) => { platform: string; teamId: string; userId: string }; +} + +interface AuthContext { + accessToken: string; + linked: LinkedUser; +} + +function helpText(): string { + return [ + "Cal.com for iMessage", + "", + "Commands:", + "/link - connect your Cal.com account", + "/unlink - disconnect your account", + "/bookings - show upcoming bookings", + "/availability - show your next available slots", + "/profile - show your linked Cal.com profile", + "/eventtypes - list your event types", + "/schedules - show your schedules", + "/book - book a public Cal.com event", + "/cancel - cancel an upcoming booking", + "/reschedule - reschedule an upcoming booking", + "", + "You can also send a natural language request after linking.", + ].join("\n"); +} + +function oauthLinkText( + platform: string, + teamId: string, + userId: string, + reason?: "expired" +): string { + const authUrl = generateAuthUrl(platform, teamId, userId); + const intro = + reason === "expired" + ? "Your Cal.com session has expired. Reconnect here:" + : "Connect your Cal.com account here:"; + return `${intro}\n${authUrl}`; +} + +function parseCommand(text: string): { command: string; rest: string } | null { + const trimmed = text.trim(); + if (!trimmed.startsWith("/")) return null; + const parts = trimmed.split(/\s+/); + const first = parts[0]?.toLowerCase(); + if (first === "/cal") { + return { + command: parts[1]?.toLowerCase() ?? "help", + rest: parts.slice(2).join(" ").trim(), + }; + } + return { + command: first?.replace(/^\//, "") ?? "", + rest: parts.slice(1).join(" ").trim(), + }; +} + +function parseSelection(text: string, max: number): number | null { + const normalized = text.trim(); + if (!/^\d+$/.test(normalized)) return null; + const value = Number(normalized); + if (value < 1 || value > max) return null; + return value - 1; +} + +function isCancelInput(text: string): boolean { + return /^(cancel|stop|nevermind|never mind|help)$/i.test(text.trim()); +} + +function isYesInput(text: string): boolean { + return /^(yes|y|confirm|ok|okay)$/i.test(text.trim()); +} + +function isNoInput(text: string): boolean { + return /^(no|n)$/i.test(text.trim()); +} + +function authUser(linked: LinkedUser) { + return { id: linked.calcomUserId, email: linked.calcomEmail }; +} + +async function requireAuth( + thread: Thread, + teamId: string, + userId: string +): Promise { + const accessToken = await getValidAccessToken(teamId, userId); + const linked = await getLinkedUser(teamId, userId); + if (!accessToken || !linked) { + await thread.post(oauthLinkText("sendblue", teamId, userId, linked ? "expired" : undefined)); + return null; + } + return { accessToken, linked }; +} + +function isBookingHost( + booking: Awaited>[number], + linked: LinkedUser +) { + const emailLower = linked.calcomEmail.toLowerCase(); + return booking.hosts?.some( + (host) => + String(host.id) === String(linked.calcomUserId) || host.email?.toLowerCase() === emailLower + ); +} + +function bookingOptions(bookings: Awaited>) { + return bookings.map((booking) => ({ + uid: booking.uid, + title: booking.title, + start: booking.start, + end: booking.end, + isRecurring: !!booking.recurringBookingUid, + eventTypeId: booking.eventType?.id, + eventTypeSlug: booking.eventType?.slug, + })); +} + +async function getUpcomingBookings(auth: AuthContext, take = 20) { + return getBookings(auth.accessToken, { status: "upcoming", take }, authUser(auth.linked)); +} + +async function getFirstSlots(params: { + eventTypeSlug: string; + username: string; + timeZone: string; + bookingUidToReschedule?: string; +}) { + const now = new Date(); + const weekLater = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); + const slotsMap = await getAvailableSlotsPublic({ + eventTypeSlug: params.eventTypeSlug, + username: params.username, + start: now.toISOString(), + end: weekLater.toISOString(), + timeZone: params.timeZone, + bookingUidToReschedule: params.bookingUidToReschedule, + }); + return Object.values(slotsMap) + .flat() + .filter((slot) => slot.available) + .slice(0, 5) + .map((slot) => ({ + time: slot.time, + label: formatSlotLabel(slot.time, params.timeZone), + })); +} + +async function startBookFlow( + thread: Thread, + teamId: string, + userId: string, + targetUsername: string +) { + const normalizedUsername = targetUsername.replace(/^@/, ""); + if (!normalizedUsername) { + await thread.post("Usage: /book "); + return; + } + + const eventTypes = await getEventTypesByUsername(normalizedUsername); + if (eventTypes.length === 0) { + await thread.post(`No public event types found for ${normalizedUsername}.`); + return; + } + + const options = eventTypes.slice(0, 20).map((eventType) => ({ + id: eventType.id, + title: eventType.title, + slug: eventType.slug, + length: eventType.length, + })); + + await setSendblueFlow(teamId, userId, { + type: "book", + targetUsername: normalizedUsername, + eventTypes: options, + step: "awaiting_event_type", + }); + + await thread.post( + [ + `Book with ${normalizedUsername}`, + "Reply with the number of the event type:", + "", + formatNumberedList(options, (eventType) => `${eventType.title} (${eventType.length} min)`), + "", + "Reply cancel to stop.", + ].join("\n") + ); +} + +async function startCancelFlow(thread: Thread, teamId: string, userId: string, auth: AuthContext) { + const bookings = await getUpcomingBookings(auth, 20); + if (bookings.length === 0) { + await thread.post(`You have no upcoming bookings. View bookings: ${CALCOM_APP_URL}/bookings`); + return; + } + + const options = bookingOptions(bookings); + await setSendblueFlow(teamId, userId, { + type: "cancel", + bookings: options, + step: "awaiting_booking", + }); + + await thread.post( + [ + "Which booking do you want to cancel?", + "", + formatNumberedList( + options, + (booking) => + `${booking.title} - ${formatBookingTime( + booking.start, + booking.end, + auth.linked.calcomTimeZone + )}` + ), + "", + "Reply with a number, or cancel to stop.", + ].join("\n") + ); +} + +async function startRescheduleFlow( + thread: Thread, + teamId: string, + userId: string, + auth: AuthContext +) { + const bookings = await getUpcomingBookings(auth, 20); + if (bookings.length === 0) { + await thread.post(`You have no upcoming bookings. View bookings: ${CALCOM_APP_URL}/bookings`); + return; + } + + const options = bookingOptions(bookings); + await setSendblueFlow(teamId, userId, { + type: "reschedule", + bookings: options, + step: "awaiting_booking", + }); + + await thread.post( + [ + "Which booking do you want to reschedule?", + "", + formatNumberedList( + options, + (booking) => + `${booking.title} - ${formatBookingTime( + booking.start, + booking.end, + auth.linked.calcomTimeZone + )}` + ), + "", + "Reply with a number, or cancel to stop.", + ].join("\n") + ); +} + +async function handleBookFlow( + thread: Thread, + text: string, + teamId: string, + userId: string, + flow: Extract +) { + const auth = await requireAuth(thread, teamId, userId); + if (!auth) return; + + if (flow.step === "awaiting_event_type") { + const index = parseSelection(text, flow.eventTypes.length); + if (index === null) { + await thread.post("Reply with a valid event type number, or cancel to stop."); + return; + } + const selectedEventType = flow.eventTypes[index]; + const slots = await getFirstSlots({ + eventTypeSlug: selectedEventType.slug, + username: flow.targetUsername, + timeZone: auth.linked.calcomTimeZone, + }); + if (slots.length === 0) { + await clearSendblueFlow(teamId, userId); + await thread.post("No available slots found in the next 7 days."); + return; + } + await setSendblueFlow(teamId, userId, { + ...flow, + selectedEventType, + slots, + step: "awaiting_slot", + }); + await thread.post( + [ + `Pick a time for ${selectedEventType.title}:`, + "", + formatNumberedList(slots, (slot) => slot.label), + "", + "Reply with a number, or cancel to stop.", + ].join("\n") + ); + return; + } + + if (flow.step === "awaiting_slot") { + const slots = flow.slots ?? []; + const index = parseSelection(text, slots.length); + if (index === null) { + await thread.post("Reply with a valid slot number, or cancel to stop."); + return; + } + const selectedSlot = slots[index]; + await setSendblueFlow(teamId, userId, { + ...flow, + selectedSlot, + step: "awaiting_confirmation", + }); + await thread.post( + [ + "Confirm booking?", + `Event: ${flow.selectedEventType?.title ?? "Meeting"}`, + `When: ${selectedSlot.label}`, + `With: ${flow.targetUsername}`, + "", + "Reply yes to confirm, or no to cancel.", + ].join("\n") + ); + return; + } + + if (!isYesInput(text)) { + if (isNoInput(text)) { + await clearSendblueFlow(teamId, userId); + await thread.post("Booking cancelled."); + return; + } + await thread.post("Reply yes to confirm, or no to cancel."); + return; + } + + if (!flow.selectedEventType || !flow.selectedSlot) { + await clearSendblueFlow(teamId, userId); + await thread.post("Booking session expired. Please start again with /book."); + return; + } + + const booking = await createBookingPublic({ + eventTypeSlug: flow.selectedEventType.slug, + username: flow.targetUsername, + start: flow.selectedSlot.time, + attendee: { + name: auth.linked.calcomUsername, + email: auth.linked.calcomEmail, + timeZone: auth.linked.calcomTimeZone, + }, + }); + await clearSendblueFlow(teamId, userId); + await thread.post( + [ + "Booking confirmed.", + booking.title, + formatBookingTime(booking.start, booking.end, auth.linked.calcomTimeZone), + ...(booking.meetingUrl ? [`Join: ${booking.meetingUrl}`] : []), + ].join("\n") + ); +} + +async function handleCancelFlow( + thread: Thread, + text: string, + teamId: string, + userId: string, + flow: Extract +) { + const auth = await requireAuth(thread, teamId, userId); + if (!auth) return; + + if (flow.step === "awaiting_booking") { + const index = parseSelection(text, flow.bookings.length); + if (index === null) { + await thread.post("Reply with a valid booking number, or cancel to stop."); + return; + } + const selectedBooking = flow.bookings[index]; + const fullBookings = await getUpcomingBookings(auth, 100); + const fullBooking = fullBookings.find((booking) => booking.uid === selectedBooking.uid); + if (!fullBooking || !isBookingHost(fullBooking, auth.linked)) { + await clearSendblueFlow(teamId, userId); + await thread.post( + "I can only cancel bookings where you are the host. If you are an attendee, please use the cancellation link from your confirmation email or manage it at app.cal.com/bookings." + ); + return; + } + + await setSendblueFlow(teamId, userId, { + ...flow, + selectedBooking, + step: "awaiting_confirmation", + }); + await thread.post( + [ + "Cancel this booking?", + selectedBooking.title, + formatBookingTime(selectedBooking.start, selectedBooking.end, auth.linked.calcomTimeZone), + "", + "Reply yes to confirm, or no to keep it.", + ].join("\n") + ); + return; + } + + if (flow.step === "awaiting_confirmation") { + if (isNoInput(text)) { + await clearSendblueFlow(teamId, userId); + await thread.post("Cancellation aborted."); + return; + } + if (!isYesInput(text)) { + await thread.post("Reply yes to confirm, or no to keep the booking."); + return; + } + if (!flow.selectedBooking) { + await clearSendblueFlow(teamId, userId); + await thread.post("Cancellation session expired. Please start again with /cancel."); + return; + } + if (flow.selectedBooking.isRecurring) { + await setSendblueFlow(teamId, userId, { + ...flow, + step: "awaiting_recurring_scope", + }); + await thread.post( + "This is recurring. Reply one to cancel only this booking, or all to cancel future occurrences." + ); + return; + } + await cancelBooking(auth.accessToken, flow.selectedBooking.uid, "Cancelled via Sendblue bot"); + await clearSendblueFlow(teamId, userId); + await thread.post(`Booking "${flow.selectedBooking.title}" has been cancelled.`); + return; + } + + if (!flow.selectedBooking) { + await clearSendblueFlow(teamId, userId); + await thread.post("Cancellation session expired. Please start again with /cancel."); + return; + } + + const normalized = text.trim().toLowerCase(); + if (normalized !== "one" && normalized !== "all") { + await thread.post( + "Reply one to cancel this booking only, or all to cancel future occurrences." + ); + return; + } + await cancelBooking( + auth.accessToken, + flow.selectedBooking.uid, + "Cancelled via Sendblue bot", + normalized === "all" + ); + await clearSendblueFlow(teamId, userId); + await thread.post( + normalized === "all" + ? `Booking "${flow.selectedBooking.title}" and future occurrences have been cancelled.` + : `Booking "${flow.selectedBooking.title}" has been cancelled.` + ); +} + +async function handleRescheduleFlow( + thread: Thread, + text: string, + teamId: string, + userId: string, + flow: Extract +) { + const auth = await requireAuth(thread, teamId, userId); + if (!auth) return; + + if (flow.step === "awaiting_booking") { + const index = parseSelection(text, flow.bookings.length); + if (index === null) { + await thread.post("Reply with a valid booking number, or cancel to stop."); + return; + } + const selectedBooking = flow.bookings[index]; + if (!selectedBooking.eventTypeSlug) { + await clearSendblueFlow(teamId, userId); + await thread.post( + "Cannot reschedule this booking because event type information is missing." + ); + return; + } + + const fullBookings = await getUpcomingBookings(auth, 100); + const fullBooking = fullBookings.find((booking) => booking.uid === selectedBooking.uid); + if (!fullBooking) { + await clearSendblueFlow(teamId, userId); + await thread.post( + "I could not verify that you are the host for this booking. Please try again or reschedule at app.cal.com/bookings." + ); + return; + } + if (!isBookingHost(fullBooking, auth.linked)) { + await clearSendblueFlow(teamId, userId); + await thread.post( + "You're an attendee on this booking, not the host. Please use the reschedule link from your confirmation email or reschedule at app.cal.com/bookings." + ); + return; + } + + const slots = await getFirstSlots({ + eventTypeSlug: selectedBooking.eventTypeSlug, + username: auth.linked.calcomUsername, + timeZone: auth.linked.calcomTimeZone, + bookingUidToReschedule: selectedBooking.uid, + }); + if (slots.length === 0) { + await clearSendblueFlow(teamId, userId); + await thread.post("No available replacement slots found in the next 7 days."); + return; + } + + await setSendblueFlow(teamId, userId, { + ...flow, + selectedBooking, + slots, + step: "awaiting_slot", + }); + await thread.post( + [ + `Pick a new time for ${selectedBooking.title}:`, + "", + formatNumberedList(slots, (slot) => slot.label), + "", + "Reply with a number, or cancel to stop.", + ].join("\n") + ); + return; + } + + if (flow.step === "awaiting_slot") { + const slots = flow.slots ?? []; + const index = parseSelection(text, slots.length); + if (index === null) { + await thread.post("Reply with a valid slot number, or cancel to stop."); + return; + } + const selectedSlot = slots[index]; + await setSendblueFlow(teamId, userId, { + ...flow, + selectedSlot, + step: "awaiting_confirmation", + }); + await thread.post( + [ + "Confirm reschedule?", + `Booking: ${flow.selectedBooking?.title ?? "Booking"}`, + `New time: ${selectedSlot.label}`, + "", + "Reply yes to confirm, or no to cancel.", + ].join("\n") + ); + return; + } + + if (isNoInput(text)) { + await clearSendblueFlow(teamId, userId); + await thread.post("Reschedule cancelled."); + return; + } + if (!isYesInput(text)) { + await thread.post("Reply yes to confirm, or no to cancel."); + return; + } + if (!flow.selectedBooking || !flow.selectedSlot) { + await clearSendblueFlow(teamId, userId); + await thread.post("Reschedule session expired. Please start again with /reschedule."); + return; + } + + const booking = await rescheduleBooking( + auth.accessToken, + flow.selectedBooking.uid, + flow.selectedSlot.time, + "Rescheduled via Sendblue bot" + ); + await clearSendblueFlow(teamId, userId); + await thread.post( + `Booking "${flow.selectedBooking.title}" rescheduled to ${formatBookingTime( + booking.start, + booking.end, + auth.linked.calcomTimeZone + )}.` + ); +} + +export async function handleSendblueFlowInput( + thread: Thread, + message: Message, + deps: RegisterSendblueHandlersDeps +): Promise { + if (thread.adapter.name !== "sendblue") return false; + + const ctx = deps.extractContext(thread, message); + const flow = await getSendblueFlow(ctx.teamId, ctx.userId); + if (!flow) return false; + + await deps.withBotErrorHandling( + async () => { + const text = message.text.trim(); + if (isCancelInput(text)) { + await clearSendblueFlow(ctx.teamId, ctx.userId); + await thread.post(/^help$/i.test(text) ? helpText() : "Flow cancelled."); + return; + } + + if (flow.type === "book") { + await handleBookFlow(thread, text, ctx.teamId, ctx.userId, flow); + } else if (flow.type === "cancel") { + await handleCancelFlow(thread, text, ctx.teamId, ctx.userId, flow); + } else { + await handleRescheduleFlow(thread, text, ctx.teamId, ctx.userId, flow); + } + }, + { + postError: (msg) => thread.post(msg).catch(() => {}), + logContext: "sendblue flow", + } + ); + + return true; +} + +export async function handleSendblueCommand( + thread: Thread, + message: Message, + deps: RegisterSendblueHandlersDeps +): Promise { + if (thread.adapter.name !== "sendblue") return false; + + const parsed = parseCommand(message.text); + if (!parsed) return false; + + const { command, rest } = parsed; + const isKnown = SENDBLUE_COMMANDS.includes(command); + const ctx = deps.extractContext(thread, message); + + logger.info("Sendblue command received", { command, userId: ctx.userId }); + + await deps.withBotErrorHandling( + async () => { + if (!isKnown) { + await clearSendblueFlow(ctx.teamId, ctx.userId); + await thread.post("Unknown command. Send /help to see available commands."); + return; + } + + await clearSendblueFlow(ctx.teamId, ctx.userId); + + if (command === "start" || command === "help") { + await thread.post(helpText()); + return; + } + + if (command === "link") { + const existing = await getLinkedUser(ctx.teamId, ctx.userId); + if (existing) { + await thread.post( + `Your Cal.com account (${existing.calcomUsername} - ${existing.calcomEmail}) is already connected. Send /unlink to disconnect.` + ); + return; + } + await thread.post(oauthLinkText(ctx.platform, ctx.teamId, ctx.userId)); + return; + } + + if (command === "unlink") { + const existing = await getLinkedUser(ctx.teamId, ctx.userId); + if (!existing) { + await thread.post("Your Cal.com account is not connected."); + return; + } + await unlinkUser(ctx.teamId, ctx.userId); + await clearSendblueFlow(ctx.teamId, ctx.userId); + await thread.post( + `Your Cal.com account (${existing.calcomUsername}) has been disconnected.` + ); + return; + } + + const auth = await requireAuth(thread, ctx.teamId, ctx.userId); + if (!auth) return; + + if (command === "bookings") { + const bookings = await getUpcomingBookings(auth, 20); + await thread.post(formatBookingsForSendblue(bookings, auth.linked.calcomTimeZone)); + return; + } + + if (command === "availability") { + const eventTypes = await getEventTypesByUsername(auth.linked.calcomUsername); + if (eventTypes.length === 0) { + await thread.post(`You have no event types. Create one at ${CALCOM_APP_URL}.`); + return; + } + const eventType = rest + ? eventTypes.find((candidate) => candidate.slug === rest) + : eventTypes[0]; + if (!eventType) { + await thread.post( + `Event type "${rest}" was not found. Send /eventtypes to see available slugs.` + ); + return; + } + const slots = await getFirstSlots({ + eventTypeSlug: eventType.slug, + username: auth.linked.calcomUsername, + timeZone: auth.linked.calcomTimeZone, + }); + if (slots.length === 0) { + await thread.post(`No available slots found for ${eventType.title} in the next 7 days.`); + return; + } + await thread.post( + [ + `Available slots for ${eventType.title}:`, + "", + formatNumberedList(slots, (slot) => slot.label), + ].join("\n") + ); + return; + } + + if (command === "profile") { + await thread.post( + [ + "Your Cal.com profile:", + `Username: ${auth.linked.calcomUsername}`, + `Email: ${auth.linked.calcomEmail}`, + `Timezone: ${auth.linked.calcomTimeZone}`, + `Linked: ${new Date(auth.linked.linkedAt).toLocaleDateString("en-US")}`, + ].join("\n") + ); + return; + } + + if (command === "eventtypes") { + const eventTypes = await getEventTypesByUsername(auth.linked.calcomUsername); + await thread.post(formatEventTypesForSendblue(eventTypes)); + return; + } + + if (command === "schedules") { + const schedules = await getSchedules(auth.accessToken); + await thread.post(formatSchedulesForSendblue(schedules)); + return; + } + + if (command === "book") { + await startBookFlow(thread, ctx.teamId, ctx.userId, rest); + return; + } + + if (command === "cancel") { + await startCancelFlow(thread, ctx.teamId, ctx.userId, auth); + return; + } + + if (command === "reschedule") { + await startRescheduleFlow(thread, ctx.teamId, ctx.userId, auth); + } + }, + { + postError: (msg) => thread.post(msg).catch(() => {}), + logContext: "sendblue command", + } + ); + + return true; +} + +export function registerSendblueHandlers(bot: Chat, deps: RegisterSendblueHandlersDeps): void { + bot.onNewMessage(/^\/.*/, async (thread, message) => { + if (thread.adapter.name !== "sendblue") return; + if (message.author.isBot || message.author.isMe) return; + await handleSendblueCommand(thread, message, deps); + }); +} diff --git a/apps/chat/lib/notifications.ts b/apps/chat/lib/notifications.ts index dd297785..cb91761d 100644 --- a/apps/chat/lib/notifications.ts +++ b/apps/chat/lib/notifications.ts @@ -13,6 +13,7 @@ import { } from "chat"; import type { CalcomWebhookPayload, ScheduleAvailability } from "./calcom/types"; import { formatBookingTime } from "./calcom/webhooks"; +import { formatAvailabilitySummary } from "./format-availability-summary"; const CALCOM_APP_URL = process.env.CALCOM_APP_URL ?? "https://app.cal.com"; @@ -355,9 +356,7 @@ export function profileCard(linked: { : []), ]), Divider(), - Actions([ - LinkButton({ url: CALCOM_APP_URL, label: "Open Cal.com" }), - ]), + Actions([LinkButton({ url: CALCOM_APP_URL, label: "Open Cal.com" })]), CardText("Use /unlink to disconnect your account."), ], }); @@ -366,7 +365,13 @@ export function profileCard(linked: { // ─── Event types list card ────────────────────────────────────────────────── export function eventTypesListCard( - eventTypes: Array<{ title: string; slug: string; length: number; hidden: boolean; bookingUrl?: string | null }> + eventTypes: Array<{ + title: string; + slug: string; + length: number; + hidden: boolean; + bookingUrl?: string | null; + }> ) { if (eventTypes.length === 0) { return Card({ @@ -401,67 +406,6 @@ export function eventTypesListCard( // ─── Schedules list card ──────────────────────────────────────────────────── -const DAY_ORDER = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]; -const DAY_SHORT: Record = { - Monday: "Mon", - Tuesday: "Tue", - Wednesday: "Wed", - Thursday: "Thu", - Friday: "Fri", - Saturday: "Sat", - Sunday: "Sun", -}; - -export function formatAvailabilitySummary(availability: ScheduleAvailability[]): string { - if (availability.length === 0) return "No hours set"; - - // Build a map of timeRange -> sorted days - const rangeMap = new Map(); - for (const entry of availability) { - const range = `${entry.startTime}-${entry.endTime}`; - for (const day of entry.days) { - const existing = rangeMap.get(range) ?? []; - existing.push(day); - rangeMap.set(range, existing); - } - } - - const parts: string[] = []; - for (const [range, days] of rangeMap.entries()) { - const sorted = days.sort((a, b) => DAY_ORDER.indexOf(a) - DAY_ORDER.indexOf(b)); - const dayStr = compressDays(sorted); - parts.push(`${dayStr} ${range}`); - } - return parts.join(", "); -} - -function compressDays(sortedDays: string[]): string { - if (sortedDays.length === 0) return ""; - if (sortedDays.length === 1) return DAY_SHORT[sortedDays[0]] ?? sortedDays[0]; - - const runs: string[][] = []; - let currentRun = [sortedDays[0]]; - - for (let i = 1; i < sortedDays.length; i++) { - const prevIdx = DAY_ORDER.indexOf(sortedDays[i - 1]); - const currIdx = DAY_ORDER.indexOf(sortedDays[i]); - if (currIdx === prevIdx + 1) { - currentRun.push(sortedDays[i]); - } else { - runs.push(currentRun); - currentRun = [sortedDays[i]]; - } - } - runs.push(currentRun); - - return runs - .map((run) => { - if (run.length <= 2) return run.map((d) => DAY_SHORT[d] ?? d).join(", "); - return `${DAY_SHORT[run[0]] ?? run[0]}-${DAY_SHORT[run[run.length - 1]] ?? run[run.length - 1]}`; - }) - .join(", "); -} - export function schedulesListCard( schedules: Array<{ name: string; @@ -534,11 +478,7 @@ export function cancelBookingPickerCard( }); } -export function cancelConfirmCard( - title: string, - date: string, - isRecurring: boolean -) { +export function cancelConfirmCard(title: string, date: string, isRecurring: boolean) { const buttons = [ Button({ id: "cancel_confirm", style: "danger" as const, label: "Cancel this booking" }), ]; @@ -552,10 +492,7 @@ export function cancelConfirmCard( return Card({ title: "Confirm Cancellation", children: [ - Fields([ - Field({ label: "Booking", value: title }), - Field({ label: "When", value: date }), - ]), + Fields([Field({ label: "Booking", value: title }), Field({ label: "When", value: date })]), Divider(), Actions(buttons), ], @@ -624,11 +561,7 @@ export function rescheduleSlotPickerCard( }); } -export function rescheduleConfirmCard( - bookingTitle: string, - oldTime: string, - newTime: string -) { +export function rescheduleConfirmCard(bookingTitle: string, oldTime: string, newTime: string) { return Card({ title: "Confirm Reschedule", children: [ @@ -679,11 +612,9 @@ export function telegramSlotPickerCard( subtitle: eventTypeTitle, children: [ CardText("Showing up to the first 5 soonest options."), - ...slots.slice(0, 5).map((s, i) => - Actions([ - Button({ id: `tg_book_slot_${i}`, label: s.label }), - ]) - ), + ...slots + .slice(0, 5) + .map((s, i) => Actions([Button({ id: `tg_book_slot_${i}`, label: s.label })])), Actions([Button({ id: "tg_book_cancel", style: "danger" as const, label: "Cancel" })]), ], }); @@ -752,9 +683,11 @@ export function bookEventTypePickerCard( id: "select_book_event_type", label: "Event Type", placeholder: "Select an event type", - options: eventTypes.slice(0, 100).map((et) => - SelectOption({ label: `${et.title} (${et.length}min)`, value: et.slug }) - ), + options: eventTypes + .slice(0, 100) + .map((et) => + SelectOption({ label: `${et.title} (${et.length}min)`, value: et.slug }) + ), }), ]), ]), @@ -790,9 +723,7 @@ export function bookSlotPickerCard( id: "select_book_slot", label: "Time", placeholder: "Select a time", - options: slots - .slice(0, 5) - .map((s) => SelectOption({ label: s.label, value: s.time })), + options: slots.slice(0, 5).map((s) => SelectOption({ label: s.label, value: s.time })), }), ]), ]), @@ -800,11 +731,7 @@ export function bookSlotPickerCard( }); } -export function bookConfirmCard( - eventTypeTitle: string, - slotLabel: string, - targetUsername: string -) { +export function bookConfirmCard(eventTypeTitle: string, slotLabel: string, targetUsername: string) { return Card({ title: "Confirm Booking", children: [ diff --git a/apps/chat/lib/user-linking.ts b/apps/chat/lib/user-linking.ts index 83261e90..e1c386c6 100644 --- a/apps/chat/lib/user-linking.ts +++ b/apps/chat/lib/user-linking.ts @@ -282,6 +282,53 @@ export interface RescheduleFlowState { selectedSlot?: string; } +interface SendblueEventTypeOption { + id: number; + title: string; + slug: string; + length: number; +} + +interface SendblueBookingOption { + uid: string; + title: string; + start: string; + end: string; + isRecurring: boolean; + eventTypeSlug?: string; + eventTypeId?: number; +} + +interface SendblueSlotOption { + time: string; + label: string; +} + +export type SendblueFlowState = + | { + type: "book"; + targetUsername: string; + eventTypes: SendblueEventTypeOption[]; + step: "awaiting_event_type" | "awaiting_slot" | "awaiting_confirmation"; + selectedEventType?: SendblueEventTypeOption; + slots?: SendblueSlotOption[]; + selectedSlot?: SendblueSlotOption; + } + | { + type: "cancel"; + bookings: SendblueBookingOption[]; + step: "awaiting_booking" | "awaiting_confirmation" | "awaiting_recurring_scope"; + selectedBooking?: SendblueBookingOption; + } + | { + type: "reschedule"; + bookings: SendblueBookingOption[]; + step: "awaiting_booking" | "awaiting_slot" | "awaiting_confirmation"; + selectedBooking?: SendblueBookingOption; + slots?: SendblueSlotOption[]; + selectedSlot?: SendblueSlotOption; + }; + export async function setBookingFlow( teamId: string, userId: string, @@ -376,6 +423,42 @@ export async function clearRescheduleFlow(teamId: string, userId: string): Promi await client.del(`calcom:reschedule_flow:${teamId}:${userId}`); } +// ─── Sendblue text flow state ──────────────────────────────────────────────── + +function sendblueFlowKey(teamId: string, userId: string): string { + return `calcom:sendblue_flow:${teamId}:${userId}`; +} + +export async function setSendblueFlow( + teamId: string, + userId: string, + state: SendblueFlowState +): Promise { + const client = getRedisClient(); + await client.set(sendblueFlowKey(teamId, userId), JSON.stringify(state), { + EX: BOOKING_FLOW_TTL_SECONDS, + }); +} + +export async function getSendblueFlow( + teamId: string, + userId: string +): Promise { + const client = getRedisClient(); + const raw = await client.get(sendblueFlowKey(teamId, userId)); + if (!raw) return null; + try { + return JSON.parse(raw) as SendblueFlowState; + } catch { + return null; + } +} + +export async function clearSendblueFlow(teamId: string, userId: string): Promise { + const client = getRedisClient(); + await client.del(sendblueFlowKey(teamId, userId)); +} + // ─── Tool context persistence (per-thread, survives across webhook invocations) ─ export interface ToolContextEntry { @@ -400,10 +483,7 @@ export async function getToolContext(threadId: string): Promise { +export async function setToolContext(threadId: string, entries: ToolContextEntry[]): Promise { const client = getRedisClient(); await client.set(toolContextKey(threadId), encryptData(JSON.stringify(entries)), { EX: TOOL_CONTEXT_TTL_SECONDS, diff --git a/apps/chat/package.json b/apps/chat/package.json index 835ae6af..108598ac 100644 --- a/apps/chat/package.json +++ b/apps/chat/package.json @@ -24,6 +24,7 @@ "@vercel/functions": "3.4.3", "ai": "6.0.116", "chat": "4.19.0", + "chat-adapter-sendblue": "0.2.0", "next": "16.1.6", "react": "19.2.4", "react-dom": "19.2.4", diff --git a/bun.lock b/bun.lock index e39f0c64..759a1616 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "cal-companion", @@ -25,6 +24,7 @@ "@vercel/functions": "3.4.3", "ai": "6.0.116", "chat": "4.19.0", + "chat-adapter-sendblue": "0.2.0", "next": "16.1.6", "react": "19.2.4", "react-dom": "19.2.4", @@ -1192,6 +1192,8 @@ "chat": ["chat@4.19.0", "", { "dependencies": { "@workflow/serde": "4.1.0-beta.2", "mdast-util-to-string": "^4.0.0", "remark-gfm": "^4.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "remend": "^1.2.1", "unified": "^11.0.5" } }, "sha512-DKcs4yQ5RyQ7LWercePBSttiKpsb6EBkQEBq6UrFyyS0R6UgxO4Ow2s0JwdeSplM9OHJ+Jx6ahYdIJKHHccJhw=="], + "chat-adapter-sendblue": ["chat-adapter-sendblue@0.2.0", "", { "dependencies": { "chat": "^4.23.0", "sendblue": "^3.6.1" } }, "sha512-JKqRa5KyoBsndRrq2LuGhob1uCdO1goWAbqaSLCld3KE9q288CnEo999GdWuDVWa9yqt1uf5F9/Oe3iFkSrW2w=="], + "check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="], "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], @@ -2428,6 +2430,8 @@ "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + "sendblue": ["sendblue@3.9.0", "", {}, "sha512-JoInW3nQO/q2Sxjz9Tnl5YzQka13cJ+xQ3fa75+xv2Ov+dXDXCzwE00xFfbCi97Awlz1zmkLgT1NjVNnH5KcuQ=="], + "serialize-error": ["serialize-error@2.1.0", "", {}, "sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw=="], "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], @@ -3056,6 +3060,8 @@ "c12/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + "chat-adapter-sendblue/chat": ["chat@4.27.0", "", { "dependencies": { "@workflow/serde": "4.1.0-beta.2", "mdast-util-to-string": "^4.0.0", "remark-gfm": "^4.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "remend": "^1.2.1", "unified": "^11.0.5" } }, "sha512-PrL4k263DSIlckhX8eHLT84RdTSznOBxCCfaDc5JVJtWaS0lJkCNctm/g3gIrI41AcDHcpc/3WDoUHVrbh0W4w=="], + "chrome-launcher/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], "chromium-edge-launcher/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],