Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
redis-data/
.env
CLAUDE.md
**/Makefile.local
33 changes: 23 additions & 10 deletions deploybot-ts/Makefile
Original file line number Diff line number Diff line change
@@ -1,3 +1,14 @@
# Include local overrides if present
-include Makefile.local

# Default settings (can be overridden in Makefile.local)
SLACK_CHANNEL_ID ?= C07HR5LJ0KT
SLACK_TEAM_ID ?=
SLACK_USER_ID ?= U07HR5DNQBB
THREAD_TS ?= 1741484452358769
# Define a literal comma for use in conditionals
COMMA := ,

.PHONY: test-curl-new-thread
test-curl-new-thread:
curl -X POST http://localhost:8001/webhook/generic \
Expand All @@ -7,14 +18,15 @@ test-curl-new-thread:
"is_test": false, \
"type": "agent_slack.received", \
"event": { \
"thread_ts": "1741484452358769", \
"channel_id": "C07HR5LJ0KT", \
"thread_ts": "$(THREAD_TS)", \
"channel_id": "$(SLACK_CHANNEL_ID)", \
$(if $(SLACK_TEAM_ID),"team_id": "$(SLACK_TEAM_ID)"$(COMMA)) \
"events": [ \
{ \
"from_user_id": "U07HR5DNQBB", \
"channel_id": "C07HR5LJ0KT", \
"from_user_id": "$(SLACK_USER_ID)", \
"channel_id": "$(SLACK_CHANNEL_ID)", \
"content": "@deploybot do we have any git commits that need to be deployed?", \
"message_ts": "1741484452358769" \
"message_ts": "$(THREAD_TS)" \
} \
] \
} \
Expand All @@ -30,14 +42,15 @@ test-curl-list-vercel-deployments:
"is_test": false, \
"type": "agent_slack.received", \
"event": { \
"thread_ts": "1741484452358769", \
"channel_id": "C07HR5LJ0KT", \
"thread_ts": "$(THREAD_TS)", \
"channel_id": "$(SLACK_CHANNEL_ID)", \
$(if $(SLACK_TEAM_ID),"team_id": "$(SLACK_TEAM_ID)"$(COMMA)) \
"events": [ \
{ \
"from_user_id": "U07HR5DNQBB", \
"channel_id": "C07HR5LJ0KT", \
"from_user_id": "$(SLACK_USER_ID)", \
"channel_id": "$(SLACK_CHANNEL_ID)", \
"content": "do we need to deploy anything to vercel?", \
"message_ts": "1741484452358769" \
"message_ts": "$(THREAD_TS)" \
} \
] \
} \
Expand Down
8 changes: 8 additions & 0 deletions deploybot-ts/Makefile.local.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Local Makefile settings
# Copy this file to Makefile.local and customize for your environment

# Slack settings for test commands
SLACK_CHANNEL_ID = C06QMLHAYP3
SLACK_TEAM_ID = T05L27H2WUX
SLACK_USER_ID = U07HR5DNQBB
THREAD_TS = 1741484452358769
4 changes: 2 additions & 2 deletions deploybot-ts/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions deploybot-ts/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,15 @@ To set up the deploybot:
For development:
- Use `npm run dev` for hot-reloading during development

### Testing locally

To test the local development setup:

1. Copy `Makefile.local.example` to `Makefile.local` and customize with your Slack workspace details
2. Run test commands using `make test-curl-new-thread` or `make test-curl-list-vercel-deployments`

This approach keeps your local testing configuration separate from the main Makefile, preventing accidental commits of your personal settings.

## Deployment

The deploybot can be deployed using:
Expand Down
53 changes: 48 additions & 5 deletions deploybot-ts/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,13 +208,29 @@ const _handleNextStep = async (
})
case 'tag_push_prod':
stateId = await saveThreadState(thread)

// Get allowed responders from environment if configured
const tagPushAllowedResponders = process.env.ALLOWED_SLACK_USER_IDS ?
process.env.ALLOWED_SLACK_USER_IDS.split(',').map(id => id.trim()) :
undefined

// Log channel information for debugging
console.log('Function call channel info:', JSON.stringify({
channel_id: thread.initial_slack_message?.channel_id,
team_id: thread.initial_slack_message?.team_id,
has_token: !!process.env.SLACK_BOT_TOKEN,
auth_mode: process.env.SLACK_AUTH_MODE,
}));

