Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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=!
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
.env
|
||||||
|
config/roles.json
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log
|
||||||
|
stoat-role-bot.tar
|
||||||
+119
@@ -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 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
|
||||||
+17
@@ -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"]
|
||||||
@@ -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 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.
|
||||||
@@ -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 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.<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 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 <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.
|
||||||
@@ -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 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 <RoleName>` (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).
|
||||||
+433
@@ -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);
|
||||||
|
});
|
||||||
Executable
+20
@@ -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"
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"servers": {
|
||||||
|
"YOUR_SERVER_ID": {
|
||||||
|
"reaction_roles": {
|
||||||
|
"MESSAGE_ID": {
|
||||||
|
"👍": "Notifications",
|
||||||
|
"🔔": "Announcements"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"assignable_roles": [
|
||||||
|
"Notifications",
|
||||||
|
"Announcements",
|
||||||
|
"Gamer"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+31
@@ -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}"
|
||||||
Reference in New Issue
Block a user