Skip to content

feat: add Slack bot app for AI-powered Cal.com scheduling#17

Draft
dhairyashiil wants to merge 4 commits intomainfrom
feat/slack-app
Draft

feat: add Slack bot app for AI-powered Cal.com scheduling#17
dhairyashiil wants to merge 4 commits intomainfrom
feat/slack-app

Conversation

@dhairyashiil
Copy link
Member

Summary

  • Adds a standalone Next.js Slack bot under slack/ that enables AI-powered Cal.com scheduling directly from Slack
  • Users can manage bookings, schedules, event types, and availability through natural language conversations with the bot
  • Built with Chat SDK, AI SDK (Groq), and Redis for multi-workspace OAuth state management

What's included

Area Files Description
Bot core lib/bot.ts Slack event handling — mentions, DMs, reactions, home tab
AI agent lib/agent.ts Tool-calling agent with 15+ Cal.com tools (list bookings, create event types, manage schedules, etc.)
Cal.com client lib/calcom/client.ts Typed API client for Cal.com v2 endpoints
Notifications lib/notifications.ts Real-time Slack notifications for Cal.com webhook events (booking created/cancelled/rescheduled)
User linking lib/user-linking.ts Links Slack users to Cal.com accounts via API key
OAuth app/api/auth/slack/callback/ Multi-workspace Slack OAuth install flow
Webhooks app/api/webhooks/ Webhook handlers for Slack events and Cal.com notifications
Landing page app/page.tsx Install page with setup instructions

Architecture

  • Standalone Next.js app — deployed independently (e.g., Vercel), does not affect the existing Expo/extension codebase
  • Redis for Slack bot token storage (multi-workspace) and conversation state
  • AI provider is swappable (Groq default, supports OpenAI/Anthropic)

Test plan

  • cd slack && npm install && npm run dev starts without errors
  • Slack OAuth install flow works end-to-end
  • Bot responds to mentions and DMs with Cal.com scheduling actions
  • Cal.com webhook notifications are delivered to Slack
  • Existing companion app builds are unaffected (npm run start, npm run ext, etc.)

Made with Cursor

Adds a standalone Next.js Slack bot under slack/ that lets users
manage Cal.com bookings, schedules, and event types through natural
language in Slack. Built with the Chat SDK, AI SDK (Groq/LLM),
and Redis for multi-workspace state management.

Made-with: Cursor
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

6 issues found across 33 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="slack/slack-manifest.yml">

<violation number="1" location="slack/slack-manifest.yml:21">
P2: Hardcoded Cloudflare tunnel URLs in the Slack manifest will break as soon as the tunnel changes and conflict with the documented deployment instructions. Replace these with a stable deployed domain or a placeholder like `https://your-domain.com` before committing.</violation>
</file>

<file name="slack/app/page.tsx">

<violation number="1" location="slack/app/page.tsx:28">
P2: The OAuth authorize URL omits a `state` parameter, so the install flow can’t validate the request origin and is vulnerable to CSRF. Generate a per-request state value, persist it (Redis/cookie), include it in the authorize URL, and verify it in the callback.</violation>
</file>

<file name="slack/lib/agent.ts">

<violation number="1" location="slack/lib/agent.ts:161">
P1: Security: `link_account` tool routes Cal.com API keys through the third-party LLM provider. When the AI calls this tool, the API key is included in the conversation payload sent to Groq/OpenAI/Anthropic, and persisted in conversation history for subsequent requests. Since `/cal link` already handles linking via slash command (bypassing the LLM), remove this tool from the agent to avoid exposing secrets to third parties. Instead, have the agent's `check_account_linked` tool direct users to the slash command (which it already does via the system prompt).</violation>
</file>

<file name="slack/lib/user-linking.ts">

<violation number="1" location="slack/lib/user-linking.ts:18">
P1: Race condition: `connect()` is not awaited, so Redis commands may be issued before the connection is established. In `node-redis` v4+, this will throw `ClientClosedError`. Make `getRedisClient()` async and store/await the connection promise.</violation>

<violation number="2" location="slack/lib/user-linking.ts:33">
P1: Security: `calcomApiKey` is stored as plain text in Redis. The README documents a `SLACK_ENCRYPTION_KEY` env var for this purpose, but no encryption is actually implemented anywhere. API keys should be encrypted at rest (e.g., using AES-256-GCM with `SLACK_ENCRYPTION_KEY`) before storing in Redis.</violation>
</file>

<file name="slack/lib/bot.ts">

<violation number="1" location="slack/lib/bot.ts:410">
P1: Missing error handling: `getMe()` and `linkUser()` can throw after API key validation succeeds, leaving the user with no feedback. Wrap this block in a try-catch to handle transient failures gracefully.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

