A Discord ↔ Chatwoot bridge bot. Discord users send DMs to the bot; messages appear as Chatwoot conversations. Agent replies are delivered back to the user's DMs automatically.
- Discord DMs → Chatwoot conversations — auto-creates contact + conversation on first message
- Chatwoot agent replies → Discord DMs via webhook
- Smart conversation reopen — respects Chatwoot's
lock_to_single_conversationinbox setting; falls back to a configurable stale-window when unlocked - Native CSAT integration — "Rate us" button → Discord modal (1–5 rating + optional comment), submitted to Chatwoot's native CSAT reports
- Dynamic bot presence synced with Chatwoot working hours (Online / Idle)
- Outside-working-hours handling — allow (accept + notify) or deny (reject with out-of-office message)
- Out-of-office & greeting messages pulled directly from Chatwoot inbox configuration (with env-var fallbacks)
- Retry queue — failed messages and CSAT submissions are queued in SQLite and retried automatically
- Health monitoring — periodic Chatwoot reachability check with graceful degradation
- User slash commands —
/status,/close,/reopen,/help - HMAC webhook signature verification
- Configurable embed colors and branding
| Requirement | Notes |
|---|---|
| Bun v1.3+ | Runtime and package manager |
| Discord bot application | Discord Developer Portal |
| Chatwoot instance | Self-hosted or cloud |
| Chatwoot API-type inbox | Settings → Inboxes → Add Inbox → API |
| Public webhook URL | Chatwoot must be able to POST to your server |
- Go to the Discord Developer Portal and create an application.
- Under Bot, enable these Privileged Gateway Intents:
- Message Content Intent
- Under OAuth2 → URL Generator, select scopes:
botapplications.commands
- Add bot permissions: Send Messages, Read Message History, Add Reactions, Use Slash Commands.
- Copy the Bot Token and Application ID for your
.env.
- Create an API inbox (Settings → Inboxes → Add Inbox → API).
- Note the Inbox ID from the inbox settings URL.
- Under the inbox Configuration tab:
- Set Working Hours and Timezone as needed.
- Fill in Out of office message (shown to users outside working hours).
- Fill in Greeting message (shown once when a user first opens a ticket).
- Enable/disable CSAT — the bot reads this setting at runtime.
- Set Lock to single conversation — when enabled, returning users always reopen their last conversation instead of creating a new one.
- Create a webhook pointing to
http://your-server:3000/webhookwith events:message_createdconversation_status_changed
- Optionally set a webhook HMAC token (copy it to
WEBHOOK_SECRETin.env).
# 1. Clone the repo
git clone https://github.com/your-org/diswoot.git
cd diswoot
# 2. Install dependencies
bun install
# 3. Configure environment
cp .env.example .env
# Edit .env and fill in all required valuesCopy .env.example to .env and fill in the values:
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 🔐 REQUIRED — Core credentials & connections
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Discord
DISCORD_TOKEN= # Bot token — Discord Developer Portal > Bot > Token
DISCORD_CLIENT_ID= # Application ID — Discord Developer Portal > General Information
# Chatwoot
CHATWOOT_BASE_URL= # e.g. https://app.chatwoot.com (no trailing slash)
CHATWOOT_ACCOUNT_ID= # Numeric account ID (visible in the URL when logged in)
CHATWOOT_API_TOKEN= # Profile > Access Token
CHATWOOT_INBOX_ID= # Settings > Inboxes > your API inbox > Settings > ID
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 🌐 Webhook — Chatwoot will POST events here
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
WEBHOOK_PORT=3000
WEBHOOK_SECRET= # Optional HMAC secret (leave blank if not using)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 🏷️ Branding — footer shown on every embed (optional)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
BRAND_NAME=Support # Short name shown in every embed footer
BRAND_ICON_URL= # Optional URL to a small icon (e.g. your logo)
BRAND_FOOTER_TEXT= # Override the full footer text (defaults to BRAND_NAME)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 🎨 Embed Colors — hex without # prefix (optional)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
COLOR_PRIMARY=7BB8F5 # pastel blue — general/branded
COLOR_SUCCESS=6FD8A0 # pastel mint — success/opened
COLOR_DANGER=F28B87 # pastel rose — closed/error
COLOR_WARNING=F5CF7B # pastel amber — warnings/idle
COLOR_INFO=A89EF5 # pastel lavender — info/status
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 🟢 Presence — bot status synced from Chatwoot working hours (optional)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
PRESENCE_POLL_INTERVAL_MS=300000
PRESENCE_ONLINE_TEXT=DM to open a support ticket
PRESENCE_OFFLINE_TEXT=Support is currently offline
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 💬 UX — Messages & user-facing text (optional)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CONFIRM_EMOJI=✅
RESOLVED_MESSAGE=Your support ticket has been resolved. DM us again to reopen it.
# Greeting — sent the very first time a user opens a ticket
# Prefers Chatwoot inbox "Greeting message" (Inbox Settings → Configuration).
# These env vars are fallback only when the Chatwoot value is blank/disabled.
GREETING_ENABLED=true
GREETING_MESSAGE=Thanks for reaching out! A support agent will get back to you as soon as possible.
# CSAT — "Rate us" button sent after a ticket is resolved
# Also respects inbox-level "CSAT" toggle in Chatwoot settings.
CSAT_ENABLED=true
CSAT_QUESTION=How would you rate your support experience?
CSAT_BUTTON_LABEL=⭐ Rate us
CSAT_COMMENT_ENABLED=true
CSAT_COMMENT_PLACEHOLDER=Any additional feedback? (optional)
# Status change notifications — leave blank to disable each one
SNOOZED_MESSAGE="Your ticket has been snoozed. We'll follow up with you soon."
PENDING_MESSAGE="Your ticket is queued and will be assigned to an agent shortly."
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 🎫 Ticket Lifecycle (optional)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Also respects Chatwoot "Lock to single conversation" inbox setting.
# If locked in Chatwoot → always reopens the existing conversation.
# If unlocked → uses the stale window below.
# 0 = always reopen the last conversation (default)
REOPEN_WINDOW_HOURS=0
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 🕐 Outside Working Hours (optional)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# The out-of-office message shown to users is pulled from Chatwoot:
# Inbox Settings → Configuration → Out of office message
#
# true — accept and forward the message, then show the out-of-office message (default)
# false — show the out-of-office message and do NOT create a ticket
OUTSIDE_HOURS_BEHAVIOR=true
OUTSIDE_HOURS_FALLBACK_MESSAGE=Our support team is currently offline.Out-of-office message content is pulled from your Chatwoot inbox configuration (Inbox Settings → Configuration → Out of office message). The fallback above is only shown when that field is empty.
CSAT toggle is read from the Chatwoot inbox at runtime. If the inbox has CSAT disabled, the "Rate us" button will not be sent even if
CSAT_ENABLED=truein.env.
Lock to single conversation is read from the Chatwoot inbox at runtime. When enabled, the bot always reopens the user's last conversation regardless of
REOPEN_WINDOW_HOURS.
Diswoot has a plugin system for extending functionality without modifying core code. Plugins can enrich Chatwoot contacts with data from external systems (e.g. email, user IDs from your platform).
Plugins live in src/plugins/. Each plugin is auto-loaded at startup — if
its required env vars are missing, it disables itself silently.
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# � Singlty Plugin (optional — leave blank to disable)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
SINGLTY_API_URL= # Backend URL, e.g. https://api.singlty.com
SINGLTY_API_KEY= # Must match INTERNAL_API_KEY in the Backend .envBackend setup: Set
INTERNAL_API_KEYin your Singlty Backend.envto a strong random secret. Use the same value asSINGLTY_API_KEYhere. The plugin callsGET /internal/users/by-discord/:discordIdwith anx-api-keyheader.
Create a file in src/plugins/ that exports a DiswootPlugin object:
// src/plugins/my-platform.ts
import type { DiswootPlugin } from "./types";
export const myPlugin: DiswootPlugin = {
name: "my-platform",
init() {
const apiKey = process.env.MY_PLATFORM_KEY ?? "";
if (!apiKey) return false; // disable if not configured
return true;
},
async enrichContact(user) {
// Look up the user in your platform by user.id (Discord snowflake)
// Return { email, customAttributes } to merge into the Chatwoot contact
return {
email: "user@example.com",
customAttributes: { platform_id: "12345" },
};
},
};Then register it in src/plugins/index.ts:
import { myPlugin } from "./my-platform";
const ALL_PLUGINS: DiswootPlugin[] = [singltyPlugin, myPlugin];Run this once (or whenever you add/change commands). Global commands take up to 1 hour to propagate to all Discord servers and DMs.
bun run deploy-commandsbun startThe bot will:
- Connect to Discord and log in
- Start the webhook HTTP server on
WEBHOOK_PORT - Begin polling Chatwoot working hours for presence updates
All commands are ephemeral (only visible to the user who ran them) and only work in DMs with the bot.
| Command | Description |
|---|---|
/status |
Show current ticket status, last activity, and ticket ID |
/close [reason] |
Close your open ticket (optional reason sent as a private note) |
/reopen |
Reopen a resolved ticket |
/help |
Show command list and how the bot works |
User DMs bot
│
▼
dmHandler.ts
├─ Working hours check
│ ├─ deny mode → send out-of-office embed, stop
│ └─ allow mode → continue
├─ Find or create Chatwoot contact + conversation
├─ Smart reopen logic
│ ├─ lock_to_single_conversation ON → always reopen
│ └─ lock_to_single_conversation OFF → reopen within REOPEN_WINDOW_HOURS, else new ticket
├─ Forward message to Chatwoot as "incoming"
├─ React ✅ to confirm receipt
└─ (allow + outside hours) → send out-of-office embed
Agent replies in Chatwoot
│
▼
Chatwoot webhook → POST /webhook
│
├─ message_created (outgoing, non-private)
│ └─ Fetch Discord user → send DM
│
└─ conversation_status_changed (resolved)
├─ Send resolved notification DM
└─ Send CSAT "Rate us" button (if inbox CSAT enabled)
│
▼ (user clicks button)
interactionHandler.ts
├─ Show Discord modal (rating 1–5 + optional comment)
├─ Save to local SQLite DB
└─ Submit to Chatwoot native CSAT API
└─ On failure → enqueue to retry queue
diswoot/
├── index.ts # Entry point
├── src/
│ ├── config.ts # Typed env-var config
│ ├── bot/
│ │ ├── client.ts # discord.js Client singleton
│ │ ├── embed.ts # Pre-styled EmbedBuilder helpers
│ │ ├── presence.ts # Working-hours presence poller
│ │ ├── deploy-commands.ts # One-shot slash command registration
│ │ ├── commands/
│ │ │ ├── close.ts
│ │ │ ├── reopen.ts
│ │ │ ├── status.ts
│ │ │ └── help.ts
│ │ └── handlers/
│ │ ├── dmHandler.ts # Incoming Discord DM → Chatwoot
│ │ └── interactionHandler.ts # CSAT modal + slash command router
│ ├── chatwoot/
│ │ ├── client.ts # Chatwoot REST API client (v4.11.1)
│ │ ├── contact-attributes.ts # Shared contact identifier & attribute keys
│ │ ├── health.ts # Periodic reachability check
│ │ ├── types.ts # TypeScript interfaces
│ │ ├── workingHours.ts # Working hours / next-opening logic
│ │ └── inboxCache.ts # 5-minute TTL inbox config cache
│ ├── plugins/
│ │ ├── types.ts # DiswootPlugin interface
│ │ ├── index.ts # Plugin registry & lifecycle
│ │ └── singlty.ts # (Example) Singlty Backend enrichment
│ ├── db/
│ │ ├── index.ts # SQLite init + migrations
│ │ └── queries.ts # Prepared statement helpers
│ ├── lib/
│ │ └── retryQueue.ts # SQLite-backed retry queue for failed API calls
│ └── webhook/
│ └── server.ts # Bun HTTP server for Chatwoot webhooks
└── data/
└── diswoot.db # SQLite database (auto-created, git-ignored)
# Type-check without running
bun run typecheck
# Run (Bun reads .env automatically)
bun startThe SQLite database is created automatically at data/diswoot.db on first run (WAL mode enabled for concurrent reads).
All endpoints are verified against Chatwoot v4.11.1 (swagger.json):
| Endpoint | Method | Purpose |
|---|---|---|
/api/v1/accounts/{id}/contacts/search |
GET | Find existing contact by Discord ID |
/api/v1/accounts/{id}/contacts |
POST | Create new contact |
/api/v1/accounts/{id}/contacts/{id} |
PUT | Update contact (name, email, custom_attributes) |
/api/v1/accounts/{id}/contacts/{id}/contact_inboxes |
POST | Link contact to inbox |
/api/v1/accounts/{id}/conversations |
POST | Create conversation |
/api/v1/accounts/{id}/conversations/{id}/messages |
POST | Send message / private note |
/api/v1/accounts/{id}/conversations/{id}/toggle_status |
POST | Open / resolve / pending |
/api/v1/accounts/{id}/conversations/{id} |
GET | Fetch conversation metadata |
/api/v1/accounts/{id}/inboxes/{id} |
GET | Fetch inbox config (CSAT, lock, hours) |
/public/api/v1/csat_survey/{uuid} |
PATCH | Submit native CSAT rating |
Diswoot and web widgets both create / identify Chatwoot contacts.
To ensure agents see a single, merged view, both sides should use the
same identifier format and custom_attributes keys.
| Field | Diswoot (Discord bot) | Web Widget (example) | Result in Chatwoot |
|---|---|---|---|
identifier |
discord:{discordId} |
discord:{discordId} (if linked) or user:{userId} |
Same contact when Discord is linked |
name |
displayName (guild nick > username) |
globalName > username > email |
Best available name |
email |
Via plugin enrichment (if configured) | From auth session | ✅ |
custom_attributes.user_id |
Via plugin enrichment (if configured) | Always set | Platform user ID |
custom_attributes.discord_id |
Always set | Set when linked | Discord snowflake |
custom_attributes.discord_username |
Always set | Set when linked | Raw Discord username |
custom_attributes.discord_display_name |
Always set | Set when linked | Display name |
custom_attributes.source |
"discord" |
"web" |
Origin of the contact |
Key files:
- Diswoot:
src/chatwoot/contact-attributes.ts— identifier helpers,ATTRkeys,pickDisplayName - Plugins:
src/plugins/— enrich contacts with platform-specific data (email, user_id, etc.)