commit 09e3b6ca66b4619553c4c842d73728d17027c29e Author: Dawnsorrow Date: Tue Feb 17 19:45:23 2026 -0600 Initial commit: Stoat Role Bot Co-authored-by: Cursor diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..62f0b22 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# Stoat (stoat.chat) / Revolt bot token from your Stoat server's Bot settings +STOAT_BOT_TOKEN=your_bot_token_here +# Alternative env name (same value) +# REVOLT_BOT_TOKEN=your_bot_token_here +# Command prefix (default: !) +PREFIX=! diff --git a/.gitea/workflows/docker.yml b/.gitea/workflows/docker.yml new file mode 100644 index 0000000..dfef975 --- /dev/null +++ b/.gitea/workflows/docker.yml @@ -0,0 +1,55 @@ +# Build and push Stoat Role Bot image to Gitea Container Registry on push to main. +# +# Setup in Gitea: +# 1. Repository → Settings → Secrets: add REGISTRY_USER and REGISTRY_PASSWORD +# (use your Gitea username and password, or a Personal Access Token with package write). +# 2. Optional: set variable REGISTRY_IMAGE to e.g. mygitea.com/john/stoat-role-bot +# (default: server_url/owner/repo name). +# +# On your NAS: pull this image and run; use Watchtower or a cron job to pull:latest and restart. + +name: Build and Push Image + +on: + push: + branches: [main, master] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Image tag and registry host + id: meta + run: | + URL="${{ gitea.server_url }}" + REG="${URL#*://}" + OWNER="${{ gitea.repository.owner.user_name }}" + [ -z "$OWNER" ] && OWNER="${{ gitea.repository.owner.login }}" + [ -z "$OWNER" ] && OWNER="${GITEA_REPOSITORY_OWNER:-}" + echo "tag=${REG}/${OWNER}/stoat-role-bot:latest" >> $GITHUB_OUTPUT + echo "registry=${REG}" >> $GITHUB_OUTPUT + env: + GITEA_SERVER_URL: ${{ gitea.server_url }} + GITEA_REPOSITORY_OWNER: ${{ gitea.repository.owner.user_name }} + + - name: Log in to Gitea Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ steps.meta.outputs.registry }} + username: ${{ secrets.REGISTRY_USER }} + password: ${{ secrets.REGISTRY_PASSWORD }} + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tag }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cb75a0f --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.env +config/roles.json +node_modules/ +npm-debug.log +stoat-role-bot.tar diff --git a/DEPLOY-NAS.md b/DEPLOY-NAS.md new file mode 100644 index 0000000..a9cfc45 --- /dev/null +++ b/DEPLOY-NAS.md @@ -0,0 +1,119 @@ +# Deploying Stoat Role Bot on a UGREEN DXP4800 Plus NAS + +The DXP4800 Plus runs **UGOS Pro** and supports Docker. Below are two ways to run the bot. + +--- + +## Option A: SSH + Docker Compose (recommended if SSH is enabled) + +If your NAS has **SSH** and **Docker** (and optionally **Docker Compose**) enabled: + +1. **Copy the project to the NAS** + - Use **SMB/CIFS**: copy the whole `Role Bot` folder to a share (e.g. `Docker` or `Containers`). + - Or from your PC with SSH: + `scp -r "/path/to/Role Bot" admin@:~/stoat-role-bot` + +2. **SSH into the NAS** + ```bash + ssh admin@ + cd ~/stoat-role-bot # or the path where you copied the project + ``` + +3. **Ensure `.env` and config exist** + - `.env` with `STOAT_BOT_TOKEN=...` (and optional `PREFIX=!`) + - `config/roles.json` (copy from `config/roles.json.example` and edit server ID / message ID) + +4. **Run with Docker** + - If **Docker Compose** is installed: + ```bash + docker compose up -d + ``` + - If only **Docker** is available, build and run manually: + ```bash + docker build -t stoat-role-bot . + docker run -d --name stoat-role-bot --restart unless-stopped \ + -e STOAT_BOT_TOKEN="$(grep STOAT_BOT_TOKEN .env | cut -d= -f2)" \ + -e PREFIX=! \ + -v "$(pwd)/config:/app/config:rw" \ + stoat-role-bot + ``` + +5. **Check logs** + ```bash + docker logs -f stoat-role-bot + ``` + +--- + +## Option B: NAS App Center / Docker UI (no SSH) + +If you manage containers only through the **UGREEN web UI** (App Center / Docker): + +### Step 1: Build the image on your PC, then transfer + +On your **PC** (where you have the project): + +```bash +cd "/path/to/Role Bot" +docker build -t stoat-role-bot:latest . +docker save stoat-role-bot:latest -o stoat-role-bot.tar +``` + +Copy `stoat-role-bot.tar` to a shared folder on the NAS (e.g. via SMB). + +### Step 2: Load image and create container on the NAS + +1. In **UGOS Pro**, open **App Center** (or **Container** / **Docker**). +2. **Load** the image: + - Look for “Import image” / “Load image” and select `stoat-role-bot.tar` from the share. +3. **Create a container** from that image: + - **Name**: e.g. `stoat-role-bot` + - **Restart policy**: “Unless stopped” or “Always” + - **Environment variables** (add each): + - `STOAT_BOT_TOKEN` = `your_bot_token_from_env_file` + - `PREFIX` = `!` + - `CONFIG_PATH` = `/app/config` + - **Volume / bind mount**: + - Host path: a folder where you will put `roles.json` (e.g. `/share/Containers/stoat-role-bot/config` or a path shown in the UI). + - Container path: `/app/config` + - No port mapping is needed (bot only makes outbound connections). + +4. Put your **config** on the NAS: + - In that host path, create `roles.json` (copy from `roles.json.example` and set your server ID and message ID). + +5. Start the container. + +--- + +## Option C: Use a pre-built image (if you publish to Docker Hub) + +If you push the image to **Docker Hub** (or another registry the NAS can use): + +1. On your PC: + ```bash + docker build -t YOUR_USERNAME/stoat-role-bot:latest . + docker push YOUR_USERNAME/stoat-role-bot:latest + ``` +2. On the NAS (SSH or App Center): create a container from image `YOUR_USERNAME/stoat-role-bot:latest` with the same **env vars** and **volume** as in Option B. + +Then the NAS only needs to pull the image; no build on the NAS. + +--- + +## Notes for UGREEN NAS + +- **Networking**: This bot only needs outbound HTTPS/WebSocket to Stoat/Revolt. No inbound ports or port mapping are required. +- **Reboots**: Use restart policy “Unless stopped” so the bot starts again after a NAS reboot. +- **Config backup**: Back up the `config` folder (and `.env` if you keep it on the NAS) so you don’t lose roles configuration. +- **UGOS / Docker issues**: If the NAS Docker stack has known issues, prefer building and running via SSH (Option A) or using a pre-built image (Option C) so the NAS doesn’t have to build the image. + +--- + +## Quick checklist + +- [ ] Project (or image) on the NAS +- [ ] `.env` with `STOAT_BOT_TOKEN` (or env vars set in the container) +- [ ] `config/roles.json` with your server ID and reaction-role message ID +- [ ] Volume mapping: host `config` → `/app/config` +- [ ] Container set to restart unless stopped +- [ ] Logs show “Logged in as …” and no errors diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c5b45f0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +# Stoat Role Bot — self-hosted Stoat (stoat.chat) role assignment +FROM node:22-slim + +WORKDIR /app + +COPY package.json ./ +RUN npm install --omit=dev + +COPY bot/ /app/bot/ + +ENV CONFIG_PATH=/app/config +ENV PREFIX=! + +RUN useradd -m -u 1001 botuser && chown -R botuser /app +USER botuser + +CMD ["node", "bot/index.js"] diff --git a/LOCAL-TEST.md b/LOCAL-TEST.md new file mode 100644 index 0000000..9bb721d --- /dev/null +++ b/LOCAL-TEST.md @@ -0,0 +1,94 @@ +# Local testing (verify bot works before/without NAS) + +Run the bot on your PC with the same token and config. If it works locally but not on the NAS, the issue is NAS/container. If it fails locally too, the issue is token, config, or Stoat server. + +--- + +## 1. Run with Node.js (fastest) + +On your PC, in the project folder: + +```bash +cd "/home/jorg/Documents/Cursor Projects/Role Bot" + +# Use the same .env as NAS (token must be set) +export $(grep -v '^#' .env | xargs) +export CONFIG_PATH=./config + +# Optional: see every message the bot receives (for debugging) +export DEBUG=1 + +# Install and run +npm install +npm start +``` + +You should see: +- `Connecting to Stoat/Revolt API...` +- `Logged in as ExodusMigrator — bot is online` +- With `DEBUG=1`, lines like `Message: !roles [command]` when you type in Stoat + +**Test in Stoat:** In a channel the bot can see, send: +- `!roles` → bot should reply (list of roles or “No self-assignable roles…”). +- If you have assignable roles set in `config/roles.json`, try `!role add `. + +Stop with **Ctrl+C**. + +--- + +## 2. Run with Docker/Podman (same as NAS) + +Same setup as the NAS, but on your machine: + +```bash +cd "/home/jorg/Documents/Cursor Projects/Role Bot" + +# Ensure .env has STOAT_BOT_TOKEN and config/roles.json exists +docker compose up +# or: podman compose up +``` + +Leave it running and test `!roles` in Stoat. Stop with **Ctrl+C** (or `docker compose down` in another terminal). + +--- + +## 3. What to check + +| Result | Meaning | +|--------|--------| +| Local Node: bot replies to `!roles` | Bot and token are fine; problem is likely NAS/container (network or env). | +| Local Node: no reply to `!roles` | Check: (1) `config/roles.json` has correct **server ID** for your Stoat server, (2) bot is in that server and has a role above the roles it assigns, (3) channel is in that server. | +| Local Node: "Login failed" or crash | Token invalid/revoked or network block; fix token or firewall. | +| Local works, NAS doesn’t | On NAS: confirm `STOAT_BOT_TOKEN` is set in the container, and that the NAS can reach the internet (no block on outbound HTTPS). | + +--- + +## 4. Get your server ID for config + +If `!roles` says "No self-assignable roles" or "Configured role names don't match", your `config/roles.json` may have the wrong server ID. + +In Stoat (web): open your server, check the URL. It often looks like: +`https://app.revolt.chat/server/01ABC123...` +The long ID after `/server/` is your **server ID**. Use it in `config/roles.json`: + +```json +{ + "servers": { + "01ABC123_YOUR_REAL_SERVER_ID": { + "reaction_roles": {}, + "assignable_roles": ["Notifications", "Gamer"] + } + } +} +``` + +Role names under `assignable_roles` must match the **exact** role names in your Stoat server (case-sensitive). + +--- + +## 5. Optional: DEBUG when running on NAS + +To see incoming messages in the NAS container logs, add this env var when creating the container: +- `DEBUG=1` + +Then in the logs you’ll see when the bot receives a message (and whether it’s treated as a command). Helps confirm if the bot gets events at all on the NAS. diff --git a/README.md b/README.md new file mode 100644 index 0000000..af5facf --- /dev/null +++ b/README.md @@ -0,0 +1,168 @@ +# Stoat Role Bot + +Self-hosted bot for **[Stoat](https://stoat.chat)** (open-source chat platform at [stoat.chat](https://stoat.chat)) that gives members **roles** via **reactions** or **text commands** in server channels. + +Stoat is the rebrand of Revolt and uses the same API; this bot works with Stoat and Revolt instances. + +## Features + +- **Reaction roles**: Users react to a specific message with an emoji to get (or remove) a role. +- **Text commands**: Users run `!role add ` / `!role remove ` for self-assignable roles; `!roles` lists them. +- **Admin commands**: Configure reaction-role message and assignable role list from the chat (or via `config/roles.json`). + +## Quick start (Docker) + +1. **Create a bot on your Stoat server** + - In Stoat, open your server → **Settings** → **Bots** (or equivalent). + - Create a bot and copy its **token**. + - Ensure the bot has **Assign Roles** permission (and that its role is above any roles it should assign). + +2. **Configure and run** + ```bash + cp .env.example .env + # Edit .env and set STOAT_BOT_TOKEN=your_bot_token + cp config/roles.json.example config/roles.json + # Edit config/roles.json: replace YOUR_SERVER_ID and MESSAGE_ID (see below) + docker compose up -d + ``` + +3. **Server ID**: In Stoat, enable developer mode if available, then right‑click your server → copy ID. Use that in `roles.json` as `YOUR_SERVER_ID`. + +4. **Reaction-role message**: Send a message in the channel (e.g. “React to get roles”), then copy that message’s ID. Put it under `reaction_roles` in `roles.json` with emoji → role name. When users add/remove reactions, the bot will add/remove the role. + +## Configuration + +- **Environment** + - `STOAT_BOT_TOKEN` or `REVOLT_BOT_TOKEN` (required): Bot token from your Stoat/Revolt server. + - `PREFIX` (optional): Command prefix (default `!`). + - `CONFIG_PATH` (optional): Path to config dir inside container (default `/app/config`). + +- **`config/roles.json`** + - `servers..reaction_roles.`: map emoji (unicode or custom emoji ID) → role name. + - `servers..assignable_roles`: list of role names that can be used with `!role add/remove`. + +Example: + +```json +{ + "servers": { + "01ABC123DEF456": { + "reaction_roles": { + "01MSG789XYZ": { + "👍": "Notifications", + "🔔": "Announcements" + } + }, + "assignable_roles": ["Notifications", "Announcements", "Gamer"] + } + } +} +``` + +The bot writes updated config when admins use the admin commands below, so the container needs write access to the mounted `config` directory. + +## Commands + +| Command | Who | Description | +|--------|-----|-------------| +| `!roles` | Everyone | List self-assignable roles. | +| `!role add ` | Everyone | Give yourself a self-assignable role. | +| `!role remove ` | Everyone | Remove a self-assignable role. | +| `!setreactionroles emoji=RoleName ...` | Admin (Manage Role) | Set which message and emoji→role pairs are used for reaction roles. | +| `!setassignableroles Role1, Role2, Role3` | Admin (Manage Role) | Set the list of self-assignable roles (comma-separated names). | + +For **custom server emojis** in reaction roles, use the emoji’s ID in `roles.json` (see Stoat/Revolt docs for how to get it). Unicode emojis (e.g. 👍) can be used as-is. + +## Setting up reaction roles in config + +1. **Create the message in Stoat** + In the channel where you want reaction roles, send a message (e.g. "React below to get roles"). That message must stay there; the bot will watch reactions on it. + +2. **Get the message ID** + In Stoat, enable developer mode if available, then **right-click that message** and copy its ID. It's a long string like `01ABC123MSG456...`. + +3. **Edit `config/roles.json`** + Under your server (same `SERVER_ID` you use for `assignable_roles`), add a `reaction_roles` block. The key is the **message ID**; the value is an object mapping **emoji to role name** (role names must match your server's roles exactly): + + ```json + "reaction_roles": { + "01ABC123MSG456": { + "👍": "FFXIV", + "🎮": "Minecraft", + "🃏": "MtG" + } + } + ``` + + - **Unicode emojis** (👍, 🎮, etc.): use the character as the key. + - **Custom server emojis**: use the emoji's ID as the key. + +4. **Save the file.** The bot reloads config on each reaction; restart the container only if needed. + +5. **Add the reactions to the message** + Add the same emojis to the message. When users click a reaction they get the role; when they remove it they lose the role. + +**Example** for your server (replace `PASTE_MESSAGE_ID_HERE` with the real message ID): + +```json +"01KHEGSQZB7HXG0YCKMCA2W5PW": { + "reaction_roles": { + "PASTE_MESSAGE_ID_HERE": { + "👍": "FFXIV", + "🎮": "Minecraft", + "🃏": "MtG" + } + }, + "assignable_roles": ["FFXIV", "Ascension", "Tarnished", "Minecraft", "Tabletop", "MtG", "Smash", "Soul Calibur", "Hearthstone", "Degenerate"] +} +``` + +**Alternative:** An admin can set reaction roles from chat: +`!setreactionroles 👍=FFXIV 🎮=Minecraft 🃏=MtG` + +## Self-hosted Stoat / custom API URL + +If you run your own Stoat (or Revolt) instance, point the bot at your API and WebSocket. Set the client configuration when building the bot (revolt.js accepts a `configuration` object with `revolt` and `ws` URLs). The default in revolt.js is the public Revolt/Stoat API (`api.revolt.chat` / `wss://ws.revolt.chat`). To use a different instance you would need to pass a custom configuration to the `Client` constructor; see [revolt.js](https://revolt.js.org/) and [Stoat developers](https://developers.stoat.chat/). + +## Hosting the image on Gitea (automated pull on NAS) + +**New to Gitea?** See **[docs/GITEA-SETUP.md](docs/GITEA-SETUP.md)** for a simple step-by-step guide. + +To build in Gitea and pull the image on your NAS: + +1. **Push this repo to your Gitea** (e.g. `myuser/stoat-role-bot`). + +2. **Enable Gitea Actions** and add **Secrets** for the container registry: + - **Settings → Secrets**: `REGISTRY_USER` = your Gitea username, `REGISTRY_PASSWORD` = your password or a Personal Access Token (with package write). + +3. **On every push to `main`**, the workflow in `.gitea/workflows/docker.yml` builds and pushes the image to your Gitea Container Registry. The image will be at: + `{your-gitea-host}/{owner}/stoat-role-bot:latest` + (e.g. `gitea.example.com/myuser/stoat-role-bot:latest`). + +4. **On your NAS**, use the image from Gitea instead of building locally: + - Create `.env` with `GITEA_IMAGE=gitea.example.com/myuser/stoat-role-bot:latest`, plus `STOAT_BOT_TOKEN`, etc. + - Run: `docker compose -f docker-compose.pull.yml pull && docker compose -f docker-compose.pull.yml up -d` + - To **auto-update**: use [Watchtower](https://containers.dev/run/watchtower) or a cron job that runs `docker compose -f docker-compose.pull.yml pull` and restarts the container. + +**Manual push** (from your PC, without Actions): +```bash +export GITEA_REGISTRY=gitea.example.com +export GITEA_OWNER=myuser +./push-to-gitea.sh +``` + +## Run without Docker + +Requires Node.js 22+. + +```bash +npm install +cp .env.example .env +# Set STOAT_BOT_TOKEN in .env +export CONFIG_PATH=./config +node bot/index.js +``` + +## License + +MIT. diff --git a/VERIFY.md b/VERIFY.md new file mode 100644 index 0000000..7a6c161 --- /dev/null +++ b/VERIFY.md @@ -0,0 +1,102 @@ +# Verifying the Stoat Role Bot (container running but bot shows offline) + +## 1. Check container logs + +On the NAS (SSH or container log viewer in UGOS): + +```bash +# If using Docker +docker logs stoat-role-bot + +# If using Podman +podman logs stoat-role-bot + +# Follow logs in real time +docker logs -f stoat-role-bot +``` + +**What to look for:** + +| Log line | Meaning | +|----------|--------| +| `Connecting to Stoat/Revolt API...` | Bot started, attempting login | +| `Logged in as — bot is online` | **Success** – gateway connected; if Stoat still shows offline, it may be a cache/UI delay | +| `Login failed: ...` | Wrong token, revoked token, or API unreachable | +| `Unhandled rejection:` / `Uncaught exception:` | Runtime error (e.g. network, API change) – the message will say what failed | +| No "Logged in" after "Connecting..." | Connection to API or WebSocket failed (network/firewall) | +| `Skipping key voice during hydration!` | **Ignore.** From revolt.js while loading server data; harmless. | + +--- + +## 2. Verify environment variables + +Ensure the container has the token: + +```bash +docker exec stoat-role-bot env | grep -E 'STOAT_BOT_TOKEN|REVOLT_BOT_TOKEN|PREFIX' +``` + +- You should see `STOAT_BOT_TOKEN=...` or `REVOLT_BOT_TOKEN=...` with a non-empty value. +- If they’re empty or missing, set them in the container creation (env vars or `.env` file) and recreate the container. + +--- + +## 3. Test outbound connectivity (from NAS) + +The bot only needs **outbound** HTTPS and WebSocket. From the NAS (SSH) or from another container on the same host: + +```bash +# Can the NAS reach the Revolt/Stoat API? +curl -s -o /dev/null -w "%{http_code}" https://api.revolt.chat +# Expect 200 or 401 (API is up) + +# Optional: WebSocket (may not be available in curl) +curl -sI "https://api.revolt.chat" +``` + +If these fail, the NAS or its Docker network may be blocking outbound traffic (firewall or proxy). + +--- + +## 4. Token and bot status + +- **Token**: Create a **new** bot token in your Stoat server (Server → Bots → your bot → regenerate/copy token). Update the container env with the new token and restart. +- **Bot role**: The bot’s role must be **above** any role it assigns. Move it up in the server’s role list. +- **Invite**: The bot must still be in the server. If you removed it, re-invite it with the correct permissions (e.g. Assign Roles). + +--- + +## 5. Test in a channel (once logs say "online") + +In a Stoat channel the bot can see: + +1. Send: `!roles` + - Expected: Bot replies with the list of self-assignable roles (or a message that none are configured). +2. If that works, try: `!role add ` (use a role from the list). + +If the bot replies, it’s working; “offline” in the UI may be a display delay. + +--- + +## 6. Restart the container + +After changing env vars or fixing config: + +```bash +docker restart stoat-role-bot +# then +docker logs -f stoat-role-bot +``` + +--- + +## Quick checklist + +- [ ] Logs show `Connecting to Stoat/Revolt API...` +- [ ] Logs show `Logged in as … — bot is online` (no login error) +- [ ] `STOAT_BOT_TOKEN` (or `REVOLT_BOT_TOKEN`) is set in the container +- [ ] NAS can reach `https://api.revolt.chat` +- [ ] Bot role is above roles it should assign; bot is still in the server +- [ ] In a channel, `!roles` gets a reply from the bot + +If logs show a specific error (e.g. `InvalidSession`, `NotAuthenticated`, or a network error), that message is the next place to look (token vs network vs permissions). diff --git a/bot/index.js b/bot/index.js new file mode 100644 index 0000000..b1b149a --- /dev/null +++ b/bot/index.js @@ -0,0 +1,433 @@ +/** + * Stoat Role Bot — Self-hosted bot for Stoat (stoat.chat) role assignment + * via reactions and text commands. Uses Revolt/Stoat API (revolt.js). + */ +import { Client } from "revolt.js"; +import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs"; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const CONFIG_PATH = process.env.CONFIG_PATH || "/app/config"; +const ROLES_FILE = join(CONFIG_PATH, "roles.json"); +const PREFIX = (process.env.PREFIX || "!").trim(); + +function loadConfig() { + if (!existsSync(ROLES_FILE)) return { servers: {} }; + try { + return JSON.parse(readFileSync(ROLES_FILE, "utf8")); + } catch (e) { + console.warn("Could not load roles config:", e.message); + return { servers: {} }; + } +} + +function saveConfig(data) { + mkdirSync(CONFIG_PATH, { recursive: true }); + writeFileSync(ROLES_FILE, JSON.stringify(data, null, 2), "utf8"); +} + +function getReactionRoles(serverId) { + const config = loadConfig(); + return config.servers?.[serverId]?.reaction_roles ?? {}; +} + +function getAssignableRoles(serverId) { + const config = loadConfig(); + return config.servers?.[serverId]?.assignable_roles ?? []; +} + +function getRoleByName(server, roleName) { + const ordered = server.orderedRoles || []; + return ordered.find((r) => r.name === roleName) ?? null; +} + +function emojiKey(emojiId) { + // Revolt: unicode emoji come as the character(s); custom as id string + return typeof emojiId === "string" ? emojiId : String(emojiId); +} + +const client = new Client(); + +// Debug: log when client emits any message-related event (so we see which event name is used) +if (process.env.DEBUG) { + const origEmit = client.emit.bind(client); + client.emit = function (event, ...args) { + const en = String(event); + if (en !== "error" && en.toLowerCase().includes("message")) { + const first = args[0]; + const id = first?.id ?? first?._id ?? (typeof first === "object" ? "(object)" : ""); + console.info("[Stoat Role Bot] Client.emit:", en, id); + } + return origEmit(event, ...args); + }; +} +// Prevent unhandled "error" from crashing the process (e.g. WebSocket errors) +client.on("error", (err) => console.warn("[Stoat Role Bot] Client error:", err?.message || err)); + +// Log connection errors and crashes so container logs show why the bot might stay offline +process.on("unhandledRejection", (err) => { + console.error("[Stoat Role Bot] Unhandled rejection:", err); +}); +process.on("uncaughtException", (err) => { + console.error("[Stoat Role Bot] Uncaught exception:", err); + process.exit(1); +}); + +client.on("ready", async () => { + console.info(`[Stoat Role Bot] Logged in as ${client.user?.username} — bot is online`); + const names = client.eventNames?.(); + if (names?.length) console.info("[Stoat Role Bot] Client event names:", names.filter((n) => typeof n === "string").slice(0, 30).join(", ")); + // Some revolt.js versions emit on the raw events client only + if (client.events && typeof client.events.on === "function") { + // Log every event from the wire so we see what actually arrives when someone sends a message + const origEmitEvents = client.events.emit.bind(client.events); + client.events.emit = function (eventName, ...args) { + if (String(eventName) !== "error") { + const data = args[0]; + const keys = data && typeof data === "object" ? Object.keys(data).slice(0, 12).join(", ") : ""; + console.info("[Stoat Role Bot] client.events.emit:", eventName, keys || "(no keys)"); + } + return origEmitEvents(eventName, ...args); + }; + client.events.on("Message", (data) => { + const keys = data && typeof data === "object" ? Object.keys(data).join(", ") : ""; + console.info("[Stoat Role Bot] Message handler, keys:", keys); + const id = data?.id ?? data?._id; + if (!id) { + console.warn("[Stoat Role Bot] Message event had no id, keys:", data && Object.keys(data)); + return; + } + setImmediate(() => { + const msg = client.messages?.get(id); + if (msg) { + handleMessage(msg); + } else { + console.warn("[Stoat Role Bot] client.messages.get(" + id + ") returned nothing, trying handleMessage with raw data"); + if (data?.content != null && data?.channel) { + handleMessage({ ...data, authorId: data.author ?? data.authorId, channel: client.channels?.get(data.channel), server: data.server ?? client.channels?.get(data.channel)?.server, content: data.content, contentPlain: data.contentPlain ?? data.content, reply: async (text) => { const ch = client.channels?.get(data.channel); if (ch?.sendMessage) await ch.sendMessage(text); } }); + } + } + }); + }); + console.info("[Stoat Role Bot] Subscribed to client.events 'Message'"); + client.events.on("MessageReact", (payload) => { + console.info("[Stoat Role Bot] MessageReact (client.events) received", payload?.id, payload?.emoji_id); + handleReactionAdd(payload); + }); + client.events.on("MessageUnreact", (payload) => { + console.info("[Stoat Role Bot] MessageUnreact (client.events) received", payload?.id, payload?.emoji_id); + handleReactionRemove(payload); + }); + // Revolt.js may emit a generic "event" with payload.type = "MessageReact" / "MessageUnreact" + client.events.on("event", (payload) => { + const t = payload?.type; + if (t === "MessageReact") { + console.info("[Stoat Role Bot] event (MessageReact) received", payload?.id, payload?.emoji_id); + handleReactionAdd(payload); + } else if (t === "MessageUnreact") { + console.info("[Stoat Role Bot] event (MessageUnreact) received", payload?.id, payload?.emoji_id); + handleReactionRemove(payload); + } + }); + console.info("[Stoat Role Bot] Subscribed to client.events 'MessageReact', 'MessageUnreact', and 'event'"); + } +}); + +// —— Reaction-based role assignment —— +// Revolt API: MessageReact, MessageUnreact (payload: id, channel_id, user_id, emoji_id) +// Like Message, these may only be emitted on client.events with capital letter + +async function handleReactionAdd(payload) { + const messageId = payload?.id ?? payload?.message_id; + const channel_id = payload?.channel_id ?? payload?.channel; + const user_id = payload?.user_id ?? payload?.user; + const emoji_id = payload?.emoji_id ?? payload?.emoji; + if (!messageId || !channel_id || !user_id) return; + if (user_id === client.user?._id) return; + const channel = client.channels.get(channel_id); + if (!channel?.server) return; + const serverId = channel.server.id; + const reactionRoles = getReactionRoles(serverId); + const key = emojiKey(emoji_id); + let roleName = null; + for (const [msgId, mapping] of Object.entries(reactionRoles)) { + if (msgId === messageId && (mapping[key] !== undefined || mapping[emoji_id] !== undefined)) { + roleName = mapping[key] ?? mapping[emoji_id]; + break; + } + } + if (!roleName) return; + const server = channel.server; + const role = getRoleByName(server, roleName); + if (!role) return; + let member = server.getMember(user_id); + if (!member) { + try { + member = await server.fetchMember(user_id); + } catch { + return; + } + } + const currentRoles = [...(member.roles || [])]; + if (currentRoles.includes(role.id)) return; + try { + await member.edit({ roles: [...currentRoles, role.id] }); + console.info(`[Stoat Role Bot] Added role "${roleName}" to user in ${server.name}`); + } catch (e) { + console.warn(`[Stoat Role Bot] Could not add role "${roleName}":`, e.message); + } +} + +async function handleReactionRemove(payload) { + const messageId = payload?.id ?? payload?.message_id; + const channel_id = payload?.channel_id ?? payload?.channel; + const user_id = payload?.user_id ?? payload?.user; + const emoji_id = payload?.emoji_id ?? payload?.emoji; + if (!messageId || !channel_id || !user_id) return; + if (user_id === client.user?._id) return; + const channel = client.channels.get(channel_id); + if (!channel?.server) return; + const serverId = channel.server.id; + const reactionRoles = getReactionRoles(serverId); + const key = emojiKey(emoji_id); + let roleName = null; + for (const [msgId, mapping] of Object.entries(reactionRoles)) { + if (msgId === messageId && (mapping[key] !== undefined || mapping[emoji_id] !== undefined)) { + roleName = mapping[key] ?? mapping[emoji_id]; + break; + } + } + if (!roleName) return; + const server = channel.server; + const role = getRoleByName(server, roleName); + if (!role) return; + let member = server.getMember(user_id); + if (!member) { + try { + member = await server.fetchMember(user_id); + } catch { + return; + } + } + const currentRoles = [...(member.roles || [])].filter((id) => id !== role.id); + try { + await member.edit({ roles: currentRoles }); + console.info(`[Stoat Role Bot] Removed role "${roleName}" from user in ${server.name}`); + } catch (e) { + console.warn(`[Stoat Role Bot] Could not remove role "${roleName}":`, e.message); + } +} + +client.on("messageReact", (payload) => { + console.info("[Stoat Role Bot] messageReact (Client) received", payload?.id, payload?.emoji_id); + handleReactionAdd(payload); +}); + +client.on("messageUnreact", (payload) => { + console.info("[Stoat Role Bot] messageUnreact (Client) received", payload?.id, payload?.emoji_id); + handleReactionRemove(payload); +}); + + +// —— Text commands —— +// revolt.js uses camelCase: authorId, and message.server (not only channel.server) +function getAuthorId(msg) { + return msg.authorId ?? msg.author_id; +} +function getServer(msg) { + return msg.server ?? msg.channel?.server; +} + +async function handleMessage(message) { + const content = message.content ?? message.contentPlain ?? ""; + const isCommand = content.startsWith(PREFIX); + const authorId = getAuthorId(message); + if (process.env.DEBUG) { + console.info(`[Stoat Role Bot] handleMessage: "${content.slice(0, 50)}" authorId=${authorId} isCmd=${isCommand} server=${!!getServer(message)}`); + } + if (!isCommand || authorId === client.user?._id) return; + const server = getServer(message); + if (!server) { + if (process.env.DEBUG) console.info("[Stoat Role Bot] Ignoring: no server (DM?)"); + return; + } + const serverId = server.id; + const channel = message.channel; + const args = content.slice(PREFIX.length).trim().split(/\s+/); + const cmd = args[0]?.toLowerCase(); + if (!cmd) return; + + const safeReply = async (text) => { + console.info("[Stoat Role Bot] Sending reply…"); + try { + if (typeof message.reply === "function") { + await message.reply(text); + } else if (channel?.sendMessage) { + await channel.sendMessage(text); + } else { + console.warn("[Stoat Role Bot] No reply/sendMessage available"); + } + } catch (e) { + console.warn("[Stoat Role Bot] reply failed:", e?.message || e); + try { + if (channel?.sendMessage) await channel.sendMessage(text); + } catch (e2) { + console.warn("[Stoat Role Bot] sendMessage also failed:", e2?.message || e2); + } + } + }; + + // Simple test command – if this works, the bot can send in this channel + if (cmd === "ping") { + await safeReply("pong"); + return; + } + + // Helper: show server ID for config/roles.json + if (cmd === "serverid") { + await safeReply(`This server's ID for \`config/roles.json\`:\n\`\`\`${serverId}\`\`\`\nAdd it under \`servers.\"${serverId}\"\` with \`assignable_roles\` and optionally \`reaction_roles\`.`); + return; + } + + if (cmd === "roles") { + const names = getAssignableRoles(serverId); + if (!names.length) { + await safeReply("No self-assignable roles are configured. Ask an admin to set them in `roles.json`."); + return; + } + const roles = names + .map((n) => getRoleByName(server, n)) + .filter(Boolean); + if (!roles.length) { + await safeReply("Configured role names don't match any server roles. Check `roles.json`."); + return; + } + const lines = roles.map((r) => `• **${r.name}**`).join("\n"); + await safeReply(`**Self-assignable roles:**\n${lines}`); + return; + } + + if (cmd === "role" && args.length >= 3) { + const action = args[1].toLowerCase(); + const roleName = args.slice(2).join(" "); + if (!["add", "remove", "give", "take"].includes(action)) { + await safeReply(`Use \`${PREFIX}role add \` or \`${PREFIX}role remove \`.`); + return; + } + const assignable = getAssignableRoles(serverId); + if (!assignable.includes(roleName)) { + await safeReply(`**${roleName}** is not a self-assignable role. Use \`${PREFIX}roles\` to list them.`); + return; + } + const role = getRoleByName(server, roleName); + if (!role) { + await safeReply("That role doesn't exist on this server."); + return; + } + let member = server.getMember(authorId); + if (!member) { + try { + member = await server.fetchMember(authorId); + } catch { + await safeReply("Could not find you as a member."); + return; + } + } + const currentRoles = [...(member.roles || [])]; + const give = action === "add" || action === "give"; + if (give) { + if (currentRoles.includes(role.id)) { + await safeReply(`You already have **${role.name}**.`); + return; + } + try { + await member.edit({ roles: [...currentRoles, role.id] }); + await safeReply(`Added **${role.name}** to you.`); + } catch (e) { + await safeReply("I don't have permission to add that role."); + } + } else { + if (!currentRoles.includes(role.id)) { + await safeReply(`You don't have **${role.name}**.`); + return; + } + try { + await member.edit({ roles: currentRoles.filter((id) => id !== role.id) }); + await safeReply(`Removed **${role.name}** from you.`); + } catch (e) { + await safeReply("I don't have permission to remove that role."); + } + } + return; + } + + // Admin: set reaction roles for a message + if (cmd === "setreactionroles" && args.length >= 2) { + const memberObj = server.getMember(authorId) ?? (await server.fetchMember(authorId).catch(() => null)); + if (!memberObj?.hasPermission?.(server, "ManageRole")) { + await safeReply("You need the Manage Role permission."); + return; + } + const messageId = args[1]; + const pairs = args.slice(2).join(" "); + const mapping = {}; + for (const part of pairs.split(/\s+/)) { + const eq = part.indexOf("="); + if (eq > 0) { + const emoji = part.slice(0, eq).trim(); + const roleName = part.slice(eq + 1).trim(); + if (emoji && roleName) mapping[emoji] = roleName; + } + } + if (Object.keys(mapping).length === 0) { + await safeReply("Provide at least one pair like `emoji=RoleName`."); + return; + } + const config = loadConfig(); + config.servers = config.servers || {}; + config.servers[serverId] = config.servers[serverId] || { reaction_roles: {}, assignable_roles: getAssignableRoles(serverId) }; + config.servers[serverId].reaction_roles = config.servers[serverId].reaction_roles || {}; + config.servers[serverId].reaction_roles[messageId] = mapping; + saveConfig(config); + await safeReply(`Reaction roles set for message \`${messageId}\`. Add those emojis to the message and users will get the roles.`); + return; + } + + // Admin: set assignable roles + if (cmd === "setassignableroles") { + const memberObj = server.getMember(authorId) ?? (await server.fetchMember(authorId).catch(() => null)); + if (!memberObj?.hasPermission?.(server, "ManageRole")) { + await safeReply("You need the Manage Role permission."); + return; + } + const names = args.slice(1).join(" ").split(",").map((s) => s.trim()).filter(Boolean); + const config = loadConfig(); + config.servers = config.servers || {}; + config.servers[serverId] = config.servers[serverId] || { reaction_roles: getReactionRoles(serverId), assignable_roles: [] }; + config.servers[serverId].assignable_roles = names; + saveConfig(config); + await safeReply(`Assignable roles set to: ${names.length ? names.join(", ") : "(none)"}.`); + } +} +// Revolt.js may emit "message", "messageCreate", or raw "Message" +client.on("message", handleMessage); +client.on("messageCreate", handleMessage); +client.on("Message", (payload) => { + const id = payload?.id ?? payload?._id; + const msg = typeof id === "string" ? client.messages?.get(id) : payload; + if (msg && typeof msg.reply === "function") handleMessage(msg); + else if (msg && (msg.content != null || msg.contentPlain != null)) handleMessage(msg); +}); + +const token = process.env.STOAT_BOT_TOKEN || process.env.REVOLT_BOT_TOKEN; +if (!token) { + console.error("[Stoat Role Bot] Set STOAT_BOT_TOKEN (or REVOLT_BOT_TOKEN) in the environment."); + process.exit(1); +} + +console.info("[Stoat Role Bot] Connecting to Stoat/Revolt API..."); +client.loginBot(token).catch((err) => { + console.error("[Stoat Role Bot] Login failed:", err?.message || err); + process.exit(1); +}); diff --git a/build-for-nas.sh b/build-for-nas.sh new file mode 100755 index 0000000..6979d3b --- /dev/null +++ b/build-for-nas.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Build Stoat Role Bot image and save to a .tar for upload to NAS. +# Run this on a machine with Docker installed (e.g. your PC). + +set -e +cd "$(dirname "$0")" + +echo "Building Docker image..." +docker build -t stoat-role-bot:latest . + +echo "Saving image to stoat-role-bot.tar..." +docker save stoat-role-bot:latest -o stoat-role-bot.tar + +echo "Done. Upload these to your NAS:" +echo " 1. stoat-role-bot.tar (the image)" +echo " 2. config/ folder with roles.json (your server ID + message ID)" +echo "" +echo "Then on the NAS: load the image and create a container with:" +echo " - Env: STOAT_BOT_TOKEN, PREFIX=!, CONFIG_PATH=/app/config" +echo " - Volume: host path with roles.json -> /app/config" diff --git a/config/roles.json.example b/config/roles.json.example new file mode 100644 index 0000000..6107e62 --- /dev/null +++ b/config/roles.json.example @@ -0,0 +1,17 @@ +{ + "servers": { + "YOUR_SERVER_ID": { + "reaction_roles": { + "MESSAGE_ID": { + "👍": "Notifications", + "🔔": "Announcements" + } + }, + "assignable_roles": [ + "Notifications", + "Announcements", + "Gamer" + ] + } + } +} diff --git a/docker-compose.pull.yml b/docker-compose.pull.yml new file mode 100644 index 0000000..ad7279e --- /dev/null +++ b/docker-compose.pull.yml @@ -0,0 +1,19 @@ +# Use this on your NAS to run the image pulled from Gitea (no local build). +# Set env vars and run: docker compose -f docker-compose.pull.yml up -d +# +# Required: set GITEA_IMAGE in .env or below, e.g.: +# GITEA_IMAGE=gitea.example.com/username/stoat-role-bot:latest +# Then: docker compose -f docker-compose.pull.yml pull && docker compose -f docker-compose.pull.yml up -d + +services: + rolebot: + image: ${GITEA_IMAGE:-gitea.example.com/username/stoat-role-bot:latest} + container_name: stoat-role-bot + restart: unless-stopped + environment: + - STOAT_BOT_TOKEN=${STOAT_BOT_TOKEN} + - REVOLT_BOT_TOKEN=${REVOLT_BOT_TOKEN} + - PREFIX=${PREFIX:-!} + - CONFIG_PATH=/app/config + volumes: + - ./config:/app/config:rw diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b57cd12 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +# Stoat Role Bot — run with: docker compose up -d +# Set STOAT_BOT_TOKEN (and optionally PREFIX) in .env or below. +# For Stoat (stoat.chat) / Revolt API. + +services: + rolebot: + build: . + container_name: stoat-role-bot + restart: unless-stopped + environment: + - STOAT_BOT_TOKEN=${STOAT_BOT_TOKEN} + - REVOLT_BOT_TOKEN=${REVOLT_BOT_TOKEN} + - PREFIX=${PREFIX:-!} + - CONFIG_PATH=/app/config + volumes: + - ./config:/app/config:rw + # env_file: .env diff --git a/docs/GITEA-SETUP.md b/docs/GITEA-SETUP.md new file mode 100644 index 0000000..0e0940d --- /dev/null +++ b/docs/GITEA-SETUP.md @@ -0,0 +1,192 @@ +# Simple Gitea setup for Stoat Role Bot + +This guide assumes you have a Gitea server (e.g. `https://gitea.yourdomain.com`) and want the bot’s Docker image to be built and stored there so your NAS can pull it. + +--- + +## Your exact values (copy-paste) + +| What | Value | +|------|--------| +| **Gitea URL** | `http://brassnet.ddns.net:33983` | +| **Username** | `Dawnsorrow` | +| **Registry host** (for Docker/Podman) | `brassnet.ddns.net:33983` | +| **Full image name** (after workflow runs) | `brassnet.ddns.net:33983/Dawnsorrow/stoat-role-bot:latest` | + +**Git remote** (from your PC, in the bot project folder): +```bash +git remote add origin http://brassnet.ddns.net:33983/Dawnsorrow/stoat-role-bot.git +``` + +**On your NAS** – in `.env` add: +``` +GITEA_IMAGE=brassnet.ddns.net:33983/Dawnsorrow/stoat-role-bot:latest +``` + +**Gitea repo Settings → Secrets** – create: +- **REGISTRY_USER** = `Dawnsorrow` +- **REGISTRY_PASSWORD** = your Gitea password (or a Personal Access Token) + +**Manual push from PC** (if you’re not using Actions): +```bash +export GITEA_REGISTRY=brassnet.ddns.net:33983 +export GITEA_OWNER=Dawnsorrow +./push-to-gitea.sh +``` + +--- + +## What you’re doing in one sentence + +You’ll put this bot’s code in a Gitea **repository**, add two **secrets** (username + password), and then every time you **push** to the repo, Gitea will **build** the Docker image and **publish** it to its built-in container registry. Your NAS will **pull** that image and run it. + +--- + +## Part 1: Create the repository in Gitea + +1. Log in to your Gitea in the browser. +2. Click **“+”** (or **New**) → **New Repository**. +3. Fill in: + - **Repository name:** `stoat-role-bot` (or any name you like). + - **Visibility:** Private or Public (your choice). + - Leave “Initialize repository” **unchecked** if you already have the code on your PC. +4. Click **Create Repository**. + +You’ll see an empty repo (or a page with clone/push instructions). That’s your “home” for this bot’s code. + +--- + +## Part 2: Push this project’s code to that repository + +From your PC, in the folder where the bot code lives (the one with `bot/`, `Dockerfile`, `.gitea/`, etc.): + +1. If this folder is **not** a git repo yet: + ```bash + cd "/home/jorg/Documents/Cursor Projects/Role Bot" + git init + git add . + git commit -m "Initial commit: Stoat Role Bot" + ``` +2. Add Gitea as the remote (replace with **your** Gitea URL and username): + ```bash + git remote add origin https://gitea.yourdomain.com/YOUR_USERNAME/stoat-role-bot.git + ``` + Example: if your Gitea is `https://git.myserver.com` and your username is `jorg`: + ```bash + git remote add origin https://git.myserver.com/jorg/stoat-role-bot.git + ``` +3. Push the code: + ```bash + git push -u origin main + ``` + If your branch is named `master` instead of `main`, use: + ```bash + git push -u origin master + ``` + +After this, the bot’s code (including the Dockerfile and the workflow file) is in Gitea. + +--- + +## Part 3: Turn on Gitea Actions (if your instance has it) + +1. In Gitea, open **your user menu** (top right) → **Site Administration** (only if you’re an admin). +2. Or ask your Gitea admin: “Is **Actions** enabled for this instance?” +3. For **this repo**: go to the repo → **Settings** → check for an **Actions** or **Workflows** section. If you see “Actions” or “Workflows” and they’re enabled, you’re good. + +If Actions are **not** available, you can skip the automated build and use the **manual push** method at the end instead. + +--- + +## Part 4: Add the two “secrets” (so Gitea can push to its own registry) + +Gitea needs to log in to its **container registry** to push the image. You give it your credentials as **secrets** (so they’re not written in the code). + +1. In your repo on Gitea, go to **Settings** (repo menu or top tabs). +2. In the left sidebar, click **Secrets** (or **Secrets and Variables**). +3. Add **two** secrets: + +| Name | Value | Notes | +|----------------------|--------------------------|--------| +| `REGISTRY_USER` | Your Gitea **username** | The one you use to log in. | +| `REGISTRY_PASSWORD` | Your Gitea **password** | Or a **Personal Access Token** (see below). | + +**Using a token instead of password (recommended):** + +1. In Gitea: your **profile icon** (top right) → **Settings** → **Applications** → **Generate New Token**. +2. Name it e.g. `stoat-bot-registry`, enable **write:package** (or “packages”) if you see it, then create the token. +3. Copy the token and use it as the value for **REGISTRY_PASSWORD** (leave REGISTRY_USER as your username). + +After saving both secrets, the workflow can log in to the registry when it runs. + +--- + +## Part 5: What happens when you push + +- The workflow file is in **`.gitea/workflows/docker.yml`**. +- When you **push to `main`** (or `master`), Gitea runs that workflow: + 1. It builds the Docker image from the Dockerfile in the repo. + 2. It logs in to Gitea’s container registry using the two secrets. + 3. It pushes the image as: **`{your-gitea-host}/{your-username}/stoat-role-bot:latest`** + +Example: if your Gitea URL is `https://git.myserver.com` and your username is `jorg`, the image will be: + +**`git.myserver.com/jorg/stoat-role-bot:latest`** + +(no `https://` in the image name) + +--- + +## Part 6: Use that image on your NAS + +1. On the NAS, create a folder for the bot (e.g. `stoat-role-bot`) and put there: + - **config/** (with your `roles.json`). + - **.env** with at least: + - `STOAT_BOT_TOKEN=your_bot_token` + - `GITEA_IMAGE=git.myserver.com/jorg/stoat-role-bot:latest` + (use **your** Gitea host and username). +2. Copy into that folder the file **`docker-compose.pull.yml`** from this repo. +3. In that folder, run: + ```bash + docker compose -f docker-compose.pull.yml pull + docker compose -f docker-compose.pull.yml up -d + ``` + +If your Gitea is only reachable on your LAN, the NAS must be able to reach that host (e.g. `git.myserver.com` or your server’s IP). If the registry is private, you may need to run `docker login git.myserver.com` on the NAS once (with your Gitea username and password/token). + +**HTTP (no HTTPS):** If your Gitea is at `http://...` (like `http://brassnet.ddns.net:33983`), Docker may treat the registry as “insecure.” On the NAS you might need to add that host to Docker’s insecure registries (e.g. in `/etc/docker/daemon.json`: `"insecure-registries": ["brassnet.ddns.net:33983"]`) and restart Docker, then run `docker login brassnet.ddns.net:33983` with your Gitea username and password. + +--- + +## If Gitea Actions aren’t available: manual push + +You can build and push the image from your PC instead of using Actions: + +1. Install Podman (or Docker) and log in to Gitea’s registry: + ```bash + podman login git.myserver.com + ``` + Use your Gitea username and password (or token). + +2. From the bot project folder: + ```bash + export GITEA_REGISTRY=git.myserver.com + export GITEA_OWNER=jorg + ./push-to-gitea.sh + ``` + Replace `git.myserver.com` and `jorg` with your Gitea host and username. + +3. On the NAS, use the same image name as above and run the same `docker compose -f docker-compose.pull.yml` commands. + +--- + +## Quick checklist + +- [ ] Repo created in Gitea. +- [ ] Code pushed to that repo (`git push origin main`). +- [ ] Actions enabled (if available). +- [ ] Secrets **REGISTRY_USER** and **REGISTRY_PASSWORD** added in repo Settings. +- [ ] After a push, the workflow runs and the image appears under the repo’s **Packages** (or **Container registry**). +- [ ] On the NAS: **GITEA_IMAGE** set in `.env`, then `docker compose -f docker-compose.pull.yml pull && up -d`. + +If you tell me your Gitea URL and username (e.g. `git.myserver.com` and `jorg`), I can give you the exact commands and `.env` line with those values filled in. diff --git a/package.json b/package.json new file mode 100644 index 0000000..13113bd --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "stoat-role-bot", + "version": "1.0.0", + "description": "Self-hosted Stoat (stoat.chat) bot for role assignment via reactions and text commands", + "type": "module", + "main": "bot/index.js", + "scripts": { + "start": "node bot/index.js", + "dev": "node --watch bot/index.js" + }, + "engines": { + "node": ">=22.15.0" + }, + "dependencies": { + "revolt.js": "^7.0.0" + } +} diff --git a/push-to-gitea.sh b/push-to-gitea.sh new file mode 100755 index 0000000..4857447 --- /dev/null +++ b/push-to-gitea.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Manual push of stoat-role-bot image to your Gitea container registry. +# Usage: +# export GITEA_REGISTRY=mygitea.example.com +# export GITEA_OWNER=myuser +# ./push-to-gitea.sh +# Or: ./push-to-gitea.sh mygitea.example.com myuser + +set -e +cd "$(dirname "$0")" + +GITEA_REGISTRY="${1:-$GITEA_REGISTRY}" +GITEA_OWNER="${2:-$GITEA_OWNER}" +IMAGE_NAME="stoat-role-bot" +TAG="${3:-latest}" + +if [ -z "$GITEA_REGISTRY" ] || [ -z "$GITEA_OWNER" ]; then + echo "Usage: GITEA_REGISTRY=host GITEA_OWNER=username $0" + echo " or: $0 [tag]" + echo "Example: $0 gitea.example.com john" + exit 1 +fi + +FULL_IMAGE="${GITEA_REGISTRY}/${GITEA_OWNER}/${IMAGE_NAME}:${TAG}" +echo "Building and pushing to ${FULL_IMAGE} ..." + +podman build -t "${FULL_IMAGE}" . +podman push "${FULL_IMAGE}" + +echo "Pushed. On your NAS run: docker pull ${FULL_IMAGE}" +echo "Then create/update the container to use image: ${FULL_IMAGE}"