data: LinkedUser
): Promise<void> {
const client = getRedisClient();
await client.set(userKey(teamId, slackUserId), JSON.stringify(data), {
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Security: calcomApiKey is stored as plain text in Redis. The README documents a SLACK_ENCRYPTION_KEY env var for this purpose, but no encryption is actually implemented anywhere. API keys should be encrypted at rest (e.g., using AES-256-GCM with SLACK_ENCRYPTION_KEY) before storing in Redis.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At slack/lib/user-linking.ts, line 33:

<comment>Security: `calcomApiKey` is stored as plain text in Redis. The README documents a `SLACK_ENCRYPTION_KEY` env var for this purpose, but no encryption is actually implemented anywhere. API keys should be encrypted at rest (e.g., using AES-256-GCM with `SLACK_ENCRYPTION_KEY`) before storing in Redis.</comment>

<file context>
@@ -0,0 +1,131 @@
+  data: LinkedUser
+): Promise<void> {
+  const client = getRedisClient();
+  await client.set(userKey(teamId, slackUserId), JSON.stringify(data), {
+    EX: 60 * 60 * 24 * 365, // 1 year TTL
+  });
</file context>
Fix with Cubic

if (!_client) {
_client = createClient({ url: process.env.REDIS_URL });
_client.on("error", (err) => console.error("Redis client error:", err));
_client.connect().catch(console.error);
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Race condition: connect() is not awaited, so Redis commands may be issued before the connection is established. In node-redis v4+, this will throw ClientClosedError. Make getRedisClient() async and store/await the connection promise.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At slack/lib/user-linking.ts, line 18:

<comment>Race condition: `connect()` is not awaited, so Redis commands may be issued before the connection is established. In `node-redis` v4+, this will throw `ClientClosedError`. Make `getRedisClient()` async and store/await the connection promise.</comment>

<file context>
@@ -0,0 +1,131 @@
+  if (!_client) {
+    _client = createClient({ url: process.env.REDIS_URL });
+    _client.on("error", (err) => console.error("Redis client error:", err));
+    _client.connect().catch(console.error);
+  }
+  return _client;
</file context>
Fix with Cubic

"users:read.email",
].join(",");

return `https://slack.com/oauth/v2/authorize?client_id=${clientId}&scope=${scopes}&redirect_uri=${encodeURIComponent(redirectUri)}`;
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The OAuth authorize URL omits a state parameter, so the install flow can’t validate the request origin and is vulnerable to CSRF. Generate a per-request state value, persist it (Redis/cookie), include it in the authorize URL, and verify it in the callback.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At slack/app/page.tsx, line 28:

<comment>The OAuth authorize URL omits a `state` parameter, so the install flow can’t validate the request origin and is vulnerable to CSRF. Generate a per-request state value, persist it (Redis/cookie), include it in the authorize URL, and verify it in the callback.</comment>

<file context>
@@ -0,0 +1,358 @@
+    "users:read.email",
+  ].join(",");
+
+  return `https://slack.com/oauth/v2/authorize?client_id=${clientId}&scope=${scopes}&redirect_uri=${encodeURIComponent(redirectUri)}`;
+}
+
</file context>
Fix with Cubic

Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

4 issues found across 12 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="slack/app/api/auth/calcom/callback/route.ts">

<violation number="1" location="slack/app/api/auth/calcom/callback/route.ts:80">
P2: NextResponse.redirect requires an absolute URL, but APP_URL falls back to an empty string. If NEXT_PUBLIC_APP_URL is unset, this builds a relative URL and the route handler throws `TypeError: Failed to parse URL` instead of redirecting. Use an absolute URL (e.g., new URL('/auth/calcom/complete', request.url)) or validate APP_URL before redirecting.</violation>
</file>

<file name="slack/slack-manifest.yml">

<violation number="1" location="slack/slack-manifest.yml:21">
P2: Hardcoding a temporary trycloudflare tunnel in the Slack app manifest will break installs once the tunnel expires and is environment-specific. Use a stable deployed domain or a placeholder that is intended to be replaced per environment.</violation>
</file>

<file name="slack/lib/user-linking.ts">

<violation number="1" location="slack/lib/user-linking.ts:91">
P2: After waiting for a concurrent refresh, the returned token is not validated for freshness. If the other process's refresh hasn't completed in 2s or failed, this returns the stale expired token. Check `tokenExpiresAt` before returning, or retry the refresh.</violation>

<violation number="2" location="slack/lib/user-linking.ts:111">
P1: Unsafe lock release: `client.del(lockKey)` in `finally` unconditionally deletes the lock without verifying ownership. If the refresh takes longer than the 10s TTL, another process may have re-acquired the lock, and this delete will remove *its* lock, defeating the concurrency protection. Use a unique lock value (e.g., UUID) and only delete if it matches, typically via a Lua `EVAL` script:
```ts
const lockValue = crypto.randomUUID();
const acquired = await client.set(lockKey, lockValue, { NX: true, EX: REFRESH_LOCK_TTL });
// ...
// In finally:
await client.eval(
  `if redis.call("get",KEYS[1])==ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end`,
  { keys: [lockKey], arguments: [lockValue] }
);
```</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

console.error("[OAuth] Token refresh failed:", err);
return null;
} finally {
await client.del(lockKey);
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Unsafe lock release: client.del(lockKey) in finally unconditionally deletes the lock without verifying ownership. If the refresh takes longer than the 10s TTL, another process may have re-acquired the lock, and this delete will remove its lock, defeating the concurrency protection. Use a unique lock value (e.g., UUID) and only delete if it matches, typically via a Lua EVAL script:

const lockValue = crypto.randomUUID();
const acquired = await client.set(lockKey, lockValue, { NX: true, EX: REFRESH_LOCK_TTL });
// ...
// In finally:
await client.eval(
  `if redis.call("get",KEYS[1])==ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end`,
  { keys: [lockKey], arguments: [lockValue] }
);
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At slack/lib/user-linking.ts, line 111:

<comment>Unsafe lock release: `client.del(lockKey)` in `finally` unconditionally deletes the lock without verifying ownership. If the refresh takes longer than the 10s TTL, another process may have re-acquired the lock, and this delete will remove *its* lock, defeating the concurrency protection. Use a unique lock value (e.g., UUID) and only delete if it matches, typically via a Lua `EVAL` script:
```ts
const lockValue = crypto.randomUUID();
const acquired = await client.set(lockKey, lockValue, { NX: true, EX: REFRESH_LOCK_TTL });
// ...
// In finally:
await client.eval(
  `if redis.call("get",KEYS[1])==ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end`,
  { keys: [lockKey], arguments: [lockValue] }
);
```</comment>

<file context>
@@ -59,7 +61,59 @@ export async function isUserLinked(teamId: string, slackUserId: string): Promise
+    console.error("[OAuth] Token refresh failed:", err);
+    return null;
+  } finally {
+    await client.del(lockKey);
+  }
+}
</file context>
Fix with Cubic


