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 " └─────────────────────────────────────────────────────┘"