// Just include allowed_responder_ids in the function spec without channel config
await hl.createFunctionCall({
spec: {
fn: 'tag_push_prod',
kwargs: {
sha_to_deploy: nextStep.new_commit.sha,
new_commit: nextStep.new_commit.markdown,
previous_commit: nextStep.previous_commit.markdown,
_allowed_responder_ids: tagPushAllowedResponders, // Pass as parameter instead
},
state: { stateId },
},
Expand All @@ -226,14 +242,30 @@ const _handleNextStep = async (
})
case 'promote_vercel_deployment':
stateId = await saveThreadState(thread)

// Get allowed responders from environment if configured
const deploymentAllowedResponders = process.env.ALLOWED_SLACK_USER_IDS ?
process.env.ALLOWED_SLACK_USER_IDS.split(',').map(id => id.trim()) :
undefined

// Log channel information for debugging
console.log('Vercel deployment channel info:', JSON.stringify({
channel_id: thread.initial_slack_message?.channel_id,
team_id: thread.initial_slack_message?.team_id,
has_token: !!process.env.SLACK_BOT_TOKEN,
auth_mode: process.env.SLACK_AUTH_MODE,
}));

// Just include allowed_responder_ids in the function spec without channel config
await hl.createFunctionCall({
spec: {
fn: 'promote_vercel_deployment',
kwargs: {
new_deployment_sha: nextStep.vercel_deployment.git_commit_sha,
new_deployment: nextStep.vercel_deployment.markdown,
previous_deployment: nextStep.previous_deployment.markdown,
},
kwargs: {
new_deployment_sha: nextStep.vercel_deployment.git_commit_sha,
new_deployment: nextStep.vercel_deployment.markdown,
previous_deployment: nextStep.previous_deployment.markdown,
_allowed_responder_ids: deploymentAllowedResponders, // Pass as parameter instead
},
state: { stateId },
},
})
Expand Down Expand Up @@ -262,12 +294,20 @@ export const handleNextStep = async (thread: Thread): Promise<void> => {
console.log('Looking up token for team:', teamId)

const slackBotToken = await getSlackTokenForTeam(teamId)
console.log('Slack token found:', !!slackBotToken)

// Get allowed responders from environment if configured
const allowedResponders = process.env.ALLOWED_SLACK_USER_IDS ?
process.env.ALLOWED_SLACK_USER_IDS.split(',').map(id => id.trim()) :
undefined

contactChannel = {
slack: {
channel_or_user_id: thread.initial_slack_message?.channel_id || "",
experimental_slack_blocks: true,
bot_token: slackBotToken || undefined,
// @ts-ignore - This property exists in the API but not in the type definition
allowed_responder_ids: allowedResponders,
}
}
} else if (thread.initial_email) {
Expand All @@ -282,6 +322,9 @@ export const handleNextStep = async (thread: Thread): Promise<void> => {
}

console.log(`contactChannel: ${JSON.stringify(contactChannel)}`)
// Log the channel configuration for debugging
console.log('Contact channel configuration:', JSON.stringify(contactChannel));

const hl = humanlayer({ contactChannel: contactChannel || undefined, apiKey: HUMANLAYER_API_KEY })

let nextThread: Thread | false = thread
Expand Down
118 changes: 94 additions & 24 deletions deploybot-ts/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,20 @@ import { getThreadState } from './state'
import crypto from 'crypto'
import { slack } from './tools/slack'
import { WebClient } from '@slack/web-api'
import { handleSlackConnect, SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, SLACK_REDIRECT_URI, generateOAuthState, verifyOAuthState, getSlackToken, handleSlackSuccess, handleSlackCallback } from './slack_server'
import {
handleSlackConnect,
SLACK_CLIENT_ID,
SLACK_CLIENT_SECRET,
SLACK_REDIRECT_URI,
SLACK_BOT_TOKEN,
SLACK_AUTH_MODE,
ALLOWED_SLACK_USER_IDS,
generateOAuthState,
verifyOAuthState,
getSlackToken,
handleSlackSuccess,
handleSlackCallback
} from './slack_server'
import { shouldDropEmail as shouldDropEmail } from './server_email'
import { isPropertyAccessChain } from 'typescript'

Expand All @@ -34,7 +47,25 @@ const port = process.env.PORT || 8000
const newSlackThreadHandler = async (payload: V1Beta2SlackEventReceived, res: Response) => {
console.log(`new slack thread received: ${JSON.stringify(payload)}`)

// Get team ID and look up token
// Check if user is allowed (if allowlist is configured)
if (ALLOWED_SLACK_USER_IDS.size > 0) {
const fromUserId = payload.event.events?.[0]?.from_user_id
if (!fromUserId || !ALLOWED_SLACK_USER_IDS.has(fromUserId)) {
console.log(`Slack message from non-allowed user ${fromUserId}, skipping`)
res.json({ status: 'ok', intent: 'user_not_allowed' })
return
}
}

// Get team ID and validate with auth mode
const teamId = payload.event.team_id;
if (!teamId) {
console.error('No team ID in Slack event payload');
res.json({ status: 'error', intent: 'no_team_id' });
return;
}

// Create the thread state
const thread: Thread = {
initial_slack_message: payload.event,
events: [
Expand Down Expand Up @@ -124,24 +155,48 @@ app.post(

// Basic health check endpoint
app.get('/health', async (req: Request, res: Response) => {
const nextStep = await b.DetermineNextStep(
'<inbound_slack>do we have any commits that need to be deployed?</inbound_slack>',
)
res.json({ status: 'ok', nextStep})
})
res.json({ status: 'ok' });
});

app.get('/', async (req: Request, res: Response) => {
res.json({
const response: any = {
welcome: 'to the deploybot assistant',
instructions: 'https://github.com/got-agents/agents',
slack: `${req.protocol}://${req.get('host')}/slack/connect`,
})
})

// Slack OAuth routes - MUST be before the 404 handler
app.get('/slack/connect', handleSlackConnect)
app.get('/slack/oauth/callback', handleSlackCallback)
app.get('/slack/oauth/success', handleSlackSuccess)
};

// Only include slack connect link in multitenant mode
if (SLACK_AUTH_MODE === 'multitenant' && SLACK_CLIENT_ID) {
response.slack = `${req.protocol}://${req.get('host')}/slack/connect`;
}

res.json(response);
});

/**
* Slack Authentication Modes:
*
* 1. Multitenant Mode (SLACK_AUTH_MODE=multitenant):
* - Requires SLACK_CLIENT_ID and SLACK_CLIENT_SECRET
* - Uses OAuth flow to connect to multiple Slack workspaces
* - Mounts /slack/connect, /slack/oauth/callback, and /slack/oauth/success routes
* - Stores tokens in Redis by team ID
*
* 2. Singletenant Mode (SLACK_AUTH_MODE=singletenant):
* - Requires SLACK_BOT_TOKEN
* - Uses a single bot token for one workspace
* - No OAuth routes mounted
* - Simpler setup for internal deployments
*
* Both modes support restricting allowed Slack users via ALLOWED_SLACK_USER_IDS
*/
if (SLACK_AUTH_MODE === 'multitenant' && SLACK_CLIENT_ID && SLACK_CLIENT_SECRET) {
console.log('Mounting Slack OAuth routes for multitenant mode');
app.get('/slack/connect', handleSlackConnect);
app.get('/slack/oauth/callback', handleSlackCallback);
app.get('/slack/oauth/success', handleSlackSuccess);
} else {
console.log(`Slack OAuth routes not mounted - auth mode: ${SLACK_AUTH_MODE}`);
}

// 404 handler - MUST be last
app.use((req: Request, res: Response) => {
Expand Down Expand Up @@ -221,15 +276,30 @@ export async function serve() {
const apiBase = process.env.HUMANLAYER_API_BASE || 'http://host.docker.internal:8080/humanlayer/v1'
console.log(`humanlayer api base: ${apiBase}`)

console.log(`fetching project from ${apiBase}/project using ${process.env.HUMANLAYER_API_KEY_NAME}`)
// Log Slack configuration
console.log('-------------------------------------')
console.log(`🤖 Slack Authentication Mode: ${SLACK_AUTH_MODE}`)
if (SLACK_AUTH_MODE === 'singletenant') {
console.log(`👉 Using bot token: ${SLACK_BOT_TOKEN ? 'YES ✓' : 'NO ❌ - required for singletenant mode'}`)
} else {
console.log(`👉 OAuth credentials: ${SLACK_CLIENT_ID && SLACK_CLIENT_SECRET ? 'OK ✓' : 'MISSING ❌ - may not work properly'}`)
console.log(`👉 Mounting OAuth routes: ${SLACK_CLIENT_ID && SLACK_CLIENT_SECRET ? 'YES ✓' : 'NO ❌'}`)
}

console.log(`👉 User restrictions: ${ALLOWED_SLACK_USER_IDS.size > 0
? `Active - ${ALLOWED_SLACK_USER_IDS.size} authorized user(s)`
: 'None - all users allowed'}`)
console.log('-------------------------------------')

const project = await fetch(`${apiBase}/project`, {
headers: {
Authorization: `Bearer ${HUMANLAYER_API_KEY}`,
},
})
console.log(await project.json())
console.log(`fetching project from ${apiBase}/project using ${process.env.HUMANLAYER_API_KEY_NAME}`)

const project = await fetch(`${apiBase}/project`, {
headers: {
Authorization: `Bearer ${HUMANLAYER_API_KEY}`,
},
})
console.log(await project.json())

console.log(`Server running at http://localhost:${port}`)
console.log(`Server running at http://localhost:${port}`)
})
}
10 changes: 10 additions & 0 deletions deploybot-ts/src/slack_server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ import { slack } from './tools/slack'
export const SLACK_CLIENT_ID = process.env.SLACK_CLIENT_ID
export const SLACK_CLIENT_SECRET = process.env.SLACK_CLIENT_SECRET
export const SLACK_REDIRECT_URI = process.env.SLACK_REDIRECT_URI || 'http://localhost:8001/slack/oauth/callback'
export const SLACK_BOT_TOKEN = process.env.SLACK_BOT_TOKEN
// Auto-detect auth mode based on available credentials if not explicitly set
export const SLACK_AUTH_MODE = process.env.SLACK_AUTH_MODE ||
(SLACK_BOT_TOKEN ? 'singletenant' :
(SLACK_CLIENT_ID && SLACK_CLIENT_SECRET ? 'multitenant' : 'multitenant'))

// Get allowed user IDs from environment
export const ALLOWED_SLACK_USER_IDS = process.env.ALLOWED_SLACK_USER_IDS
? new Set(process.env.ALLOWED_SLACK_USER_IDS.split(',').map(id => id.trim()))
: new Set<string>()

const redis = new Redis(process.env.REDIS_CACHE_URL || 'redis://redis:6379/1')

Expand Down
Loading