An AI-powered Cloudflare Worker that automatically ingests Telegram messages, classifies them (bug/request/normal), drafts AI responses, and escalates to Slack with Linear ticket creation for bugs and feature requests.
- 🔐 Chat Approval Flow: All groups and DMs require manual Slack approval before the bot responds
- ⏱️ 72-Hour Auto-Expiration: Pending approvals expire with automatic chat departure
- 🚫 Blacklist: Rejected chats are blacklisted and auto-rejected if re-added
- 📊 Daily Summaries: Morning (8 AM PST) and evening (4 PM PST) activity reports
- 🤖 AI Classification: Rule-based + AI model fallback for message classification
- 📝 Linear Integration: Automatic triage issue creation for bugs and feature requests
Before diving into full setup, here's the critical path:
- Get Bot Token → Message @BotFather, create bot, copy token
- Generate Secret →
openssl rand -hex 16(save this value!) - Deploy Worker →
bun run deploy(creates D1, R2 automatically) - Apply Migrations →
bun run db:migrate:remote(sets up approval tables) - Set Secrets →
wrangler secret put TELEGRAM_BOT_TOKENandTELEGRAM_WEBHOOK_SECRET(use SAME secret from step 2) - Set Webhook → Tell Telegram your worker URL + secret (must match step 4 exactly)
- Configure Slack → Create Slack app, set up webhooks and slash commands (see Slack Setup below)
- Test →
wrangler tailthen add bot to a group (should trigger approval request)
When someone mentions your bot in a Telegram chat:
- Ingests the message via webhook
- Classifies it as
bug,request,normal, orunknownusing rule-based + AI classification - Generates an AI draft response
- Evaluates response policy:
- Auto-send (≥85% confidence + "normal" label): Sends response immediately
- Escalate (<40% confidence or "unknown"): Sends to Slack with context
- Draft-only (middle confidence): Saves draft for human review
- Creates Linear triage issues for bugs and feature requests
┌─────────────┐ ┌──────────┐ ┌─────────────┐
│ Telegram │────▶│ Webhook │────▶│ Rate Limit │
└─────────────┘ └──────────┘ └─────────────┘
│
▼
┌─────────────────────────────────────────────────────┐
│ Ingest Pipeline │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────┐ │
│ │Normalize │─▶│ Persist │─▶│ Classify │─▶│Respond │ │
│ └──────────┘ └──────────┘ └──────────┘ └────────┘ │
└─────────────────────────────────────────────────────┘
│
┌───────────────────┼───────────────────┐
▼ ▼ ▼
┌─────────┐ ┌───────────┐ ┌──────────┐
│Telegram │ │ Slack │ │ Linear │
│(auto) │ │(escalate) │ │(bug/req) │
└─────────┘ └───────────┘ └──────────┘
- Runtime: Cloudflare Workers
- Framework: Hono
- Database: Cloudflare D1 (SQLite)
- Storage: Cloudflare R2 (archived messages)
- AI: Vercel AI SDK with multi-provider routing (Workers AI, NVIDIA NIM, OpenAI, OpenRouter)
- Language: TypeScript
- Node.js 18+ or Bun
- Cloudflare account
- Telegram Bot (via @BotFather)
- Slack incoming webhook URL
- Linear API key
git clone https://github.com/YOUR_USERNAME/telegram-triage-agent.git
cd telegram-triage-agent
bun install# Login to Cloudflare
npx wrangler login
# Create D1 database
npx wrangler d1 create triage-agent-db
# Copy the database_id from output (you'll need it for deployment)
# Create R2 bucket
npx wrangler r2 bucket create triage-agent-archiveThe project uses a hybrid approach for managing resource IDs:
The project uses environment variable substitution in wrangler.jsonc:
DATABASE_ID: Your D1 database ID from step 2
For Cloudflare Dashboard (GitHub integration):
- Go to your Worker in Cloudflare Dashboard
- Settings → Environment Variables
- Add:
DATABASE_ID=your-real-database-id-from-step-2
For local deployment:
export DATABASE_ID="your-real-database-id-from-step-2"
bun run deployFor local development:
cp .dev.vars.example .dev.vars
# Edit .dev.vars with your actual secrets (local only)Note: .dev.vars is only for local dev. Production secrets must be set via wrangler secret put and must match your Telegram webhook configuration.
# Apply to production database
npx wrangler d1 migrations apply triage-agent-db --remoteGenerate a random secret (or use any strong password):
openssl rand -hex 16
# Example output: a3f5c8e9d2b1a7f4e6c9d8b3a1f2e5c7Important: Use the SAME webhook secret value in both commands below.
# Required - your bot token from @BotFather
npx wrangler secret put TELEGRAM_BOT_TOKEN
# Required - use the value you generated above
npx wrangler secret put TELEGRAM_WEBHOOK_SECRET
# Required for Slack escalation (existing webhook-based feature)
npx wrangler secret put SLACK_WEBHOOK_URL
# Required for Slack approval flow (modern Bot API - no webhooks needed)
npx wrangler secret put SLACK_BOT_TOKEN # xoxb- token from OAuth
npx wrangler secret put SLACK_SIGNING_SECRET # From Slack app Basic Info
npx wrangler secret put SLACK_APPROVAL_CHANNEL_ID # Cxxx ID from #triage-approvals
npx wrangler secret put SLACK_SUMMARY_CHANNEL_ID # Cxxx ID from #triage-summaries
# Optional: Enable activation notifications (default: silent)
npx wrangler secret put NOTIFY_ON_APPROVAL # Set to "true" to notify
# Required for Linear integration
npx wrangler secret put LINEAR_API_KEY
npx wrangler secret put LINEAR_TEAM_ID
npx wrangler secret put LINEAR_TRIAGE_STATE_ID
# Optional (for alternative AI providers)
npx wrangler secret put NVIDIA_API_KEY
npx wrangler secret put OPENAI_API_KEY
npx wrangler secret put OPENROUTER_API_KEYVerify secrets are set:
npx wrangler secret list-
Create a bot via @BotFather and copy the bot token
-
Get your worker URL (run
npx wrangler whoamito see your account subdomain):https://triage-agent.<your-subdomain>.workers.dev -
Set Telegram webhook (use the SAME secret you set in Step 5b):
export TELEGRAM_BOT_TOKEN="your-bot-token-from-botfather" export TELEGRAM_WEBHOOK_SECRET="same-secret-from-step-5b" curl -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/setWebhook" \ -H "Content-Type: application/json" \ -d "{\"url\":\"https://triage-agent.YOUR_SUBDOMAIN.workers.dev/webhook/telegram\",\"secret_token\":\"${TELEGRAM_WEBHOOK_SECRET}\"}"
Expected response:
{"ok":true,"result":true,"description":"Webhook was set"}Verify webhook is set:
curl "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getWebhookInfo" -
Deploy to Cloudflare:
# Type check npx tsc --noEmit # Deploy bun run deploy
-
Add your bot to a Telegram group (or message it directly)
The approval flow requires a Slack app with interactive components. This enables manual approval of chats before the bot responds.
- Go to api.slack.com/apps → Create New App
- Choose "From scratch" → Name it "TriageBot Approvals" → Select your workspace
- Go to OAuth & Permissions
- Add these Bot Token Scopes:
chat:write- Post messagescommands- Slash commandsusers:read- Resolve user mentions
- Install to Workspace and copy the Bot User OAuth Token (starts with
xoxb-) - Set this as
SLACK_BOT_TOKENin wrangler secrets
- Go to Basic Information
- Copy Signing Secret (under App Credentials)
- Set this as
SLACK_SIGNING_SECRETin wrangler secrets
- Go to Interactivity & Shortcuts → Enable
- Set Request URL:
https://triage-agent.YOUR_SUBDOMAIN.workers.dev/webhook/slack/interactions - Save Changes
Go to Slash Commands → Create New Command for each:
| Command | Request URL | Description |
|---|---|---|
/pending-chats |
https://triage-agent.YOUR_SUBDOMAIN.workers.dev/webhook/slack/commands |
List pending approvals |
/batch-approve |
Same as above | Batch approve multiple chats |
/batch-reject |
Same as above | Batch reject multiple chats |
/rejected-chats |
Same as above | View blacklisted chats |
Create three Slack channels (or use existing ones):
- #triage-escalations - For message escalations (uses legacy
SLACK_WEBHOOK_URL) - #triage-approvals - For approval requests (uses modern Bot API)
- #triage-summaries - For daily stats (uses modern Bot API)
Get Channel IDs (required for Bot API):
Method 1 - Right-click (fastest):
- Right-click the channel name in Slack
- Copy link → Paste somewhere
- Extract ID from URL:
https://yourworkspace.slack.com/archives/C1234567890 - Channel ID =
C1234567890
Method 2 - Invite bot:
- In channel:
/invite @TriageBot Approvals - Channel ID appears in the URL
Invite Bot to Channels: In each channel (#triage-approvals, #triage-summaries, #triage-escalations):
/add apps → TriageBot Approvals
Or: Channel settings → Add apps → TriageBot Approvals
The bot uses SLACK_BOT_TOKEN to post directly to these channels - no webhooks needed for #triage-approvals or #triage-summaries.
# Check health endpoint
curl https://telegram-triage-agent.YOUR_SUBDOMAIN.workers.dev/health
# View live logs
npx wrangler tailThe approval flow ensures the bot only operates in explicitly approved chats. This prevents unauthorized usage and gives you control over where the bot is active.
Bot Added to Chat
↓
Check Blacklist ──Blacklisted?──→ Auto-reject & Leave
↓ No
Check Existing ──Approved?──→ Silent activation
↓ No
Create Pending Approval
↓
Send Slack Request (minimal or rich view)
↓
Wait for Admin Decision (72 hours max)
↓
┌──────────┬──────────┬──────────┐
▼ ▼ ▼ ▼
Approved Rejected Expired Unblacklist
↓ ↓ ↓ ↓
Activate Blacklist Leave New request
+ Notify? + Leave + Notify
The approval request uses complexity scoring to determine view richness:
| Factor | Weight | Threshold |
|---|---|---|
| Member count | 30% | >10 triggers rich view |
| Message density | 25% | >5 msg/hour adds weight |
| Urgency signals | 25% | Keywords like "urgent", "broken" |
| Questions | 10% | Presence of ? |
| Code/links | 10% | Code blocks or URLs |
| Prior summary | +15% boost | Previous chat history |
Rich view (>0.6 complexity, >10 members, or prior summary):
- Shows recent messages (max 5, token-aware)
- Displays prior chat summary if available
- Lists complexity factors
Minimal view (low complexity):
- Basic metadata only
- Faster to scan for batch operations
| Command | Usage | Description |
|---|---|---|
/pending-chats |
/pending-chats [filter] |
List pending approvals. Filters: all, groups, dms, recent, rich |
/batch-approve |
/batch-approve |
Open modal to approve multiple chats at once |
/batch-reject |
/batch-reject |
Open modal to reject multiple chats at once |
/rejected-chats |
/rejected-chats [all] |
View blacklisted chats. Use all to see more than 10 |
| Environment Variable | Default | Description |
|---|---|---|
NOTIFY_ON_APPROVAL |
false |
Send Telegram message when approved (true/false) |
| Approval expiration | 72 hours | Hard-coded, sends warning before leaving |
The approval system uses these tables (see migrations/0002_chat_approval.sql):
chats- Addedapproval_status,is_blacklisted, timestampspending_approvals- Queue with complexity scores and expirationchat_membership_history- Audit log of add/remove/approve/reject eventsdaily_stats- Aggregated statistics for summariesapp_config- Bot metadata cache
| Setting | Default | Description |
|---|---|---|
escalationThreshold |
0.4 | Below this → escalate to Slack |
autoSendThreshold |
0.85 | Above this → auto-send if label is "normal" |
autoSendLabels |
["normal"] |
Labels eligible for auto-send |
noResponseDelaySeconds |
30 | Timer delay before auto-draft if no human responds |
maxHotMessages |
200 | Per-chat message limit before R2 archival |
Edit TASK_MODELS to change providers/models per task:
const TASK_MODELS: Record<AITask, ModelConfig> = {
classify: { provider: "workers-ai", model: "@cf/meta/llama-3.1-8b-instruct" },
draft: { provider: "nvidia", model: "meta/llama-3.3-70b-instruct" },
summarize:{ provider: "openai", model: "gpt-4o-mini" },
};Available providers: workers-ai, nvidia, openai, openrouter.
One Agent = One Team/Project. Deploy one Worker per team. Add your team members so their responses skip AI processing.
Add Telegram users as team members so their responses skip AI processing:
# Add single member
bun run telegram:add @username "Display Name" role slack-id
bun run telegram:add @alice "Alice Chen" agent U123456
# Add multiple members (comma-separated)
bun run telegram:add @alice,@bob,@charlie --names "Alice,Bob,Charlie"
bun run telegram:add @alice,@bob --names "Alice,Bob" --roles "agent,supervisor"
bun run telegram:add @alice,@bob --names "Alice,Bob" --slack "U123,U456"
# List all team members
bun run telegram:list
# Remove (deactivate) a member
bun run telegram:remove @username
bun run telegram:remove @aliceRoles: agent (default), admin, supervisor — all skip AI processing when responding.
Add external tools (search, docs) for AI context enrichment:
# Add MCP server
bun run mcp:add <name> <url> <description> [tools...]
bun run mcp:add web-search https://search.example.com "Web search" search query
# List all MCP servers
bun run mcp:list
# Remove MCP server
bun run mcp:remove <name>
bun run mcp:remove web-searchPre-configured MCPs (migration 0004):
- Parallel (
PARALLEL_API_KEY) - Web search for bugs/requests - Context7 (
CONTEXT7_API_KEY) - Documentation lookup
# Start local dev server
bun run dev
# Type check
npx tsc --noEmit
# Apply local migrations
bun run db:migrate:localsrc/
index.ts # Hono app + scheduled handler
routes/
webhook.ts # POST /webhook/telegram
health.ts # GET /health
slack.ts # Slack interactions & slash commands
pipeline/
ingest.ts # Full ingest pipeline (with approval gate)
respond.ts # Draft → policy → action
timer.ts # Scheduled timer + approval expiration + daily summaries
lib/
ai.ts # AI provider routing
approval.ts # Core approval flow logic
classifier.ts # Rule + AI classification
config.ts # Policy thresholds
drafter.ts # Draft generation
escalation.ts # Slack escalation
linear.ts # Linear issue creation
persistence.ts # D1 operations (includes approval queries)
archiver.ts # R2 archival
slack.ts # Slack API + verification
slack-blocks.ts # Block Kit UI builders
state.ts # Conversation state & timers
telegram-api.ts # Telegram Bot API helpers
rate-limiter.ts # Per-chat rate limiting
telegram.ts # Webhook verification
logger.ts # Structured logging
metrics.ts # Pipeline timing
types/ # TypeScript types
approval.ts # Approval flow types
classification.ts # Classification types
draft.ts # Draft/response types
env.ts # Environment bindings
escalation.ts # Escalation types
events.ts # Internal event types
telegram.ts # Telegram API types
migrations/ # D1 schema migrations
0001_initial_schema.sql
0002_chat_approval.sql
- Webhook receives Telegram update with secret token verification
- Rate limiter prevents spam (max messages per chat)
- Pipeline processes asynchronously via
waitUntil() - Classification runs rule-first, then AI model fallback
- Response follows policy: auto_send / escalate / draft_only
- Linear creates triage issues for bugs and feature requests
- Timer processing: Handle expired "no response" timers
- Archival: Move old messages to R2, prune from D1
| Label | Description | Action |
|---|---|---|
bug |
Bug report | Escalate + Linear issue |
request |
Feature request | Escalate + Linear issue |
normal |
General message | Draft response (auto-send if high confidence) |
unknown |
Unclear intent | Escalate to Slack |
- Never commit
.dev.varsor secrets - All API keys stored via
wrangler secret put - Webhook secret verifies Telegram payloads
- SQL queries use parameterized statements
Symptoms: No logs appear when sending Telegram messages, or you see {"error":"Unauthorized"}
Root cause: The TELEGRAM_WEBHOOK_SECRET in Cloudflare doesn't match what Telegram is sending.
Fix:
# 1. Check what secret is in Cloudflare
npx wrangler secret get TELEGRAM_WEBHOOK_SECRET
# 2. Export that exact value locally
export TELEGRAM_WEBHOOK_SECRET="the-exact-value-from-above"
# 3. Re-set the Telegram webhook with the same secret
curl -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/setWebhook" \
-H "Content-Type: application/json" \
-d "{\"url\":\"https://triage-agent.YOUR_SUBDOMAIN.workers.dev/webhook/telegram\",\"secret_token\":\"${TELEGRAM_WEBHOOK_SECRET}\"}"| Issue | Solution |
|---|---|
| Deploy fails | Check database_id is valid in wrangler.jsonc |
| No logs at all | Run wrangler tail first, then test. Check observability is enabled in wrangler.jsonc |
| Webhook 401 Unauthorized | Secret mismatch - see above fix |
| Webhook not receiving | Verify getWebhookInfo shows correct URL. Check pending_update_count is 0 |
| AI not responding | Check Workers AI binding or API keys |
| Slack not receiving | Verify webhook URL is correct |
| Linear issues not created | Check LINEAR_API_KEY and team permissions |
# Watch logs
npx wrangler tail
# In another terminal, test with curl (use your actual secret)
curl -X POST "https://triage-agent.YOUR_SUBDOMAIN.workers.dev/webhook/telegram" \
-H "Content-Type: application/json" \
-H "X-Telegram-Bot-Api-Secret-Token: ${TELEGRAM_WEBHOOK_SECRET}" \
-d '{"update_id":123,"message":{"message_id":1,"from":{"id":123,"is_bot":false,"first_name":"Test"},"chat":{"id":123,"type":"private"},"date":1710000000,"text":"test"}}'Expected: {"ok":true} and logs appear in wrangler tail
| Issue | Solution |
|---|---|
| No approval request in Slack | Check SLACK_BOT_TOKEN, SLACK_APPROVAL_CHANNEL_ID, and SLACK_SIGNING_SECRET are set. Verify bot was invited to #triage-approvals. Check wrangler tail for errors. |
| Slack signature invalid | Ensure SLACK_SIGNING_SECRET matches your Slack app's Basic Info → Signing Secret. |
| Slash commands not working | Verify Request URL in Slack app points to /webhook/slack/commands. Check command is installed to workspace. |
| Can't approve/reject | Ensure SLACK_BOT_TOKEN has chat:write scope and is installed to the correct workspace. Verify bot is invited to #triage-approvals. |
| Bot not leaving rejected chats | Check TELEGRAM_BOT_TOKEN is correct. Bot must be admin to leave groups. |
| Daily summaries not sending | Verify SLACK_BOT_TOKEN and SLACK_SUMMARY_CHANNEL_ID. Check bot is invited to #triage-summaries. Check cron triggers in wrangler.jsonc. |
This project is currently unlicensed and considered experimental. While the source code is publicly available:
- Not free to sell — This code may not be sold or commercially redistributed without explicit permission
- Subject to change — The API, architecture, and functionality may change at any time without notice
- Use at your own risk — No warranties or guarantees are provided. Use in production at your own discretion
You are welcome to use, explore, and learn from this codebase.
For inquiries, permissions, or questions about usage, please connect with me: