Skip to content

Commit e605c0e

Browse files
Merge feature/discord-adapter: Discord as second chat interface (v1.3.2)
2 parents 5bcacc7 + 7999fd6 commit e605c0e

9 files changed

Lines changed: 290 additions & 5 deletions

File tree

.env.example

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,14 @@ PAPERCLIP_INTERNAL_URL=
8181
# Agent API Key generated in Paperclip UI
8282
PAPERCLIP_AGENT_KEY=
8383

84+
# --- Optional: Discord (chat interface) ---
85+
# Set only if you want Discord as a chat interface alongside Telegram.
86+
# setup.sh asks "Enable Discord? [y/N]" and fills these for you — no manual edit needed.
87+
# Bot token from https://discord.com/developers/applications → your app → Bot
88+
DISCORD_BOT_TOKEN=
89+
# Enables the discord-bridge sidecar container (empty = not started)
90+
COMPOSE_PROFILES=
91+
8492
# --- Optional: Nextcloud CalDAV ---
8593
NEXTCLOUD_URL=https://your-nextcloud-instance
8694
NEXTCLOUD_USER=your_user

CLAUDE.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,9 @@ n8n-claw/
5454
├── setup.sh # Automated setup script (see below)
5555
├── file-bridge/ # Binary file passthrough REST API (Node.js, session-bound temp files)
5656
├── email-bridge/ # IMAP/SMTP REST API microservice (Node.js)
57+
├── discord-bridge/ # Discord Gateway ↔ webhook bridge (Node.js, optional — compose profile "discord")
5758
├── searxng/ # SearXNG web search engine config
58-
├── docker-compose.yml # All services: n8n, postgres, postgrest, kong, studio, meta, searxng, crawl4ai, email-bridge
59+
├── docker-compose.yml # All services: n8n, postgres, postgrest, kong, studio, meta, searxng, crawl4ai, email-bridge, discord-bridge (optional)
5960
├── .env.example # Environment variable template
6061
├── README.md # User-facing installation guide
6162
└── CLAUDE.md # This file
@@ -165,7 +166,7 @@ A unified adapter workflow with multiple triggers for connecting external system
165166
- **Slack Trigger** (disabled) — native n8n Slack integration
166167
- **Teams Trigger** (disabled) — native n8n Teams integration
167168

168-
Each trigger has a mapper node → calls `/webhook/agent` → routes response back via `metadata._responseChannel`. Imported by setup.sh but **not activated**user enables what they need.
169+
Each trigger has a mapper node → calls `/webhook/agent` → routes response back via `metadata._responseChannel`. The workflow is **activated by setup.sh** — Slack/Teams triggers inside it are node-level disabled and stay dormant until the user enables them in the UI. Generic + custom webhooks are auth-protected via `X-API-Key` (WEBHOOK_SECRET).
169170

170171
### MCP Builder Pattern
171172

README.md

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -377,18 +377,19 @@ Content-Type: application/json
377377

378378
The agent uses `session_id` and `user_id` (with source prefix) for conversation history and user profiles — same as Telegram, just with different prefixes.
379379

380-
### Webhook Adapter (Slack, Teams, Paperclip)
380+
### Webhook Adapter (Slack, Teams, Discord, Paperclip)
381381

382382
For systems that need input/output mapping (different message formats, response routing), use the **Webhook Adapter** workflow. It translates between external formats and the agent's webhook API.
383383

384-
The adapter ships with four triggers:
384+
The adapter ships with these input paths:
385385

386386
| Trigger | Endpoint | Default state | Use case |
387387
|---|---|---|---|
388-
| **Generic Webhook** | `/webhook/adapter` | Active | Paperclip, API power-users (Code node with fallback chains) |
388+
| **Generic Webhook** | `/webhook/adapter` | Active | Paperclip, Discord bridge, API power-users (Code node with fallback chains) |
389389
| **Custom Webhook** | `/webhook/custom` | Active | Your own apps — simple Set node, easy to customize without code |
390390
| **Slack Trigger** || Disabled | Slack workspace integration |
391391
| **Teams Trigger** || Disabled | Microsoft Teams integration |
392+
| **Discord** | `/webhook/adapter` (via bot sidecar) | Opt-in | Discord servers — enabled via `setup.sh` prompt, uses the `discord-bridge` container |
392393

