initial slack challenge verification and receive messages

This commit is contained in:
dal 2025-07-14 16:08:01 -06:00
parent 4ab3ae97a2
commit a751d22baa
No known key found for this signature in database
GPG Key ID: 16F4B0E1E9F61122
12 changed files with 868 additions and 35 deletions

View File

@ -0,0 +1,58 @@
# Slack API Routes
This directory contains the Slack integration API endpoints.
## Events Endpoint
The `/events` endpoint handles Slack Events API webhooks, including:
- URL verification challenges
- App mention events
### Implementation Details
The endpoint uses a custom `slackWebhookValidator` middleware that:
1. Verifies the request signature using HMAC-SHA256
2. Validates the request timestamp (within 5 minutes)
3. Parses and validates the payload using Zod schemas
4. Handles URL verification challenges automatically
### Using Slack Schemas with zValidator
If you need to validate Slack payloads in other routes, you can use the exported schemas:
```typescript
import { zValidator } from '@hono/zod-validator';
import { appMentionEventSchema, urlVerificationSchema } from '@buster/slack';
// Example: Validate just the inner event
.post('/custom-slack-handler',
zValidator('json', appMentionEventSchema),
async (c) => {
const event = c.req.valid('json');
// event is fully typed as AppMentionEvent
}
)
// Example: Validate URL verification
.post('/verify',
zValidator('json', urlVerificationSchema),
async (c) => {
const { challenge } = c.req.valid('json');
return c.text(challenge);
}
)
```
### Available Schemas
From `@buster/slack`:
- `urlVerificationSchema` - URL verification challenge
- `appMentionEventSchema` - App mention event
- `eventCallbackSchema` - Event callback envelope
- `slackWebhookPayloadSchema` - Union of all webhook payloads
### Security Notes
- Always verify webhook signatures before processing
- Respond with 200 OK even for errors to prevent Slack retries
- Keep the signing secret (`SLACK_SIGNING_SECRET`) secure

View File

@ -1,4 +1,33 @@
export async function eventsHandler(_body: unknown): Promise<{ success: boolean }> {
// Simply accept any JSON payload and return success
return { success: true };
}
import { isEventCallback, type SlackWebhookPayload } from '@buster/slack';
import type { SlackEventsResponse } from '@buster/server-shared/slack';
/**
* Handles Slack Events API webhook requests
* Processes validated webhook payloads
*/
export async function eventsHandler(
payload: SlackWebhookPayload
): Promise<SlackEventsResponse> {
try {
// Handle the event based on type
if (isEventCallback(payload)) {
// Handle app_mention event
const event = payload.event;
console.info('App mentioned:', {
team_id: payload.team_id,
channel: event.channel,
user: event.user,
text: event.text,
event_id: payload.event_id,
});
// TODO: Process app mention and respond
}
return { success: true };
} catch (error) {
console.error('Failed to process Slack event:', error);
throw error;
}
}

View File

