Slack bot behavior, message parsing, confirmation flow, and button handlers.
The bot uses Slack's Socket Mode for real-time communication without needing a public URL. It monitors a specific channel for leave-related messages and processes them through an LLM-powered parser.
| Scope | Purpose |
|---|---|
channels:history |
Read messages in public channels |
channels:read |
View basic channel info |
chat:write |
Send messages and replies |
users:read |
Get user info (display names) |
users:read.email |
Get user email for matching |
reactions:write |
Add reactions (optional) |
Subscribe to:
message.channels- Messages in public channels
- Enable Socket Mode in app settings
- Generate App-Level Token with
connections:writescope - No public URL required
- Create a new Slack app at https://api.slack.com/apps
- Enable Socket Mode under "Socket Mode"
- Add bot token scopes under "OAuth & Permissions"
- Subscribe to events under "Event Subscriptions"
- Install the app to your workspace
- Invite the bot to
#wfh-leaves-ooo
leave_bot/bot/handlers.py
def should_process_message(message, channel_id, trigger_keywords):
# 1. Must be in the configured leave channel
if message["channel"] != channel_id:
return False
# 2. Ignore bot messages
if message.get("bot_id"):
return False
# 3. Must contain a trigger keyword
text = message.get("text", "").lower()
keywords = trigger_keywords.split(",")
return any(keyword.strip() in text for keyword in keywords)leave, ooo, wfh, sick, vacation, pto, day off
- Top-level messages: Primary trigger point, require trigger keywords
- Thread replies: Processed without keyword check (thread context is the signal)
- After confirmation: Bot stops listening to the thread once a leave is confirmed/completed
- Superseding: New confirmations in a thread expire and update previous pending confirmations
leave_bot/bot/parser.py
The LLM receives context about:
- User's timezone (from Slack profile)
- Current date and day of week
- Parsing rules for dates, leave types, categories
class LeaveDate(BaseModel):
date: str # YYYY-MM-DD
type: Literal["full", "half_am", "half_pm"]
category: Literal["vacation", "sick"]
class ParsedLeave(BaseModel):
is_leave_request: bool
is_cancellation: bool
confidence: Literal["high", "medium", "low"]
dates: list[LeaveDate]
original_text_summary: str
ambiguity_notes: str| Rule | Behavior |
|---|---|
| Relative dates | "tomorrow", "next Monday" → absolute dates |
| Date ranges | "5th to 10th" → 6 individual date entries |
| Half days | "first half", "afternoon" → half_am or half_pm |
| Sick vs vacation | Keywords like "fever", "unwell" → sick |
| WFH messages | Return is_leave_request: false |
| Policy questions | Return is_leave_request: false |
| Level | Action |
|---|---|
high |
Proceed with confirmation |
medium |
Include ambiguity note in confirmation |
low |
Ask for clarification instead |
- Reject dates before Jan 1 of current year
- Past dates within current year are allowed (retroactive logging)
- Future dates in following months are accepted
# Input: "on leave tomorrow"
{
"is_leave_request": True,
"dates": [{"date": "2026-01-07", "type": "full", "category": "vacation"}],
"confidence": "high"
}
# Input: "down with fever, taking today off"
{
"is_leave_request": True,
"dates": [{"date": "2026-01-06", "type": "full", "category": "sick"}],
"confidence": "high"
}
# Input: "half day tomorrow afternoon"
{
"is_leave_request": True,
"dates": [{"date": "2026-01-07", "type": "half_pm", "category": "vacation"}],
"confidence": "high"
}
# Input: "can someone explain leave policy?"
{
"is_leave_request": False,
"dates": [],
"confidence": "high"
}Location: leave_bot/bot/blocks.py
┌────────────────────────────────────────────────────────────┐
│ 📅 I'll record the following leave for you: │
│ │
│ • Friday, Jan 3, 2026 — Full day (Vacation) │
│ • Monday, Jan 6, 2026 — Full day (Vacation) │
│ │
│ This will: │
│ • Create events in the Leave calendar │
│ • Log 8 hours per full day in Harvest │
│ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ ✓ Confirm │ │ ✗ Cancel │ │
│ └─────────────┘ └─────────────┘ │
└────────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────┐
│ ⚠️ You already have leave recorded for Jan 3, 2026. │
│ │
│ I'll record the following NEW leave: │
│ • Monday, Jan 6, 2026 — Full day (Vacation) │
│ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ ✓ Confirm │ │ ✗ Cancel │ │
│ └─────────────┘ └─────────────┘ │
└────────────────────────────────────────────────────────────┘
After sync completes:
┌────────────────────────────────────────────────────────────┐
│ ✅ Leave recorded successfully! │
│ │
│ • Friday, Jan 3, 2026 — Full day (Vacation) │
│ │
│ ✓ Calendar event created │
│ ✓ Harvest time entry logged │
└────────────────────────────────────────────────────────────┘
If sync fails:
┌────────────────────────────────────────────────────────────┐
│ ⚠️ Leave recorded with issues: │
│ │
│ • Friday, Jan 3, 2026 — Full day (Vacation) │
│ │
│ ✓ Calendar event created │
│ ✗ Harvest sync failed: Rate limit exceeded │
│ │
│ The admin can retry the Harvest sync later. │
└────────────────────────────────────────────────────────────┘
| Action ID | Purpose |
|---|---|
leave_confirm |
User confirms the leave request |
leave_cancel |
User cancels the request |
@app.action("leave_confirm")
async def handle_leave_confirm(ack, body, client):
# 1. Acknowledge immediately (Slack 3-second requirement)
await ack()
# 2. Extract pending action ID from button value
action_id = body["actions"][0]["value"]
# 3. Mark pending action as confirmed
# Worker will pick it up for processing
# 4. Update message to show "Processing..."@app.action("leave_cancel")
async def handle_leave_cancel(ack, body, client):
# 1. Acknowledge immediately
await ack()
# 2. Mark pending action as cancelled
# 3. Update message to show "Cancelled"- Double-clicks are handled gracefully
- Second click shows "already processed" message
- PendingAction status is checked before processing
When LLM detects is_cancellation: true:
-
With specific dates mentioned:
- Look up those dates in leave_records
- Offer to delete calendar events and Harvest entries
-
Without specific dates:
- Reply asking for clarification
┌────────────────────────────────────────────────────────────┐
│ 🔄 Which leave dates would you like to cancel? │
│ │
│ Please reply in this thread with the specific dates, │
│ e.g., "cancel my leave on Jan 3" or "cancel Jan 3-5" │
└────────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────┐
│ 🔄 I'll cancel your leave for: │
│ │
│ • Friday, Jan 3, 2026 │
│ │
│ This will delete the calendar event and Harvest entry. │
│ │
│ ┌─────────────────────┐ ┌─────────────────┐ │
│ │ ✓ Yes, cancel │ │ ✗ Keep it │ │
│ └─────────────────────┘ └─────────────────┘ │
└────────────────────────────────────────────────────────────┘
leave_bot/bot/worker.py
class Worker:
POLL_INTERVAL_SECONDS = 5
EXPIRE_CHECK_INTERVAL_SECONDS = 60
async def run(self):
while self.running:
await self.process_confirmed_actions()
await self.expire_old_actions()
await asyncio.sleep(POLL_INTERVAL_SECONDS)- Fetch
pending_actionswithstatus='confirmed' - Mark as
'processing' - Create
leave_recordsentries - Call sync service (Calendar + Harvest)
- Mark as
'completed' - Update Slack message with results
- Pending actions expire after 1 hour (configurable)
- Worker marks expired actions and disables buttons
- User must post a new message to try again
The worker does not automatically retry failed syncs. This is intentional:
- The database is not the source of truth—Slack confirmations are
- Users may delete leaves externally (in Calendar/Harvest)
- Automatic retries would recreate deleted entries
Failed syncs can be manually retried via the web admin API (POST /api/leaves/{id}/retry).
When a user posts a follow-up message in a thread that triggers a new confirmation:
- All previous
pendingactions in that thread are markedexpired - Their Slack messages are updated to show "↩️ This request was superseded by a newer one below"
- Only the newest confirmation has active buttons
This ensures users only have one active confirmation to deal with at a time.
Once a leave is confirmed in a thread:
- The
PendingActionstatus becomesconfirmed→processing→completed - The bot stops listening to further messages in that thread
- Users wanting to modify the leave must use a new top-level message or the web admin
- Slack Events API provides unique
event_id - Stored in
pending_actions.slack_event_id - Unique index prevents duplicate processing
- Insert conflict → treat as retry, no-op
| Scenario | Handling |
|---|---|
| Message edits | Ignored; user must cancel and re-post |
| User deletes message | Pending action expires naturally |
| Bot message deleted | Pending action expires naturally |
| Weekend dates | Optional warning in confirmation |
| @mentions in message | Stripped before parsing |
- Architecture - System overview
- Database - PendingAction and LeaveRecord models
- Integrations - OpenAI configuration
- Configuration - Slack environment variables