diff --git a/apps/chat/.env.example b/apps/chat/.env.example index 4de42e9e..10ee57c1 100644 --- a/apps/chat/.env.example +++ b/apps/chat/.env.example @@ -40,6 +40,11 @@ CALCOM_WEBHOOK_SECRET=your-calcom-webhook-secret # Cal.com app URL for links CALCOM_APP_URL=https://app.cal.com +# Shared secret for the push-notification delivery endpoint (POST /api/notifications/deliver). +# Must match CALCOM_CHAT_DELIVERY_SECRET on the /cal backend. +# Generate with: openssl rand -hex 32 +CALCOM_DELIVERY_SECRET=your-delivery-secret + # ─── AI / LLM (Vercel AI Gateway) ──────────────────────────────────────────── # Single API key for all AI models. Get yours at vercel.com/ai-gateway AI_GATEWAY_API_KEY=your-ai-gateway-api-key diff --git a/apps/chat/app/api/notifications/deliver/route.ts b/apps/chat/app/api/notifications/deliver/route.ts new file mode 100644 index 00000000..032febdf --- /dev/null +++ b/apps/chat/app/api/notifications/deliver/route.ts @@ -0,0 +1,111 @@ +import crypto from "node:crypto"; +import { getLogger } from "@/lib/logger"; +import { type DeliverRequest, deliverNotifications } from "@/lib/push-notifications/service"; + +const logger = getLogger("deliver-route"); + +function verifyDeliverySecret(header: string | null): boolean { + const secret = process.env.CALCOM_DELIVERY_SECRET; + if (!secret || !header) return false; + try { + const secretHmac = crypto.createHmac("sha256", "delivery-verify").update(secret).digest(); + const headerHmac = crypto.createHmac("sha256", "delivery-verify").update(header).digest(); + return crypto.timingSafeEqual(headerHmac, secretHmac); + } catch { + return false; + } +} + +function isValidTimeZone(tz: string): boolean { + try { + Intl.DateTimeFormat("en-US", { timeZone: tz }); + return true; + } catch { + return false; + } +} + +function parseDeliverRequest(body: unknown): DeliverRequest | null { + if (typeof body !== "object" || body === null) return null; + const b = body as Record; + if (b.platform !== "SLACK" && b.platform !== "TELEGRAM") return null; + if ( + !Array.isArray(b.subscriptions) || + b.subscriptions.length === 0 || + b.subscriptions.length > 500 + ) + return null; + if (typeof b.payload !== "object" || b.payload === null) return null; + + const p = b.payload as Record; + if (typeof p.title !== "string" || typeof p.timeZone !== "string") return null; + if (typeof p.start !== "string" || typeof p.end !== "string") return null; + if (typeof p.notificationType !== "string" || p.notificationType === "") return null; + if (!Array.isArray(p.hosts) || !Array.isArray(p.attendees)) return null; + if (!isValidTimeZone(p.timeZone)) return null; + if (isNaN(Date.parse(p.start)) || isNaN(Date.parse(p.end))) return null; + + if (b.platform === "SLACK") { + const valid = b.subscriptions.every( + (s: unknown) => + typeof s === "object" && + s !== null && + typeof (s as Record).identifier === "string" && + typeof (s as Record).teamId === "string" + ); + if (!valid) return null; + } else { + const valid = b.subscriptions.every( + (s: unknown) => + typeof s === "object" && + s !== null && + typeof (s as Record).identifier === "string" + ); + if (!valid) return null; + } + + return body as DeliverRequest; +} + +export async function POST(request: Request) { + const secret = request.headers.get("x-cal-delivery-secret"); + if (!verifyDeliverySecret(secret)) { + logger.warn("Delivery auth rejected"); + return new Response(null, { status: 401 }); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + logger.warn("Failed to parse delivery request body"); + return new Response(null, { status: 400 }); + } + + const parsed = parseDeliverRequest(body); + if (!parsed) { + logger.warn("Invalid delivery request body", { + platform: (body as Record)?.platform, + }); + return new Response(null, { status: 400 }); + } + + let results; + try { + results = await deliverNotifications(parsed); + } catch (err) { + logger.error("deliverNotifications threw", { error: String(err) }); + return new Response(null, { status: 422 }); + } + + const failed = results.filter((r) => !r.success).length; + const invalid = results.filter((r) => r.invalidIdentifier).length; + logger.info("Delivery complete", { + platform: parsed.platform, + total: results.length, + failed, + invalidIdentifiers: invalid, + }); + + return Response.json({ results }); +} diff --git a/apps/chat/lib/calcom/client.ts b/apps/chat/lib/calcom/client.ts index 9c9ce4bf..20d646c9 100644 --- a/apps/chat/lib/calcom/client.ts +++ b/apps/chat/lib/calcom/client.ts @@ -83,15 +83,19 @@ async function calcomFetch( retries: number = MAX_RETRIES ): Promise { const url = `${CALCOM_API_URL}${path}`; - const res = await fetchWithRetry(url, { - ...options, - headers: { - "cal-api-version": apiVersion, - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - ...options.headers, + const res = await fetchWithRetry( + url, + { + ...options, + headers: { + "cal-api-version": apiVersion, + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + ...options.headers, + }, }, - }, retries); + retries + ); if (!res.ok) { let errorMessage = `Cal.com API error: ${res.status} ${res.statusText}`; @@ -160,7 +164,9 @@ export async function getAvailableSlots( end: params.end, ...(params.timeZone ? { timeZone: params.timeZone } : {}), ...(params.duration ? { duration: String(params.duration) } : {}), - ...(params.bookingUidToReschedule ? { bookingUidToReschedule: params.bookingUidToReschedule } : {}), + ...(params.bookingUidToReschedule + ? { bookingUidToReschedule: params.bookingUidToReschedule } + : {}), }); // The v2/slots API (2024-09-04) returns `data` as a date-keyed map of // `{ start }` objects — there is no wrapper `slots` property. Normalize to @@ -203,7 +209,9 @@ export async function getAvailableSlotsPublic( end: params.end, ...(params.timeZone ? { timeZone: params.timeZone } : {}), ...(params.duration ? { duration: String(params.duration) } : {}), - ...(params.bookingUidToReschedule ? { bookingUidToReschedule: params.bookingUidToReschedule } : {}), + ...(params.bookingUidToReschedule + ? { bookingUidToReschedule: params.bookingUidToReschedule } + : {}), }); const url = `${CALCOM_API_URL}/v2/slots?${query}`; const res = await fetchWithRetry(url, { @@ -284,9 +292,7 @@ export async function getBookings( const isHost = booking.hosts?.some( (h) => idEq(h.id, currentUser.id) || emailEq(h.email, emailLower) ); - const isAttendee = booking.attendees?.some( - (a) => emailEq(a.email, emailLower) - ); + const isAttendee = booking.attendees?.some((a) => emailEq(a.email, emailLower)); return isHost || isAttendee; }); } @@ -299,31 +305,41 @@ export async function createBooking( accessToken: string, input: CreateBookingInput ): Promise { - return calcomFetch("/v2/bookings", accessToken, { - method: "POST", - body: JSON.stringify(input), - }, API_VERSION, 0); + return calcomFetch( + "/v2/bookings", + accessToken, + { + method: "POST", + body: JSON.stringify(input), + }, + API_VERSION, + 0 + ); } -export async function createBookingPublic( - input: CreatePublicBookingInput -): Promise { +export async function createBookingPublic(input: CreatePublicBookingInput): Promise { const url = `${CALCOM_API_URL}/v2/bookings`; - const res = await fetchWithRetry(url, { - method: "POST", - headers: { - "cal-api-version": "2024-08-13", - "Content-Type": "application/json", + const res = await fetchWithRetry( + url, + { + method: "POST", + headers: { + "cal-api-version": "2024-08-13", + "Content-Type": "application/json", + }, + body: JSON.stringify(input), }, - body: JSON.stringify(input), - }, 0); + 0 + ); if (!res.ok) { const body = await res.text(); let message = `Booking failed (${res.status})`; try { const parsed = JSON.parse(body); message = parsed.error?.message ?? parsed.message ?? message; - } catch { /* use default */ } + } catch { + /* use default */ + } throw new CalcomApiError(message, res.status); } const json = (await res.json()) as CalcomApiResponse; @@ -339,13 +355,19 @@ export async function cancelBooking( reason?: string, cancelSubsequentBookings?: boolean ): Promise { - await calcomFetch(`/v2/bookings/${bookingUid}/cancel`, accessToken, { - method: "POST", - body: JSON.stringify({ - cancellationReason: reason, - ...(cancelSubsequentBookings ? { cancelSubsequentBookings: true } : {}), - }), - }, API_VERSION, 0); + await calcomFetch( + `/v2/bookings/${bookingUid}/cancel`, + accessToken, + { + method: "POST", + body: JSON.stringify({ + cancellationReason: reason, + ...(cancelSubsequentBookings ? { cancelSubsequentBookings: true } : {}), + }), + }, + API_VERSION, + 0 + ); } export async function rescheduleBooking( @@ -355,14 +377,20 @@ export async function rescheduleBooking( reason?: string, rescheduledBy?: string ): Promise { - return calcomFetch(`/v2/bookings/${bookingUid}/reschedule`, accessToken, { - method: "POST", - body: JSON.stringify({ - start: newStart, - reschedulingReason: reason, - ...(rescheduledBy ? { rescheduledBy } : {}), - }), - }, API_VERSION, 0); + return calcomFetch( + `/v2/bookings/${bookingUid}/reschedule`, + accessToken, + { + method: "POST", + body: JSON.stringify({ + start: newStart, + reschedulingReason: reason, + ...(rescheduledBy ? { rescheduledBy } : {}), + }), + }, + API_VERSION, + 0 + ); } export interface CalcomMe { @@ -380,10 +408,16 @@ export async function getMe(accessToken: string): Promise { } export async function updateMe(accessToken: string, input: UpdateMeInput): Promise { - return calcomFetch("/v2/me", accessToken, { - method: "PATCH", - body: JSON.stringify(input), - }, API_VERSION, 0); + return calcomFetch( + "/v2/me", + accessToken, + { + method: "PATCH", + body: JSON.stringify(input), + }, + API_VERSION, + 0 + ); } // ─── Schedules ─────────────────────────────────────────────────────────────── @@ -461,9 +495,15 @@ export async function confirmBooking( accessToken: string, bookingUid: string ): Promise { - return calcomFetch(`/v2/bookings/${bookingUid}/confirm`, accessToken, { - method: "POST", - }, API_VERSION, 0); + return calcomFetch( + `/v2/bookings/${bookingUid}/confirm`, + accessToken, + { + method: "POST", + }, + API_VERSION, + 0 + ); } export async function declineBooking( @@ -471,10 +511,16 @@ export async function declineBooking( bookingUid: string, reason?: string ): Promise { - return calcomFetch(`/v2/bookings/${bookingUid}/decline`, accessToken, { - method: "POST", - body: JSON.stringify({ reason }), - }, API_VERSION, 0); + return calcomFetch( + `/v2/bookings/${bookingUid}/decline`, + accessToken, + { + method: "POST", + body: JSON.stringify({ reason }), + }, + API_VERSION, + 0 + ); } // ─── Calendar busy times ────────────────────────────────────────────────────── @@ -546,10 +592,16 @@ export async function addBookingAttendee( bookingUid: string, input: AddAttendeeInput ): Promise { - await calcomFetch(`/v2/bookings/${bookingUid}/attendees`, accessToken, { - method: "POST", - body: JSON.stringify(input), - }, API_VERSION, 0); + await calcomFetch( + `/v2/bookings/${bookingUid}/attendees`, + accessToken, + { + method: "POST", + body: JSON.stringify(input), + }, + API_VERSION, + 0 + ); } // ─── Public event types (no auth) ───────────────────────────────────────────── @@ -590,13 +642,19 @@ export async function markNoShow( host?: boolean, attendees?: Array<{ email: string; absent: boolean }> ): Promise { - await calcomFetch(`/v2/bookings/${bookingUid}/mark-absent`, accessToken, { - method: "POST", - body: JSON.stringify({ - ...(host !== undefined ? { host } : {}), - ...(attendees ? { attendees } : {}), - }), - }, API_VERSION, 0); + await calcomFetch( + `/v2/bookings/${bookingUid}/mark-absent`, + accessToken, + { + method: "POST", + body: JSON.stringify({ + ...(host !== undefined ? { host } : {}), + ...(attendees ? { attendees } : {}), + }), + }, + API_VERSION, + 0 + ); } // ─── AI Agent Credits ──────────────────────────────────────────────────────── @@ -639,12 +697,84 @@ export async function chargeCredits( accessToken: string, params: { externalRef: string; credits?: number } ): Promise { - return calcomFetch("/v2/credits/charge", accessToken, { - method: "POST", - body: JSON.stringify({ - credits: params.credits ?? AI_AGENT_CREDITS_PER_MESSAGE, - creditFor: "AI_AGENT", - externalRef: params.externalRef, - }), - }, API_VERSION, 0); + return calcomFetch( + "/v2/credits/charge", + accessToken, + { + method: "POST", + body: JSON.stringify({ + credits: params.credits ?? AI_AGENT_CREDITS_PER_MESSAGE, + creditFor: "AI_AGENT", + externalRef: params.externalRef, + }), + }, + API_VERSION, + 0 + ); +} + +// ─── Chat push notification subscriptions ──────────────────────────────────── + +export async function registerSlackSubscription( + accessToken: string, + input: { identifier: string; teamId: string } +): Promise { + await calcomFetch( + "/v2/notifications/subscriptions/slack", + accessToken, + { + method: "POST", + body: JSON.stringify(input), + }, + API_VERSION, + 0 + ); +} + +export async function removeSlackSubscription( + accessToken: string, + input: { identifier: string } +): Promise { + await calcomFetch( + "/v2/notifications/subscriptions/slack", + accessToken, + { + method: "DELETE", + body: JSON.stringify(input), + }, + API_VERSION, + 0 + ); +} + +export async function registerTelegramSubscription( + accessToken: string, + input: { identifier: string } +): Promise { + await calcomFetch( + "/v2/notifications/subscriptions/telegram", + accessToken, + { + method: "POST", + body: JSON.stringify(input), + }, + API_VERSION, + 0 + ); +} + +export async function removeTelegramSubscription( + accessToken: string, + input: { identifier: string } +): Promise { + await calcomFetch( + "/v2/notifications/subscriptions/telegram", + accessToken, + { + method: "DELETE", + body: JSON.stringify(input), + }, + API_VERSION, + 0 + ); } diff --git a/apps/chat/lib/env.ts b/apps/chat/lib/env.ts index 343a9712..754a65cd 100644 --- a/apps/chat/lib/env.ts +++ b/apps/chat/lib/env.ts @@ -22,6 +22,16 @@ export function validateRequiredEnv(): void { ); } + if (!process.env.CALCOM_DELIVERY_SECRET) { + if (process.env.NODE_ENV === "production") { + missing.push("CALCOM_DELIVERY_SECRET"); + } else if (process.env.NODE_ENV === "development") { + console.warn( + "CALCOM_DELIVERY_SECRET not set — POST /api/notifications/deliver will reject all requests." + ); + } + } + if (missing.length > 0) { throw new Error(`Missing required environment variables: ${missing.join(", ")}`); } diff --git a/apps/chat/lib/handlers/slack.ts b/apps/chat/lib/handlers/slack.ts index e08c66dc..640ff4fc 100644 --- a/apps/chat/lib/handlers/slack.ts +++ b/apps/chat/lib/handlers/slack.ts @@ -30,6 +30,8 @@ import { getBookings, getEventTypesByUsername, getSchedules, + registerSlackSubscription, + removeSlackSubscription, rescheduleBooking, } from "../calcom/client"; import { generateAuthUrl } from "../calcom/oauth"; @@ -302,7 +304,8 @@ export function registerSlackHandlers( blocks: cardToBlockKit(homeCard), }); } catch (err) { - const isAuthError = err instanceof CalcomApiError && (err.statusCode === 401 || err.statusCode === 403); + const isAuthError = + err instanceof CalcomApiError && (err.statusCode === 401 || err.statusCode === 403); const authUrl = generateAuthUrl("slack", teamId, userId); const errorCard = isAuthError ? Card({ @@ -314,9 +317,7 @@ export function registerSlackHandlers( }) : Card({ title: "Could Not Load Bookings", - children: [ - CardText("Could not load bookings. Please try again later."), - ], + children: [CardText("Could not load bookings. Please try again later.")], }); await slack.publishHomeView(userId, { type: "home", @@ -398,6 +399,78 @@ export function registerSlackHandlers( case "unlink": await handleUnlink(event, teamId, userId); break; + case "notify": { + const notifyArg = args[1]?.toLowerCase(); + if (notifyArg !== "on" && notifyArg !== "off") { + await event.channel.postEphemeral( + event.user, + "Usage: `/cal notify on` or `/cal notify off`", + { fallbackToDM: true } + ); + break; + } + const notifyToken = await getValidAccessToken(teamId, userId); + if (!notifyToken) { + await event.channel.postEphemeral( + event.user, + oauthLinkMessage("slack", teamId, userId), + { fallbackToDM: true } + ); + break; + } + const notifyLinked = await getLinkedUser(teamId, userId); + if (!notifyLinked) { + await event.channel.postEphemeral( + event.user, + oauthLinkMessage("slack", teamId, userId), + { fallbackToDM: true } + ); + break; + } + if (notifyArg === "on") { + try { + await registerSlackSubscription(notifyToken, { + identifier: userId, + teamId, + }); + await event.channel.postEphemeral( + event.user, + "✅ You'll now receive booking notifications via DM.", + { fallbackToDM: true } + ); + } catch (err) { + if (err instanceof CalcomApiError && err.statusCode === 409) { + await event.channel.postEphemeral( + event.user, + "You're already subscribed to booking notifications.", + { fallbackToDM: true } + ); + } else { + throw err; + } + } + } else { + try { + await removeSlackSubscription(notifyToken, { identifier: userId }); + await event.channel.postEphemeral( + event.user, + "🔕 Booking push notifications turned off.", + { fallbackToDM: true } + ); + } catch (err) { + if (err instanceof CalcomApiError && err.statusCode === 404) { + await event.channel.postEphemeral( + event.user, + "You don't have push notifications enabled.", + { fallbackToDM: true } + ); + } else { + throw err; + } + } + } + break; + } case "help": await event.channel.postEphemeral(event.user, helpCard(), { fallbackToDM: true }); break; @@ -499,7 +572,11 @@ export function registerSlackHandlers( { fallbackToDM: true } ); } else { - await event.channel.postEphemeral(event.user, availabilityListCard(allSlots, eventType.title), { fallbackToDM: true }); + await event.channel.postEphemeral( + event.user, + availabilityListCard(allSlots, eventType.title), + { fallbackToDM: true } + ); } break; } @@ -795,7 +872,9 @@ export function registerSlackHandlers( await chargeCredits(accessTokenForAgent, { externalRef }); } catch { // Don't fail the interaction if charging fails - bot.getLogger("/cal").warn("Failed to charge credits via slash command", { userId, externalRef }); + bot + .getLogger("/cal") + .warn("Failed to charge credits via slash command", { userId, externalRef }); } } } @@ -1180,7 +1259,9 @@ export function registerSlackHandlers( if (err instanceof CalcomApiError) { return friendlyCalcomError(err, "booking"); } - return err instanceof Error ? "Failed to create the booking. Please try again." : undefined; + return err instanceof Error + ? "Failed to create the booking. Please try again." + : undefined; }, } ); @@ -1205,7 +1286,9 @@ export function registerSlackHandlers( ]); if (!flow || !flow.targetUsername || !linked) { - await thread.post("Booking session expired. Please start again with `/cal book `."); + await thread.post( + "Booking session expired. Please start again with `/cal book `." + ); return; } @@ -1213,7 +1296,9 @@ export function registerSlackHandlers( const targetEventTypes = await getEventTypesByUsername(targetUsername); const selectedEt = targetEventTypes.find((et) => et.slug === selectedSlug); if (!selectedEt) { - await thread.post("Event type not found. Please start again with `/cal book `."); + await thread.post( + "Event type not found. Please start again with `/cal book `." + ); return; } @@ -1275,7 +1360,9 @@ export function registerSlackHandlers( const flow = await getBookingFlow(teamId, userId); if (!flow || !flow.targetUsername) { - await thread.post("Booking session expired. Please start again with `/cal book `."); + await thread.post( + "Booking session expired. Please start again with `/cal book `." + ); return; } @@ -1287,9 +1374,7 @@ export function registerSlackHandlers( selectedSlot: selectedTime, }); - await thread.post( - bookConfirmCard(flow.eventTypeTitle, slotLabel, flow.targetUsername) - ); + await thread.post(bookConfirmCard(flow.eventTypeTitle, slotLabel, flow.targetUsername)); }, { postError: (msg) => thread.post(msg).catch(() => {}), @@ -1327,13 +1412,7 @@ export function registerSlackHandlers( getLinkedUser(teamId, userId), ]); - if ( - !flow || - !flow.selectedSlot || - !flow.eventTypeSlug || - !flow.targetUsername || - !linked - ) { + if (!flow || !flow.selectedSlot || !flow.eventTypeSlug || !flow.targetUsername || !linked) { await thread.post( "Booking session expired. Please start again with `/cal book `." ); @@ -1519,17 +1598,17 @@ export function registerSlackHandlers( const eventTypeSlug = selected.eventType?.slug; const eventTypeId = selected.eventType?.id ?? 0; if (!eventTypeSlug) { - await thread.post("Cannot reschedule: event type information is missing for this booking."); + await thread.post( + "Cannot reschedule: event type information is missing for this booking." + ); return; } const emailLower = linked.calcomEmail.toLowerCase(); - const isHost = - selected.hosts?.some( - (h) => - String(h.id) === String(linked.calcomUserId) || - h.email?.toLowerCase() === emailLower - ); + const isHost = selected.hosts?.some( + (h) => + String(h.id) === String(linked.calcomUserId) || h.email?.toLowerCase() === emailLower + ); if (!isHost) { await thread.post( "You're an attendee on this booking, not the host. Rescheduling as an attendee isn't supported here — please use the reschedule link in your booking confirmation email or reschedule at ." @@ -1601,7 +1680,9 @@ export function registerSlackHandlers( async () => { const flow = await getRescheduleFlow(teamId, userId); if (!flow) { - await thread.post("Reschedule session expired. Please start again with `/cal reschedule`."); + await thread.post( + "Reschedule session expired. Please start again with `/cal reschedule`." + ); return; } @@ -1651,7 +1732,9 @@ export function registerSlackHandlers( async () => { const flow = await getRescheduleFlow(teamId, userId); if (!flow?.selectedSlot) { - await thread.post("Reschedule session expired. Please start again with `/cal reschedule`."); + await thread.post( + "Reschedule session expired. Please start again with `/cal reschedule`." + ); return; } const accessToken = await getValidAccessToken(teamId, userId); @@ -1742,7 +1825,10 @@ export function registerSlackHandlers( const messageText = lastUserMessage.content as string; const msgHash = createHash("sha256").update(messageText).digest("hex").slice(0, 12); - const retryEventId = createHash("sha256").update(getRetryEventId(event.raw)).digest("hex").slice(0, 12); + const retryEventId = createHash("sha256") + .update(getRetryEventId(event.raw)) + .digest("hex") + .slice(0, 12); const externalRef = `agent-${event.adapter.name}-${thread.id}-${msgHash}-retry-${retryEventId}`; try { await chargeCredits(accessToken, { externalRef }); diff --git a/apps/chat/lib/handlers/telegram.ts b/apps/chat/lib/handlers/telegram.ts index a778c42e..fd0514dd 100644 --- a/apps/chat/lib/handlers/telegram.ts +++ b/apps/chat/lib/handlers/telegram.ts @@ -1,12 +1,15 @@ import type { Chat, ChatElement, Message, Thread } from "chat"; import { Actions, Button, Card, CardText, LinkButton } from "chat"; import { + CalcomApiError, cancelBooking, createBookingPublic, getAvailableSlotsPublic, getBookings, getEventTypesByUsername, getSchedules, + registerTelegramSubscription, + removeTelegramSubscription, rescheduleBooking, } from "../calcom/client"; import { generateAuthUrl } from "../calcom/oauth"; @@ -47,6 +50,7 @@ export const TELEGRAM_COMMANDS = [ "help", "link", "unlink", + "notify", "bookings", "availability", "profile", @@ -192,6 +196,56 @@ export async function handleTelegramCommand( return; } + if (cmd === "notify") { + if (isGroup) { + await thread.post("Please check your DMs — `/notify` only works in a private chat with the bot."); + return; + } + const notifyArg = rest.split(/\s+/)[0]?.toLowerCase(); + if (notifyArg !== "on" && notifyArg !== "off") { + await thread.post("Usage: `/notify on` or `/notify off`"); + return; + } + const auth = await requireAuth(); + if (!auth) return; + if (notifyArg === "on") { + try { + await registerTelegramSubscription(auth.accessToken, { + identifier: ctx.userId, + }); + await thread.post( + "✅ You'll now receive booking notifications via DM." + ); + } catch (err) { + if (err instanceof CalcomApiError && err.statusCode === 409) { + await thread.post( + "You're already subscribed to booking notifications." + ); + } else { + throw err; + } + } + } else { + try { + await removeTelegramSubscription(auth.accessToken, { + identifier: ctx.userId, + }); + await thread.post( + "🔕 Booking push notifications turned off." + ); + } catch (err) { + if (err instanceof CalcomApiError && err.statusCode === 404) { + await thread.post( + "You don't have push notifications enabled." + ); + } else { + throw err; + } + } + } + return; + } + if (cmd === "bookings") { const auth = await requireAuth(); if (!auth) return; diff --git a/apps/chat/lib/notifications.ts b/apps/chat/lib/notifications.ts index dd297785..c561af75 100644 --- a/apps/chat/lib/notifications.ts +++ b/apps/chat/lib/notifications.ts @@ -292,6 +292,7 @@ export function helpCard() { Field({ label: "/cal event-types", value: "List your event types" }), Field({ label: "/cal schedules", value: "Show your working hours" }), Field({ label: "/cal profile", value: "Show your profile" }), + Field({ label: "/cal notify on|off", value: "Enable or disable booking notifications" }), Field({ label: "/cal link, /cal unlink", value: "Connect or disconnect Cal.com" }), Field({ label: "/cal help", value: "Show this help message" }), ]), @@ -317,6 +318,7 @@ export function telegramHelpCard() { Field({ label: "/eventtypes", value: "List your event types" }), Field({ label: "/schedules", value: "Show your working hours" }), Field({ label: "/profile", value: "Show your profile" }), + Field({ label: "/notify on · /notify off", value: "Enable or disable booking notifications" }), Field({ label: "/link / /unlink", value: "Connect or disconnect Cal.com" }), Field({ label: "/help · @mention", value: "Help or ask in natural language" }), ]), diff --git a/apps/chat/lib/push-notifications/deliver-slack.ts b/apps/chat/lib/push-notifications/deliver-slack.ts new file mode 100644 index 00000000..06e1d29f --- /dev/null +++ b/apps/chat/lib/push-notifications/deliver-slack.ts @@ -0,0 +1,48 @@ +import type { ChatElement } from "chat"; +import { bot, slackAdapter } from "@/lib/bot"; +import { getLogger } from "@/lib/logger"; +import type { DeliverResult } from "./types"; + +const logger = getLogger("deliver-slack"); + +const INVALID_SLACK_ERROR_CODES = new Set([ + "channel_not_found", + "not_in_channel", + "account_inactive", +]); + +export async function deliverSlack( + identifier: string, + teamId: string, + card: ChatElement +): Promise { + const installation = await slackAdapter.getInstallation(teamId); + if (!installation) { + logger.warn("No installation found", { identifier, teamId }); + return { identifier, success: false, invalidIdentifier: true, error: "no_installation" }; + } + + try { + await slackAdapter.withBotToken(installation.botToken, async () => { + await bot.channel(`slack:${identifier}`).post(card); + }); + return { identifier, success: true }; + } catch (err) { + if ( + err instanceof Error && + "code" in err && + (err as Record).code === "slack_webapi_platform_error" + ) { + const data = (err as Record).data as Record | undefined; + const slackError = typeof data?.error === "string" ? data.error : ""; + if (INVALID_SLACK_ERROR_CODES.has(slackError)) { + logger.warn("Invalid Slack identifier", { identifier, teamId, slackError }); + return { identifier, success: false, invalidIdentifier: true, error: slackError }; + } + logger.error("Slack API error", { identifier, teamId, slackError }); + return { identifier, success: false, error: slackError || "slack_api_error" }; + } + logger.error("Unexpected Slack delivery error", { identifier, teamId, error: String(err) }); + return { identifier, success: false, error: String(err) }; + } +} diff --git a/apps/chat/lib/push-notifications/deliver-telegram.ts b/apps/chat/lib/push-notifications/deliver-telegram.ts new file mode 100644 index 00000000..d6966ad7 --- /dev/null +++ b/apps/chat/lib/push-notifications/deliver-telegram.ts @@ -0,0 +1,31 @@ +import type { ChatElement } from "chat"; +import { bot } from "@/lib/bot"; +import { getLogger } from "@/lib/logger"; +import type { DeliverResult } from "./types"; + +const logger = getLogger("deliver-telegram"); + +const TELEGRAM_NOT_FOUND_PHRASES = [ + "chat not found", + "user not found", + "bot was blocked by the user", +]; + +export async function deliverTelegram( + identifier: string, + card: ChatElement +): Promise { + try { + await bot.channel(`telegram:${identifier}`).post(card); + return { identifier, success: true }; + } catch (err) { + const msg = String(err).toLowerCase(); + const isInvalid = TELEGRAM_NOT_FOUND_PHRASES.some((phrase) => msg.includes(phrase)); + if (isInvalid) { + logger.warn("Invalid Telegram identifier", { identifier, error: msg }); + return { identifier, success: false, invalidIdentifier: true, error: msg }; + } + logger.error("Telegram delivery error", { identifier, error: msg }); + return { identifier, success: false, error: msg }; + } +} diff --git a/apps/chat/lib/push-notifications/formatter.ts b/apps/chat/lib/push-notifications/formatter.ts new file mode 100644 index 00000000..47fc36d0 --- /dev/null +++ b/apps/chat/lib/push-notifications/formatter.ts @@ -0,0 +1,113 @@ +// Mirrors ChatPushPayload from calcom/cal PR #2927 +// packages/features/notifications/send-chat-push-notification.ts + +import type { ChatElement } from "chat"; +import { Actions, Card, Divider, Field, Fields, LinkButton } from "chat"; + +export type ChatPushPayload = { + title: string; + body: string; + data?: Record; + notificationType: string; + hosts: Array<{ name: string; email: string }>; + attendees: Array<{ name: string; email: string }>; + start: string; + end: string; + timeZone: string; + location?: string; + meetingUrl?: string; + cancellationReason?: string; +}; + +const NOTIFICATION_BADGES: Record = { + BOOKING_CONFIRMED: "✅ Booking Confirmed", + BOOKING_CANCELLED: "❌ Booking Cancelled", + BOOKING_RESCHEDULED: "🔄 Booking Rescheduled", + BOOKING_REQUESTED: "🕐 Booking Requested", + BOOKING_REJECTED: "🚫 Booking Rejected", +}; + +function isHttpUrl(url: string): boolean { + return /^https?:\/\//i.test(url); +} + +function formatPushTime(start: string, end: string, timeZone: string): string { + const startDate = new Date(start); + const endDate = new Date(end); + const dateFmt = new Intl.DateTimeFormat("en-US", { + timeZone, + weekday: "short", + month: "short", + day: "numeric", + }); + const timeFmt = new Intl.DateTimeFormat("en-US", { + timeZone, + hour: "numeric", + minute: "2-digit", + hour12: true, + timeZoneName: "short", + }); + const startTimeFmt = new Intl.DateTimeFormat("en-US", { + timeZone, + hour: "numeric", + minute: "2-digit", + hour12: true, + }); + const datePart = dateFmt.format(startDate).replace(/,/g, ""); + return `${datePart} · ${startTimeFmt.format(startDate)}–${timeFmt.format(endDate)}`; +} + +function formatPeople(people: Array<{ name: string; email: string }>): string { + return people.map((p) => `${p.name} · ${p.email}`).join(", "); +} + +export function buildPushCard(payload: ChatPushPayload): ChatElement { + const badge = + NOTIFICATION_BADGES[payload.notificationType] ?? + `📅 ${payload.notificationType.replace(/_/g, " ").toLowerCase()}`; + const when = formatPushTime(payload.start, payload.end, payload.timeZone); + + const meetingField = payload.meetingUrl + ? isHttpUrl(payload.meetingUrl) + ? [Field({ label: "Meeting", value: `[Join](${payload.meetingUrl})` })] + : [Field({ label: "Meeting", value: payload.meetingUrl })] + : payload.location + ? [Field({ label: "Location", value: payload.location })] + : []; + + const reasonField = + (payload.notificationType === "BOOKING_CANCELLED" || + payload.notificationType === "BOOKING_REJECTED") && + payload.cancellationReason + ? [Field({ label: "Reason", value: payload.cancellationReason })] + : []; + + const bookingUrl = payload.data?.url; + const viewBookingUrl = + bookingUrl && isHttpUrl(bookingUrl) ? bookingUrl : "https://app.cal.com/bookings"; + + return Card({ + title: badge, + subtitle: payload.title, + children: [ + Fields([ + Field({ label: "When", value: when }), + ...(payload.hosts.length > 0 + ? [Field({ label: "Hosts", value: formatPeople(payload.hosts) })] + : []), + ...(payload.attendees.length > 0 + ? [Field({ label: "Attendees", value: formatPeople(payload.attendees) })] + : []), + ...meetingField, + ...reasonField, + ]), + Divider(), + Actions([ + LinkButton({ + url: viewBookingUrl, + label: "View Booking", + }), + ]), + ], + }); +} diff --git a/apps/chat/lib/push-notifications/service.ts b/apps/chat/lib/push-notifications/service.ts new file mode 100644 index 00000000..f047c52b --- /dev/null +++ b/apps/chat/lib/push-notifications/service.ts @@ -0,0 +1,48 @@ +import { getLogger } from "@/lib/logger"; +import { deliverSlack } from "./deliver-slack"; +import { deliverTelegram } from "./deliver-telegram"; +import { buildPushCard, type ChatPushPayload } from "./formatter"; +import type { DeliverResult } from "./types"; + +const logger = getLogger("push-service"); + +export type DeliverRequest = + | { + platform: "SLACK"; + subscriptions: Array<{ identifier: string; teamId: string }>; + payload: ChatPushPayload; + } + | { + platform: "TELEGRAM"; + subscriptions: Array<{ identifier: string }>; + payload: ChatPushPayload; + }; + +export type { DeliverResult }; + +export async function deliverNotifications(request: DeliverRequest): Promise { + const card = buildPushCard(request.payload); + + const settled = + request.platform === "SLACK" + ? await Promise.allSettled( + request.subscriptions.map((sub) => deliverSlack(sub.identifier, sub.teamId, card)) + ) + : await Promise.allSettled( + request.subscriptions.map((sub) => deliverTelegram(sub.identifier, card)) + ); + + return settled.map((result, i) => { + if (result.status === "fulfilled") return result.value; + logger.error("Delivery promise rejected", { + identifier: request.subscriptions[i].identifier, + platform: request.platform, + reason: String(result.reason), + }); + return { + identifier: request.subscriptions[i].identifier, + success: false, + error: String(result.reason), + }; + }); +} diff --git a/apps/chat/lib/push-notifications/types.ts b/apps/chat/lib/push-notifications/types.ts new file mode 100644 index 00000000..5dc559ad --- /dev/null +++ b/apps/chat/lib/push-notifications/types.ts @@ -0,0 +1,6 @@ +export type DeliverResult = { + identifier: string; + success: boolean; + invalidIdentifier?: boolean; + error?: string; +}; diff --git a/apps/mobile/components/PushNotificationProvider.tsx b/apps/mobile/components/PushNotificationProvider.tsx index 8db5b704..fbd7d97c 100644 --- a/apps/mobile/components/PushNotificationProvider.tsx +++ b/apps/mobile/components/PushNotificationProvider.tsx @@ -68,7 +68,10 @@ export function PushNotificationProvider({ children }: PushNotificationProviderP } else if (result.token) { // Server registration failed but token was obtained — store it so // we can still deregister on logout. + console.warn("[PushNotif] push registration failed:", result.reason); registeredTokenRef.current = result.token; + } else { + console.warn("[PushNotif] push registration failed:", result.reason); } })(); return () => { diff --git a/apps/mobile/hooks/use-push-notifications.ts b/apps/mobile/hooks/use-push-notifications.ts index 590dfa8c..dd6915d5 100644 --- a/apps/mobile/hooks/use-push-notifications.ts +++ b/apps/mobile/hooks/use-push-notifications.ts @@ -21,8 +21,10 @@ async function getDeviceId(): Promise { id = Crypto.randomUUID(); await secureStorage.set(DEVICE_ID_KEY, id); return id; - } catch { - return id ?? Crypto.randomUUID(); + } catch (err) { + const fallback = id ?? Crypto.randomUUID(); + console.warn("[PushNotif] secureStorage error getting deviceId, using fallback:", err); + return fallback; } } @@ -59,6 +61,7 @@ export async function requestAndRegisterPushToken(): Promise { try { await CalComAPIService.removeAppPushSubscription(token); - } catch { + } catch (error) { // Best-effort: server cleans up stale tokens on failed send attempts. + console.warn("[PushNotif] deregisterPushToken failed (non-fatal):", error); } } diff --git a/apps/mobile/services/calcom/notifications.ts b/apps/mobile/services/calcom/notifications.ts index 53bfff25..b34986c6 100644 --- a/apps/mobile/services/calcom/notifications.ts +++ b/apps/mobile/services/calcom/notifications.ts @@ -19,7 +19,7 @@ export async function registerAppPushSubscription( } ); } catch (error) { - console.error("registerAppPushSubscription error"); + console.error("registerAppPushSubscription error", error); throw error; } } @@ -32,7 +32,7 @@ export async function removeAppPushSubscription(token: string): Promise<{ status body: JSON.stringify({ token }), }); } catch (error) { - console.error("removeAppPushSubscription error"); + console.error("removeAppPushSubscription error", error); throw error; } }