Initial commit: Stoat Role Bot
Build and Push Image / build (push) Has been cancelled

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Dawnsorrow
2026-02-17 19:45:23 -06:00
commit 09e3b6ca66
16 changed files with 1312 additions and 0 deletions
+6
View File
@@ -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=!
+55
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
.env
config/roles.json
node_modules/
npm-debug.log
stoat-role-bot.tar
+119
View File
@@ -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@<NAS_IP>:~/stoat-role-bot`
2. **SSH into the NAS**
```bash
ssh admin@<NAS_IP>
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 dont 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 doesnt 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
+17
View File
@@ -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"]
+94
View File
@@ -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 <RoleName>`.
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 doesnt | 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 youll see when the bot receives a message (and whether its treated as a command). Helps confirm if the bot gets events at all on the NAS.
+168
View File
@@ -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 <name>` / `!role remove <name>` 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 rightclick 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 messages 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.<SERVER_ID>.reaction_roles.<MESSAGE_ID>`: map emoji (unicode or custom emoji ID) → role name.
- `servers.<SERVER_ID>.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 <name>` | Everyone | Give yourself a self-assignable role. |
| `!role remove <name>` | Everyone | Remove a self-assignable role. |
| `!setreactionroles <message_id> 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 emojis 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 <message_id> 👍=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.
+102
View File
@@ -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 <name> — 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 theyre 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 bots role must be **above** any role it assigns. Move it up in the servers 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 <RoleName>` (use a role from the list).
If the bot replies, its 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).
+433
View File
@@ -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 <role name>\` or \`${PREFIX}role remove <role name>\`.`);
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);
});
+20
View File
@@ -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"
+17
View File
@@ -0,0 +1,17 @@
{
"servers": {
"YOUR_SERVER_ID": {
"reaction_roles": {
"MESSAGE_ID": {
"👍": "Notifications",
"🔔": "Announcements"
}
},
"assignable_roles": [
"Notifications",
"Announcements",
"Gamer"
]
}
}
}
+19
View File
@@ -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
+17
View File
@@ -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
+192
View File
@@ -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 bots 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 youre not using Actions):
```bash
export GITEA_REGISTRY=brassnet.ddns.net:33983
export GITEA_OWNER=Dawnsorrow
./push-to-gitea.sh
```
---
## What youre doing in one sentence
Youll put this bots 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**.
Youll see an empty repo (or a page with clone/push instructions). Thats your “home” for this bots code.
---
## Part 2: Push this projects 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 bots 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 youre 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 theyre enabled, youre 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 theyre 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 Giteas 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 servers 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 Dockers 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 arent 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 Giteas 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 repos **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.
+17
View File
@@ -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"
}
}
+31
View File
@@ -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 <gitea-host> <owner-username> [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}"