Slack Handlers Documentation
This document provides technical documentation for all Slack event handlers in the Yappa Knowledge Hub bot.
Architecture Overview
The Slack bot uses the Bolt framework for Node.js with Socket Mode. Handlers are organized by responsibility:
- events.ts - Message and file events
- commands.ts - Slash commands
- actions.ts - Button clicks and select menus
- submissions.ts - Modal submissions and closures
- shortcuts.ts - Global and message shortcuts
- home.ts - App Home tab
- dashboard.ts - Dashboard modal logic
- items.ts - Knowledge item operations
- lists.ts - Category/list management
- saveModals.ts - Save modal builders
- helpers.ts - Validation and parsing utilities
Handler Registration
Handlers are registered in src/app.ts:
// Events
app.message(handleMessageEvent);
app.event('file_shared', handleFileShared);
// Commands
app.command('/knowledge', handleKnowledgeCommand);
// Actions
app.action(/.+/, handleActions);
// Shortcuts
app.shortcut('add_to_hub', handleAddToHubShortcut);
// Submissions
app.view('add_knowledge', handleViewSubmission);
app.view({ callback_id: /.+/, type: 'view_closed' }, handleViewClosed);
// App Home
app.event('app_home_opened', handleAppHomeOpened);events.ts - Message and File Events
Purpose
Detects URLs and files shared in channels, offering to save them to the knowledge hub.
Key Functions
handleMessageEvent()
Processes all message events in channels where the bot is present.
async function handleMessageEvent({
message,
client
}: SlackEventMiddlewareArgs<'message'> & AllMiddlewareArgs): Promise<void>Flow:
- Skip bot messages and messages with subtypes (edits, deletes)
- Extract URLs using regex:
/(https?:\/\/[^\s<>]+)/g - Call
handleUrlDetection()if URLs found
Event Filtering:
- Ignores messages with
subtype(bot messages, edits, etc.) - Requires
textproperty - Only processes first URL in message
handleUrlDetection()
Scrapes URL metadata and posts interactive message.
async function handleUrlDetection({
message,
urls,
client
}: {
message: any;
urls: string[];
client: WebClient;
}): Promise<void>Flow:
- Clean URL (remove trailing punctuation)
- Validate URL format
- Scrape metadata using
scrapeUrlMetadata() - Build Block Kit message with:
- Title and description
- Preview image (if available)
- Site name, type, author (context)
- "Opslaan in Hub" button
- Post ephemeral message to user
Button Value:
{
"url": "https://example.com",
"messageText": "Original message text...",
"messageTs": "1234567890.123456",
"channelId": "C01234567",
"metadata": {
"title": "Page Title",
"description": "Page description",
"image": "https://example.com/image.jpg"
}
}handleFileShared()
Detects file shares and offers to save them.
async function handleFileShared({
event,
client
}: SlackEventMiddlewareArgs<'file_shared'> & AllMiddlewareArgs): Promise<void>Flow:
- Fetch file info using
files.infoAPI - Extract file details (name, type, URL)
- Post ephemeral message with "Opslaan in Hub" button
- Button opens save file modal
Supported File Types:
- PDFs
- Images (jpg, png, gif)
- Documents (docx, xlsx, pptx)
- Text files
Error Handling
- Catches scraping errors and logs them
- Falls back to URL-only if metadata unavailable
- Validates URLs before processing
Dependencies
urlScraper.ts- Metadata scrapingsaveModals.ts- Modal builders
commands.ts - Slash Commands
Purpose
Handles the /knowledge slash command to open the dashboard.
Key Functions
handleKnowledgeCommand()
Opens the knowledge dashboard modal.
async function handleKnowledgeCommand({
command,
ack,
client
}: SlackCommandMiddlewareArgs & AllMiddlewareArgs): Promise<void>Flow:
- Acknowledge command immediately
- Call
openDashboard()with trigger_id - Handle errors with ephemeral message
openDashboard()
Opens dashboard modal with loading state, then updates with content.
async function openDashboard(
client: WebClient,
triggerId: string,
userId: string,
page: number = 0,
selectedCategories: string[] = []
): Promise<void>Flow:
- Open modal immediately with loading state (prevents trigger_id expiration)
- Fetch categories from cache (fast)
- Fetch items only if categories selected
- Paginate items (10 per page)
- Update modal with full content
Loading State:
{
type: 'modal',
callback_id: 'knowledge_dashboard',
title: { type: 'plain_text', text: 'Knowledge Hub' },
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: ':hourglass_flowing_sand: *Laden...*\n_Dashboard wordt geladen_'
}
}
]
}Performance Optimization:
- Opens modal immediately to avoid trigger_id expiration (3 seconds)
- Uses category cache (30-second TTL)
- Only loads items when categories selected
- Pagination reduces payload size
Trigger ID Expiration
Trigger IDs expire after 3 seconds. The loading state pattern ensures the modal opens before expiration, then updates with content.
actions.ts - Button Clicks and Select Menus
Purpose
Routes all action events to appropriate handlers based on action_id.
Key Functions
handleActions()
Main router for all action events.
async function handleActions({
ack,
body,
action,
client
}: SlackActionMiddlewareArgs & AllMiddlewareArgs): Promise<void>Action Routing:
| Action ID | Handler | Description |
|---|---|---|
open_add_modal | openAddModalFromAction() | Open add modal from dashboard |
home_add_knowledge | openAddModal() | Open add modal from App Home |
list_select_with_update | handleCategorySelectionChange() | Update target groups on category change |
refresh_dashboard | refreshDashboard() | Refresh dashboard content |
open_search | openSearchModal() | Open search modal |
open_digest | openDigestModalFromDashboard() | Open digest modal |
manage_lists_dashboard | handleManageLists() | Open manage lists modal |
dashboard_next_page | updateDashboardPage() | Next page |
dashboard_prev_page | updateDashboardPage() | Previous page |
apply_category_filter | applyDashboardCategoryFilter() | Apply category filter |
clear_category_filter | clearDashboardCategoryFilter() | Clear category filter |
manage_item_* | Item handlers | Edit, delete, share operations |
browse_list_* | handleBrowseList() | Browse category items |
save_url_to_knowledge | openSaveUrlModal() | Save URL from detection |
save_file_to_knowledge | openSaveFileModal() | Save file from detection |
save_reaction_to_knowledge | openSaveReactionModal() | Save from reaction |
Flow:
- Acknowledge action immediately
- Extract
action_idfrom action object - Route to appropriate handler
- Handle errors with user-facing messages
Item Management Actions
Overflow menu actions follow pattern: manage_item_{itemId}
Value Format: {operation}_{itemId}
Operations:
delete_{itemId}- Delete itemedit_{itemId}- Edit itemshare_{itemId}- Share item to channel
Parsing:
const value = action.selected_option.value;
const underscoreIndex = value.indexOf('_');
const operation = value.substring(0, underscoreIndex);
const itemId = value.substring(underscoreIndex + 1);Category Selection Change
The list_select_with_update action enables cascading selection:
- User selects category
- Action handler fetches category's default target groups
- Modal is updated with pre-selected target groups
Note: This uses views.update API, not dispatch_action (which doesn't exist in Slack API).
submissions.ts - Modal Submissions
Purpose
Handles modal submissions and view_closed events.
Key Functions
handleViewSubmission()
Processes modal submissions based on callback_id.
async function handleViewSubmission({
ack,
body,
view,
client
}: SlackViewMiddlewareArgs<SlackViewAction> & AllMiddlewareArgs): Promise<void>Callback IDs:
| Callback ID | Handler | Description |
|---|---|---|
add_knowledge | handleAddKnowledge() | Add new knowledge item |
edit_knowledge | handleEditKnowledge() | Edit existing item |
save_url_modal | handleSaveUrl() | Save URL from detection |
save_file_modal | handleSaveFile() | Save file from detection |
add_list_modal | handleAddList() | Add new category |
edit_list_modal | handleEditList() | Edit category |
search_modal | handleSearch() | Search knowledge |
Flow:
- Extract
callback_idfrom view - Parse
private_metadata(JSON) - Extract form values from
view.state.values - Validate input
- Call backend API
- Acknowledge with success/error
- Refresh relevant views
handleAddKnowledge()
Adds new knowledge item to backend and Notion.
async function handleAddKnowledge(
ack: any,
view: any,
client: WebClient,
userId: string
): Promise<void>Form Values:
const values = view.state.values;
const title = values["title_block"]?.["title_input"]?.value || '';
const url = values["url_block"]?.["url_input"]?.value || '';
const summary = values["summary_block"]?.["summary_input"]?.value || '';
const selectedCategories = values["list_block"]?.["list_select_with_update"]?.selected_options || [];
const selectedTargetGroups = values["target_groups_block"]?.["target_groups_select"]?.selected_options || [];
const tags = values["tags_block"]?.["tags_input"]?.value || '';Validation:
- Title required (max 200 chars)
- URL optional but must be valid format
- At least one category required
- At least one target group required
Backend API Call:
const response = await fetch(`${BACKEND_URL}/api/knowledge`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title,
url,
summary,
categories: categoryIds,
targetGroups: targetGroupNames,
tags: parsedTags,
source: 'slack',
addedBy: userId
})
});Success Response:
await ack({
response_action: 'update',
view: {
type: 'modal',
title: { type: 'plain_text', text: 'Succes!' },
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: ':white_check_mark: *Item toegevoegd!*\n\nHet item is opgeslagen in de Knowledge Hub.'
}
}
]
}
});Error Response:
await ack({
response_action: 'errors',
errors: {
title_input: 'Titel is verplicht',
list_select_with_update: 'Selecteer minimaal n categorie'
}
});handleViewClosed()
Cleans up when modals are closed without submission.
async function handleViewClosed({
body,
view
}: SlackViewMiddlewareArgs<SlackViewClosedAction> & AllMiddlewareArgs): Promise<void>Use Cases:
- User clicks "Cancel" or "X"
- Modal times out
- User presses Escape
Cleanup:
- Log closure for analytics
- Clear temporary data
- No API calls needed
Private Metadata
Modals use private_metadata to pass data between views:
const metadata = {
itemId: '123',
returnView: 'dashboard',
page: 0,
filters: ['cat1', 'cat2']
};
view.private_metadata = JSON.stringify(metadata);Size Limit: 3000 characters
Best Practice: Only store IDs and minimal state, not full objects.
shortcuts.ts - Global and Message Shortcuts
Purpose
Handles the add_to_hub global shortcut.
Key Functions
handleAddToHubShortcut()
Opens add knowledge modal from anywhere in Slack.
async function handleAddToHubShortcut({
shortcut,
ack,
client
}: SlackShortcutMiddlewareArgs<GlobalShortcut> & AllMiddlewareArgs): Promise<void>Flow:
- Acknowledge shortcut immediately
- Fetch categories from cache
- Open add modal with pre-populated categories
- Handle trigger_id expiration with loading state
Performance Issue:
- Originally took 3+ seconds due to fetching 26 categories from Notion
- Fixed with 30-second category cache
- Pre-cache categories on app startup
Shortcut Configuration:
- Type: Global shortcut
- Name: "Toevoegen aan Hub"
- Callback ID:
add_to_hub - Description: "Voeg een item toe aan de Knowledge Hub"
helpers.ts - Validation and Parsing
Purpose
Shared utilities for validation and data parsing.
Key Functions
parseTags()
Parses comma-separated tag string into array.
function parseTags(tagsString: string): string[]Input: "technology, innovation, AI"Output: ["technology", "innovation", "AI"]
Processing:
- Split on commas
- Trim whitespace
- Remove empty strings
- Deduplicate
parseMultiSelect()
Extracts values from Slack multi-select options.
function parseMultiSelect(options: any[]): string[]Input:
[
{ value: '1', text: { type: 'plain_text', text: 'Option 1' } },
{ value: '2', text: { type: 'plain_text', text: 'Option 2' } }
]Output: ['1', '2']
validateAddKnowledge()
Validates add knowledge form data.
function validateAddKnowledge(data: {
title: string;
url?: string;
categories: string[];
targetGroups: string[];
}): { valid: boolean; errors: Record<string, string> }Validation Rules:
- Title: Required, 1-200 characters
- URL: Optional, must be valid URL format
- Categories: At least one required
- Target Groups: At least one required
Error Format:
{
valid: false,
errors: {
title_input: 'Titel is verplicht',
list_select_with_update: 'Selecteer minimaal n categorie'
}
}buildAddKnowledgeBlocks()
Builds Block Kit blocks for add knowledge modal.
function buildAddKnowledgeBlocks(options: {
categories: Category[];
targetGroups: TargetGroup[];
initialValues?: {
title?: string;
url?: string;
summary?: string;
selectedCategories?: string[];
selectedTargetGroups?: string[];
tags?: string;
};
}): any[]Block Structure:
- Title input (plain_text_input)
- URL input (url_text_input)
- Summary input (plain_text_input, multiline)
- Category select (static_select with
list_select_with_update) - Target groups multi-select (multi_static_select)
- Tags input (plain_text_input)
Category Select:
{
type: 'input',
block_id: 'list_block',
label: { type: 'plain_text', text: 'Thematische Lijst' },
element: {
type: 'static_select',
action_id: 'list_select_with_update',
placeholder: { type: 'plain_text', text: 'Selecteer een lijst' },
options: categoryOptions
}
}Error Handling Patterns
Try-Catch Blocks
All handlers use try-catch for error handling:
try {
// Handler logic
} catch (error) {
console.error('[Handler] Error:', error);
// User-facing error message
}User-Facing Errors
Errors are shown to users via:
- Ephemeral messages (for actions):
await client.chat.postEphemeral({
channel: channelId,
user: userId,
text: ':x: Er is een fout opgetreden. Probeer het opnieuw.'
});- Modal errors (for submissions):
await ack({
response_action: 'errors',
errors: {
field_id: 'Error message'
}
});- Update modal (for severe errors):
await ack({
response_action: 'update',
view: {
type: 'modal',
title: { type: 'plain_text', text: 'Fout' },
blocks: [/* error blocks */]
}
});Logging
All handlers log key events:
console.log('[Handler] Action:', actionId);
console.log('[Handler] User:', userId);
console.log('[Handler] Error:', error);Log Format: [HandlerName] Event: details
Performance Considerations
Trigger ID Expiration
Trigger IDs expire after 3 seconds. Strategies:
Loading State Pattern:
- Open modal immediately with loading message
- Fetch data asynchronously
- Update modal with content
Caching:
- Cache frequently accessed data (categories)
- Pre-load on app startup
- Use short TTL (30 seconds)
Minimize API Calls:
- Batch requests when possible
- Only fetch required data
- Use pagination
Modal Size Limits
- Blocks: Max 100 blocks per view
- Private Metadata: Max 3000 characters
- Text: Max 3000 characters per text block
Rate Limiting
Slack API rate limits:
- Tier 1: 1+ requests per minute
- Tier 2: 20+ requests per minute
- Tier 3: 50+ requests per minute
- Tier 4: 100+ requests per minute
Best Practices:
- Acknowledge immediately (within 3 seconds)
- Use
views.updateinstead ofviews.openwhen possible - Batch operations
- Implement exponential backoff
Testing
Manual Testing
Message Events:
- Share URL in channel
- Verify detection message appears
- Click "Opslaan in Hub" button
Commands:
- Run
/knowledgecommand - Verify dashboard opens
- Test pagination and filtering
- Run
Shortcuts:
- Open shortcuts menu (Cmd+Shift+A)
- Select "Toevoegen aan Hub"
- Verify modal opens quickly
Modals:
- Fill out form
- Submit
- Verify success message
- Check backend for saved data
Automated Testing
import { describe, it, expect, vi } from 'vitest';
describe('handleMessageEvent', () => {
it('detects URLs in messages', async () => {
const mockClient = {
chat: {
postEphemeral: vi.fn()
}
};
const message = {
text: 'Check out https://example.com',
user: 'U123',
channel: 'C123',
ts: '1234567890.123456'
};
await handleMessageEvent({
message,
client: mockClient as any
});
expect(mockClient.chat.postEphemeral).toHaveBeenCalled();
});
});Next Steps
- Slack Bot Guide - User-facing documentation
- Slack Modals - Modal structure details
- Backend API - API endpoints used by handlers