feat: add Slack bot app for AI-powered Cal.com scheduling#17
feat: add Slack bot app for AI-powered Cal.com scheduling#17dhairyashiil wants to merge 4 commits intomainfrom
Conversation
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
There was a problem hiding this comment.
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), { |
There was a problem hiding this comment.
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>
| if (!_client) { | ||
| _client = createClient({ url: process.env.REDIS_URL }); | ||
| _client.on("error", (err) => console.error("Redis client error:", err)); | ||
| _client.connect().catch(console.error); |
There was a problem hiding this comment.
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>
| "users:read.email", | ||
| ].join(","); | ||
|
|
||
| return `https://slack.com/oauth/v2/authorize?client_id=${clientId}&scope=${scopes}&redirect_uri=${encodeURIComponent(redirectUri)}`; |
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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>
|
|
||
| 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}`); |
There was a problem hiding this comment.
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>
| // 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; |
There was a problem hiding this comment.
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>
Summary
slack/that enables AI-powered Cal.com scheduling directly from SlackWhat's included
lib/bot.tslib/agent.tslib/calcom/client.tslib/notifications.tslib/user-linking.tsapp/api/auth/slack/callback/app/api/webhooks/app/page.tsxArchitecture
Test plan
cd slack && npm install && npm run devstarts without errorsnpm run start,npm run ext, etc.)Made with Cursor