@ -2,6 +2,7 @@ import { SlackError } from '@buster/server-shared/slack';
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { requireAuth } from '../../../middleware/auth';
import { slackWebhookValidator } from '../../../middleware/slack-webhook-validator';
import { eventsHandler } from './events';
import { slackHandler } from './handler';
@ -15,38 +16,23 @@ const app = new Hono()
.get('/channels', requireAuth, (c) => slackHandler.getChannels(c))
.delete('/integration', requireAuth, (c) => slackHandler.removeIntegration(c))
// Events endpoint (no auth required for Slack webhooks)
.post('/events', async (c) => {
try {
// Slack sends different content types for different events
// For URL verification, it's application/x-www-form-urlencoded
// For actual events, it's application/json
const contentType = c.req.header('content-type');
if (contentType?.includes('application/x-www-form-urlencoded')) {
// Handle URL verification challenge
const formData = await c.req.parseBody();
if (formData.challenge) {
return c.text(formData.challenge as string);
}
}
// For JSON payloads, try to parse but don't fail
let body = null;
if (contentType?.includes('application/json')) {
try {
body = await c.req.json();
} catch {
// If JSON parsing fails, just continue
}
}
const response = await eventsHandler(body);
return c.json(response);
} catch (error) {
// Log the error but always return 200 OK for Slack
console.error('Error processing Slack event:', error);
return c.json({ success: true });
.post('/events', slackWebhookValidator(), async (c) => {
// Check if this is a URL verification challenge
const challenge = c.get('slackChallenge');
if (challenge) {
return c.text(challenge);
}
// Get the validated payload
const payload = c.get('slackPayload');
if (!payload) {
// This shouldn't happen if middleware works correctly
return c.json({ success: false });
}
// Process the event
const response = await eventsHandler(payload);
return c.json(response);
})
// Error handling
.onError((e, c) => {

View File

@ -0,0 +1,76 @@
import {
handleUrlVerification,
parseSlackWebhookPayload,
verifySlackRequest,
type SlackWebhookPayload,
} from '@buster/slack';
import type { Context, MiddlewareHandler } from 'hono';
import { HTTPException } from 'hono/http-exception';
/**
* Middleware to validate Slack webhook requests
* Combines signature verification and payload parsing
*/
export function slackWebhookValidator(): MiddlewareHandler {
return async (c: Context, next) => {
console.info('Slack webhook received');
try {
// Get the raw body for signature verification
const rawBody = await c.req.text();
console.info('Raw body length:', rawBody.length);
// Get headers for verification
const headers: Record<string, string | string[] | undefined> = {};
c.req.raw.headers.forEach((value, key) => {
headers[key] = value;
});
// Get signing secret from environment
const signingSecret = process.env.SLACK_SIGNING_SECRET;
if (!signingSecret) {
throw new HTTPException(500, {
message: 'SLACK_SIGNING_SECRET not configured',
});
}
// Verify the request signature
verifySlackRequest(rawBody, headers, signingSecret);
// Parse the request body
const parsedBody = JSON.parse(rawBody);
// Check if this is a URL verification request
const challenge = handleUrlVerification(parsedBody);
if (challenge) {
// Set the challenge in context for the handler to use
c.set('slackChallenge', challenge);
c.set('slackPayload', null);
return next();
}
// Parse and validate the webhook payload
const payload = parseSlackWebhookPayload(parsedBody);
// Set the validated payload in context
c.set('slackPayload', payload);
c.set('slackChallenge', null);
return next();
} catch (error) {
// For Slack webhooks, we should always return 200 OK
// to prevent retries, but log the error
console.error('Slack webhook validation error:', error);
// Return 200 OK with success: false
return c.json({ success: false });
}
};
}
// Type extensions for Hono context
declare module 'hono' {
interface ContextVariableMap {
slackPayload: SlackWebhookPayload | null;
slackChallenge: string | null;
}
}

View File

@ -115,3 +115,12 @@ export type RemoveIntegrationResult = z.infer<typeof RemoveIntegrationResultSche
// }
// }
// });
/**
* Response schema for Slack events endpoint
*/
export const SlackEventsResponseSchema = z.object({
success: z.boolean(),
});
export type SlackEventsResponse = z.infer<typeof SlackEventsResponseSchema>;

View File

@ -1,11 +1,28 @@
// Types
export * from './types';
export * from './types/errors';
export * from './types/webhooks';
// Schemas for validation (useful with zValidator)
export {
urlVerificationSchema,
slackRequestHeadersSchema,
appMentionEventSchema,
eventCallbackSchema,
slackEventEnvelopeSchema,
slackWebhookPayloadSchema,
} from './types/webhooks';
// Services
export { SlackAuthService } from './services/auth';
export { SlackChannelService } from './services/channels';
export { SlackMessagingService } from './services/messaging';
export {
verifySlackRequest,
handleUrlVerification,
parseSlackWebhookPayload,
getRawBody,
} from './services/webhook-verification';
// Interfaces
export type {

View File

@ -185,6 +185,27 @@ export class SlackAuthService {
}
}
/**
* Get an authenticated Slack WebClient for a team
* @param teamId The Slack team ID
* @returns WebClient instance configured with the team's access token
*/
async getSlackClient(teamId: string): Promise<WebClient> {
const accessToken = await this.tokenStorage.getToken(teamId);
if (!accessToken) {
throw new SlackIntegrationError(
'TOKEN_NOT_FOUND',
`No access token found for team ${teamId}`,
false
);
}
// Validate token before returning client
await this.validateToken(accessToken);
return new WebClient(accessToken);
}
/**
* Exchange authorization code for access token
* @param code Authorization code from Slack

View File

@ -0,0 +1,198 @@
import { createHmac } from 'node:crypto';
import { describe, expect, it } from 'vitest';
import { SlackIntegrationError } from '../types/errors';
import type { SlackEventEnvelope, UrlVerification } from '../types/webhooks';
import {
handleUrlVerification,
parseSlackWebhookPayload,
verifySlackRequest,
} from './webhook-verification';
describe('webhook-verification integration tests', () => {
// Mock signing secret (in real scenario, this would be from environment)
const MOCK_SIGNING_SECRET = 'b6a8e2d4f9c3e1a7d5b2f8e4c9a3d6f1';
describe('Full webhook verification flow', () => {
it('should handle URL verification challenge end-to-end', () => {
// 1. Slack sends URL verification
const urlVerificationPayload: UrlVerification = {
token: 'Jhj5dZrVaK7ZwHHjRyZWjbDl',
challenge: '3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P',
type: 'url_verification',
};
const rawBody = JSON.stringify(urlVerificationPayload);
const timestamp = Math.floor(Date.now() / 1000).toString();
// 2. Generate signature like Slack would
const baseString = `v0:${timestamp}:${rawBody}`;
const hmac = createHmac('sha256', MOCK_SIGNING_SECRET);
hmac.update(baseString, 'utf8');
const signature = `v0=${hmac.digest('hex')}`;
const headers = {
'x-slack-request-timestamp': timestamp,
'x-slack-signature': signature,
};
// 3. Verify the request
const isValid = verifySlackRequest(rawBody, headers, MOCK_SIGNING_SECRET);
expect(isValid).toBe(true);
// 4. Parse the payload
const parsedPayload = parseSlackWebhookPayload(JSON.parse(rawBody));
expect(parsedPayload).toEqual(urlVerificationPayload);
// 5. Handle URL verification
const challenge = handleUrlVerification(parsedPayload);
expect(challenge).toBe(urlVerificationPayload.challenge);
});
it('should handle event callback with verification', () => {
// 1. Slack sends an event
const eventPayload: SlackEventEnvelope = {
token: 'XXYYZZ',
team_id: 'TXXXXXXXX',
api_app_id: 'AXXXXXXXXX',
event: {
type: 'app_mention',
user: 'U123456',
text: '<@U0LAN0Z89> is it a bird?',
ts: '1515449522.000016',
channel: 'C123456',
event_ts: '1515449522000016',
},
type: 'event_callback',
event_id: 'Ev08MFMKH6',
event_time: 1234567890,
};
const rawBody = JSON.stringify(eventPayload);
const timestamp = Math.floor(Date.now() / 1000).toString();
// 2. Generate signature
const baseString = `v0:${timestamp}:${rawBody}`;
const hmac = createHmac('sha256', MOCK_SIGNING_SECRET);
hmac.update(baseString, 'utf8');
const signature = `v0=${hmac.digest('hex')}`;
const headers = {
'x-slack-request-timestamp': timestamp,
'x-slack-signature': signature,
};
// 3. Verify the request
const isValid = verifySlackRequest(rawBody, headers, MOCK_SIGNING_SECRET);
expect(isValid).toBe(true);
// 4. Parse the payload
const parsedPayload = parseSlackWebhookPayload(JSON.parse(rawBody));
expect(parsedPayload).toEqual(eventPayload);
// 5. Verify it's not a URL verification
const challenge = handleUrlVerification(parsedPayload);
expect(challenge).toBeNull();
});
it('should reject tampered requests', () => {
const payload = {
token: 'test-token',
type: 'event_callback',
event: { type: 'message', text: 'hello' },
};
const rawBody = JSON.stringify(payload);
const timestamp = Math.floor(Date.now() / 1000).toString();
// Generate signature with correct secret
const baseString = `v0:${timestamp}:${rawBody}`;
const hmac = createHmac('sha256', MOCK_SIGNING_SECRET);
hmac.update(baseString, 'utf8');
const signature = `v0=${hmac.digest('hex')}`;
// Tamper with the body after signature generation
const tamperedBody = JSON.stringify({ ...payload, text: 'tampered' });
const headers = {
'x-slack-request-timestamp': timestamp,
'x-slack-signature': signature,
};
// Should fail verification due to body tampering
expect(() => verifySlackRequest(tamperedBody, headers, MOCK_SIGNING_SECRET)).toThrow(
SlackIntegrationError
);
expect(() => verifySlackRequest(tamperedBody, headers, MOCK_SIGNING_SECRET)).toThrow(
'Invalid request signature'
);
});
it('should handle replay attack prevention', () => {
const payload = { type: 'event_callback', event: {} };
const rawBody = JSON.stringify(payload);
// Create timestamp 6 minutes ago
const oldTimestamp = (Math.floor(Date.now() / 1000) - 360).toString();
const baseString = `v0:${oldTimestamp}:${rawBody}`;
const hmac = createHmac('sha256', MOCK_SIGNING_SECRET);
hmac.update(baseString, 'utf8');
const signature = `v0=${hmac.digest('hex')}`;
const headers = {
'x-slack-request-timestamp': oldTimestamp,
'x-slack-signature': signature,
};
// Should reject due to old timestamp
expect(() => verifySlackRequest(rawBody, headers, MOCK_SIGNING_SECRET)).toThrow(
'Request timestamp is too old'
);
});
});
describe('Edge cases and error scenarios', () => {
it('should handle malformed JSON gracefully', () => {
const malformedBody = '{"type": "event_callback", "event": {';
const timestamp = Math.floor(Date.now() / 1000).toString();
// Even with valid signature, parsing should fail
const baseString = `v0:${timestamp}:${malformedBody}`;
const hmac = createHmac('sha256', MOCK_SIGNING_SECRET);
hmac.update(baseString, 'utf8');
const signature = `v0=${hmac.digest('hex')}`;
const headers = {
'x-slack-request-timestamp': timestamp,
'x-slack-signature': signature,
};
// Verification passes (signature is valid for the malformed body)
const isValid = verifySlackRequest(malformedBody, headers, MOCK_SIGNING_SECRET);
expect(isValid).toBe(true);
// But parsing should fail
expect(() => parseSlackWebhookPayload(malformedBody)).toThrow('Invalid webhook payload');
});
it('should handle different content types', () => {
// Form-encoded body (though Slack sends JSON for events)
const formBody = 'token=test&challenge=abc123&type=url_verification';
const timestamp = Math.floor(Date.now() / 1000).toString();
const baseString = `v0:${timestamp}:${formBody}`;
const hmac = createHmac('sha256', MOCK_SIGNING_SECRET);
hmac.update(baseString, 'utf8');
const signature = `v0=${hmac.digest('hex')}`;
const headers = {
'x-slack-request-timestamp': timestamp,
'x-slack-signature': signature,
};
// Should verify successfully (signature is valid)
const isValid = verifySlackRequest(formBody, headers, MOCK_SIGNING_SECRET);
expect(isValid).toBe(true);
});
});
});

View File

@ -0,0 +1,211 @@
import { createHmac } from 'node:crypto';
import { describe, expect, it, vi } from 'vitest';
import { SlackIntegrationError } from '../types/errors';
import {
getRawBody,
handleUrlVerification,
parseSlackWebhookPayload,
verifySlackRequest,
} from './webhook-verification';
describe('webhook-verification', () => {
const signingSecret = 'test-signing-secret';
const timestamp = Math.floor(Date.now() / 1000).toString();
const rawBody = '{"test":"data"}';
const generateSignature = (body: string, ts: string, secret: string): string => {
const baseString = `v0:${ts}:${body}`;
const hmac = createHmac('sha256', secret);
hmac.update(baseString, 'utf8');
return `v0=${hmac.digest('hex')}`;
};
describe('verifySlackRequest', () => {
it('should verify a valid request', () => {
const signature = generateSignature(rawBody, timestamp, signingSecret);
const headers = {
'x-slack-request-timestamp': timestamp,
'x-slack-signature': signature,
};
const result = verifySlackRequest(rawBody, headers, signingSecret);
expect(result).toBe(true);
});
it('should handle headers with different casing', () => {
const signature = generateSignature(rawBody, timestamp, signingSecret);
const headers = {
'X-Slack-Request-Timestamp': timestamp,
'X-SLACK-SIGNATURE': signature,
};
const result = verifySlackRequest(rawBody, headers, signingSecret);
expect(result).toBe(true);
});
it('should handle array header values', () => {
const signature = generateSignature(rawBody, timestamp, signingSecret);
const headers = {
'x-slack-request-timestamp': [timestamp],
'x-slack-signature': [signature],
};
const result = verifySlackRequest(rawBody, headers, signingSecret);
expect(result).toBe(true);
});
it('should throw error for missing headers', () => {
const headers = {};
expect(() => verifySlackRequest(rawBody, headers, signingSecret)).toThrow(
SlackIntegrationError
);
expect(() => verifySlackRequest(rawBody, headers, signingSecret)).toThrow(
'Missing required Slack headers'
);
});
it('should throw error for old timestamp', () => {
const oldTimestamp = (Math.floor(Date.now() / 1000) - 400).toString(); // 6+ minutes old
const signature = generateSignature(rawBody, oldTimestamp, signingSecret);
const headers = {
'x-slack-request-timestamp': oldTimestamp,
'x-slack-signature': signature,
};
expect(() => verifySlackRequest(rawBody, headers, signingSecret)).toThrow(
'Request timestamp is too old'
);
});
it('should throw error for invalid signature', () => {
const headers = {
'x-slack-request-timestamp': timestamp,
'x-slack-signature': 'v0=invalid_signature',
};
expect(() => verifySlackRequest(rawBody, headers, signingSecret)).toThrow(
'Invalid request signature'
);
});
it('should throw error for wrong signing secret', () => {
const signature = generateSignature(rawBody, timestamp, 'wrong-secret');
const headers = {
'x-slack-request-timestamp': timestamp,
'x-slack-signature': signature,
};
expect(() => verifySlackRequest(rawBody, headers, signingSecret)).toThrow(
'Invalid request signature'
);
});
});
describe('handleUrlVerification', () => {
it('should return challenge for valid URL verification', () => {
const body = {
token: 'test-token',
challenge: 'test-challenge-string',
type: 'url_verification',
};
const result = handleUrlVerification(body);
expect(result).toBe('test-challenge-string');
});
it('should return null for non-URL verification requests', () => {
const body = {
type: 'event_callback',
event: { type: 'message' },
};
const result = handleUrlVerification(body);
expect(result).toBeNull();
});
it('should return null for invalid body', () => {
const result = handleUrlVerification('invalid');
expect(result).toBeNull();
});
});
describe('parseSlackWebhookPayload', () => {
it('should parse URL verification payload', () => {
const payload = {
token: 'test-token',
challenge: 'test-challenge',
type: 'url_verification',
};
const result = parseSlackWebhookPayload(payload);
expect(result).toEqual(payload);
});
it('should parse event callback payload', () => {
const payload = {
token: 'test-token',
team_id: 'T123456',
api_app_id: 'A123456',
event: {
type: 'app_mention',
user: 'U123456',
text: '<@U0LAN0Z89> hello',
ts: '1515449522.000016',
channel: 'C123456',
event_ts: '1515449522000016',
},
type: 'event_callback',
event_id: 'Ev123456',
event_time: 1234567890,
};
const result = parseSlackWebhookPayload(payload);
expect(result).toEqual(payload);
});
it('should throw error for invalid payload', () => {
const invalidPayload = {
type: 'invalid_type',
};
expect(() => parseSlackWebhookPayload(invalidPayload)).toThrow(SlackIntegrationError);
expect(() => parseSlackWebhookPayload(invalidPayload)).toThrow('Invalid webhook payload');
});
it('should throw error for non-object payload', () => {
expect(() => parseSlackWebhookPayload('string')).toThrow('Invalid webhook payload');
expect(() => parseSlackWebhookPayload(123)).toThrow('Invalid webhook payload');
expect(() => parseSlackWebhookPayload(null)).toThrow('Invalid webhook payload');
});
});
describe('getRawBody', () => {
it('should return string as-is', () => {
const body = 'test string';
expect(getRawBody(body)).toBe(body);
});
it('should convert Buffer to string', () => {
const body = Buffer.from('test buffer', 'utf8');
expect(getRawBody(body)).toBe('test buffer');
});
it('should stringify objects', () => {
const body = { test: 'data' };
expect(getRawBody(body)).toBe('{"test":"data"}');
});
it('should handle null', () => {
expect(getRawBody(null)).toBe('');
});
it('should handle undefined', () => {
expect(getRawBody(undefined)).toBe('');
});
it('should handle numbers', () => {
expect(getRawBody(123)).toBe('');
});
});
});

View File

@ -0,0 +1,136 @@
import { createHmac } from 'node:crypto';
import { z } from 'zod';
import { SlackIntegrationError } from '../types/errors';
import {
type SlackRequestHeaders,
type SlackWebhookPayload,
type UrlVerification,
slackRequestHeadersSchema,
slackWebhookPayloadSchema,
urlVerificationSchema,
} from '../types/webhooks';
/**
* Maximum age for a valid request (5 minutes in seconds)
*/
const MAX_REQUEST_AGE_SECONDS = 300;
/**
* Verifies that a request is from Slack by checking the signature
* @param rawBody - The raw request body as a string
* @param headers - The request headers containing Slack signature and timestamp
* @param signingSecret - Your app's signing secret from Slack
* @returns true if the request is valid, throws an error otherwise
*/
export function verifySlackRequest(
rawBody: string,
headers: Record<string, string | string[] | undefined>,
signingSecret: string
): boolean {
// Normalize headers to lowercase
const normalizedHeaders: Record<string, string | string[] | undefined> = {};
for (const [key, value] of Object.entries(headers)) {
normalizedHeaders[key.toLowerCase()] = value;
}
// Extract and validate required headers
const timestamp = normalizedHeaders['x-slack-request-timestamp'];
const signature = normalizedHeaders['x-slack-signature'];
if (!timestamp || !signature) {
throw new SlackIntegrationError('VERIFICATION_FAILED', 'Missing required Slack headers');
}
// Ensure headers are strings
const timestampStr = Array.isArray(timestamp) ? timestamp[0] : timestamp;
const signatureStr = Array.isArray(signature) ? signature[0] : signature;
if (!timestampStr || !signatureStr) {
throw new SlackIntegrationError('VERIFICATION_FAILED', 'Missing required Slack headers');
}
// Validate headers with schema
try {
slackRequestHeadersSchema.parse({
'x-slack-request-timestamp': timestampStr,
'x-slack-signature': signatureStr,
});
} catch {
throw new SlackIntegrationError('VERIFICATION_FAILED', 'Invalid Slack headers format');
}
// Check timestamp to prevent replay attacks
const requestTimestamp = Number.parseInt(timestampStr, 10);
const currentTimestamp = Math.floor(Date.now() / 1000);
if (Math.abs(currentTimestamp - requestTimestamp) > MAX_REQUEST_AGE_SECONDS) {
throw new SlackIntegrationError('VERIFICATION_FAILED', 'Request timestamp is too old');
}
// Create the base string for signature
const baseString = `v0:${timestampStr}:${rawBody}`;
// Calculate expected signature
const hmac = createHmac('sha256', signingSecret);
hmac.update(baseString, 'utf8');
const expectedSignature = `v0=${hmac.digest('hex')}`;
// Compare signatures
if (expectedSignature !== signatureStr) {
throw new SlackIntegrationError('VERIFICATION_FAILED', 'Invalid request signature');
}
return true;
}
/**
* Handles URL verification challenges from Slack
* @param body - The request body
* @returns The challenge value if it's a URL verification, null otherwise
*/
export function handleUrlVerification(body: unknown): string | null {
try {
const parsed = urlVerificationSchema.parse(body);
return parsed.challenge;
} catch {
// Not a URL verification request
return null;
}
}
/**
* Parses and validates a Slack webhook payload
* @param body - The request body
* @returns The parsed and validated webhook payload
*/
export function parseSlackWebhookPayload(body: unknown): SlackWebhookPayload {
try {
return slackWebhookPayloadSchema.parse(body);
} catch (error) {
if (error instanceof z.ZodError) {
throw new SlackIntegrationError(
'INVALID_PAYLOAD',
`Invalid webhook payload: ${error.errors.map((e) => e.message).join(', ')}`
);
}
throw new SlackIntegrationError('INVALID_PAYLOAD', 'Failed to parse webhook payload');
}
}
/**
* Helper to get the raw body string from various request formats
* @param body - The request body in various formats
* @returns The raw body as a string
*/
export function getRawBody(body: unknown): string {
if (typeof body === 'string') {
return body;
}
if (Buffer.isBuffer(body)) {
return body.toString('utf8');
}
if (typeof body === 'object' && body !== null) {
return JSON.stringify(body);
}
return '';
}

View File

@ -5,11 +5,14 @@ export const SlackErrorCodeSchema = z.enum([
'OAUTH_INVALID_STATE',
'OAUTH_TOKEN_EXCHANGE_FAILED',
'INVALID_TOKEN',
'TOKEN_NOT_FOUND',
'CHANNEL_NOT_FOUND',
'NOT_IN_CHANNEL',
'RATE_LIMITED',
'NETWORK_ERROR',
'UNKNOWN_ERROR',
'VERIFICATION_FAILED',
'INVALID_PAYLOAD',
]);
export type SlackErrorCode = z.infer<typeof SlackErrorCodeSchema>;

View File

@ -0,0 +1,89 @@
import { z } from 'zod';
/**
* URL Verification Challenge
* Sent by Slack when configuring a Request URL for the Events API
*/
export const urlVerificationSchema = z.object({
token: z.string(),
challenge: z.string(),
type: z.literal('url_verification'),
});
export type UrlVerification = z.infer<typeof urlVerificationSchema>;
/**
* Slack Request Headers
* Headers sent with every request from Slack
*/
export const slackRequestHeadersSchema = z.object({
'x-slack-request-timestamp': z.string(),
'x-slack-signature': z.string(),
});
export type SlackRequestHeaders = z.infer<typeof slackRequestHeadersSchema>;
/**
* App Mention Event
* Sent when a user mentions your app in a message
*/
export const appMentionEventSchema = z.object({
type: z.literal('app_mention'),
user: z.string(),
text: z.string(),
ts: z.string(),
channel: z.string(),
event_ts: z.string(),
});
export type AppMentionEvent = z.infer<typeof appMentionEventSchema>;
/**
* Event Callback Envelope
* The wrapper for all event_callback type events
*/
export const eventCallbackSchema = z.object({
token: z.string(),
team_id: z.string(),
api_app_id: z.string(),
event: appMentionEventSchema,
type: z.literal('event_callback'),
event_id: z.string(),
event_time: z.number(),
});
export type EventCallback = z.infer<typeof eventCallbackSchema>;
/**
* Base Event Envelope (for backwards compatibility)
* Uses record for event field to accept any event type
*/
export const slackEventEnvelopeSchema = z.object({
token: z.string(),
team_id: z.string(),
api_app_id: z.string(),
event: z.record(z.unknown()),
type: z.literal('event_callback'),
event_id: z.string(),
event_time: z.number(),
});
export type SlackEventEnvelope = z.infer<typeof slackEventEnvelopeSchema>;
/**
* Union type for all possible Slack webhook payloads
*/
export const slackWebhookPayloadSchema = z.union([urlVerificationSchema, eventCallbackSchema]);
export type SlackWebhookPayload = z.infer<typeof slackWebhookPayloadSchema>;
/**
* Helper function to determine event type
*/
export function isUrlVerification(payload: SlackWebhookPayload): payload is UrlVerification {
return payload.type === 'url_verification';
}
export function isEventCallback(payload: SlackWebhookPayload): payload is EventCallback {
return payload.type === 'event_callback';
}