From 02d28c2973f1d2fc615af7533317d2c91f996fc2 Mon Sep 17 00:00:00 2001 From: bysejin Date: Fri, 20 Mar 2026 02:35:21 +0900 Subject: [PATCH] feat: add Discord bridge integration - Add scripts/discord-bridge.js (host-side bridge using discord.js) - Update scripts/start-services.sh (service lifecycle integration) - Add docs/deployment/set-up-discord-bridge.md (setup guide) - Update docs/reference/commands.md (updated command docs) - Update bin/nemoclaw.js (credential forwarding in deploy flow) - Add discord.js dependency to package.json --- bin/nemoclaw.js | 2 + docs/deployment/set-up-discord-bridge.md | 127 ++++++++++++ docs/reference/commands.md | 4 +- package-lock.json | 243 +++++++++++++++++++++++ package.json | 1 + scripts/discord-bridge.js | 219 ++++++++++++++++++++ scripts/start-services.sh | 17 +- 7 files changed, 610 insertions(+), 3 deletions(-) create mode 100644 docs/deployment/set-up-discord-bridge.md create mode 100644 scripts/discord-bridge.js diff --git a/bin/nemoclaw.js b/bin/nemoclaw.js index a8a31188a1b..7b7fd9d975a 100755 --- a/bin/nemoclaw.js +++ b/bin/nemoclaw.js @@ -113,6 +113,8 @@ async function deploy(instanceName) { if (ghToken) envLines.push(`GITHUB_TOKEN=${ghToken}`); const tgToken = getCredential("TELEGRAM_BOT_TOKEN"); if (tgToken) envLines.push(`TELEGRAM_BOT_TOKEN=${tgToken}`); + const dcToken = getCredential("DISCORD_BOT_TOKEN"); + if (dcToken) envLines.push(`DISCORD_BOT_TOKEN=${dcToken}`); const envTmp = path.join(os.tmpdir(), `nemoclaw-env-${Date.now()}`); fs.writeFileSync(envTmp, envLines.join("\n") + "\n", { mode: 0o600 }); run(`scp -q -o StrictHostKeyChecking=no -o LogLevel=ERROR "${envTmp}" ${name}:/home/ubuntu/nemoclaw/.env`); diff --git a/docs/deployment/set-up-discord-bridge.md b/docs/deployment/set-up-discord-bridge.md new file mode 100644 index 00000000000..35c80dbeabf --- /dev/null +++ b/docs/deployment/set-up-discord-bridge.md @@ -0,0 +1,127 @@ +--- +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 Discord Bridge + +Forward messages between a Discord bot and the OpenClaw agent running inside the sandbox. +The Discord bridge is an auxiliary service managed by `nemoclaw start`. + +## Prerequisites + +- 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 + +1. Go to the [Discord Developer Portal](https://discord.com/developers/applications) and click **New Application**. +2. Click **Bot** on the sidebar. +3. Under **Privileged Gateway Intents**, enable: + - **Message Content Intent** (required) + - **Server Members Intent** (recommended) +4. Click **Reset Token** to generate a bot token. Copy it. +5. Go to **OAuth2 → URL Generator**, enable scopes `bot` and `applications.commands`. +6. Under **Bot Permissions**, enable: View Channels, Send Messages, Read Message History, Embed Links, Attach Files. +7. Copy the generated URL, open it in a browser, and add the bot to your server. + +## Apply the Discord Network Policy + +Before the agent can make external calls that Discord requires, add the Discord policy preset to your sandbox: + +```console +$ nemoclaw policy-add +``` + +Select `discord` from the list. + +## 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 Telegram bridge forwards messages between Telegram and the agent (if configured). +- The cloudflared tunnel provides external access to the sandbox. + +The Discord bridge starts only when the `DISCORD_BOT_TOKEN` environment variable is set. + +## 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 in any channel where the bot is present, or DM the bot directly. +The bridge forwards the message to the OpenClaw agent inside the sandbox and returns the agent response. + +In guild channels, the bot responds to all messages by default. +In DMs, the bot always responds. + +## Restrict Access + +### By User ID + +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="123456789012345678,987654321098765432" +$ nemoclaw start +``` + +### By Channel ID + +To restrict which channels the bot responds in, set the `ALLOWED_CHANNEL_IDS` environment variable: + +```console +$ export ALLOWED_CHANNEL_IDS="123456789012345678,987654321098765432" +$ nemoclaw start +``` + +## Stop the Services + +To stop the Discord bridge and all other auxiliary services: + +```console +$ nemoclaw stop +``` + +## Related Topics + +- [Set Up the Telegram Bridge](set-up-telegram-bridge.md) for Telegram support. +- [Deploy NemoClaw to a Remote GPU Instance](deploy-to-remote-gpu.md) for remote deployment. +- [Commands](../reference/commands.md) for the full `start` and `stop` command reference. diff --git a/docs/reference/commands.md b/docs/reference/commands.md index af4f130e81c..7b84136d5b8 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -185,13 +185,13 @@ For a remote Brev instance, SSH to the instance and run `openshell term` there, ### `nemoclaw start` -Start auxiliary services, such as the Telegram bridge and cloudflared tunnel. +Start auxiliary services, such as the Telegram bridge, Discord bridge, and cloudflared tunnel. ```console $ nemoclaw start ``` -Requires `TELEGRAM_BOT_TOKEN` for the Telegram bridge. +Requires `TELEGRAM_BOT_TOKEN` for the Telegram bridge and `DISCORD_BOT_TOKEN` for the Discord bridge. ### `nemoclaw stop` diff --git a/package-lock.json b/package-lock.json index 223aeaaec6e..63bdbfe9e3e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "license": "Apache-2.0", "dependencies": { + "discord.js": "^14.25.1", "openclaw": "2026.3.11" }, "bin": { @@ -948,6 +949,110 @@ "license": "MIT OR Apache-2.0", "optional": true }, + "node_modules/@discordjs/builders": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.13.1.tgz", + "integrity": "sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/formatters": "^0.6.2", + "@discordjs/util": "^1.2.0", + "@sapphire/shapeshift": "^4.0.0", + "discord-api-types": "^0.38.33", + "fast-deep-equal": "^3.1.3", + "ts-mixer": "^6.0.4", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/collection": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz", + "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.11.0" + } + }, + "node_modules/@discordjs/formatters": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz", + "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.0.tgz", + "integrity": "sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.1", + "@discordjs/util": "^1.1.1", + "@sapphire/async-queue": "^1.5.3", + "@sapphire/snowflake": "^3.5.3", + "@vladfrangu/async_event_emitter": "^2.4.6", + "discord-api-types": "^0.38.16", + "magic-bytes.js": "^1.10.0", + "tslib": "^2.6.3", + "undici": "6.21.3" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest/node_modules/undici": { + "version": "6.21.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz", + "integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/@discordjs/util": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", + "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, "node_modules/@discordjs/voice": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@discordjs/voice/-/voice-0.19.1.tgz", @@ -994,6 +1099,41 @@ } } }, + "node_modules/@discordjs/ws": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz", + "integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.0", + "@discordjs/rest": "^2.5.1", + "@discordjs/util": "^1.1.0", + "@sapphire/async-queue": "^1.5.2", + "@types/ws": "^8.5.10", + "@vladfrangu/async_event_emitter": "^2.2.4", + "discord-api-types": "^0.38.1", + "tslib": "^2.6.2", + "ws": "^8.17.0" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, "node_modules/@emnapi/core": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz", @@ -3246,6 +3386,39 @@ "node": ">= 10" } }, + "node_modules/@sapphire/async-queue": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", + "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@sapphire/shapeshift": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz", + "integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=v16" + } + }, + "node_modules/@sapphire/snowflake": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz", + "integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, "node_modules/@silvia-odwyer/photon-node": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz", @@ -4488,6 +4661,16 @@ "@types/node": "*" } }, + "node_modules/@vladfrangu/async_event_emitter": { + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz", + "integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, "node_modules/@whiskeysockets/baileys": { "version": "7.0.0-rc.9", "resolved": "https://registry.npmjs.org/@whiskeysockets/baileys/-/baileys-7.0.0-rc.9.tgz", @@ -5497,6 +5680,42 @@ "scripts/actions/documentation" ] }, + "node_modules/discord.js": { + "version": "14.25.1", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.25.1.tgz", + "integrity": "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/builders": "^1.13.0", + "@discordjs/collection": "1.5.3", + "@discordjs/formatters": "^0.6.2", + "@discordjs/rest": "^2.6.0", + "@discordjs/util": "^1.2.0", + "@discordjs/ws": "^1.2.3", + "@sapphire/snowflake": "3.5.3", + "discord-api-types": "^0.38.33", + "fast-deep-equal": "3.1.3", + "lodash.snakecase": "4.1.1", + "magic-bytes.js": "^1.10.0", + "tslib": "^2.6.3", + "undici": "6.21.3" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/discord.js/node_modules/undici": { + "version": "6.21.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz", + "integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -7107,6 +7326,12 @@ "uc.micro": "^2.0.0" } }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", @@ -7174,6 +7399,12 @@ "integrity": "sha512-AZV+GsS/6ckvPOVQPXSiFFacKvKB4kOQu6ynt9wz0F3LO4R9Ij4K1ddYsIytDpSgLz88JHd9P+oaLeej5/Sl7Q==", "license": "MIT" }, + "node_modules/lodash.snakecase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", + "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", + "license": "MIT" + }, "node_modules/log-symbols": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz", @@ -7222,6 +7453,12 @@ "node": "20 || >=22" } }, + "node_modules/magic-bytes.js": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz", + "integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==", + "license": "MIT" + }, "node_modules/markdown-it": { "version": "14.1.1", "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", @@ -9444,6 +9681,12 @@ "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", "license": "MIT" }, + "node_modules/ts-mixer": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", + "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==", + "license": "MIT" + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", diff --git a/package.json b/package.json index 64b91062ca3..bdaff4a0aa2 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "prepublishOnly": "cd nemoclaw && npm install --ignore-scripts && npm run build" }, "dependencies": { + "discord.js": "^14.25.1", "openclaw": "2026.3.11" }, "files": [ diff --git a/scripts/discord-bridge.js b/scripts/discord-bridge.js new file mode 100644 index 00000000000..86e71a11019 --- /dev/null +++ b/scripts/discord-bridge.js @@ -0,0 +1,219 @@ +#!/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 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 Discord. + * + * Env: + * DISCORD_BOT_TOKEN — from Discord Developer Portal + * NVIDIA_API_KEY — for inference + * SANDBOX_NAME — sandbox name (default: nemoclaw) + * ALLOWED_USER_IDS — comma-separated Discord user IDs to accept (optional, accepts all if unset) + * ALLOWED_CHANNEL_IDS — comma-separated channel IDs to accept (optional, accepts all if unset) + */ + +const { Client, GatewayIntentBits, Partials } = require("discord.js"); +const { execSync, spawn } = require("child_process"); + +const TOKEN = process.env.DISCORD_BOT_TOKEN; +const API_KEY = process.env.NVIDIA_API_KEY; +const SANDBOX = process.env.SANDBOX_NAME || "nemoclaw"; +const ALLOWED_USERS = process.env.ALLOWED_USER_IDS + ? process.env.ALLOWED_USER_IDS.split(",").map((s) => s.trim()) + : null; +const ALLOWED_CHANNELS = process.env.ALLOWED_CHANNEL_IDS + ? process.env.ALLOWED_CHANNEL_IDS.split(",").map((s) => s.trim()) + : null; + +if (!TOKEN) { console.error("DISCORD_BOT_TOKEN required"); process.exit(1); } +if (!API_KEY) { console.error("NVIDIA_API_KEY required"); process.exit(1); } + +const activeSessions = new Map(); // uniqueKey → running flag +const MAX_MSG_LEN = 2000; // Discord max message length + +// ── Run agent inside sandbox ────────────────────────────────────── + +function runAgentInSandbox(message, sessionId) { + return new Promise((resolve) => { + let sshConfig; + try { + sshConfig = execSync(`openshell sandbox ssh-config ${SANDBOX}`, { encoding: "utf-8" }); + } catch (err) { + resolve(`Error: cannot connect to sandbox '${SANDBOX}'. Is it running?`); + return; + } + + const confPath = `/tmp/nemoclaw-dc-ssh-${sessionId}.conf`; + require("fs").writeFileSync(confPath, sshConfig); + + const escaped = message.replace(/'/g, "'\\''"); + const cmd = `export NVIDIA_API_KEY='${API_KEY}' && 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}`); + }); + }); +} + +// ── Discord helpers ─────────────────────────────────────────────── + +async function sendLongMessage(channel, text, replyTo) { + const chunks = []; + for (let i = 0; i < text.length; i += MAX_MSG_LEN) { + chunks.push(text.slice(i, i + MAX_MSG_LEN)); + } + for (let i = 0; i < chunks.length; i++) { + const opts = { content: chunks[i] }; + if (i === 0 && replyTo) { + await replyTo.reply(opts).catch(() => channel.send(opts)); + } else { + await channel.send(opts).catch(() => { }); + } + } +} + +// ── Main ────────────────────────────────────────────────────────── + +async function main() { + const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.DirectMessages, + GatewayIntentBits.MessageContent, + ], + partials: [Partials.Channel], // needed for DMs + }); + + client.once("clientReady", () => { + console.log(""); + console.log(" ┌─────────────────────────────────────────────────────┐"); + console.log(" │ NemoClaw Discord Bridge │"); + console.log(" │ │"); + console.log(` │ Bot: ${(client.user.tag + " ").slice(0, 40)}│`); + console.log(" │ Sandbox: " + (SANDBOX + " ").slice(0, 40) + "│"); + console.log(" │ Model: nvidia/nemotron-3-super-120b-a12b │"); + 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(""); + }); + + client.on("messageCreate", async (message) => { + // Ignore own messages + if (message.author.id === client.user.id) return; + // Ignore bots + if (message.author.bot) return; + + const userId = message.author.id; + const channelId = message.channel.id; + const isDM = !message.guild; + + // Access control — users + if (ALLOWED_USERS && !ALLOWED_USERS.includes(userId)) { + console.log(`[ignored] user ${userId} not in allowed list`); + return; + } + + // Access control — channels (skip for DMs) + if (!isDM && ALLOWED_CHANNELS && !ALLOWED_CHANNELS.includes(channelId)) { + return; + } + + // Strip bot mention from message text + let text = message.content + .replace(new RegExp(`<@!?${client.user.id}>`, "g"), "") + .trim(); + + if (!text) return; + + const userName = message.author.username; + const sessionKey = isDM ? `dm-${userId}` : `ch-${channelId}`; + console.log(`[${sessionKey}] ${userName}: ${text}`); + + // Handle /reset or !reset + if (text === "/reset" || text === "!reset") { + activeSessions.delete(sessionKey); + await message.reply("Session reset.").catch(() => { }); + return; + } + + // Prevent concurrent requests in same session + if (activeSessions.get(sessionKey)) { + await message.reply("⏳ Still processing the previous message...").catch(() => { }); + return; + } + + activeSessions.set(sessionKey, true); + + // Show typing indicator + const typingInterval = setInterval(() => { + message.channel.sendTyping().catch(() => { }); + }, 5000); + message.channel.sendTyping().catch(() => { }); + + try { + const response = await runAgentInSandbox(text, sessionKey); + clearInterval(typingInterval); + console.log(`[${sessionKey}] agent: ${response.slice(0, 100)}...`); + await sendLongMessage(message.channel, response, message); + } catch (err) { + clearInterval(typingInterval); + await message.reply(`Error: ${err.message}`).catch(() => { }); + } finally { + activeSessions.set(sessionKey, false); + } + }); + + client.login(TOKEN); +} + +main(); diff --git a/scripts/start-services.sh b/scripts/start-services.sh index 7c3bd30ecc8..a0c2a9ea3f3 100755 --- a/scripts/start-services.sh +++ b/scripts/start-services.sh @@ -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 @@ -116,6 +116,7 @@ do_stop() { mkdir -p "$PIDDIR" stop_service cloudflared stop_service telegram-bridge + stop_service discord-bridge info "All services stopped." } @@ -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 + 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,14 @@ do_start() { echo " │ Telegram: not started (no token) │" fi + if is_running discord-bridge; then + echo " │ Discord: bridge running │" + elif [ -n "${DISCORD_BOT_TOKEN:-}" ]; then + echo " │ Discord: starting... │" + else + echo " │ Discord: not started (no token) │" + fi + echo " │ │" echo " │ Run 'openshell term' to monitor egress approvals │" echo " └─────────────────────────────────────────────────────┘"