function redirectWithMessage(type: "success" | "error", message: string) {
const params = new URLSearchParams({ [type === "success" ? "calcom_linked" : "error"]: message });
return NextResponse.redirect(`${APP_URL}/auth/calcom/complete?${params}`);
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: NextResponse.redirect requires an absolute URL, but APP_URL falls back to an empty string. If NEXT_PUBLIC_APP_URL is unset, this builds a relative URL and the route handler throws TypeError: Failed to parse URL instead of redirecting. Use an absolute URL (e.g., new URL('/auth/calcom/complete', request.url)) or validate APP_URL before redirecting.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At slack/app/api/auth/calcom/callback/route.ts, line 80:

<comment>NextResponse.redirect requires an absolute URL, but APP_URL falls back to an empty string. If NEXT_PUBLIC_APP_URL is unset, this builds a relative URL and the route handler throws `TypeError: Failed to parse URL` instead of redirecting. Use an absolute URL (e.g., new URL('/auth/calcom/complete', request.url)) or validate APP_URL before redirecting.</comment>

<file context>
@@ -0,0 +1,81 @@
+
+function redirectWithMessage(type: "success" | "error", message: string) {
+  const params = new URLSearchParams({ [type === "success" ? "calcom_linked" : "error"]: message });
+  return NextResponse.redirect(`${APP_URL}/auth/calcom/complete?${params}`);
+}
</file context>
Fix with Cubic

// Another process is refreshing — wait briefly and read the updated token
await new Promise((r) => setTimeout(r, 2000));
const updated = await getLinkedUser(teamId, slackUserId);
return updated?.accessToken ?? null;
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: After waiting for a concurrent refresh, the returned token is not validated for freshness. If the other process's refresh hasn't completed in 2s or failed, this returns the stale expired token. Check tokenExpiresAt before returning, or retry the refresh.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At slack/lib/user-linking.ts, line 91:

<comment>After waiting for a concurrent refresh, the returned token is not validated for freshness. If the other process's refresh hasn't completed in 2s or failed, this returns the stale expired token. Check `tokenExpiresAt` before returning, or retry the refresh.</comment>

<file context>
@@ -59,7 +61,59 @@ export async function isUserLinked(teamId: string, slackUserId: string): Promise
+    // Another process is refreshing — wait briefly and read the updated token
+    await new Promise((r) => setTimeout(r, 2000));
+    const updated = await getLinkedUser(teamId, slackUserId);
+    return updated?.accessToken ?? null;
+  }
+
</file context>
Fix with Cubic

@dhairyashiil dhairyashiil marked this pull request as draft February 27, 2026 19:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant