From 7ac55c7f42ff45f2d775a0d8f7b7c9933a303f6f Mon Sep 17 00:00:00 2001 From: dlinux-host Date: Mon, 20 Jan 2025 17:04:10 -0500 Subject: [PATCH] first commit --- .gitignore | 4 + README.md | 161 ++++++++++++++ config.json.dist | 12 ++ package.json | 8 + teamspeak.js | 548 +++++++++++++++++++++++++++++++++++++++++++++++ tokens.json.dist | 2 + 6 files changed, 735 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config.json.dist create mode 100644 package.json create mode 100644 teamspeak.js create mode 100644 tokens.json.dist diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..757f13a --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +config.json +tokens.json +node_modules +package-lock.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..607c4f8 --- /dev/null +++ b/README.md @@ -0,0 +1,161 @@ +# Linux TeamSpeak Bot + +A TeamSpeak 3 bot written in Node.js (using [`ts3-nodejs-library`](https://www.npmjs.com/package/ts3-nodejs-library)) that can receive commands in channels or private messages, authenticate users via token, and communicate with a custom API to perform various container and server operations. + +> **Table of Contents** +> - [Features](#features) +> - [Prerequisites](#prerequisites) +> - [Installation](#installation) +> - [Configuration](#configuration) +> - [Usage](#usage) +> - [Available Commands](#available-commands) +> - [Folder Structure](#folder-structure) +> - [Contributing](#contributing) +> - [License](#license) + +--- + +## Features + +- **TeamSpeak Connection**: Connects to a TS3 server as a Query Client bot. +- **Token Management**: Asks users for their API token via private message, and stores them securely in a JSON file. +- **API Integration**: Makes authenticated API calls (with a custom header `x-ssh-auth`) to manage a container (start, stop, restart, get stats/info, etc.). +- **Command Parsing**: In-channel commands prefixed with `!`, and private message commands. +- **Directory/Command Execution**: Allows users to run commands (`!x `) inside the container and navigate directories (`cd`, `cd ..`, `cd ~`, etc.). +- **Extensible**: Easily add more commands or API endpoints. + +--- + +## Prerequisites + +1. **Node.js (v14 or later)** + Make sure you have Node.js installed. You can check by running: + ```bash + node -v + ``` +2. **TeamSpeak 3 server credentials** + - Hostname/IP + - Query Port + - Server Port + - Query Username & Password +3. **API Endpoint** (for container/server control): + - A valid base URL for the API (e.g., `https://your-api.example.com` or `http://localhost:3000`). + +--- + +## Installation + +1. **Clone or download** this repository: + ```bash + git clone https://github.com/yourusername/linux-teamspeak-bot.git + ``` +2. **Install dependencies** in the project directory: + ```bash + cd linux-teamspeak-bot + npm install + ``` +3. **Create configuration files**: + - `config.json` + - `tokens.json` (an empty file to store user tokens) + + See [Configuration](#configuration) below for details. + +--- + +## Configuration + +You need two main JSON files to make the bot work: + +1. **`config.json`** + An example configuration might look like this: + ```json + { + "TS_HOST": "127.0.0.1", + "TS_QUERY_PORT": 10011, + "TS_SERVER_PORT": 9987, + "TS_USERNAME": "serveradmin", + "TS_PASSWORD": "supersecretpassword", + "apiBaseURL": "http://localhost:3000" + } + ``` + - `TS_HOST`: The IP/hostname of your TeamSpeak server. + - `TS_QUERY_PORT`: The query port (default is `10011`). + - `TS_SERVER_PORT`: The virtual server port (default is `9987`). + - `TS_USERNAME` & `TS_PASSWORD`: Your TeamSpeak query login credentials. + - `apiBaseURL`: The base URL of your custom API (for container operations). + +2. **`tokens.json`** + This file stores the tokens per user, in JSON format. It’s empty or non-existent on first run: + ```json + {} + ``` + The bot automatically populates this file with user tokens after requesting them via private message. + +Make sure both files are located in the same directory where the bot is run. + +--- + +## Usage + +1. **Start the bot**: + ```bash + npm start + ``` + or + ```bash + node index.js + ``` + _(Adjust the filename if necessary.)_ + +2. **Check the console** for the bot’s output: + - It will print `TeamSpeak bot is ready and connected.` once fully started. + - When a user connects, it may request a token if one is not already stored. + +3. **Interact in TeamSpeak**: + - Open your TeamSpeak client and connect to the same server. + - In a channel or server chat, prefix commands with `!` (e.g., `!hello`, `!stats`, etc.). + - If you don’t have a token on record, the bot will send you a private message prompting you to reply with your token. Just **reply without `!`** to that private message. + +--- + +## Available Commands + +All commands should be typed in a channel or server chat with an exclamation mark (`!`) prefix. Some **private message** commands (`!ping`, or direct token entry) are also available. + +| Command | Description | Example | +|------------------------|---------------------------------------------------------------------------------------------------|----------------------------------| +| `!hello` | Calls `/hello` on the API and returns the “hello” message. | `!hello` | +| `!name` | Returns the username from the API (`/name`). | `!name` | +| `!stats` | Shows the container/server stats (`/stats`). | `!stats` | +| `!uptime` | Returns uptime information (`/uptime`). | `!uptime` | +| `!start` | Starts the container/server via `/start`. | `!start` | +| `!stop` | Stops the container/server via `/stop`. | `!stop` | +| `!restart` | Restarts the container/server via `/restart`. | `!restart` | +| `!info` | Shows container/server info such as IP, memory, CPU, restarts, status, etc. (`/info`). | `!info` | +| `!time` | Shows when the container will expire (`/time`). | `!time` | +| `!root-password` | Requests and returns a new root password (`/rootpass`). | `!root-password` | +| `!new-api-key` | Generates and returns a new API key (`/new-key`). | `!new-api-key` | +| `!key-expire-time` | Checks when the current API key will expire (`/key-time`). | `!key-expire-time` | +| `!x ` | Executes a command in the container at the user’s working directory (`/exec`). Allows `cd` usage. | `!x cd /root`, `!x ls -la`, etc. | +| `!notify ` | Sends a custom notification via the API (`/notify`). | `!notify System going offline` | + +### Special `!x` Sub-Commands (In-Container Navigation) + +- `!x cd ` — Change directory. + - `!x cd /root` (absolute path) + - `!x cd ..` (move up one directory) + - `!x cd ~` (go to `/root`) +- `!x ` — Executes `` in the current directory of the container. + +--- + +## Folder Structure + +``` +linux-teamspeak-bot/ +├── config.json // Your TS3 + API config +├── tokens.json // Stored user tokens +├── package.json // Node.js dependencies +├── teamspeak.js // Main bot code +└── README.md // This documentation +``` \ No newline at end of file diff --git a/config.json.dist b/config.json.dist new file mode 100644 index 0000000..c1aea15 --- /dev/null +++ b/config.json.dist @@ -0,0 +1,12 @@ +{ + "TS_HOST": "ssh.surf", + "TS_QUERY_PORT": 10011, + "TS_SERVER_PORT": 9987, + "TS_USERNAME": "serveradmin", + "TS_PASSWORD": "", + "apiBaseURL": "https://api.ssh.surf", + "SQLHOST": "127.0.0.1", + "SQLUSER": "", + "SQLDATABASE": "", + "SQLPASSWORD": "" + } \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..ed6fff7 --- /dev/null +++ b/package.json @@ -0,0 +1,8 @@ +{ + "type": "module", + "dependencies": { + "jsonfile": "^6.1.0", + "node-fetch": "^2.7.0", + "ts3-nodejs-library": "^3.5.1" + } +} diff --git a/teamspeak.js b/teamspeak.js new file mode 100644 index 0000000..8290784 --- /dev/null +++ b/teamspeak.js @@ -0,0 +1,548 @@ +import { TeamSpeak } from "ts3-nodejs-library"; +import fetch from "node-fetch"; +import fs from "fs"; +import jsonfile from "jsonfile"; +let botUniqueIdentifier = null; + +// ------------------------------------------------------- +// CONFIG / TOKENS +// ------------------------------------------------------- + +// File paths +const tokensFile = "./tokens.json"; +const configFile = "./config.json"; + +// Load config (must have TS server info & apiBaseURL) +let config; +try { + config = JSON.parse(fs.readFileSync(configFile, "utf8")); +} catch (error) { + console.error("Failed to read config.json:", error); + process.exit(1); +} + +// Load the existing tokens from tokens.json +function loadTokens() { + try { + return jsonfile.readFileSync(tokensFile); + } catch (error) { + console.error("Error reading tokens file:", error); + return {}; + } +} + +// Save the tokens object to tokens.json +function saveTokens(tokenObj) { + try { + jsonfile.writeFileSync(tokensFile, tokenObj, { spaces: 2 }); + } catch (error) { + console.error("Error saving tokens file:", error); + } +} + +// Keep our tokens in memory +const tokens = loadTokens(); + +// ------------------------------------------------------- +// TEAM SPEAK CONNECTION +// ------------------------------------------------------- +const ts3 = new TeamSpeak({ + host: config.TS_HOST, + queryport: config.TS_QUERY_PORT, + serverport: config.TS_SERVER_PORT, + username: config.TS_USERNAME, + password: config.TS_PASSWORD, + nickname: "Linux", // The displayed name of your bot +}); + +// ------------------------------------------------------- +// HELPER: REQUEST A TOKEN VIA DM +// ------------------------------------------------------- +async function requestTokenViaDM(client) { + // If the user already has a token, do nothing + if (tokens[client.uniqueIdentifier]) return; + + console.log(`Requesting token from ${client.nickname} via DM...`); + + try { + // Send a private message to the user to request the token + await client.message( + `Hello ${client.nickname}! You have no token on record. ` + + `Please reply to *this private message* with your token (no ! prefix).` + ); + } catch (error) { + console.error(`Failed to send DM to ${client.nickname}:`, error); + } +} + +// ------------------------------------------------------- +// API REQUEST FUNCTION +// ------------------------------------------------------- +async function makeApiRequest(endpoint, token, client, method = "GET", body = null) { + const url = `${config.apiBaseURL}${endpoint}`; + const options = { + method, + headers: { + "Accept": "application/json", + "Content-Type": "application/json", + "x-ssh-auth": token, // custom header for auth + }, + }; + if (body) { + options.body = JSON.stringify(body); + } + + try { + console.log(`Making API request to ${url} for user ${client.nickname}`); + const response = await fetch(url, options); + + if (!response.ok) { + throw new Error(`API request failed: ${response.status} ${response.statusText}`); + } + return await response.json(); + } catch (error) { + console.error(`Error making API request for ${client.nickname}:`, error); + throw error; + } +} + +// ------------------------------------------------------- +// GET TOKEN (CHECK & REQUEST IF MISSING) +// ------------------------------------------------------- +async function getTokenForClient(client) { + // If we already have the token, return it + if (tokens[client.uniqueIdentifier]) { + return tokens[client.uniqueIdentifier]; + } + // Otherwise, request token via DM + await requestTokenViaDM(client); + return null; +} + +// ------------------------------------------------------- +// EVENT: TEXT MESSAGE +// ------------------------------------------------------- +ts3.on("textmessage", async (event) => { + const { targetmode, msg } = event; + const message = msg.trim(); + const senderNickname = event.invoker.nickname; + + console.log(`${senderNickname} sent message: "${message}" (targetmode=${targetmode})`); + + // Attempt to get the client object for more info + let client; + try { + client = await ts3.getClientById(event.invoker.clid); + } catch (error) { + console.error("Error fetching client object:", error); + return; + } + + // + // 1) PRIVATE MESSAGES (DM) + // +// For private messages: +if (targetmode === 1) { + // If user sends something that doesn't start with "!", treat it as a potential token + if (!message.startsWith("!")) { + // 1) Skip if it's the bot's own message + if (event.invoker.uniqueIdentifier === botUniqueIdentifier) { + // It's the bot talking to itself—ignore + return; + } + + // Otherwise, handle the incoming token + const client = await ts3.getClientById(event.invoker.clid); + if (client.nickname == "Linux") return + console.log(`Potential token received from ${client.nickname}: "${message}"`); + tokens[client.uniqueIdentifier] = message; + saveTokens(tokens); + + // Confirm receipt + await client.message("Your token has been saved! You can now use channel commands."); + return; + } + + // If it does start with "!", you can handle optional DM commands here. + // For example, let’s do a simple DM-only command: + const dmCommand = message.slice(1).toLowerCase(); + switch (dmCommand) { + case "ping": + await client.message("Pong! (DM response)"); + break; + default: + await client.message("Unknown DM command. You can just send your token or use `!ping` here."); + break; + } + return; + } + + // + // 2) CHANNEL (targetmode=2) OR SERVER (targetmode=3) MESSAGES + // + if (targetmode === 2 || targetmode === 3) { + // We only handle commands that start with "!" + if (!message.startsWith("!")) return; + + + // Extract command and arguments + const parts = message.slice(1).split(" "); // remove "!" + const command = parts[0].toLowerCase(); + const args = parts.slice(1); + + + let token = await getTokenForClient(client); + + // If user doesn't have a token, let them know in the channel, then DM them + if (!token) { + // Let them know publicly + await ts3.sendTextMessage( + event.target, // either channel ID or virtual server ID + targetmode, + `${senderNickname}, you have no token on record! Check your private messages.` + ); + // The DM request was already done in getTokenForClient, so we’re done + return; + } + + // Example: store the working directory per user (for !x command): +const userWorkingDirectories = new Map(); + +// Helper function for "../" logic in directories +function removeLastDirectoryPartOf(thePath, levelsUp) { + let parts = thePath.split("/").filter(Boolean); + for (let i = 0; i < levelsUp; i++) { + parts.pop(); + } + return "/" + parts.join("/"); +} + +// If we do have a token, let's handle the commands +try { + switch (command) { + // + // Command: !hello + // + case "hello": { + const helloResponse = await makeApiRequest("/hello", token, client); + const reply = `API says: ${helloResponse.message}`; + await ts3.sendTextMessage(event.target, targetmode, `${senderNickname}, ${reply}`); + break; + } + + // + // Command: !name + // + case "name": { + const nameResponse = await makeApiRequest("/name", token, client); + const reply = `Your API username: ${nameResponse.message}`; + await ts3.sendTextMessage(event.target, targetmode, `${senderNickname}, ${reply}`); + break; + } + + // + // Command: !stats + // + case "stats": { + const statsResponse = await makeApiRequest("/stats", token, client); + // Example: just stringify or format it how you like + const reply = `Stats: ${JSON.stringify(statsResponse)}`; + await ts3.sendTextMessage(event.target, targetmode, `${senderNickname}, ${reply}`); + break; + } + + // + // Command: !uptime + // + case "uptime": { + const uptimeResponse = await makeApiRequest("/uptime", token, client); + const reply = `Uptime: ${uptimeResponse.message}`; + await ts3.sendTextMessage(event.target, targetmode, `${senderNickname}, ${reply}`); + break; + } + + // + // Command: !start + // + case "start": { + const startResponse = await makeApiRequest("/start", token, client); + const reply = `Start Server:\nStatus: Success\nMessage: ${startResponse.message}`; + await ts3.sendTextMessage(event.target, targetmode, `${senderNickname}, ${reply}`); + break; + } + + // + // Command: !stop + // + case "stop": { + const stopResponse = await makeApiRequest("/stop", token, client); + const reply = `Stop Server:\nStatus: Success\nMessage: ${stopResponse.message}`; + await ts3.sendTextMessage(event.target, targetmode, `${senderNickname}, ${reply}`); + break; + } + + // + // Command: !restart + // + case "restart": { + const restartResponse = await makeApiRequest("/restart", token, client); + const reply = `Restart Server:\nStatus: Success\nMessage: ${restartResponse.message}`; + await ts3.sendTextMessage(event.target, targetmode, `${senderNickname}, ${reply}`); + break; + } + + // + // Command: !info + // + case "info": { + const infoResponse = await makeApiRequest("/info", token, client); + // Safely extract data + const containerName = infoResponse.data?.name || "N/A"; + const ipAddress = infoResponse.data?.IPAddress || "N/A"; + const macAddress = infoResponse.data?.MacAddress || "N/A"; + const memory = infoResponse.data?.memory || "N/A"; + const cpus = infoResponse.data?.cpus || "N/A"; + const restartPolicy = infoResponse.data?.restartPolicy?.Name || "N/A"; + const restarts = (infoResponse.data?.restarts !== undefined) + ? infoResponse.data.restarts + : "N/A"; + const status = infoResponse.data?.state?.Status || "Unknown"; + const pid = infoResponse.data?.state?.Pid || "N/A"; + const startedAt = infoResponse.data?.state?.StartedAt || "N/A"; + const createdAt = infoResponse.data?.created || "N/A"; + + const reply = [ + "Container Info:", + `Name: ${containerName}`, + `IP Address: ${ipAddress}`, + `MAC Address: ${macAddress}`, + `Memory: ${memory}`, + `CPUs: ${cpus}`, + `Restart Policy: ${restartPolicy}`, + `Restarts: ${restarts}`, + `Status: ${status}`, + `PID: ${pid}`, + `Started At: ${startedAt}`, + `Created At: ${createdAt}` + ].join("\n"); + + await ts3.sendTextMessage(event.target, targetmode, `${senderNickname}, ${reply}`); + break; + } + + // + // Command: !time + // + case "time": { + const timeResponse = await makeApiRequest("/time", token, client); + const reply = `Container Expire Time:\nExpire Date: ${timeResponse.expireDate}`; + await ts3.sendTextMessage(event.target, targetmode, `${senderNickname}, ${reply}`); + break; + } + + // + // Command: !root-password + // + case "root-password": { + const rootPassResponse = await makeApiRequest("/rootpass", token, client); + const reply = `Root Password:\nNew Root Password: ${rootPassResponse.newRootPass}`; + await ts3.sendTextMessage(event.target, targetmode, `${senderNickname}, ${reply}`); + break; + } + + // + // Command: !new-api-key + // + case "new-api-key": { + const newKeyResponse = await makeApiRequest("/new-key", token, client); + const reply = `New API Key: ${newKeyResponse.newAPIKey}`; + await ts3.sendTextMessage(event.target, targetmode, `${senderNickname}, ${reply}`); + break; + } + + // + // Command: !key-expire-time + // + case "key-expire-time": { + const keyTimeResponse = await makeApiRequest("/key-time", token, client); + const reply = `API Key Expire Time:\nExpire Date: ${keyTimeResponse.expireDate}`; + await ts3.sendTextMessage(event.target, targetmode, `${senderNickname}, ${reply}`); + break; + } + + // + // Command: !x + // Handles directory changes and command execution + // + case "x": { + // Combine args into a single string (e.g., user typed: !x cd /root) + const userCommand = args.join(" "); + + // Identify user by their unique TS3 ID + const sshSurfID = event.invoker.uid; // or event.invoker.clid + if (!userWorkingDirectories.has(sshSurfID)) { + userWorkingDirectories.set(sshSurfID, "/"); // Default to root + } + let userPWD = userWorkingDirectories.get(sshSurfID) || "/"; + + if (userCommand.startsWith("cd")) { + const argscmd = userCommand.replace("cd ", "").trim(); + + // cd .. + if (argscmd === "..") { + if (userPWD !== "/") { + const newPWD = userPWD.split("/").slice(0, -1).join("/") || "/"; + userPWD = newPWD; + userWorkingDirectories.set(sshSurfID, newPWD); + const reply = `Directory changed to: ${newPWD}`; + await ts3.sendTextMessage(event.target, targetmode, `${senderNickname}, ${reply}`); + } else { + const reply = `Already at the root directory: ${userPWD}`; + await ts3.sendTextMessage(event.target, targetmode, `${senderNickname}, ${reply}`); + } + break; + } + + // cd ~ + if (argscmd === "~") { + userPWD = "/root"; + userWorkingDirectories.set(sshSurfID, userPWD); + const reply = `Directory changed to: ${userPWD}`; + await ts3.sendTextMessage(event.target, targetmode, `${senderNickname}, ${reply}`); + break; + } + + // cd /some/absolute/path or cd relativePath + let newPWD; + if (argscmd.startsWith("/")) { + newPWD = argscmd; + } else { + newPWD = `${userPWD}/${argscmd}`; + } + // Remove double slashes + newPWD = newPWD.replace(/([^:]\/)\/+/g, "$1"); + + // If user typed "cd ../" or multiple "../" + if (argscmd.includes("../")) { + const numDirsBack = argscmd.split("../").length - 1; + newPWD = removeLastDirectoryPartOf(userPWD, numDirsBack); + } + + userPWD = newPWD; + userWorkingDirectories.set(sshSurfID, newPWD); + const reply = `Directory changed to: ${newPWD}`; + await ts3.sendTextMessage(event.target, targetmode, `${senderNickname}, ${reply}`); + break; + } + + // Otherwise, execute the command in the container via /exec + const execResponse = await makeApiRequest("/exec", token, client, "post", { + cmd: userCommand, + pwd: userPWD + }); + + // Handle large output by truncating or chunking for TS3 + let stdoutMsg = execResponse.stdout || "No output"; + let stderrMsg = execResponse.stderr || ""; + // const MAX_TS3_MESSAGE_LENGTH = 800; + + // if (stdoutMsg.length > MAX_TS3_MESSAGE_LENGTH) { + // stdoutMsg = stdoutMsg.slice(0, MAX_TS3_MESSAGE_LENGTH) + "...[truncated]"; + // } + // if (stderrMsg.length > MAX_TS3_MESSAGE_LENGTH) { + // stderrMsg = stderrMsg.slice(0, MAX_TS3_MESSAGE_LENGTH) + "...[truncated]"; + // } + + let reply = `Executed: ${userCommand}\n\nOutput:\n\`\`\`bash\n${stdoutMsg}\n\`\`\``; + if (stderrMsg.trim()) { + reply += `\n\nError:\n${stderrMsg}`; + } + await ts3.sendTextMessage(event.target, targetmode, `${senderNickname}, ${reply}`); + break; + } + + // + // Command: !notify + // + case "notify": { + // Combine args as the notification message + const messageText = args.join(" "); + const notifyResponse = await makeApiRequest("/notify", token, client, "post", { + message: messageText + }); + const reply = `Notification:\nStatus: Success\nMessage: ${notifyResponse.message}`; + await ts3.sendTextMessage(event.target, targetmode, `${senderNickname}, ${reply}`); + break; + } + + // + // Unrecognized command + // + default: { + const reply = "Command not recognized."; + await ts3.sendTextMessage(event.target, targetmode, `${senderNickname}, ${reply}`); + break; + } + } +} catch (error) { + console.error("Command error:", error); + const reply = "An error occurred while processing your request."; + await ts3.sendTextMessage(event.target, targetmode, `${senderNickname}, ${reply}`); +} + + } +}); + +// ------------------------------------------------------- +// EVENT: CLIENT CONNECT +// ------------------------------------------------------- +ts3.on("clientconnect", async (event) => { + try { + const client = await ts3.getClientById(event.client.clid); + console.log(`${client.nickname} connected.`); + // Optionally request their token in DM if missing + await requestTokenViaDM(client); + } catch (error) { + console.error("Failed to handle clientconnect event:", error); + } +}); + +// ------------------------------------------------------- +// EVENT: CLIENT DISCONNECT +// ------------------------------------------------------- +ts3.on("clientdisconnect", (event) => { + console.log(`A client disconnected: ${event.client?.nickname || "Unknown"}`); +}); + +// ------------------------------------------------------- +// EVENT: ERROR +// ------------------------------------------------------- +ts3.on("error", (error) => { + console.error("TeamSpeak error event:", error.message); +}); + +// ------------------------------------------------------- +// ON READY +// ------------------------------------------------------- +ts3.on("ready", async () => { + console.log("TeamSpeak bot is ready and connected."); + + try { + // whoami() gives information about this query client (the bot itself) + const whoAmI = await ts3.whoami(); + botUniqueIdentifier = whoAmI.virtualserverUniqueIdentifier; + console.log("Bot's unique ID is:", botUniqueIdentifier); + } catch (error) { + console.error("Error retrieving bot ID:", error); + } +}); + +// ------------------------------------------------------- +// GRACEFUL SHUTDOWN +// ------------------------------------------------------- +process.on("SIGINT", () => { + ts3.quit().then(() => { + console.log("Disconnected from TeamSpeak server."); + process.exit(0); + }); +}); diff --git a/tokens.json.dist b/tokens.json.dist new file mode 100644 index 0000000..2c63c08 --- /dev/null +++ b/tokens.json.dist @@ -0,0 +1,2 @@ +{ +}