From 0eb9794fd3b295503a43f63d4ca00770cc6f8ef4 Mon Sep 17 00:00:00 2001 From: Justin Johnson Date: Mon, 16 Mar 2026 20:53:07 -0400 Subject: [PATCH] feat: add Discord bridge for NemoClaw agent chat Add Discord bridge support to NemoClaw, mirroring the existing Telegram bridge functionality. Users can now interact with the OpenClaw agent through Discord messages. Changes: - Add scripts/discord-bridge.js: Discord bot that forwards messages to the OpenClaw agent inside the sandbox - Update scripts/start-services.sh: Add Discord bridge service management alongside Telegram bridge - Add docs/deployment/set-up-discord-bridge.md: Complete setup guide for Discord bridge deployment Features: - Forward Discord messages to OpenClaw agent - Support optional channel restrictions via DISCORD_CHANNEL_ID - Support optional user restrictions via ALLOWED_USER_IDS - Automatic message chunking for Discord 2000-char limit - Typing indicators during agent processing - Concurrent run protection per user Signed-off-by: Justin Johnson --- .dockerignore | 1 + docs/deployment/set-up-discord-bridge.md | 128 +++++++++++++++ scripts/discord-bridge.js | 189 +++++++++++++++++++++++ scripts/start-discord-bridge.sh | 15 ++ scripts/start-services.sh | 25 ++- 5 files changed, 352 insertions(+), 6 deletions(-) create mode 100644 docs/deployment/set-up-discord-bridge.md create mode 100755 scripts/discord-bridge.js create mode 100755 scripts/start-discord-bridge.sh diff --git a/.dockerignore b/.dockerignore index 8d42c6b173e..3175509699a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,3 +4,4 @@ node_modules *.pyc __pycache__ .pytest_cache +!nemoclaw/dist/ diff --git a/docs/deployment/set-up-discord-bridge.md b/docs/deployment/set-up-discord-bridge.md new file mode 100644 index 00000000000..ffbdd17a46a --- /dev/null +++ b/docs/deployment/set-up-discord-bridge.md @@ -0,0 +1,128 @@ +--- +title: + page: "Set Up the NemoClaw Discord Bridge for Remote Agent Chat" + nav: "Set Up Discord Bridge" +description: "Forward messages between Discord and the sandboxed OpenClaw agent." +keywords: ["nemoclaw discord bridge", "discord bot openclaw agent"] +topics: ["generative_ai", "ai_agents"] +tags: ["openclaw", "openshell", "discord", "deployment", "nemoclaw"] +content: + type: how_to + difficulty: intermediate + audience: ["developer", "engineer"] +status: published +--- + + + +# Set Up the NemoClaw Discord Bridge for Remote Agent Chat + +Forward messages between a Discord bot and the OpenClaw agent running inside the sandbox. +`nemoclaw start` manages the Discord bridge as an auxiliary service. + +## Prerequisites + +Before you begin, ensure the following are in place: + +- A running NemoClaw sandbox, either local or remote. +- A Discord bot token from the [Discord Developer Portal](https://discord.com/developers/applications). + +## Create a Discord Bot + +Visit the [Discord Developer Portal](https://discord.com/developers/applications) and create a new application. + +1. Click "New Application" and give it a name. +2. Go to the "Bot" tab and click "Add Bot". +3. Under the TOKEN section, click "Copy" to copy your bot token. +4. Keep the token secure and do not share it. + +## Add the Bot to Your Server + +Configure OAuth2 permissions and invite the bot to your Discord server. + +1. In the Developer Portal, go to the "OAuth2" tab. +2. Under "SCOPES", select `bot`. +3. Under "PERMISSIONS", select at least: + - Send Messages + - Read Messages and View Channels + - Read Message History + +4. Copy the generated URL and open it in your browser to invite the bot to your Discord server. + +## Set the Environment Variable + +Export the bot token as an environment variable: + +```console +$ export DISCORD_BOT_TOKEN= +``` + +## Start Auxiliary Services + +Start the Discord bridge and other auxiliary services: + +```console +$ nemoclaw start +``` + +The `start` command launches the following services: + +- The Discord bridge forwards messages between Discord and the agent. +- The cloudflared tunnel provides external access to the sandbox. + +The `start` command launches the Discord bridge only when you set the `DISCORD_BOT_TOKEN` environment variable. + +## Verify the Services + +Check that the Discord bridge is running: + +```console +$ nemoclaw status +``` + +The output shows the status of all auxiliary services. + +## Send a Message + +Open Discord and send a message to your bot. +You can either mention the bot directly in a channel or send a direct message. +The bridge forwards the message to the OpenClaw agent inside the sandbox. +The agent returns its response to the channel. + +## Restrict Access by Channel + +To restrict which Discord channels the agent can respond in, set the `DISCORD_CHANNEL_ID` environment variable: + +```console +$ export DISCORD_CHANNEL_ID= +$ nemoclaw start +``` + +The bot only responds to messages in the specified channel. + +## Restrict Access by User + +To restrict which Discord users can interact with the agent, set the `ALLOWED_USER_IDS` environment variable to a comma-separated list of Discord user IDs: + +```console +$ export ALLOWED_USER_IDS="123456789,987654321" +$ nemoclaw start +``` + +## Stop the Services + +To stop the Discord bridge and all other auxiliary services: + +```console +$ nemoclaw stop +``` + +## Next Steps + +Explore these guides for more advanced configurations: + +- [Deploy NemoClaw to a Remote GPU Instance](deploy-to-remote-gpu.md) for remote deployment with Discord support. +- [Commands](../reference/commands.md) for the full `start` and `stop` command reference. diff --git a/scripts/discord-bridge.js b/scripts/discord-bridge.js new file mode 100755 index 00000000000..ff60b0ed61a --- /dev/null +++ b/scripts/discord-bridge.js @@ -0,0 +1,189 @@ +#!/usr/bin/env node +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// NemoClaw Discord Bridge +// +// Forwards Discord messages to the OpenClaw gateway running inside the +// NemoClaw sandbox. The gateway handles agent sessions, system prompts, +// SOUL.md loading, and memory. +// +// Env: +// DISCORD_BOT_TOKEN - Discord bot token +// SANDBOX_NAME - sandbox name (default: nemoclaw) +// DISCORD_GUILD_ID - allowed guild (optional, accepts all if unset) +// DISCORD_CHANNEL_ID - channel to listen on (optional) +// ALLOWED_USER_IDS - comma-separated Discord user IDs (optional) +// GATEWAY_TOKEN - OpenClaw gateway auth token + +const { Client, GatewayIntentBits, Partials } = require("discord.js"); +const { spawn } = require("child_process"); +const path = require("path"); + +const TOKEN = process.env.DISCORD_BOT_TOKEN; +const SANDBOX = process.env.SANDBOX_NAME || "nemoclaw"; +const GUILD_ID = process.env.DISCORD_GUILD_ID || ""; +const CHANNEL_ID = process.env.DISCORD_CHANNEL_ID || ""; +const ALLOWED_USERS = process.env.ALLOWED_USER_IDS + ? process.env.ALLOWED_USER_IDS.split(",").map((s) => s.trim()) + : null; + +if (!TOKEN) { + console.error("DISCORD_BOT_TOKEN required"); + process.exit(1); +} + +const activeLocks = new Set(); + +const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.MessageContent, + GatewayIntentBits.DirectMessages, + ], + partials: [Partials.Channel], +}); + +// -- Run agent via gateway inside sandbox -- + +function runAgentViaGateway(message, sessionId) { + return new Promise((resolve) => { + const escaped = message.replace(/'/g, "'\\''"); + + // Use nemoclaw-start to ensure gateway is running, then run agent through it + const cmd = `nemoclaw-start openclaw agent --agent main --local -m '${escaped}' --session-id '${sessionId}'`; + + const proc = spawn("ssh", ["-T", "openshell-nemoclaw", cmd], { + timeout: 180000, + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env, PATH: `${process.env.HOME}/.local/bin:${process.env.PATH}` }, + }); + + let stdout = ""; + let stderr = ""; + + proc.stdout.on("data", (d) => (stdout += d.toString())); + proc.stderr.on("data", (d) => (stderr += d.toString())); + + proc.on("close", (code) => { + const lines = stdout.split("\n"); + const responseLines = lines.filter( + (l) => + !l.startsWith("Setting up NemoClaw") && + !l.startsWith("[plugins]") && + !l.startsWith("[gateway]") && + !l.startsWith("[auto-pair]") && + !l.startsWith("(node:") && + !l.includes("NemoClaw registered") && + !l.includes("UNDICI-EHPA") && + !l.includes("openclaw gateway") && + !l.includes("auto-pair watcher") && + !l.includes("Local UI:") && + !l.includes("Remote UI:") && + !/^[\s\u2502\u250c\u2514\u2500\u2501]+$/.test(l) && + !/^\s*\|/.test(l) && + l.trim() !== "", + ); + + const response = responseLines.join("\n").trim(); + + if (response) { + resolve(response); + } else if (code !== 0) { + resolve(`Agent exited with code ${code}. ${stderr.trim().slice(0, 500)}`); + } else { + resolve("(no response)"); + } + }); + + proc.on("error", (err) => resolve(`Error: ${err.message}`)); + }); +} + +// -- Discord message handler -- + +client.on("messageCreate", async (msg) => { + if (msg.author.bot) return; + if (GUILD_ID && msg.guild && msg.guild.id !== GUILD_ID) return; + if (CHANNEL_ID && msg.channel.id !== CHANNEL_ID) return; + if (ALLOWED_USERS && !ALLOWED_USERS.includes(msg.author.id)) return; + + const isMention = msg.mentions.has(client.user); + const isDM = !msg.guild; + const isDesignatedChannel = CHANNEL_ID && msg.channel.id === CHANNEL_ID; + + if (!isMention && !isDM && !isDesignatedChannel) return; + + let content = msg.content.replace(/<@!?\d+>/g, "").trim(); + if (!content) { + await msg.reply("Send me a message and I'll run it through the NemoClaw agent."); + return; + } + + const userId = msg.author.id; + if (activeLocks.has(userId)) { + await msg.reply("Still working on your last message. Hang tight."); + return; + } + + activeLocks.add(userId); + // Use a persistent session per user so context carries across messages + const sessionId = `discord-${userId}`; + + console.log(`[${msg.channel.name || "DM"}] ${msg.author.username}: ${content.slice(0, 80)}`); + + const typingInterval = setInterval(() => { + msg.channel.sendTyping().catch(() => {}); + }, 5000); + msg.channel.sendTyping().catch(() => {}); + + try { + const response = await runAgentViaGateway(content, sessionId); + clearInterval(typingInterval); + + console.log(`[${msg.channel.name || "DM"}] agent: ${response.slice(0, 80)}...`); + + const chunks = []; + for (let i = 0; i < response.length; i += 1950) { + chunks.push(response.slice(i, i + 1950)); + } + + for (let i = 0; i < chunks.length; i++) { + if (i === 0) { + await msg.reply(chunks[i]); + } else { + await msg.channel.send(chunks[i]); + } + } + } catch (err) { + clearInterval(typingInterval); + console.error(`Error: ${err.message}`); + await msg.reply(`Error: ${err.message}`).catch(() => {}); + } finally { + activeLocks.delete(userId); + } +}); + +client.once("ready", () => { + console.log(""); + console.log(" +---------------------------------------------------------+"); + console.log(" | NemoClaw Discord Bridge |"); + console.log(" | |"); + console.log(` | Bot: ${(client.user.tag + " ").slice(0, 44)}|`); + console.log(` | Sandbox: ${(SANDBOX + " ").slice(0, 44)}|`); + console.log(" | Mode: gateway (nemoclaw-start + openclaw agent) |"); + console.log(" | Guild: " + (GUILD_ID ? "restricted" : "all") + " |"); + console.log(" | |"); + console.log(" | Messages forwarded through OpenClaw gateway with |"); + console.log(" | full session context, SOUL.md, and memory support. |"); + console.log(" +---------------------------------------------------------+"); + console.log(""); + + client.user.setPresence({ + status: "online", + activities: [{ name: "NemoClaw on DGX Spark", type: 0 }], + }); +}); + +client.login(TOKEN); diff --git a/scripts/start-discord-bridge.sh b/scripts/start-discord-bridge.sh new file mode 100755 index 00000000000..049d13db495 --- /dev/null +++ b/scripts/start-discord-bridge.sh @@ -0,0 +1,15 @@ +#!/bin/bash +# Load NemoClaw Discord bot token from pass and start the bridge + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +token="$(pass show api-keys/nemoclaw-discord-token 2>/dev/null | head -1)" +if [ $? -ne 0 ] || [ -z "$token" ]; then + echo "ERROR: Could not load NemoClaw Discord bot token from pass" + exit 1 +fi + +export DISCORD_BOT_TOKEN="$token" +export PATH="$HOME/.local/bin:$PATH" + +exec node "$SCRIPT_DIR/discord-bridge.js" diff --git a/scripts/start-services.sh b/scripts/start-services.sh index cbce0f18359..754c3c2bac7 100755 --- a/scripts/start-services.sh +++ b/scripts/start-services.sh @@ -2,7 +2,7 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # -# Start NemoClaw auxiliary services: Telegram bridge +# Start NemoClaw auxiliary services: Telegram and Discord bridges # and cloudflared tunnel for public access. # # Usage: @@ -94,7 +94,7 @@ stop_service() { show_status() { mkdir -p "$PIDDIR" echo "" - for svc in telegram-bridge cloudflared; do + for svc in telegram-bridge discord-bridge cloudflared; do if is_running "$svc"; then echo -e " ${GREEN}●${NC} $svc (PID $(cat "$PIDDIR/$svc.pid"))" else @@ -115,6 +115,7 @@ show_status() { do_stop() { mkdir -p "$PIDDIR" stop_service cloudflared + stop_service discord-bridge stop_service telegram-bridge info "All services stopped." } @@ -122,9 +123,9 @@ do_stop() { do_start() { [ -n "${NVIDIA_API_KEY:-}" ] || fail "NVIDIA_API_KEY required" - if [ -z "${TELEGRAM_BOT_TOKEN:-}" ]; then - warn "TELEGRAM_BOT_TOKEN not set — Telegram bridge will not start." - warn "Create a bot via @BotFather on Telegram and set the token." + if [ -z "${TELEGRAM_BOT_TOKEN:-}" ] && [ -z "${DISCORD_BOT_TOKEN:-}" ]; then + warn "TELEGRAM_BOT_TOKEN and DISCORD_BOT_TOKEN not set — bridges will not start." + warn "Create a Telegram bot via @BotFather or a Discord bot via Discord Developer Portal." fi command -v node > /dev/null || fail "node not found. Install Node.js first." @@ -132,7 +133,7 @@ do_start() { # Verify sandbox is running if command -v openshell > /dev/null 2>&1; then if ! openshell sandbox list 2>&1 | grep -q "Ready"; then - warn "No sandbox in Ready state. Telegram bridge may not work until sandbox is running." + warn "No sandbox in Ready state. Bridges may not work until sandbox is running." fi fi @@ -144,6 +145,12 @@ do_start() { node "$REPO_DIR/scripts/telegram-bridge.js" fi + # Discord bridge (only if token provided) + if [ -n "${DISCORD_BOT_TOKEN:-}" ]; then + SANDBOX_NAME="$SANDBOX_NAME" start_service discord-bridge \ + node "$REPO_DIR/scripts/discord-bridge.js" + fi + # 3. cloudflared tunnel if command -v cloudflared > /dev/null 2>&1; then start_service cloudflared \ @@ -186,6 +193,12 @@ do_start() { echo " │ Telegram: not started (no token) │" fi + if is_running discord-bridge; then + echo " │ Discord: bridge running │" + else + echo " │ Discord: not started (no token) │" + fi + echo " │ │" echo " │ Run 'openshell term' to monitor egress approvals │" echo " └─────────────────────────────────────────────────────┘"