393394
Each trigger has a mapper node that normalizes messages → calls `/webhook/agent` → routes the response back to the right system via `metadata._responseChannel`. Paperclip payloads are auto-detected and get a dedicated response branch that posts the agent's answer as a comment and marks the issue as done.
394395

@@ -415,6 +416,21 @@ Each trigger has a mapper node that normalizes messages → calls `/webhook/agen
415416
5. **Enable the Teams Trigger + Teams Reply** nodes in the Webhook Adapter workflow
416417
6. **Activate the Webhook Adapter** workflow
417418

419+
### Enabling Discord
420+
421+
Unlike Slack and Teams, n8n does not ship a native Discord trigger. n8n-claw solves this with a small sidecar container (`discord-bridge`) that connects to Discord's Gateway as a bot and forwards messages to the adapter. The container is optional — it is only built and started if you enable Discord during setup.
422+
423+
1. **Create a Discord Application** at [discord.com/developers/applications](https://discord.com/developers/applications)
424+
2. **Add a Bot**, enable **Message Content Intent** under Bot settings
425+
3. **Copy the Bot Token**
426+
4. **Invite the Bot** to your server with `bot` + `applications.commands` scopes
427+
5. **Run `./setup.sh`** (or `./setup.sh --force` on an existing install) — when asked *"Enable Discord as an additional chat interface?"* answer `y` and paste the token
428+
6. **Activate the Webhook Adapter** workflow in n8n
429+
430+
That's it. No Discord credential in n8n itself — the bot token lives only inside the sidecar container, set via `DISCORD_BOT_TOKEN` in `.env`. The sidecar posts incoming messages to `/webhook/adapter` and receives replies via an internal `POST /reply` endpoint, so n8n never talks to Discord's API directly.
431+
432+
Users who do not enable Discord never see the extra container: `discord-bridge` uses a docker-compose `profiles: ["discord"]` marker, so it is not built or started unless `COMPOSE_PROFILES=discord` is set in `.env` (which setup.sh does for you).
433+
418434
### Enabling Paperclip
419435

420436
[Paperclip](https://github.com/paperclipai/paperclip) is an open-source agent orchestration platform. n8n-claw works as a Paperclip agent out of the box — no extra configuration in n8n needed.

discord-bridge/Dockerfile

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
FROM node:20-alpine
2+
WORKDIR /app
3+
COPY package*.json ./
4+
RUN npm install --omit=dev
5+
COPY bot.js .
6+
EXPOSE 3300
7+
CMD ["node", "bot.js"]

discord-bridge/bot.js

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/**
2+
* n8n-claw Discord Bridge
3+
*
4+
* Connects to Discord's Gateway as a bot, forwards incoming channel messages
5+
* to n8n's webhook adapter, and exposes a /reply endpoint so n8n can send
6+
* replies back through the same bot (no duplicate bot token in n8n).
7+
*
8+
* Enabled only when COMPOSE_PROFILES=discord and DISCORD_BOT_TOKEN is set.
9+
*/
10+
11+
const express = require('express');
12+
const { Client, GatewayIntentBits, ChannelType } = require('discord.js');
13+
14+
const BOT_TOKEN = process.env.DISCORD_BOT_TOKEN;
15+
const N8N_WEBHOOK_URL = process.env.N8N_WEBHOOK_URL || 'http://n8n:5678/webhook/adapter';
16+
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || '';
17+
const BRIDGE_PORT = parseInt(process.env.BRIDGE_PORT || '3300', 10);
18+
19+
if (!BOT_TOKEN) {
20+
console.error('[discord-bridge] DISCORD_BOT_TOKEN not set — exiting');
21+
process.exit(1);
22+
}
23+
24+
const client = new Client({
25+
intents: [
26+
GatewayIntentBits.Guilds,
27+
GatewayIntentBits.GuildMessages,
28+
GatewayIntentBits.MessageContent,
29+
GatewayIntentBits.DirectMessages,
30+
],
31+
});
32+
33+
const recentIds = new Map();
34+
setInterval(() => {
35+
const cutoff = Date.now() - 30000;
36+
for (const [id, ts] of recentIds) if (ts < cutoff) recentIds.delete(id);
37+
}, 30000);
38+
39+
client.once('ready', () => {
40+
console.log(`[discord-bridge] logged in as ${client.user.tag}`);
41+
});
42+
43+
client.on('messageCreate', async (message) => {
44+
if (message.author.bot) return;
45+
if (message.webhookId) return;
46+
if (!message.content || !message.content.trim()) return;
47+
if (recentIds.has(message.id)) return;
48+
recentIds.set(message.id, Date.now());
49+
50+
try {
51+
const resp = await fetch(N8N_WEBHOOK_URL, {
52+
method: 'POST',
53+
headers: {
54+
'Content-Type': 'application/json',
55+
'X-API-Key': WEBHOOK_SECRET,
56+
},
57+
body: JSON.stringify({
58+
message: message.content,
59+
user_id: message.author.id,
60+
session_id: `discord:${message.channel.id}`,
61+
source: 'discord',
62+
metadata: {
63+
_responseChannel: 'discord',
64+
channelId: message.channel.id,
65+
messageId: message.id,
66+
guildId: message.guild ? message.guild.id : null,
67+
authorName: message.author.username,
68+
},
69+
}),
70+
});
71+
if (!resp.ok) {
72+
console.error(`[discord-bridge] n8n returned ${resp.status}`);
73+
}
74+
} catch (e) {
75+
console.error(`[discord-bridge] forward error: ${e.message}`);
76+
}
77+
});
78+
79+
client.on('error', (e) => console.error(`[discord-bridge] client error: ${e.message}`));
80+
81+
client.login(BOT_TOKEN).catch((e) => {
82+
console.error(`[discord-bridge] login failed: ${e.message}`);
83+
process.exit(1);
84+
});
85+
86+
const app = express();
87+
app.use(express.json({ limit: '1mb' }));
88+
89+
app.post('/reply', async (req, res) => {
90+
const { channelId, content } = req.body || {};
91+
if (!channelId || typeof content !== 'string') {
92+
return res.status(400).json({ error: 'channelId + content (string) required' });
93+
}
94+
try {
95+
const channel = await client.channels.fetch(channelId);
96+
if (!channel || !channel.isTextBased || !channel.isTextBased()) {
97+
return res.status(404).json({ error: 'channel not found or not text-based' });
98+
}
99+
const chunks = [];
100+
for (let i = 0; i < content.length; i += 1900) chunks.push(content.slice(i, i + 1900));
101+
if (chunks.length === 0) chunks.push('');
102+
for (const chunk of chunks) await channel.send(chunk);
103+
return res.json({ ok: true, chunks: chunks.length });
104+
} catch (e) {
105+
console.error(`[discord-bridge] reply error: ${e.message}`);
106+
return res.status(500).json({ error: e.message });
107+
}
108+
});
109+
110+
app.get('/health', (_req, res) => {
111+
res.json({ ok: client.isReady(), bot: client.user ? client.user.tag : null });
112+
});
113+
114+
app.listen(BRIDGE_PORT, () => {
115+
console.log(`[discord-bridge] http listening on :${BRIDGE_PORT}`);
116+
});

discord-bridge/package.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"name": "n8n-claw-discord-bridge",
3+
"version": "1.0.0",
4+
"description": "Discord Gateway <-> n8n webhook bridge (optional, enabled via COMPOSE_PROFILES=discord)",
5+
"main": "bot.js",
6+
"scripts": {
7+
"start": "node bot.js"
8+
},
9+
"dependencies": {
10+
"discord.js": "^14.16.3",
11+
"express": "^4.21.2"
12+
}
13+
}

docker-compose.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,22 @@ services:
161161
networks:
162162
- n8n-claw-net
163163

164+
# ── Discord Bridge (Gateway → webhook, optional — enabled via COMPOSE_PROFILES=discord) ──
165+
discord-bridge:
166+
build: ./discord-bridge
167+
container_name: n8n-claw-discord-bridge
168+
profiles: ["discord"]
169+
restart: unless-stopped
170+
expose:
171+
- "3300"
172+
environment:
173+
- DISCORD_BOT_TOKEN=${DISCORD_BOT_TOKEN:-}
174+
- N8N_WEBHOOK_URL=http://n8n:5678/webhook/adapter
175+
- WEBHOOK_SECRET=${WEBHOOK_SECRET:-}
176+
- BRIDGE_PORT=3300
177+
networks:
178+
- n8n-claw-net
179+
164180
# ── Crawl4AI (Web Reader — clean markdown from URLs) ────────
165181
crawl4ai:
166182
image: unclecode/crawl4ai:latest

setup.sh

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,36 @@ echo "────────────────────────
219219
ask "N8N_API_KEY" "n8n API Key (Settings → API → Create key)" "" 1
220220
ask "TELEGRAM_BOT_TOKEN" "Telegram Bot Token (from @BotFather)" "" 1
221221
ask "TELEGRAM_CHAT_ID" "Your Telegram Chat ID (from @userinfobot)" "" 0
222+
223+
# ── Discord (optional) ──
224+
# Users who don't want Discord see a single y/N and move on. Opting in adds
225+
# 'discord' to COMPOSE_PROFILES so the bot-sidecar container starts with the
226+
# rest of the stack further down this script.
227+
EXISTING_DISCORD_TOKEN=$(grep '^DISCORD_BOT_TOKEN=' .env 2>/dev/null | cut -d= -f2-)
228+
EXISTING_PROFILES=$(grep '^COMPOSE_PROFILES=' .env 2>/dev/null | cut -d= -f2-)
229+
if [ -n "$EXISTING_DISCORD_TOKEN" ] && [ "$INSTALL_MODE" != "update" ]; then
230+
echo -e " ${GREEN}💬 Discord: Using existing bot token${NC}"
231+
elif [ -z "$EXISTING_DISCORD_TOKEN" ]; then
232+
read -rp "💬 Enable Discord as an additional chat interface? (y/N): " DISCORD_ENABLE
233+
if [[ "$DISCORD_ENABLE" =~ ^[Yy]$ ]]; then
234+
echo " Setup: https://discord.com/developers/applications → New Application → Bot"
235+
echo " Enable 'Message Content Intent' under Bot settings, then copy the token."
236+
read -rp " Discord Bot Token: " DISCORD_BOT_TOKEN_INPUT
237+
if [ -n "$DISCORD_BOT_TOKEN_INPUT" ]; then
238+
set_env DISCORD_BOT_TOKEN "$DISCORD_BOT_TOKEN_INPUT"
239+
if [ -z "$EXISTING_PROFILES" ]; then
240+
set_env COMPOSE_PROFILES "discord"
241+
elif [[ ",${EXISTING_PROFILES}," != *",discord,"* ]]; then
242+
set_env COMPOSE_PROFILES "${EXISTING_PROFILES},discord"
243+
fi
244+
echo -e " ${GREEN}✅ Discord bridge will start with the rest of the stack${NC}"
245+
echo " Invite the bot to your server with 'bot' + 'applications.commands' scopes."
246+
else
247+
echo -e " ⏭️ Skipped — no token provided"
248+
fi
249+
fi
250+
fi
251+
222252
# ── LLM Provider Selection ──
223253
echo ""
224254
echo -e " ${GREEN}🤖 LLM Provider${NC}"
@@ -1669,6 +1699,16 @@ if [ -n "$HEARTBEAT_ID" ]; then
16691699
echo -e " ${GREEN}✅ Heartbeat workflow activated${NC}"
16701700
fi
16711701

1702+
# Activate Webhook Adapter — safe regardless of chat channel choice.
1703+
# Slack/Teams triggers inside it are node-level disabled and stay dormant.
1704+
# The generic + custom webhooks are auth-protected via X-API-Key (WEBHOOK_SECRET).
1705+
ADAPTER_ID=${WF_IDS['webhook-adapter']}
1706+
if [ -n "$ADAPTER_ID" ]; then
1707+
curl -s -X POST "${N8N_BASE}/api/v1/workflows/${ADAPTER_ID}/activate" \
1708+
-H "X-N8N-API-KEY: ${N8N_API_KEY}" > /dev/null 2>&1
1709+
echo -e " ${GREEN}✅ Webhook Adapter workflow activated${NC}"
1710+
fi
1711+
16721712
# Final deactivate/activate cycle for agent — forces Telegram webhook re-registration
16731713
# (n8n sometimes skips webhook registration on first activate after API import)
16741714
if [ -n "$AGENT_ID" ]; then

workflows/adapters/webhook-adapter.json

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,13 @@
115115
"type": "main",
116116
"index": 0
117117
}
118+
],
119+
[
120+
{
121+
"node": "Discord Reply",
122+
"type": "main",
123+
"index": 0
124+
}
118125
]
119126
]
120127
},
@@ -376,6 +383,28 @@
376383
},
377384
"renameOutput": true,
378385
"outputKey": "Paperclip"
386+
},
387+
{
388+
"conditions": {
389+
"options": {
390+
"caseSensitive": true,
391+
"leftValue": "",
392+
"typeValidation": "strict"
393+
},
394+
"conditions": [
395+
{
396+
"leftValue": "={{ $json.metadata?._responseChannel }}",
397+
"rightValue": "discord",
398+
"operator": {
399+
"type": "string",
400+
"operation": "equals"
401+
}
402+
}
403+
],
404+
"combinator": "and"
405+
},
406+
"renameOutput": true,
407+
"outputKey": "Discord"
379408
}
380409
]
381410
},
@@ -683,6 +712,45 @@
683712
-500,
684713
800
685714
]
715+
},
716+
{
717+
"parameters": {
718+
"method": "POST",
719+
"url": "http://discord-bridge:3300/reply",
720+
"sendBody": true,
721+
"specifyBody": "json",
722+
"jsonBody": "={\n \"channelId\": \"{{ $json.metadata?.channelId }}\",\n \"content\": {{ JSON.stringify($json.response || $json.message || \"\") }}\n}",
723+
"options": {
724+
"response": {
725+
"response": {
726+
"neverError": true
727+
}
728+
}
729+
}
730+
},
731+
"id": "discord-reply",
732+
"name": "Discord Reply",
733+
"type": "n8n-nodes-base.httpRequest",
734+
"typeVersion": 4.2,
735+
"position": [
736+
1600,
737+
-600
738+
]
739+
},
740+
{
741+
"parameters": {
742+
"content": "## Discord Setup\n\nDiscord uses a small bot-sidecar container (`discord-bridge`). Opt in during `./setup.sh` when asked for a bot token — the container is only built and started if you enable it.\n\n**Get a bot token:**\n1. discord.com/developers/applications → New Application\n2. Add a Bot, enable **Message Content Intent**\n3. Copy the token, paste it during setup.sh\n4. Invite the bot with `bot` + `applications.commands` scopes\n\n**How it works:**\n- Sidecar subscribes to Discord Gateway\n- On each channel message it POSTs to this workflow's Webhook Trigger\n- Route Response → Discord Reply sends the agent's reply back via the same bot\n\nNo Discord credential in n8n — the bot token lives only in the sidecar.",
743+
"height": 320,
744+
"width": 340
745+
},
746+
"id": "sticky-discord-setup",
747+
"name": "Discord Setup",
748+
"type": "n8n-nodes-base.stickyNote",
749+
"typeVersion": 1,
750+
"position": [
751+
-500,
752+
-800
753+
]
686754
}
687755
]
688756
}

0 commit comments

Comments
 (0)