diff --git a/docs/deployment/set-up-discord-bridge.md b/docs/deployment/set-up-discord-bridge.md new file mode 100644 index 00000000000..adc5a822a60 --- /dev/null +++ b/docs/deployment/set-up-discord-bridge.md @@ -0,0 +1,146 @@ +--- +title: + page: "Set Up the NemoClaw Discord Bridge for Remote Agent Chat" + nav: "Set Up Discord Bridge" +description: "Forward messages between Discord channels 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 channel and the OpenClaw agent running inside the sandbox. +The Discord bridge runs on the host because the sandbox proxy does not support the WebSocket connections that the Discord gateway requires. + +## Prerequisites + +Before you begin, confirm that you have the following items in place. + +- A running NemoClaw sandbox, either local or remote. +- A Discord application and bot token from the [Discord Developer Portal](https://discord.com/developers/applications). +- Node.js 20 or later. +- The `discord.js` package installed (`npm install` from the repo root installs it). + +## Create a Discord Application and Bot + +Create a Discord application and add a bot user to obtain the token the bridge needs. + +1. Open the [Discord Developer Portal](https://discord.com/developers/applications) and select **New Application**. +2. Give the application a name and select **Create**. +3. Go to the **Bot** tab and select **Add Bot**. +4. Under **Token**, select **Reset Token** and copy the token. + Store it securely. + Discord does not show it again. +5. Under **Privileged Gateway Intents**, enable **Message Content Intent**. + The bridge requires this intent to read message text. +6. Select **Save Changes**. + +## Invite the Bot to Your Server + +Generate an invite URL and add the bot to the server where you want it to respond. + +1. Go to the **OAuth2 → URL Generator** tab. +2. Under **Scopes**, select `bot`. +3. Under **Bot Permissions**, select **Send Messages** and **Read Message History**. +4. Copy the generated URL, open it in a browser, and select the server you want to add the bot to. + +## Set the Environment Variables + +Export the bot token and your NVIDIA API key before starting the bridge. +The bridge requires both variables — `nemoclaw start` exits with an error if `NVIDIA_API_KEY` is missing. + +```console +$ export DISCORD_BOT_TOKEN= +$ export NVIDIA_API_KEY= +``` + +To target a non-default sandbox or model, set the following optional variables before running `nemoclaw start`. + +```console +$ export SANDBOX_NAME= +$ export NEMOCLAW_MODEL=nvidia/nemotron-3-nano-30b-a3b +``` + +`SANDBOX_NAME` selects which sandbox the bridge connects to. The default is `default`. +`NEMOCLAW_MODEL` sets the model the agent uses for inference. The default is `nvidia/nemotron-3-super-120b-a12b`. + +## 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 channels and the agent. +- The cloudflared tunnel provides external access to the sandbox. + +Set the `DISCORD_BOT_TOKEN` environment variable before running `nemoclaw start` to enable the Discord bridge. + +## Verify the Services + +Check that the Discord bridge is running. + +```console +$ nemoclaw status +``` + +The output shows the status of all auxiliary services, including the Discord bridge. + +## Send a Message + +Open Discord, go to any channel the bot has access to, and send a message. +The bridge forwards the message to the OpenClaw agent inside the sandbox and posts the agent response back to the same channel. + +Each channel maintains its own session. +The agent remembers the conversation context within a channel across messages. + +## Reset a Session + +To clear the conversation history for a channel, send the following message in that channel: + +```text +!reset +``` + +The agent starts a fresh session for the next message in that channel. + +## Restrict Access by Guild + +To restrict which Discord servers (guilds) can interact with the agent, set the `ALLOWED_GUILD_IDS` environment variable to a comma-separated list of guild IDs: + +```console +$ export ALLOWED_GUILD_IDS="123456789012345678,987654321098765432" +$ nemoclaw start +``` + +To find a guild ID, open Discord, go to **Settings → Advanced**, enable **Developer Mode**, then right-click the server name and select **Copy Server ID**. + +## Stop the Services + +To stop the Discord bridge and all other auxiliary services, run the following command. + +```console +$ nemoclaw stop +``` + +## Next Steps + +Continue with related setup guides and reference documentation. + +- [Set Up the Telegram Bridge](set-up-telegram-bridge.md) to enable Telegram messaging alongside Discord. +- [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/docs/index.md b/docs/index.md index eb50dd8077c..72e02b424bb 100644 --- a/docs/index.md +++ b/docs/index.md @@ -231,7 +231,9 @@ Customize the Network Policy Deploy to a Remote GPU Instance Set Up the Telegram Bridge + Sandbox Hardening +Set Up the Discord Bridge ``` ```{toctree} diff --git a/package.json b/package.json index 3eec63a48f8..7fc1e6d15e1 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "prepublishOnly": "cd nemoclaw && env -u npm_config_global -u npm_config_prefix -u npm_config_omit npm install --ignore-scripts && ./node_modules/.bin/tsc" }, "dependencies": { + "discord.js": "^14.16.3", "openclaw": "2026.3.11" }, "files": [ diff --git a/scripts/discord-bridge.js b/scripts/discord-bridge.js new file mode 100644 index 00000000000..9c97ed1340a --- /dev/null +++ b/scripts/discord-bridge.js @@ -0,0 +1,250 @@ +#!/usr/bin/env node +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Discord → NemoClaw bridge. + * + * Messages from Discord channels are forwarded to the OpenClaw agent + * running inside the sandbox. When the agent needs external access, the + * OpenShell TUI lights up for approval. Responses go back to the channel. + * + * The bridge runs on the host because the sandbox proxy does not support + * CONNECT tunneling for WebSockets, which the Discord gateway requires. + * + * Env: + * DISCORD_BOT_TOKEN — from the Discord Developer Portal + * NVIDIA_API_KEY — for inference + * SANDBOX_NAME — sandbox name (default: default, matches start-services.sh) + * NEMOCLAW_MODEL — model ID (default: nvidia/nemotron-3-super-120b-a12b) + * ALLOWED_GUILD_IDS — comma-separated guild IDs to accept (optional, accepts all if unset) + * DEBUG_DISCORD — set to "true" to log full message content (default: off) + */ + +const { execSync, spawn } = require("child_process"); +const { Client, GatewayIntentBits } = require("discord.js"); +const { resolveOpenshell } = require("../bin/lib/resolve-openshell"); + +const OPENSHELL = resolveOpenshell(); +if (!OPENSHELL) { + console.error("openshell not found on PATH or in common locations"); + process.exit(1); +} + +const TOKEN = process.env.DISCORD_BOT_TOKEN; +const API_KEY = process.env.NVIDIA_API_KEY; +const SANDBOX = process.env.SANDBOX_NAME || "default"; +const MODEL = process.env.NEMOCLAW_MODEL || "nvidia/nemotron-3-super-120b-a12b"; +const ALLOWED_GUILDS = process.env.ALLOWED_GUILD_IDS + ? process.env.ALLOWED_GUILD_IDS.split(",").map((s) => s.trim()) + : null; +const DEBUG = process.env.DEBUG_DISCORD === "true"; + +if (!TOKEN) { console.error("DISCORD_BOT_TOKEN required"); process.exit(1); } +if (!API_KEY) { console.error("NVIDIA_API_KEY required"); process.exit(1); } + +// Discord max message length is 2000 characters +const DISCORD_MAX_LENGTH = 2000; + +// Per-channel session continuity: channelId → sessionId +// Default session ID is stable (ch-) so it survives restarts. +// !reset replaces it with a timestamped ID to force a fresh history. +const activeSessions = new Map(); + +// Per-channel serialization queue: channelId → Promise chain +// Ensures only one agent call runs per channel at a time so replies +// are never interleaved or out of order. +const channelQueues = new Map(); + +// ── Run agent inside sandbox ────────────────────────────────────── + +/** + * Forward a message to the OpenClaw agent running inside the sandbox via SSH + * and return the agent's response as a string. + * + * @param {string} message - The user message to send to the agent. + * @param {string} sessionId - The session identifier for conversation continuity. + * @returns {Promise} The agent's response text. + */ +function runAgentInSandbox(message, sessionId) { + return new Promise((resolve) => { + const sshConfig = execSync(`"${OPENSHELL}" sandbox ssh-config "${SANDBOX}"`, { encoding: "utf-8" }); + + // Use a unique path per invocation to avoid file collisions on concurrent calls. + const confPath = `/tmp/nemoclaw-dc-ssh-${sessionId}-${Date.now()}-${Math.random().toString(36).slice(2)}.conf`; + require("fs").writeFileSync(confPath, sshConfig); + + const escaped = message.replace(/'/g, "'\\''"); + const cmd = `export NVIDIA_API_KEY='${API_KEY}' && export NEMOCLAW_MODEL='${MODEL}' && nemoclaw-start openclaw agent --agent main --local -m '${escaped}' --session-id 'dc-${sessionId}'`; + + const proc = spawn("ssh", ["-T", "-F", confPath, `openshell-${SANDBOX}`, cmd], { + timeout: 120000, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + + proc.stdout.on("data", (d) => (stdout += d.toString())); + proc.stderr.on("data", (d) => (stderr += d.toString())); + + proc.on("close", (code) => { + try { require("fs").unlinkSync(confPath); } catch {} + + // Extract the actual agent response — skip setup lines + const lines = stdout.split("\n"); + const responseLines = lines.filter( + (l) => + !l.startsWith("Setting up NemoClaw") && + !l.startsWith("[plugins]") && + !l.startsWith("(node:") && + !l.includes("NemoClaw ready") && + !l.includes("NemoClaw registered") && + !l.includes("openclaw agent") && + !l.includes("┌─") && + !l.includes("│ ") && + !l.includes("└─") && + 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}`); + }); + }); +} + +// ── Send chunked message ────────────────────────────────────────── + +/** + * Send a text response to a Discord channel, splitting it into chunks + * when it exceeds Discord's 2000-character message limit. + * + * @param {import("discord.js").TextChannel} channel - The Discord channel to send to. + * @param {string} text - The full response text to send. + * @returns {Promise} + */ +async function sendChunked(channel, text) { + const chunks = []; + for (let i = 0; i < text.length; i += DISCORD_MAX_LENGTH) { + chunks.push(text.slice(i, i + DISCORD_MAX_LENGTH)); + } + for (const chunk of chunks) { + await channel.send(chunk); + } +} + +/** + * Enqueue a task for a channel so only one agent call runs per channel at a time. + * + * @param {string} channelId - The Discord channel ID used as the queue key. + * @param {() => Promise} task - The async task to serialize. + * @returns {Promise} + */ +function enqueueForChannel(channelId, task) { + const prev = channelQueues.get(channelId) ?? Promise.resolve(); + const next = prev.then(task).catch(() => {}); + channelQueues.set(channelId, next); + return next; +} + +// ── Discord client ──────────────────────────────────────────────── + +const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.MessageContent, + ], + allowedMentions: { parse: [], repliedUser: false }, +}); + +client.on("messageCreate", async (message) => { + // Ignore bot messages + if (message.author.bot) return; + + // Require a guild context (no DMs) + if (!message.guild) return; + + // Access control + if (ALLOWED_GUILDS && !ALLOWED_GUILDS.includes(message.guild.id)) { + return; + } + + const channelId = message.channel.id; + const content = message.content.trim(); + + // Handle !reset — replace the stable session ID with a timestamped one + // so the agent starts fresh while the next non-reset message reuses it. + if (content === "!reset") { + activeSessions.set(channelId, `ch-${channelId}-${Date.now()}`); + await message.reply("Session reset."); + return; + } + + if (!content) return; + + // Log only metadata by default; full content only when DEBUG_DISCORD=true + if (DEBUG) { + console.log(`[${message.guild.id}/#${channelId}] ${message.author.username}: ${content}`); + } else { + console.log(`[${message.guild.id}/#${channelId}] ${message.author.id}: ${content.length} chars`); + } + + // Stable session ID by default; only changes after !reset + if (!activeSessions.has(channelId)) { + activeSessions.set(channelId, `ch-${channelId}`); + } + const sessionId = activeSessions.get(channelId); + + // Serialize per-channel: queue this message behind any in-flight agent call + enqueueForChannel(channelId, async () => { + const typingInterval = setInterval(() => { + message.channel.sendTyping().catch(() => {}); + }, 8000); + await message.channel.sendTyping().catch(() => {}); + + try { + const response = await runAgentInSandbox(content, sessionId); + clearInterval(typingInterval); + console.log(`[${channelId}] agent responded (${response.length} chars)`); + await sendChunked(message.channel, response); + } catch (err) { + clearInterval(typingInterval); + await message.reply(`Error: ${err.message}`); + } + }); +}); + +client.once("ready", () => { + console.log(""); + console.log(" ┌─────────────────────────────────────────────────────┐"); + console.log(" │ NemoClaw Discord Bridge │"); + console.log(" │ │"); + console.log(` │ Bot: ${(client.user.tag + " ").slice(0, 42)}│`); + console.log(" │ Sandbox: " + (SANDBOX + " ").slice(0, 40) + "│"); + console.log(" │ Model: " + (MODEL + " ").slice(0, 40) + "│"); + console.log(" │ │"); + console.log(" │ Messages are forwarded to the OpenClaw agent │"); + console.log(" │ inside the sandbox. Run 'openshell term' in │"); + console.log(" │ another terminal to monitor + approve egress. │"); + console.log(" │ │"); + console.log(" │ Commands: !reset — clear channel session │"); + console.log(" └─────────────────────────────────────────────────────┘"); + console.log(""); +}); + +client.login(TOKEN).catch((err) => { + console.error("Failed to connect to Discord:", err.message); + process.exit(1); +}); diff --git a/scripts/start-services.sh b/scripts/start-services.sh index 303caf69610..002d4ec3725 100755 --- a/scripts/start-services.sh +++ b/scripts/start-services.sh @@ -2,11 +2,12 @@ # 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 bridge, Discord bridge, # and cloudflared tunnel for public access. # # Usage: # TELEGRAM_BOT_TOKEN=... ./scripts/start-services.sh # start all +# DISCORD_BOT_TOKEN=... ./scripts/start-services.sh # start all # ./scripts/start-services.sh --status # check status # ./scripts/start-services.sh --stop # stop all # ./scripts/start-services.sh --sandbox mybox # start for specific sandbox @@ -97,7 +98,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 @@ -119,15 +120,16 @@ do_stop() { mkdir -p "$PIDDIR" stop_service cloudflared stop_service telegram-bridge + stop_service discord-bridge info "All services stopped." } 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 "Neither TELEGRAM_BOT_TOKEN nor DISCORD_BOT_TOKEN is set — no bridge will start." + warn "Set at least one token to enable messaging." fi command -v node >/dev/null || fail "node not found. Install Node.js first." @@ -135,7 +137,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 @@ -147,6 +149,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 \ @@ -189,6 +197,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 " └─────────────────────────────────────────────────────┘"