diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b401bb5 --- /dev/null +++ b/.env.example @@ -0,0 +1,78 @@ +# --- Required --- +# Discord bot token (https://discord.com/developers/applications) +BOT_TOKEN= + +# --- Core IDs (defaults target the official Arduino server) --- +# SERVER_ID=420594746990526466 +# BOT_COMMANDS_CHANNEL_ID=451158319361556491 + +# Channel where the automod posts image-spam alerts for moderators. +# REQUIRED to enable the anti-spam console; leave empty to disable automod. +MOD_LOG_CHANNEL_ID= + +# --- Server-management features (optional; each self-disables when unset) --- +# JOIN_LEAVE_LOG_CHANNEL_ID= # member join/leave + invite-source logging +# CROSSPOST_LOG_CHANNEL_ID= # where auto-crosspost results are logged +# CROSSPOST_CHANNEL_IDS= # comma-separated announcement channels to auto-publish +# ROLE_SELECT_MESSAGE_ID= # message whose buttons toggle opt-in roles +# EVENT_NOTIFS_ROLE_ID= # role toggled by the "events" button +# SERVER_UPDATE_NOTIFS_ROLE_ID= # role toggled by the "server_updates" button + +# --- Helper-assist features --- +# Channels where tag/code/ask suggestions are suppressed (staff channels, +# announcements, etc.). Comma-separated channel IDs. For a forum channel, +# listing the parent channel ID silences all its threads. +# SUGGEST_IGNORE_CHANNEL_IDS= +# Role IDs whose holders never receive suggestions (Trusted, Knowledgeable, +# Helper, Moderator, etc.). Do NOT include self-assignable notification roles. +# SUGGEST_IMMUNE_ROLE_IDS= +# Help channels may be FORUM channels (each post is a thread) or regular TEXT +# channels (threads opened inside them get the same treatment). Comma-separated. +# HELP_CHANNEL_IDS= # help channels (forum or text) β€” enables solve button, auto-needinfo, sweep, /openposts +# HELP_FORUM_CHANNEL_IDS= # legacy alias for HELP_CHANNEL_IDS (still honoured; merged in) +# TAG_SUGGEST_ENABLED=true # auto-suggest a relevant tag on common error messages (set false to disable) +# CODE_FORMAT_SUGGEST_ENABLED=true # nudge users who paste unformatted code toward the codeblock tag +# ASK_SUGGEST_ENABLED=true # nudge "can I ask?" / "anyone here?" non-questions toward the ask tag +# HELP_AUTO_NEEDINFO=true # auto-post the needinfo checklist when a new help post is too thin +# HELP_NEEDINFO_MIN_CHARS=60 # opening post shorter than this (and without code/image) counts as thin +# HELP_STALE_SWEEP_ENABLED=true # nudge + auto-archive abandoned help posts +# HELP_STALE_NUDGE_HOURS=72 # idle hours before a "still need help?" nudge (default 3 days) +# HELP_STALE_ARCHIVE_HOURS=168 # idle hours after a nudge (no human reply) before auto-archiving (default 7 days) + +# --- Persistence (optional) --- +# When unset, the bot runs fully in-memory and the spam-image blocklist +# resets on restart. With docker-compose this is wired automatically. +# DATABASE_URL=postgresql://arduino:arduino@db:5432/arduino + +# Postgres credentials used by docker-compose +# POSTGRES_USER=arduino +# POSTGRES_PASSWORD=arduino +# POSTGRES_DB=arduino + +# --- Automod tunables (optional; defaults in src/utils/config.ts) --- +# AUTOMOD_BURST_THRESHOLD=3 # image messages to flag a same-channel burst +# AUTOMOD_BURST_WINDOW_MS=60000 # window for the burst count +# AUTOMOD_FANOUT_CHANNELS=2 # distinct channels for a cross-channel fan-out +# AUTOMOD_FANOUT_WINDOW_MS=120000 # window for fan-out detection +# AUTOMOD_PHASH_THRESHOLD=6 # max Hamming distance (of 64) for near-dupe images +# AUTOMOD_TIMEOUT_MS=3600000 # auto/console timeout duration (1 hour) +# AUTOMOD_ALERT_COOLDOWN_MS=30000 # min gap between alerts per user +# AUTOMOD_IMMUNE_ROLE_IDS= # comma-separated role ids never inspected +# AUTOMOD_NEW_MEMBER_WINDOW_MS=259200000 # how long a member counts as "new" (72h) +# AUTOMOD_NEW_MEMBER_BURST_THRESHOLD=2 # stricter burst threshold for new members + +# --- Text-flooding detector (needs MOD_LOG_CHANNEL_ID to post alerts) --- +# AUTOMOD_FLOOD_ENABLED=true # set false to disable text-flooding detection +# AUTOMOD_FLOOD_THRESHOLD=5 # short messages within the window to flag flooding +# AUTOMOD_FLOOD_WINDOW_MS=15000 # sliding window for the flood count +# AUTOMOD_FLOOD_MAX_CHARS=25 # a message counts as "short" at or below this length +# AUTOMOD_FLOOD_AUTO_TIMEOUT=false # also auto-timeout on a flood hit (default: alert only) + +# --- Cross-channel question-spam detector (needs MOD_LOG_CHANNEL_ID) --- +# Catches new users fanning the same question across many channels at once. +# AUTOMOD_CROSSPOST_ENABLED=true # set false to disable +# AUTOMOD_CROSSPOST_CHANNELS=2 # distinct channels for a near-identical repeat +# AUTOMOD_CROSSPOST_SPREAD_CHANNELS=3 # distinct channels for new-member shotgunning +# AUTOMOD_CROSSPOST_WINDOW_MS=120000 # window for cross-channel detection (2 min) +# AUTOMOD_CROSSPOST_MIN_CHARS=12 # ignore messages shorter than this (greetings/reactions) +# AUTOMOD_CROSSPOST_SIMILARITY_PCT=80 # token-overlap % at which two messages are the "same" question diff --git a/Dockerfile b/Dockerfile index d72fd64..b1bd54c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,15 +4,15 @@ LABEL org.opencontainers.image.source https://github.com/arduinodiscord/bot WORKDIR /srv -COPY ./package*.json . -COPY ./prisma ./prisma - -COPY . . - -RUN apk add --update --no-cache openssl1.1-compat - +# Install dependencies first for better layer caching. The postinstall hook runs +# `prisma generate`, which needs the schema, so copy prisma/ before installing. +COPY package*.json ./ +COPY prisma ./prisma +COPY prisma.config.ts ./ RUN npm install +# Then copy the rest of the source and build. +COPY . . RUN npm run build CMD ["npm", "start"] diff --git a/README.md b/README.md index 9288e22..e37215c 100644 --- a/README.md +++ b/README.md @@ -1,69 +1,256 @@ -# Arduino Discord Bot +
-> Now with slash commands! +# πŸ€– Arduino Discord Bot + +### The community bot powering the official **[Arduino Discord](https://arduino.cc/discord)** + +Helpful tags, image-spam moderation, and server automation β€” built for the people who keep the server running. + +[![License: GPL-3.0](https://img.shields.io/badge/License-GPL--3.0-blue.svg)](LICENSE) +[![discord.js](https://img.shields.io/badge/discord.js-14-5865F2?logo=discord&logoColor=white)](https://discord.js.org) +[![Sapphire](https://img.shields.io/badge/framework-Sapphire-1e88e5)](https://www.sapphirejs.dev) +[![TypeScript](https://img.shields.io/badge/TypeScript-6-3178c6?logo=typescript&logoColor=white)](https://www.typescriptlang.org) +[![Node](https://img.shields.io/badge/Node-LTS-339933?logo=node.js&logoColor=white)](https://nodejs.org) + +
+ +--- + +## ✨ Highlights + +| | | +|---|---| +| 🏷️ **Slash-command tags** | Curated troubleshooting guides (`/tag`) β€” AVRDUDE errors, level shifters, powering boards, and more | +| πŸ›‘οΈ **Image-spam automod** | Catches the image-cluster spam wave with burst + cross-channel fan-out + perceptual-hash detection, and a one-click moderator console | +| 🧰 **Server automation** | Auto-crosspost, join/leave + invite-source logging, role-select buttons, message-link quoting | +| πŸ—£οΈ **Staff tooling** | `/say` to broadcast rich embeds as the bot | +| 🐳 **One-command deploy** | `docker compose up` β€” bot + Postgres, migrations applied automatically | + +--- + +## πŸš€ Quick start + +### With Docker (recommended) + +```bash +git clone https://github.com/arduinodiscord/bot.git +cd bot +cp .env.example .env # add your BOT_TOKEN (+ any feature channel IDs) +docker compose up -d # builds the bot, starts Postgres, applies migrations +``` + +That's it β€” the bot connects, registers its slash commands, and is ready. + +### Local development + +```bash +npm install +cp .env.example .env # set BOT_TOKEN +npm run dev # hot-reloading dev server (ts-node-dev) +``` + +> **Privileged intents:** enable **Message Content** and **Server Members** for +> your application in the [Discord Developer Portal](https://discord.com/developers/applications). +> For invite-source logging the bot also needs the **Manage Server** permission. + +--- + +## βš™οΈ Configuration + +Everything is driven by environment variables (see [`.env.example`](.env.example)). +Only `BOT_TOKEN` is required; **every other feature self-disables** until you +give it a channel or role id, so you can adopt them one at a time. + +| Variable | Purpose | +|---|---| +| `BOT_TOKEN` | **Required.** Your Discord bot token | +| `MOD_LOG_CHANNEL_ID` | Enables the image-spam automod console | +| `DATABASE_URL` | Postgres connection (optional β€” runs in-memory without it; wired automatically by Docker) | +| `JOIN_LEAVE_LOG_CHANNEL_ID` | Member join/leave + invite-source logging | +| `CROSSPOST_CHANNEL_IDS` Β· `CROSSPOST_LOG_CHANNEL_ID` | Auto-publish announcement channels | +| `ROLE_SELECT_MESSAGE_ID` Β· `EVENT_NOTIFS_ROLE_ID` Β· `SERVER_UPDATE_NOTIFS_ROLE_ID` | Button-based opt-in roles | +| `AUTOMOD_*` | Detector thresholds & timeouts (sensible defaults; see `.env.example`) | + +--- + +## πŸ’¬ Commands + +All commands are Discord **slash commands** β€” type `/` in the server. + +| Command | Description | +|---|---| +| `/tag name: [user:@user]` | Post a curated troubleshooting guide, optionally pinging someone | +| `/solved [helper:@user]` | Mark the current help post solved (and thank a helper); closes the thread | +| `/openposts` | List open help posts waiting for an answer, oldest first (ephemeral) | +| `/about` | Bot, Node, and version info | +| `/ping` | Latency & uptime | +| `/say channel:<#ch> title:… description:…` | **Staff only** β€” send a custom embed (optional `fields` as `name \| value` per line, and `thumbnail`) | + +Plus a **"Request more info"** right-click (message context-menu) action that +posts the `needinfo` checklist to an asker in one click. + +
+πŸ“š Available /tag topics + +`ai` Β· `ask` Β· `avrdude` Β· `codeblock` Β· `debounce` Β· `espcomm` Β· `help` Β· +`hid` Β· `lab` Β· `language` Β· `levelShifter` Β· `libmissing` Β· `needinfo` Β· +`ninevolt` Β· `power` Β· `pullup` Β· `reinstall` Β· `wiki` + +
+ +--- + +## πŸ›‘οΈ Image-spam automod + +A wave of spam β€” both freshly-joined accounts and **compromised long-time +members** β€” posts *clusters of images* to advertise. This bot targets exactly +that pattern without ever disabling image sharing server-wide. + +**Detection** +- **Burst** β€” several image messages from one user in a short window (stricter for new members) β†’ *alerts mods*. +- **Cross-channel fan-out** β€” the same image across multiple channels β†’ *high confidence* (this is what catches compromised veterans, where account age tells you nothing). +- **Known-spam blocklist** β€” once a mod confirms an alert, that image is fingerprinted and future copies are caught instantly. + +Each image gets a cheap **metadata signature** (catches identical re-uploads) +*and* a **perceptual hash** (dHash from a tiny thumbnail β€” catches re-encoded / +resized copies via Hamming distance). + +**Response (tiered):** high-confidence hits auto-delete + timeout and then +alert; bursts only alert. Every alert lands in the mod-log channel with +**Confirm / Timeout / Ban / Delete / Not spam** buttons β€” a human stays in the +loop. Members with *Manage Messages* (or a configured immune role) are never +inspected. + +> Complements your existing YAGPDB AutoMod rather than replacing them. --- -This is the custom Discord bot powering the official Arduino Discord server at [https://arduino.cc/discord](https://arduino.cc/discord). +## 🌊 Text-flooding automod -## What does this bot do? +Some users fragment a single thought across a stream of one-word messages +instead of sending it as one message β€” which buries ongoing conversation and +leaves no room to reply. The bot flags this pattern: **N short messages from +one user inside a short window** (`AUTOMOD_FLOOD_*`, defaults: 5 messages ≀ 25 +chars in 15 s) lands a **Message flooding** alert in the same mod-log console as +the image automod, with the same Confirm / Timeout / Ban / Delete / Not-spam +buttons. By default it only alerts (flooding is usually a habit, not an attack); +set `AUTOMOD_FLOOD_AUTO_TIMEOUT=true` to also time the user out automatically. +Members with *Manage Messages* (or a configured immune role) are never flagged. -The Arduino Bot provides helpful information, troubleshooting steps, and community resources for Arduino users. It responds to slash commands with detailed guides, tips, and links, making it easier for users to get help and learn about Arduino topics. +--- + +## πŸ“‘ Cross-channel question-spam automod -## Current Slash Commands +New members often fire the *same* question into every channel at once instead +of the one that fits. Two tenure-aware signals catch this (`AUTOMOD_CROSSPOST_*`): -All commands are used as Discord slash commands (type `/` in Discord): +- **Near-identical fan-out** (any tenure) β€” the same question (token-overlap β‰₯ + `SIMILARITY_PCT`, default 80%) across `CHANNELS`+ channels (default 2) inside + the window. High confidence, so the bot **auto-deletes the duplicate copies, + keeping the first**, then alerts. +- **New-member spread** (recent joiners only) β€” posting substantive messages in + `SPREAD_CHANNELS`+ channels (default 3) at once, even when reworded enough to + dodge the similarity check. Alerts only β€” these aren't strict duplicates. -| Command | Usage Example | Description | -|-----------|---------------------------|--------------------------------------------------------------------| -| `/about` | `/about` | Shows information about the bot. | -| `/tag` | `/tag name: [user:@username]` | Sends an informational tag to the bot-commands channel, optionally pinging a user. | +Both surface in the same mod-log console with the usual action buttons, reuse +the existing new-member window, and respect the immune-role/permission checks. +Short greetings and reactions (below `MIN_CHARS`) are ignored. -### `/tag` options (alphabetical) +--- -- `ask` β€” Guidance on how to ask good questions. -- `avrdude` β€” AVRDUDE error troubleshooting. -- `codeblock` β€” How to format code in Discord. -- `espcomm` β€” ESP board communication troubleshooting. -- `hid` β€” Info about Arduino HID (keyboard/mouse) support. -- `language` β€” What language Arduino uses. -- `levelShifter` β€” Logic level shifter explanation. -- `libmissing` β€” Fixing missing library errors. -- `power` β€” Powering Arduino safely. -- `pullup` β€” Pull-up/pull-down resistor explanation. -- `wiki` β€” Link to the Arduino Discord community wiki. +## 🧰 Server management -**Example:** -`/tag name:power` β€” Sends information about powering Arduino boards to the bot-commands channel. -`/tag name:avrdude user:@someuser` β€” Sends AVRDUDE troubleshooting info to the bot-commands channel and pings `@someuser`. +Ambient helpers, each self-disabling until configured: -## Environment Variables & Configuration +- **Auto-crosspost** announcement channels, with logging. +- **Join/leave logging** with invite-source attribution (+ optional analytics when a DB is present). +- **Role-select buttons** to toggle event / server-update opt-in roles. +- **Message-link flattening** β€” quote a linked message inline for context. -This bot requires the following environment variables to be set: +--- -- `BOT_TOKEN`: Your Discord bot token. +## πŸ™Œ Helper assist -> Additional configuration options can be set in `config.ts`. +Features aimed at taking repetitive load off the community members who answer +the most questions: +- **Keyword β†’ tag suggestions** β€” when a message matches a known signature + (AVRDUDE, missing-library, ESP upload, level shifters, power, pull-ups, 9V, + HID, debounce…), the bot offers the matching tag via a single button, so + askers self-serve before a helper repeats a canned answer. Each trigger lives + next to its tag in `tags.ts`. Per-user cooldown; toggle with `TAG_SUGGEST_ENABLED`. + **Only fires for members with no server role** β€” helpers, knowledgeable members, + and any other role-holder are never shown suggestions. +- **Unformatted-code nudge** β€” detects code pasted as plain text and offers the + `codeblock` tag, the single most-repeated ask. Toggle `CODE_FORMAT_SUGGEST_ENABLED`. + Subject to the same role exemption as keyword suggestions. +- **"Just ask" nudge** β€” replies to low-effort pings ("can I ask?", "anyone + here?") with the `ask` tag. Conservative patterns; toggle `ASK_SUGGEST_ENABLED`. + Subject to the same role exemption as keyword suggestions. +- **Suggestion ignore list** β€” set `SUGGEST_IGNORE_CHANNEL_IDS` to suppress all + three suggestions in specific channels (e.g. staff channels). Listing a forum + channel's ID silences all its threads. +- **"Request more info" context-menu** β€” right-click any message β†’ *Request more + info* to post the `needinfo` checklist to the asker in one click. +- **Auto-needinfo on thin posts** β€” when a new help thread lacks substance (no + code, image, URL, or inline code, and the post title + body together are under + `HELP_NEEDINFO_MIN_CHARS` characters), the bot sends a concise checklist and the + Mark Solved button in a single message. **Off by default** (`HELP_AUTO_NEEDINFO=true` + to enable). The full `/tag needinfo` checklist used by helpers is unaffected. +- **Solve workflow** β€” a **Mark Solved** button on new help threads (set + `HELP_CHANNEL_IDS`) plus `/solved [helper:@user]`, which closes the post and + credits whoever helped. +- **Stale-post nudge & auto-archive** β€” abandoned help posts get a "still need + help?" nudge after a few days, then auto-archive only if no one replies for a + while longer (defaults 3 then 7 days β€” helpers often take a while; tune with + `HELP_STALE_NUDGE_HOURS` / `HELP_STALE_ARCHIVE_HOURS`). +- **`/openposts`** β€” an ephemeral digest of open help posts, oldest-waiting + first, so helpers can pick up whatever's been waiting longest. -## Contributing +> **Help channels can be forum *or* text channels.** List both kinds in +> `HELP_CHANNEL_IDS` β€” forum posts and threads opened inside text help channels +> get the same Mark-Solved / needinfo / stale-sweep / `/openposts` treatment. +> The keyword/code/ask suggestions and *Request more info* work server-wide +> regardless of channel type (subject to role exemptions and ignore lists). +> Plain, thread-less messages in a text channel can't be archived, so the thread +> lifecycle simply doesn't apply to them. -Want to add a new tag or feature? It’s easy! +## πŸ—οΈ Project structure -1. **Clone the repo** and create a new branch. -2. **Add your tag:** - - Edit [`src/utils/tags.ts`](src/utils/tags.ts) and add your tag object to the exported object. - - If adding a new command, create a new file in [`src/commands/`](src/commands/). -3. **Register your tag:** - - For `/tag`, add your tag to the `.addChoices()` list in [`src/commands/tag.ts`](src/commands/tag.ts). -4. **Test your changes** locally. -5. **Make a Pull Request:** - - Push your branch and open a PR on GitHub. - - Clearly describe your changes. +``` +src/ +β”œβ”€β”€ index.ts # client bootstrap (intents, presence, login) +β”œβ”€β”€ commands/ # slash commands (about, ping, tag, say, solved, openposts) +β”œβ”€β”€ listeners/ # gateway events (ready, messages, members, invites…) +β”œβ”€β”€ interaction-handlers/ # button handlers (spam console, role-select, tag buttons) +└── utils/ + β”œβ”€β”€ automod/ # spam detection: tracker, flood, crosspost, phash, blocklist, console, incidents + β”œβ”€β”€ config.ts # env-driven configuration + β”œβ”€β”€ db.ts # optional Prisma (pg driver adapter) with in-memory fallback + β”œβ”€β”€ tags.ts # tag content + schema + └── embed.ts # shared base embed +prisma/ # schema + migrations +``` -If you find a bug or want to request a feature, please [open an issue](https://github.com/max-bromberg/arduino-bot/issues). +**Stack:** [discord.js v14](https://discord.js.org) Β· [Sapphire framework](https://www.sapphirejs.dev) Β· TypeScript 6 Β· Prisma 7 (+ Postgres, optional). --- -06-13-2025 Update -**License:** GPL-3.0-or-later -See [LICENSE](LICENSE) for details. + +## 🀝 Contributing + +Want to add a tag or a feature? PRs welcome! + +1. Fork & branch. +2. **Add a tag:** edit [`src/utils/tags.ts`](src/utils/tags.ts) and add it to the `.addChoices()` list in [`src/commands/tag.ts`](src/commands/tag.ts). +3. `npm run build` to type-check. +4. Open a PR against `staging` with a clear description. + +Found a bug or have an idea? [Open an issue](https://github.com/arduinodiscord/bot/issues). + +--- + +
+ +**License:** [GPL-3.0-or-later](LICENSE) · Made with ❀️ by the Arduino Discord community + +
diff --git a/SETUP.md b/SETUP.md new file mode 100644 index 0000000..3243cfa --- /dev/null +++ b/SETUP.md @@ -0,0 +1,217 @@ +# Setup Guide + +Step-by-step deployment on a Dockge server. + +--- + +## 1. Create the Discord application + +1. Go to [discord.com/developers/applications](https://discord.com/developers/applications) β†’ **New Application**. +2. Name it (e.g. *Arduino Bot*) β†’ **Create**. +3. In the left sidebar β†’ **Bot**: + - Click **Reset Token** β†’ copy it. This is your `BOT_TOKEN`. Keep it secret. + - Under **Privileged Gateway Intents**, enable: + - **Server Members Intent** (required for join/leave logging and new-member detection) + - **Message Content Intent** (required for tag suggestions, automod, and help-post detection) +4. In the left sidebar β†’ **OAuth2 β†’ URL Generator**: + - Scopes: `bot`, `applications.commands` + - Bot permissions: `Manage Messages`, `Moderate Members`, `Ban Members`, `Send Messages`, `Embed Links`, `Read Message History`, `View Channels` + - Copy the generated URL, paste it in a browser, and invite the bot to your server. + +> **Manage Server permission** is also needed if you want invite-source logging (`JOIN_LEAVE_LOG_CHANNEL_ID`). Add it to the bot permissions above if you plan to use that feature. + +--- + +## 2. Collect your Discord IDs + +Enable Developer Mode: **User Settings β†’ Advanced β†’ Developer Mode**. You can then right-click any server, channel, role, or message to **Copy ID**. + +Gather the IDs you need for the features you want to use: + +| What to copy | Used for | +|---|---| +| Server (guild) ID | `SERVER_ID` | +| Bot commands channel | `BOT_COMMANDS_CHANNEL_ID` | +| Mod-log channel | `MOD_LOG_CHANNEL_ID` (enables automod console) | +| Help channel(s) | `HELP_CHANNEL_IDS` (comma-separated; forum or text) | +| Join/leave log channel | `JOIN_LEAVE_LOG_CHANNEL_ID` | +| Announcement channels | `CROSSPOST_CHANNEL_IDS` (comma-separated) | +| Crosspost log channel | `CROSSPOST_LOG_CHANNEL_ID` | +| Role-select message | `ROLE_SELECT_MESSAGE_ID` | +| Events role | `EVENT_NOTIFS_ROLE_ID` | +| Server-updates role | `SERVER_UPDATE_NOTIFS_ROLE_ID` | +| Mod / immune roles | `AUTOMOD_IMMUNE_ROLE_IDS` (comma-separated) | + +You only need the IDs for features you intend to use. Features self-disable when their ID is left blank. + +--- + +## 3. Get the code onto your server + +SSH into your Dockge host and clone the repo into Dockge's stacks directory: + +```bash +cd /opt/stacks +git clone https://github.com/arduinodiscord/bot.git arduino-bot +cd arduino-bot +git checkout revamp # active development branch +``` + +> Dockge looks for compose files inside `/opt/stacks//`. Cloning there means Dockge can pick up the stack automatically. + +--- + +## 4. Create your `.env` file + +```bash +cp .env.example .env +nano .env # or use your editor of choice +``` + +Fill in at minimum: + +```dotenv +BOT_TOKEN=your_token_here +``` + +Then add the IDs and enable the features you want (see section 2 and the full reference below). + +--- + +## 5. Deploy in Dockge + +1. Open Dockge in your browser (typically `http://:5001`). +2. Click **+ Compose** (or **Scan** if Dockge has already picked up the stack). +3. If creating manually, paste the contents of `docker-compose.yml` into the editor. +4. Dockge will find the `.env` file in the same directory and use it automatically. +5. Click **Deploy**. Dockge will: + - Build the bot image (takes a minute on first run) + - Start Postgres + - Run `prisma migrate deploy` to create the database schema + - Start the bot + +Watch the log output. You should see: +``` +Logged in as Arduino Bot#0000 (...) +Database connection success +Automod: loaded 0 signature(s) and 0 perceptual hash(es) from the blocklist. +``` + +--- + +## 6. Verify it's working + +**Slash commands registered?** +Type `/` in your server β€” the bot's commands (`/tag`, `/solved`, `/openposts`, `/ping`, `/about`, `/say`) should appear. + +**Automod console active?** +Confirm `MOD_LOG_CHANNEL_ID` is set. Post a test image in two different channels quickly β€” you should see an alert appear in the mod-log channel within seconds. + +**Help-channel features active?** +Open a new thread in a configured help channel. You should see a "Mark Solved" button appear. Post a short message with no code/image in a new thread β€” the needinfo checklist should appear automatically. + +**Tag suggestions active?** +Post a message containing `avrdude` or `stk500` in any channel β€” the bot should reply with a suggestion button. + +--- + +## 7. Updating + +```bash +cd /opt/stacks/arduino-bot +git pull +``` + +Then in Dockge, click **Rebuild** on the stack. The `prisma migrate deploy` step in the startup command applies any new migrations automatically. + +--- + +## Environment variable reference + +All variables are optional except `BOT_TOKEN`. Leaving a variable blank uses the default shown. + +### Core + +| Variable | Default | Description | +|---|---|---| +| `BOT_TOKEN` | β€” | **Required.** Your bot token | +| `SERVER_ID` | `420594746990526466` | Your server's ID | +| `BOT_COMMANDS_CHANNEL_ID` | `451158319361556491` | Channel where bot-command-only tags are allowed | +| `DATABASE_URL` | auto-set by compose | Set automatically by docker-compose; do not override | + +### Automod console + +| Variable | Default | Description | +|---|---|---| +| `MOD_LOG_CHANNEL_ID` | *(disabled)* | Channel where automod alerts appear; **required to enable automod** | + +### Image-spam automod + +| Variable | Default | Description | +|---|---|---| +| `AUTOMOD_BURST_THRESHOLD` | `3` | Image messages from one user within the window to flag a burst | +| `AUTOMOD_BURST_WINDOW_MS` | `60000` | Window for burst counting (ms) | +| `AUTOMOD_NEW_MEMBER_BURST_THRESHOLD` | `2` | Stricter burst threshold for recently-joined members | +| `AUTOMOD_NEW_MEMBER_WINDOW_MS` | `259200000` | How long a member counts as "new" (72h) | +| `AUTOMOD_FANOUT_CHANNELS` | `2` | Distinct channels the same image must appear in to flag fan-out | +| `AUTOMOD_FANOUT_WINDOW_MS` | `120000` | Window for fan-out detection (ms) | +| `AUTOMOD_PHASH_THRESHOLD` | `6` | Max Hamming distance (of 64) for two images to count as the same | +| `AUTOMOD_TIMEOUT_MS` | `3600000` | Duration of auto-applied or console timeouts (1h) | +| `AUTOMOD_ALERT_COOLDOWN_MS` | `30000` | Minimum gap between alerts for the same user | +| `AUTOMOD_IMMUNE_ROLE_IDS` | *(none)* | Comma-separated role IDs that are never inspected | + +### Text-flooding automod + +| Variable | Default | Description | +|---|---|---| +| `AUTOMOD_FLOOD_ENABLED` | `true` | Set `false` to disable | +| `AUTOMOD_FLOOD_THRESHOLD` | `5` | Short messages in the window to flag flooding | +| `AUTOMOD_FLOOD_WINDOW_MS` | `15000` | Window for flood counting (ms) | +| `AUTOMOD_FLOOD_MAX_CHARS` | `25` | A message is "short" at or below this character count | +| `AUTOMOD_FLOOD_AUTO_TIMEOUT` | `false` | Set `true` to also timeout automatically (default: alert only) | + +### Cross-channel question-spam automod + +| Variable | Default | Description | +|---|---|---| +| `AUTOMOD_CROSSPOST_ENABLED` | `true` | Set `false` to disable | +| `AUTOMOD_CROSSPOST_CHANNELS` | `2` | Distinct channels for a near-identical message to trigger | +| `AUTOMOD_CROSSPOST_SPREAD_CHANNELS` | `3` | Distinct channels for new-member spread detection | +| `AUTOMOD_CROSSPOST_WINDOW_MS` | `120000` | Detection window (ms) | +| `AUTOMOD_CROSSPOST_MIN_CHARS` | `12` | Messages shorter than this are ignored (greetings, reactions) | +| `AUTOMOD_CROSSPOST_SIMILARITY_PCT` | `80` | Token-overlap % at which two messages count as the same question | + +### Helper-assist + +| Variable | Default | Description | +|---|---|---| +| `HELP_CHANNEL_IDS` | *(disabled)* | Comma-separated forum or text channel IDs β€” enables solve button, auto-needinfo, stale sweep, and `/openposts` | +| `TAG_SUGGEST_ENABLED` | `true` | Keyword β†’ tag auto-suggestion | +| `CODE_FORMAT_SUGGEST_ENABLED` | `true` | Nudge for unformatted code pastes | +| `ASK_SUGGEST_ENABLED` | `true` | Nudge for "can I ask?" / "anyone here?" messages | +| `SUGGEST_IMMUNE_ROLE_IDS` | *(none)* | Comma-separated role IDs whose holders never receive suggestions (Trusted, Knowledgeable, Helper, Moderator, etc.). Self-assignable notification roles should **not** be listed. | +| `SUGGEST_IGNORE_CHANNEL_IDS` | *(none)* | Comma-separated channel IDs where suggestions are suppressed entirely (e.g. staff channels). Listing a forum channel's ID silences all its threads. | +| `HELP_AUTO_NEEDINFO` | `false` | Set `true` to auto-post a concise checklist when a new help post is thin. Off by default β€” enable after observing the Mark Solved rollout. | +| `HELP_NEEDINFO_MIN_CHARS` | `120` | Combined character count (thread title + post body) below which a post counts as thin. Posts with a code block, inline code, image, or URL are never considered thin. | +| `HELP_STALE_SWEEP_ENABLED` | `true` | Nudge + auto-archive abandoned help posts | +| `HELP_STALE_NUDGE_HOURS` | `72` | Hours idle before a "still need help?" nudge (3 days) | +| `HELP_STALE_ARCHIVE_HOURS` | `168` | Hours after the nudge with no human reply before auto-archiving (7 days) | + +### Server management + +| Variable | Default | Description | +|---|---|---| +| `JOIN_LEAVE_LOG_CHANNEL_ID` | *(disabled)* | Member join/leave + invite-source logging | +| `CROSSPOST_CHANNEL_IDS` | *(disabled)* | Comma-separated announcement channels to auto-publish | +| `CROSSPOST_LOG_CHANNEL_ID` | *(disabled)* | Where auto-crosspost results are logged | +| `ROLE_SELECT_MESSAGE_ID` | *(disabled)* | Message whose buttons toggle opt-in roles | +| `EVENT_NOTIFS_ROLE_ID` | *(disabled)* | Role toggled by the "events" button | +| `SERVER_UPDATE_NOTIFS_ROLE_ID` | *(disabled)* | Role toggled by the "server_updates" button | + +### Postgres (docker-compose only) + +| Variable | Default | Description | +|---|---|---| +| `POSTGRES_USER` | `arduino` | Database user | +| `POSTGRES_PASSWORD` | `arduino` | Database password β€” **change this** | +| `POSTGRES_DB` | `arduino` | Database name | diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0e5ccf1 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,90 @@ +services: + bot: + build: . + restart: unless-stopped + depends_on: + db: + condition: service_healthy + environment: + # ── Required ────────────────────────────────────────────────────────── + BOT_TOKEN: ${BOT_TOKEN} + + # ── Core IDs (defaults target the official Arduino server) ──────────── + SERVER_ID: ${SERVER_ID:-} + BOT_COMMANDS_CHANNEL_ID: ${BOT_COMMANDS_CHANNEL_ID:-} + + # ── Database (wired to the bundled Postgres service) ────────────────── + DATABASE_URL: postgresql://${POSTGRES_USER:-arduino}:${POSTGRES_PASSWORD:-arduino}@db:5432/${POSTGRES_DB:-arduino} + + # ── Automod console ─────────────────────────────────────────────────── + MOD_LOG_CHANNEL_ID: ${MOD_LOG_CHANNEL_ID:-} + + # ── Server-management features ──────────────────────────────────────── + JOIN_LEAVE_LOG_CHANNEL_ID: ${JOIN_LEAVE_LOG_CHANNEL_ID:-} + CROSSPOST_CHANNEL_IDS: ${CROSSPOST_CHANNEL_IDS:-} + CROSSPOST_LOG_CHANNEL_ID: ${CROSSPOST_LOG_CHANNEL_ID:-} + ROLE_SELECT_MESSAGE_ID: ${ROLE_SELECT_MESSAGE_ID:-} + EVENT_NOTIFS_ROLE_ID: ${EVENT_NOTIFS_ROLE_ID:-} + SERVER_UPDATE_NOTIFS_ROLE_ID: ${SERVER_UPDATE_NOTIFS_ROLE_ID:-} + + # ── Helper-assist features ──────────────────────────────────────────── + SUGGEST_IGNORE_CHANNEL_IDS: ${SUGGEST_IGNORE_CHANNEL_IDS:-} + SUGGEST_IMMUNE_ROLE_IDS: ${SUGGEST_IMMUNE_ROLE_IDS:-} + HELP_CHANNEL_IDS: ${HELP_CHANNEL_IDS:-} + HELP_FORUM_CHANNEL_IDS: ${HELP_FORUM_CHANNEL_IDS:-} + TAG_SUGGEST_ENABLED: ${TAG_SUGGEST_ENABLED:-} + CODE_FORMAT_SUGGEST_ENABLED: ${CODE_FORMAT_SUGGEST_ENABLED:-} + ASK_SUGGEST_ENABLED: ${ASK_SUGGEST_ENABLED:-} + HELP_AUTO_NEEDINFO: ${HELP_AUTO_NEEDINFO:-} + HELP_NEEDINFO_MIN_CHARS: ${HELP_NEEDINFO_MIN_CHARS:-} + HELP_STALE_SWEEP_ENABLED: ${HELP_STALE_SWEEP_ENABLED:-} + HELP_STALE_NUDGE_HOURS: ${HELP_STALE_NUDGE_HOURS:-} + HELP_STALE_ARCHIVE_HOURS: ${HELP_STALE_ARCHIVE_HOURS:-} + + # ── Image-spam automod tunables ─────────────────────────────────────── + AUTOMOD_BURST_THRESHOLD: ${AUTOMOD_BURST_THRESHOLD:-} + AUTOMOD_BURST_WINDOW_MS: ${AUTOMOD_BURST_WINDOW_MS:-} + AUTOMOD_FANOUT_CHANNELS: ${AUTOMOD_FANOUT_CHANNELS:-} + AUTOMOD_FANOUT_WINDOW_MS: ${AUTOMOD_FANOUT_WINDOW_MS:-} + AUTOMOD_PHASH_THRESHOLD: ${AUTOMOD_PHASH_THRESHOLD:-} + AUTOMOD_TIMEOUT_MS: ${AUTOMOD_TIMEOUT_MS:-} + AUTOMOD_ALERT_COOLDOWN_MS: ${AUTOMOD_ALERT_COOLDOWN_MS:-} + AUTOMOD_IMMUNE_ROLE_IDS: ${AUTOMOD_IMMUNE_ROLE_IDS:-} + AUTOMOD_NEW_MEMBER_WINDOW_MS: ${AUTOMOD_NEW_MEMBER_WINDOW_MS:-} + AUTOMOD_NEW_MEMBER_BURST_THRESHOLD: ${AUTOMOD_NEW_MEMBER_BURST_THRESHOLD:-} + + # ── Text-flooding automod tunables ──────────────────────────────────── + AUTOMOD_FLOOD_ENABLED: ${AUTOMOD_FLOOD_ENABLED:-} + AUTOMOD_FLOOD_THRESHOLD: ${AUTOMOD_FLOOD_THRESHOLD:-} + AUTOMOD_FLOOD_WINDOW_MS: ${AUTOMOD_FLOOD_WINDOW_MS:-} + AUTOMOD_FLOOD_MAX_CHARS: ${AUTOMOD_FLOOD_MAX_CHARS:-} + AUTOMOD_FLOOD_AUTO_TIMEOUT: ${AUTOMOD_FLOOD_AUTO_TIMEOUT:-} + + # ── Cross-channel question-spam automod tunables ────────────────────── + AUTOMOD_CROSSPOST_ENABLED: ${AUTOMOD_CROSSPOST_ENABLED:-} + AUTOMOD_CROSSPOST_CHANNELS: ${AUTOMOD_CROSSPOST_CHANNELS:-} + AUTOMOD_CROSSPOST_SPREAD_CHANNELS: ${AUTOMOD_CROSSPOST_SPREAD_CHANNELS:-} + AUTOMOD_CROSSPOST_WINDOW_MS: ${AUTOMOD_CROSSPOST_WINDOW_MS:-} + AUTOMOD_CROSSPOST_MIN_CHARS: ${AUTOMOD_CROSSPOST_MIN_CHARS:-} + AUTOMOD_CROSSPOST_SIMILARITY_PCT: ${AUTOMOD_CROSSPOST_SIMILARITY_PCT:-} + + # Migrate the database schema, then start the bot. + command: sh -c "node_modules/.bin/prisma migrate deploy && npm start" + + db: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-arduino} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-arduino} + POSTGRES_DB: ${POSTGRES_DB:-arduino} + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:-arduino}'] + interval: 5s + timeout: 5s + retries: 5 + +volumes: + pgdata: diff --git a/package-lock.json b/package-lock.json index da3bc31..099cea7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,31 +1,46 @@ { "name": "arduino-bot", - "version": "0.2.0", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "arduino-bot", - "version": "0.2.0", + "version": "1.0.0", + "hasInstallScript": true, "license": "GPL-3.0-or-later", "dependencies": { - "@prisma/client": "^4.16.2", + "@prisma/adapter-pg": "^7.8.0", + "@prisma/client": "^7.8.0", "@sapphire/discord.js-utilities": "^7.3.3", - "@sapphire/framework": "^5.3.6", + "@sapphire/framework": "^5.5.0", "@sapphire/plugin-hmr": "^3.0.2", - "@sapphire/plugin-logger": "^4.0.2", + "@sapphire/plugin-logger": "^4.1.0", "@sapphire/plugin-subcommands": "^7.0.1", "@sapphire/type": "^2.6.0", "@sapphire/utilities": "^3.18.2", - "discord.js": "^14.19.3", - "dotenv": "^16.5.0" + "discord.js": "^14.26.4", + "dotenv": "^17.4.2", + "jimp": "^1.6.1", + "pg": "^8.21.0" }, "devDependencies": { - "@sapphire/ts-config": "^5.0.1", - "@types/node": "^20.14.2", - "prisma": "^4.16.2", + "@sapphire/ts-config": "^5.0.3", + "@types/node": "^25.9.3", + "@types/pg": "^8.20.0", + "prisma": "^7.8.0", "ts-node-dev": "^2.0.0", - "typescript": "^5.4.5" + "typescript": "^6.0.3" + } + }, + "node_modules/@borewit/text-codec": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", + "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" } }, "node_modules/@cspotcode/source-map-support": { @@ -42,15 +57,15 @@ } }, "node_modules/@discordjs/builders": { - "version": "1.11.2", - "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.11.2.tgz", - "integrity": "sha512-F1WTABdd8/R9D1icJzajC4IuLyyS8f3rTOz66JsSI3pKvpCAtsMBweu8cyNYsIyvcrKAVn9EPK+Psoymq+XC0A==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz", + "integrity": "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==", "license": "Apache-2.0", "dependencies": { - "@discordjs/formatters": "^0.6.1", - "@discordjs/util": "^1.1.1", + "@discordjs/formatters": "^0.6.2", + "@discordjs/util": "^1.2.0", "@sapphire/shapeshift": "^4.0.0", - "discord-api-types": "^0.38.1", + "discord-api-types": "^0.38.40", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" @@ -72,12 +87,12 @@ } }, "node_modules/@discordjs/formatters": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.1.tgz", - "integrity": "sha512-5cnX+tASiPCqCWtFcFslxBVUaCetB0thvM/JyavhbXInP1HJIEU+Qv/zMrnuwSsX3yWH2lVXNJZeDK3EiP4HHg==", + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz", + "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==", "license": "Apache-2.0", "dependencies": { - "discord-api-types": "^0.38.1" + "discord-api-types": "^0.38.33" }, "engines": { "node": ">=16.11.0" @@ -107,20 +122,20 @@ } }, "node_modules/@discordjs/rest": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.5.0.tgz", - "integrity": "sha512-PWhchxTzpn9EV3vvPRpwS0EE2rNYB9pvzDU/eLLW3mByJl0ZHZjHI2/wA8EbH2gRMQV7nu+0FoDF84oiPl8VAQ==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.1.tgz", + "integrity": "sha512-wwQdgjeaoYFiaG+atbqx6aJDpqW7JHAo0HrQkBTbYzM3/PJ3GweQIpgElNcGZ26DCUOXMyawYd0YF7vtr+fZXg==", "license": "Apache-2.0", "dependencies": { "@discordjs/collection": "^2.1.1", - "@discordjs/util": "^1.1.1", + "@discordjs/util": "^1.2.0", "@sapphire/async-queue": "^1.5.3", - "@sapphire/snowflake": "^3.5.3", + "@sapphire/snowflake": "^3.5.5", "@vladfrangu/async_event_emitter": "^2.4.6", - "discord-api-types": "^0.38.1", - "magic-bytes.js": "^1.10.0", + "discord-api-types": "^0.38.40", + "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", - "undici": "6.21.1" + "undici": "6.24.1" }, "engines": { "node": ">=18" @@ -141,11 +156,24 @@ "url": "https://github.com/discordjs/discord.js?sponsor" } }, + "node_modules/@discordjs/rest/node_modules/@sapphire/snowflake": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.5.tgz", + "integrity": "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, "node_modules/@discordjs/util": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.1.1.tgz", - "integrity": "sha512-eddz6UnOBEB1oITPinyrB2Pttej49M9FZQY8NxgEvc3tq6ZICZ19m70RsmzRdDHk80O9NoYN/25AqJl8vPVf/g==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", + "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==", "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, "engines": { "node": ">=18" }, @@ -154,13 +182,13 @@ } }, "node_modules/@discordjs/ws": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.2.tgz", - "integrity": "sha512-dyfq7yn0wO0IYeYOs3z79I6/HumhmKISzFL0Z+007zQJMtAFGtt3AEoq1nuLXtcunUE5YYYQqgKvybXukAK8/w==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz", + "integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==", "license": "Apache-2.0", "dependencies": { "@discordjs/collection": "^2.1.0", - "@discordjs/rest": "^2.5.0", + "@discordjs/rest": "^2.5.1", "@discordjs/util": "^1.1.0", "@sapphire/async-queue": "^1.5.2", "@types/ws": "^8.5.10", @@ -188,6 +216,461 @@ "url": "https://github.com/discordjs/discord.js?sponsor" } }, + "node_modules/@electric-sql/pglite": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.1.tgz", + "integrity": "sha512-mZ9NzzUSYPOCnxHH1oAHPRzoMFJHY472raDKwXl/+6oPbpdJ7g8LsCN4FSaIIfkiCKHhb3iF/Zqo3NYxaIhU7Q==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@electric-sql/pglite-socket": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.1.1.tgz", + "integrity": "sha512-p2hoXw3Z3LQHwTeikdZNsFBOvXGqKY2hk51BBw+8NKND8eoH+8LFOtW9Z8CQKmTJ2qqGYu82ipqiyFZOTTXNfw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "pglite-server": "dist/scripts/server.js" + }, + "peerDependencies": { + "@electric-sql/pglite": "0.4.1" + } + }, + "node_modules/@electric-sql/pglite-tools": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite-tools/-/pglite-tools-0.3.1.tgz", + "integrity": "sha512-C+T3oivmy9bpQvSxVqXA1UDY8cB9Eb9vZHL9zxWwEUfDixbXv4G3r2LjoTdR33LD8aomR3O9ZXEO3XEwr/cUCA==", + "devOptional": true, + "license": "Apache-2.0", + "peerDependencies": { + "@electric-sql/pglite": "0.4.1" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", + "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@jimp/core": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/core/-/core-1.6.1.tgz", + "integrity": "sha512-+BoKC5G6hkrSy501zcJ2EpfnllP+avPevcBfRcZe/CW+EwEfY6X1EZ8QWyT7NpDIvEEJb1fdJnMMfUnFkxmw9A==", + "license": "MIT", + "dependencies": { + "@jimp/file-ops": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", + "await-to-js": "^3.0.0", + "exif-parser": "^0.1.12", + "file-type": "^21.3.3", + "mime": "3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/diff": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/diff/-/diff-1.6.1.tgz", + "integrity": "sha512-YkKDPdHjLgo1Api3+Bhc0GLAygldlpt97NfOKoNg1U6IUNXA6X2MgosCjPfSBiSvJvrrz1fsIR+/4cfYXBI/HQ==", + "license": "MIT", + "dependencies": { + "@jimp/plugin-resize": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", + "pixelmatch": "^5.3.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/file-ops": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/file-ops/-/file-ops-1.6.1.tgz", + "integrity": "sha512-T+gX6osHjprbDRad0/B71Evyre7ZdVY1z/gFGEG9Z8KOtZPKboWvPeP2UjbZYWQLy9UKCPQX1FNAnDiOPkJL7w==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/js-bmp": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/js-bmp/-/js-bmp-1.6.1.tgz", + "integrity": "sha512-xzWzNT4/u5zGrTT3Tme9sGU7YzIKxi13+BCQwLqACbt5DXf9SAfdzRkopZQnmDko+6In5nqaT89Gjs43/WdnYQ==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", + "bmp-ts": "^1.0.9" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/js-gif": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/js-gif/-/js-gif-1.6.1.tgz", + "integrity": "sha512-YjY2W26rQa05XhanYhRZ7dingCiNN+T2Ymb1JiigIbABY0B28wHE3v3Cf1/HZPWGu0hOg36ylaKgV5KxF2M58w==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", + "gifwrap": "^0.10.1", + "omggif": "^1.0.10" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/js-jpeg": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/js-jpeg/-/js-jpeg-1.6.1.tgz", + "integrity": "sha512-HT9H3yOmlOFzYmdI15IYdfy6ggQhSRIaHeA+OTJSEORXBqEo97sUZu/DsgHIcX5NJ7TkJBTgZ9BZXsV6UbsyMg==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", + "jpeg-js": "^0.4.4" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/js-png": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/js-png/-/js-png-1.6.1.tgz", + "integrity": "sha512-SZ/KVhI5UjcSzzlXsXdIi/LhJ7UShf2NkMOtVrbZQcGzsqNtynAelrOXeoTxcanfVqmNhAoVHg8yR2cYoqrYjA==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", + "pngjs": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/js-tiff": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/js-tiff/-/js-tiff-1.6.1.tgz", + "integrity": "sha512-jDG/eJquID1M4MBlKMmDRBmz2TpXMv7TUyu2nIRUxhlUc2ogC82T+VQUkca9GJH1BBJ9dx5sSE5dGkWNjIbZxw==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", + "utif2": "^4.1.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-blit": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-blit/-/plugin-blit-1.6.1.tgz", + "integrity": "sha512-MwnI7C7K81uWddY9FLw1fCOIy6SsPIUftUz36Spt7jisCn8/40DhQMlSxpxTNelnZb/2SnloFimQfRZAmHLOqQ==", + "license": "MIT", + "dependencies": { + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-blur": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-blur/-/plugin-blur-1.6.1.tgz", + "integrity": "sha512-lIo7Tzp5jQu30EFFSK/phXANK3citKVEjepDjQ6ljHoIFtuMRrnybnmI2Md24ulvWlDaz+hh3n6qrMb8ydwhZQ==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/utils": "1.6.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-circle": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-circle/-/plugin-circle-1.6.1.tgz", + "integrity": "sha512-kK1PavY6cKHNNKce37vdV4Tmpc1/zDKngGoeOV3j+EMatoHFZUinV3s6F9aWryPs3A0xhCLZgdJ6Zeea1d5LCQ==", + "license": "MIT", + "dependencies": { + "@jimp/types": "1.6.1", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-color": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-color/-/plugin-color-1.6.1.tgz", + "integrity": "sha512-LtUN1vAP+LRlZAtTNVhDRSiXx+26Kbz3zJaG6a5k59gQ95jgT5mknnF8lxkHcqJthM4MEk3/tPxkdJpEybyF/A==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", + "tinycolor2": "^1.6.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-contain": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-contain/-/plugin-contain-1.6.1.tgz", + "integrity": "sha512-m0qhrfA8jkTqretGv4w+T/ADFR4GwBpE0sCOC2uJ0dzr44/ddOMsIdrpi89kabqYiPYIrxkgdCVCLm3zn1Vkkg==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/plugin-blit": "1.6.1", + "@jimp/plugin-resize": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-cover": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-cover/-/plugin-cover-1.6.1.tgz", + "integrity": "sha512-hZytnsth0zoll6cPf434BrT+p/v569Wr5tyO6Dp0dH1IDPhzhB5F38sZGMLDo7bzQiN9JFVB3fxkcJ/WYCJ3Mg==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/plugin-crop": "1.6.1", + "@jimp/plugin-resize": "1.6.1", + "@jimp/types": "1.6.1", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-crop": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-crop/-/plugin-crop-1.6.1.tgz", + "integrity": "sha512-EerRSLlclXyKDnYc/H9w/1amZW7b7v3OGi/VlerPd2M/pAu5X8TkyYWtfqYCXnNp1Ixtd8oCo9zGfY9zoXT4rg==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-displace": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-displace/-/plugin-displace-1.6.1.tgz", + "integrity": "sha512-K07QVl7xQwIfD6KfxRV/c3E9e7ZBXxUXdWuvoTWcKHL2qV48MOF5Nqbz/aJW4ThnQARIsxvYlZjPFiqkCjlU+g==", + "license": "MIT", + "dependencies": { + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-dither": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-dither/-/plugin-dither-1.6.1.tgz", + "integrity": "sha512-+2V+GCV2WycMoX1/z977TkZ8Zq/4MVSKElHYatgUqtwXMi2fDK2gKYU2g9V39IqFvTJsTIsK0+58VFz/ROBVew==", + "license": "MIT", + "dependencies": { + "@jimp/types": "1.6.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-fisheye": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-fisheye/-/plugin-fisheye-1.6.1.tgz", + "integrity": "sha512-XtS5ZyoZ0vxZxJ6gkqI63SivhtI58vX95foMPM+cyzYkRsJXMOYCr8DScxF5bp4Xr003NjYm/P+7+08tibwzHA==", + "license": "MIT", + "dependencies": { + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-flip": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-flip/-/plugin-flip-1.6.1.tgz", + "integrity": "sha512-ws38W/sGj7LobNRayQ83garxiktOyWxM5vO/y4a/2cy9v65SLEUzVkrj+oeAaUSSObdz4HcCEla7XtGlnAGAaA==", + "license": "MIT", + "dependencies": { + "@jimp/types": "1.6.1", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-hash": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-hash/-/plugin-hash-1.6.1.tgz", + "integrity": "sha512-sZt6ZcMX6i8vFWb4GYnw0pR/o9++ef0dTVcboTB5B/g7nrxCODIB4wfEkJ/YqZM5wUvol77K1qeS0/rVO6z21A==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/js-bmp": "1.6.1", + "@jimp/js-jpeg": "1.6.1", + "@jimp/js-png": "1.6.1", + "@jimp/js-tiff": "1.6.1", + "@jimp/plugin-color": "1.6.1", + "@jimp/plugin-resize": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", + "any-base": "^1.1.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-mask": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-mask/-/plugin-mask-1.6.1.tgz", + "integrity": "sha512-SIG0/FcmEj3tkwFxc7fAGLO8o4uNzMpSOdQOhbCgxefQKq5wOVMk9BQx/sdMPBwtMLr9WLq0GzLA/rk6t2v20A==", + "license": "MIT", + "dependencies": { + "@jimp/types": "1.6.1", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-print": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-print/-/plugin-print-1.6.1.tgz", + "integrity": "sha512-BYVz/X3Xzv8XYilVeDy11NOp0h7BTDjlOtu0BekIFHP1yHVd24AXNzbOy52XlzYZWQ0Dl36HOHEpl/nSNrzc6w==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/js-jpeg": "1.6.1", + "@jimp/js-png": "1.6.1", + "@jimp/plugin-blit": "1.6.1", + "@jimp/types": "1.6.1", + "parse-bmfont-ascii": "^1.0.6", + "parse-bmfont-binary": "^1.0.6", + "parse-bmfont-xml": "^1.1.6", + "simple-xml-to-json": "^1.2.2", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-quantize": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-quantize/-/plugin-quantize-1.6.1.tgz", + "integrity": "sha512-J2En9PLURfP+vwYDtuZ9T8yBW6BWYZBScydAjRiPBmJfEhTcNQqiiQODrZf7EqbbX/Sy5H6dAeRiqkgoV9N6Ww==", + "license": "MIT", + "dependencies": { + "image-q": "^4.0.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-resize": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-resize/-/plugin-resize-1.6.1.tgz", + "integrity": "sha512-CLkrtJoIz2HdWnpYiN6p8KYcPc00rCH/SUu6o+lfZL05Q4uhecJlnvXuj9x+U6mDn3ldPmJj6aZqMHuUJzdVqg==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-rotate": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-rotate/-/plugin-rotate-1.6.1.tgz", + "integrity": "sha512-nOjVjbbj705B02ksysKnh0POAwEBXZtJ9zQ5qC+X7Tavl3JNn+P3BzQovbBxLPSbUSld6XID9z5ijin4PtOAUg==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/plugin-crop": "1.6.1", + "@jimp/plugin-resize": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-threshold": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-threshold/-/plugin-threshold-1.6.1.tgz", + "integrity": "sha512-JOKv9F8s6tnVLf4sB/2fF0F339EFnHvgEdFYugO6VhowKLsap0pEZmLyE/DlRnYtIj2RddHZVxVMp/eKJ04l2Q==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/plugin-color": "1.6.1", + "@jimp/plugin-hash": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/types": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/types/-/types-1.6.1.tgz", + "integrity": "sha512-leI7YbveTNi565m910XgIOwXyuu074H5qazAD1357HImJSv2hqxnWXpwxQbadGWZ7goZRYBDZy5lpqud0p7q5w==", + "license": "MIT", + "dependencies": { + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/utils": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/utils/-/utils-1.6.1.tgz", + "integrity": "sha512-veFPRd93FCnS7AgmCkPgARVGoDRrJ9cm1ujuNyA+UfQ5VKbED2002sm5XfFLFwTsKC8j04heTrwe+tU1dluXOw==", + "license": "MIT", + "dependencies": { + "@jimp/types": "1.6.1", + "tinycolor2": "^1.6.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -216,41 +699,370 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@prisma/adapter-pg": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.8.0.tgz", + "integrity": "sha512-ygb3UkerK3v8MDpXVgCISdRNDozpxh6+JVJgiIGbSr5KBgz10LLf5ejUskPGoXlsIjxsOu6nuy1JVQr2EKGSlg==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/driver-adapter-utils": "7.8.0", + "@types/pg": "^8.16.0", + "pg": "^8.16.3", + "postgres-array": "3.0.4" + } + }, "node_modules/@prisma/client": { - "version": "4.16.2", - "resolved": "https://registry.npmjs.org/@prisma/client/-/client-4.16.2.tgz", - "integrity": "sha512-qCoEyxv1ZrQ4bKy39GnylE8Zq31IRmm8bNhNbZx7bF2cU5aiCCnSa93J2imF88MBjn7J9eUQneNxUQVJdl/rPQ==", - "hasInstallScript": true, + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.8.0.tgz", + "integrity": "sha512-HFp3Dawv/3sU3JtlPha90IB+48lS7zHiH4LKZPjmcE8YH5P9DOXGPvo8dqOtO7MqLDd1p2hOWMcFlRT1DMblHw==", "license": "Apache-2.0", "dependencies": { - "@prisma/engines-version": "4.16.1-1.4bc8b6e1b66cb932731fb1bdbbc550d1e010de81" + "@prisma/client-runtime-utils": "7.8.0" }, "engines": { - "node": ">=14.17" + "node": "^20.19 || ^22.12 || >=24.0" }, "peerDependencies": { - "prisma": "*" + "prisma": "*", + "typescript": ">=5.4.0" }, "peerDependenciesMeta": { "prisma": { "optional": true + }, + "typescript": { + "optional": true } } }, + "node_modules/@prisma/client-runtime-utils": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.8.0.tgz", + "integrity": "sha512-5NQZztQ0oY/ADFkmd9gPuweH5A1/CCY8YQPorLLO0Mu6a87mY5gsnDkzmFmIHs9NFaLnZojzgddFVN4RpKYrdw==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/config": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.8.0.tgz", + "integrity": "sha512-HFESzd9rx2ZQxlK+TL7tu1HPvCqrHiL6LCxYykI2c34mvaUuIVVl3lYuicJD/MNnzgPnyeBEMlK4WTomJCV5jw==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "c12": "3.3.4", + "deepmerge-ts": "7.1.5", + "effect": "3.20.0", + "empathic": "2.0.0" + } + }, + "node_modules/@prisma/debug": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.8.0.tgz", + "integrity": "sha512-p+QZReysDUqXC+mk17q9a+Y/qzh4c2KYliDK30buYUyfrGeTGSyfmc0AIrJRhZJrLHhRiJa9Au/J72h3C+szvA==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/dev": { + "version": "0.24.3", + "resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.24.3.tgz", + "integrity": "sha512-ffHlQuKXZiaDt9Go0OnCTdJZrHxK0k7omJKNV86/VjpsXu5EIHZLK0T7JSWgvNlJwh56kW9JFu9v0qJciFzepg==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "@electric-sql/pglite": "0.4.1", + "@electric-sql/pglite-socket": "0.1.1", + "@electric-sql/pglite-tools": "0.3.1", + "@hono/node-server": "1.19.11", + "@prisma/get-platform": "7.2.0", + "@prisma/query-plan-executor": "7.2.0", + "@prisma/streams-local": "0.1.2", + "foreground-child": "3.3.1", + "get-port-please": "3.2.0", + "hono": "^4.12.8", + "http-status-codes": "2.3.0", + "pathe": "2.0.3", + "proper-lockfile": "4.1.2", + "remeda": "2.33.4", + "std-env": "3.10.0", + "valibot": "1.2.0", + "zeptomatch": "2.1.0" + } + }, + "node_modules/@prisma/driver-adapter-utils": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.8.0.tgz", + "integrity": "sha512-/Q13o0ZT0rjc1Xk0Q9KhZYwuq2EW/vSbWUBKfgEKkaCuB/Sg6bqnjmTZqC5cD4d6y1vfFAEwBRzfzoSMIVJ55A==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.8.0" + } + }, "node_modules/@prisma/engines": { - "version": "4.16.2", - "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-4.16.2.tgz", - "integrity": "sha512-vx1nxVvN4QeT/cepQce68deh/Turxy5Mr+4L4zClFuK1GlxN3+ivxfuv+ej/gvidWn1cE1uAhW7ALLNlYbRUAw==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.8.0.tgz", + "integrity": "sha512-jx3rCnNNrt5uzbkKlegtQ2GZHxSlihMCzutgT/BP6UIDF1r9tDI39hV/0T/cHZgzJ3ELbuQPXlVZy+Y1n0pcgw==", "devOptional": true, "hasInstallScript": true, - "license": "Apache-2.0" + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.8.0", + "@prisma/engines-version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", + "@prisma/fetch-engine": "7.8.0", + "@prisma/get-platform": "7.8.0" + } }, "node_modules/@prisma/engines-version": { - "version": "4.16.1-1.4bc8b6e1b66cb932731fb1bdbbc550d1e010de81", - "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-4.16.1-1.4bc8b6e1b66cb932731fb1bdbbc550d1e010de81.tgz", - "integrity": "sha512-q617EUWfRIDTriWADZ4YiWRZXCa/WuhNgLTVd+HqWLffjMSPzyM5uOWoauX91wvQClSKZU4pzI4JJLQ9Kl62Qg==", + "version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a.tgz", + "integrity": "sha512-fJPQxCkLgA5EayWaW8eArgCvjJ+N+Kz3VyeNKMEeYiQC4alNkxRKFVAGxv/ZUzuJISKqdw+zGeDbS6mn6RCPOA==", + "devOptional": true, "license": "Apache-2.0" }, + "node_modules/@prisma/engines/node_modules/@prisma/get-platform": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.8.0.tgz", + "integrity": "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.8.0" + } + }, + "node_modules/@prisma/fetch-engine": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.8.0.tgz", + "integrity": "sha512-gwB0Euiz/DDRyxFRpLXYlK3RfaZUj1c5dAYMuhZYfApg7arknJlcb9bIsOHDppJmbqYaVA+yBIiFMDBfprsNPQ==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.8.0", + "@prisma/engines-version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", + "@prisma/get-platform": "7.8.0" + } + }, + "node_modules/@prisma/fetch-engine/node_modules/@prisma/get-platform": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.8.0.tgz", + "integrity": "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.8.0" + } + }, + "node_modules/@prisma/get-platform": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.2.0.tgz", + "integrity": "sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.2.0" + } + }, + "node_modules/@prisma/get-platform/node_modules/@prisma/debug": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.2.0.tgz", + "integrity": "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/query-plan-executor": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/query-plan-executor/-/query-plan-executor-7.2.0.tgz", + "integrity": "sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/streams-local": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@prisma/streams-local/-/streams-local-0.1.2.tgz", + "integrity": "sha512-l49yTxKKF2odFxaAXTmwmkBKL3+bVQ1tFOooGifu4xkdb9NMNLxHj27XAhTylWZod8I+ISGM5erU1xcl/oBCtg==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "ajv": "^8.12.0", + "better-result": "^2.7.0", + "env-paths": "^3.0.0", + "proper-lockfile": "^4.1.2" + }, + "engines": { + "bun": ">=1.3.6", + "node": ">=22.0.0" + } + }, + "node_modules/@prisma/studio-core": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.27.3.tgz", + "integrity": "sha512-AADjNFPdsrglxHQVTmHFqv6DuKQZ5WY4p5/gVFY017twvNrSwpLJ9lqUbYYxEu2W7nbvVxTZA8deJ8LseNALsw==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@radix-ui/react-toggle": "1.1.10", + "chart.js": "4.5.1" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24.0", + "pnpm": "8" + }, + "peerDependencies": { + "@types/react": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", + "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@sapphire/async-queue": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", @@ -301,18 +1113,18 @@ } }, "node_modules/@sapphire/framework": { - "version": "5.3.6", - "resolved": "https://registry.npmjs.org/@sapphire/framework/-/framework-5.3.6.tgz", - "integrity": "sha512-VDNsW6S8uMTVXUGSu9fwOYZ3zaMIQbgVvrglnPpjKSmW4GA6M3iewPZgtH/PDtqOXQe6khj1gY1ouhZnyYitNg==", + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@sapphire/framework/-/framework-5.5.0.tgz", + "integrity": "sha512-jC+zS8yR/MtJaTFEQutBBWj7Scuvny4d0adtQ3s0OCFWe3rUqNfENomBG3P4tsQ/kRF7L3WCqucyLV8ywG24hw==", "license": "MIT", "dependencies": { - "@discordjs/builders": "^1.11.2", - "@sapphire/discord-utilities": "^3.5.0", + "@discordjs/builders": "^1.13.0", + "@sapphire/discord-utilities": "^4.0.0", "@sapphire/discord.js-utilities": "^7.3.3", - "@sapphire/lexure": "^1.1.10", + "@sapphire/lexure": "^1.1.12", "@sapphire/pieces": "^4.4.1", "@sapphire/ratelimits": "^2.4.11", - "@sapphire/result": "^2.7.2", + "@sapphire/result": "^2.8.0", "@sapphire/stopwatch": "^1.5.4", "@sapphire/utilities": "^3.18.2" }, @@ -321,13 +1133,26 @@ "npm": ">=7" } }, + "node_modules/@sapphire/framework/node_modules/@sapphire/discord-utilities": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sapphire/discord-utilities/-/discord-utilities-4.0.0.tgz", + "integrity": "sha512-QAvrKNHgswz+ZX48WqSYpRiRzQcugNXXB1C3fR1qbpTJGd7Ckr2OWyFK88TyOksi3U2isrk8sMriTcAgaIe7Qg==", + "license": "MIT", + "dependencies": { + "discord-api-types": "^0.38.30" + }, + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, "node_modules/@sapphire/lexure": { - "version": "1.1.10", - "resolved": "https://registry.npmjs.org/@sapphire/lexure/-/lexure-1.1.10.tgz", - "integrity": "sha512-odE4FD0SkCxkwEOhzAOqEnCJ/oJlPUuyFEw2KJacIuGiwY86WRTPIHLg1rt6XmfSYLxGXiqRf74req43+wRV9g==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@sapphire/lexure/-/lexure-1.1.12.tgz", + "integrity": "sha512-F7Z3QzRnAZGunRl24/qQMhzRogZU/foumu2EBBunRnQi/o/DLTCwdAbLgJATyPlvJa8N6FrJq0JJwvzM/vXoXg==", "license": "MIT", "dependencies": { - "@sapphire/result": "^2.7.2" + "@sapphire/result": "^2.8.0" }, "engines": { "node": ">=v14.0.0", @@ -403,12 +1228,12 @@ } }, "node_modules/@sapphire/plugin-logger": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@sapphire/plugin-logger/-/plugin-logger-4.0.2.tgz", - "integrity": "sha512-5Nr++u+fA3/jZwj1aL9Z16RgyJZRE1gyUftfWjrzdndE5FkcbnLiVCKvnI8WzSupVhdn6kMaCWAteOSgAaq3lQ==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@sapphire/plugin-logger/-/plugin-logger-4.1.0.tgz", + "integrity": "sha512-+vLTITQw3DI8+kfDPNJ0yhIK/LrfM/Gro/UFcxgYWLLm+lwNmSpLZxdWe9v7ESXprody7rTp8JXPNBK7UWyE8w==", "license": "MIT", "dependencies": { - "@sapphire/timestamp": "^1.0.3", + "@sapphire/timestamp": "^1.0.5", "colorette": "^2.0.20" }, "engines": { @@ -440,9 +1265,9 @@ } }, "node_modules/@sapphire/result": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/@sapphire/result/-/result-2.7.2.tgz", - "integrity": "sha512-DJbCGmvi8UZAu/hh85auQL8bODFlpcS3cWjRJZ5/cXTLekmGvs/CrRxrIzwbA6+poyYojo5rK4qu8trmjfneog==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@sapphire/result/-/result-2.8.0.tgz", + "integrity": "sha512-693yWouX+hR9uJm1Jgq0uSSjbSD3UrblMaxiuGbHPjSwzLCSZTcm0h3kvdVhq3o/yl4+oeAWW3hiaJ0TELuRJQ==", "license": "MIT", "engines": { "node": ">=v14.0.0", @@ -496,20 +1321,34 @@ } }, "node_modules/@sapphire/ts-config": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@sapphire/ts-config/-/ts-config-5.0.1.tgz", - "integrity": "sha512-86YBYNBDNs6/bCrTsv274553v43Bz8YljfrrIQ4N8ll2npUxbf6cpC0gjfJY+FMa1HwKUgoMF4lvhzY0Ph0smw==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@sapphire/ts-config/-/ts-config-5.0.3.tgz", + "integrity": "sha512-bFyGYHFT3TpOf5Sg2P+zY2ad0t5IA2epc5HtewlghhL7MYvbZvxtKsdaNaMwAdNObBx7hpiQm5OcOhyzEwQvbQ==", "dev": true, "license": "MIT", "dependencies": { - "tslib": "^2.6.2", - "typescript": "^5.4.2" + "tslib": "^2.8.1", + "typescript": "~5.4.5" }, "engines": { "node": ">=v16.0.0", "npm": ">=8.0.0" } }, + "node_modules/@sapphire/ts-config/node_modules/typescript": { + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", + "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/@sapphire/type": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/@sapphire/type/-/type-2.6.0.tgz", @@ -535,6 +1374,36 @@ "node": ">=v14.0.0" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@tokenizer/inflate": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", + "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "token-types": "^6.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT" + }, "node_modules/@tsconfig/node10": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", @@ -564,12 +1433,34 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "20.19.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.0.tgz", - "integrity": "sha512-hfrc+1tud1xcdVTABC2JiomZJEklMcXYNTVtZLAeqTVWD+qL5jkHKT+1lOtqDdGxt+mB53DTtiz673vfjU8D1Q==", + "version": "25.9.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz", + "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==", + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" } }, "node_modules/@types/strip-bom": { @@ -596,9 +1487,9 @@ } }, "node_modules/@vladfrangu/async_event_emitter": { - "version": "2.4.6", - "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.6.tgz", - "integrity": "sha512-RaI5qZo6D2CVS6sTHFKg1v5Ohq/+Bo2LZ5gzUEwZ/WkHhwtGTCB/sVLw8ijOkAUxasZ+WshN/Rzj4ywsABJ5ZA==", + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz", + "integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==", "license": "MIT", "engines": { "node": ">=v14.0.0", @@ -649,6 +1540,23 @@ "node": ">= 6.0.0" } }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -658,6 +1566,12 @@ "node": ">=8" } }, + "node_modules/any-base": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/any-base/-/any-base-1.1.0.tgz", + "integrity": "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg==", + "license": "MIT" + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -699,12 +1613,38 @@ "dev": true, "license": "MIT" }, + "node_modules/await-to-js": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/await-to-js/-/await-to-js-3.0.0.tgz", + "integrity": "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/better-result": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/better-result/-/better-result-2.9.2.tgz", + "integrity": "sha512-WIFoBPCdnTOdk9inkE1ZRvCZ4P0CpSkAiLlchC65N7n9DcjZ3NhqkBOlafzpOVnO8ixyi37kicmSJ3ENhPZl7Q==", + "devOptional": true, + "license": "MIT" + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -718,6 +1658,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bmp-ts": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/bmp-ts/-/bmp-ts-1.0.9.tgz", + "integrity": "sha512-cTEHk2jLrPyi+12M3dhpEbnnPOsaZuq7C45ylbbQIiWgDFZq4UVYPEY5mlqjvsj/6gJv9qX5sa+ebDzLXT28Vw==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -748,6 +1694,78 @@ "dev": true, "license": "MIT" }, + "node_modules/c12": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz", + "integrity": "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "chokidar": "^5.0.0", + "confbox": "^0.2.4", + "defu": "^6.1.6", + "dotenv": "^17.3.1", + "exsolve": "^1.0.8", + "giget": "^3.2.0", + "jiti": "^2.6.1", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^2.1.0", + "pkg-types": "^2.3.0", + "rc9": "^3.0.1" + }, + "peerDependencies": { + "magicast": "*" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, + "node_modules/c12/node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/c12/node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -803,6 +1821,13 @@ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "license": "MIT" }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "devOptional": true, + "license": "MIT" + }, "node_modules/console-control-strings": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", @@ -816,10 +1841,33 @@ "dev": true, "license": "MIT" }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT", + "peer": true + }, "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -833,12 +1881,46 @@ } } }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "devOptional": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "devOptional": true, + "license": "MIT" + }, "node_modules/delegates": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", "license": "MIT" }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "devOptional": true, + "license": "MIT" + }, "node_modules/detect-libc": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", @@ -859,33 +1941,33 @@ } }, "node_modules/discord-api-types": { - "version": "0.38.11", - "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.11.tgz", - "integrity": "sha512-XN0qhcQpetkyb/49hcDHuoeUPsQqOkb17wbV/t48gUkoEDi4ajhsxqugGcxvcN17BBtI9FPPWEgzv6IhQmCwyw==", + "version": "0.38.48", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.48.tgz", + "integrity": "sha512-WFUE/2o0lBlLeCQonQ+Pu2RqHAqbytBJ2RlXR91gzk05InSS6k9ShzzLYoymrA4c2oRgRKGE7/VqQJNNdGWSxQ==", "license": "MIT", "workspaces": [ "scripts/actions/documentation" ] }, "node_modules/discord.js": { - "version": "14.19.3", - "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.19.3.tgz", - "integrity": "sha512-lncTRk0k+8Q5D3nThnODBR8fR8x2fM798o8Vsr40Krx0DjPwpZCuxxTcFMrXMQVOqM1QB9wqWgaXPg3TbmlHqA==", + "version": "14.26.4", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.26.4.tgz", + "integrity": "sha512-4oBp8tc6Kf8IDBwAHhbsMaAqx1b5fob9SNasZT7V6yyyUydoO5i5fGuX7TmvRtR+q/WgKRnRViRoAWnG7fNyvA==", "license": "Apache-2.0", "dependencies": { - "@discordjs/builders": "^1.11.2", + "@discordjs/builders": "^1.14.1", "@discordjs/collection": "1.5.3", - "@discordjs/formatters": "^0.6.1", - "@discordjs/rest": "^2.5.0", - "@discordjs/util": "^1.1.1", - "@discordjs/ws": "^1.2.2", + "@discordjs/formatters": "^0.6.2", + "@discordjs/rest": "^2.6.1", + "@discordjs/util": "^1.2.0", + "@discordjs/ws": "^1.2.3", "@sapphire/snowflake": "3.5.3", - "discord-api-types": "^0.38.1", + "discord-api-types": "^0.38.40", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", - "magic-bytes.js": "^1.10.0", + "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", - "undici": "6.21.1" + "undici": "6.24.1" }, "engines": { "node": ">=18" @@ -895,9 +1977,9 @@ } }, "node_modules/dotenv": { - "version": "16.5.0", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", - "integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==", + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -916,18 +1998,122 @@ "xtend": "^4.0.0" } }, + "node_modules/effect": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz", + "integrity": "sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, + "node_modules/empathic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/exif-parser": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/exif-parser/-/exif-parser-0.1.12.tgz", + "integrity": "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw==" + }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/file-type": { + "version": "21.3.4", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz", + "integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==", + "license": "MIT", + "dependencies": { + "@tokenizer/inflate": "^0.4.1", + "strtok3": "^10.3.4", + "token-types": "^6.1.1", + "uint8array-extras": "^1.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -941,6 +2127,36 @@ "node": ">=8" } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "devOptional": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/fs-minipass": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", @@ -1017,6 +2233,43 @@ "node": ">=10" } }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, + "node_modules/get-port-please": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.2.0.tgz", + "integrity": "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/gifwrap": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/gifwrap/-/gifwrap-0.10.1.tgz", + "integrity": "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw==", + "license": "MIT", + "dependencies": { + "image-q": "^4.0.0", + "omggif": "^1.0.10" + } + }, + "node_modules/giget": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-3.3.0.tgz", + "integrity": "sha512-gzi2D96p+AMfDcmJHGDj3KJ9NRiwvlFAU5yfa3ROwWZmFUjX4P43x3BcyRaOMMLto1vUo7C+86+MFhYTl6Ryiw==", + "devOptional": true, + "license": "MIT", + "bin": { + "giget": "dist/cli.mjs" + } + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -1051,6 +2304,27 @@ "node": ">= 6" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/grammex": { + "version": "3.1.12", + "resolved": "https://registry.npmjs.org/grammex/-/grammex-3.1.12.tgz", + "integrity": "sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/graphmatch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/graphmatch/-/graphmatch-1.1.1.tgz", + "integrity": "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==", + "devOptional": true, + "license": "MIT" + }, "node_modules/has-unicode": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", @@ -1070,6 +2344,23 @@ "node": ">= 0.4" } }, + "node_modules/hono": { + "version": "4.12.25", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz", + "integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-status-codes": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz", + "integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==", + "devOptional": true, + "license": "MIT" + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -1083,6 +2374,58 @@ "node": ">= 6" } }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/image-q": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/image-q/-/image-q-4.0.0.tgz", + "integrity": "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw==", + "license": "MIT", + "dependencies": { + "@types/node": "16.9.1" + } + }, + "node_modules/image-q/node_modules/@types/node": { + "version": "16.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.9.1.tgz", + "integrity": "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==", + "license": "MIT" + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -1171,10 +2514,85 @@ "node": ">=0.12.0" } }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/jimp": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/jimp/-/jimp-1.6.1.tgz", + "integrity": "sha512-hNQh6rZtWfSVWSNVmvq87N5BPJsNH7k7I7qyrXf9DOma9xATQk3fsyHazCQe51nCjdkoWdTmh0vD7bjVSLoxxw==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/diff": "1.6.1", + "@jimp/js-bmp": "1.6.1", + "@jimp/js-gif": "1.6.1", + "@jimp/js-jpeg": "1.6.1", + "@jimp/js-png": "1.6.1", + "@jimp/js-tiff": "1.6.1", + "@jimp/plugin-blit": "1.6.1", + "@jimp/plugin-blur": "1.6.1", + "@jimp/plugin-circle": "1.6.1", + "@jimp/plugin-color": "1.6.1", + "@jimp/plugin-contain": "1.6.1", + "@jimp/plugin-cover": "1.6.1", + "@jimp/plugin-crop": "1.6.1", + "@jimp/plugin-displace": "1.6.1", + "@jimp/plugin-dither": "1.6.1", + "@jimp/plugin-fisheye": "1.6.1", + "@jimp/plugin-flip": "1.6.1", + "@jimp/plugin-hash": "1.6.1", + "@jimp/plugin-mask": "1.6.1", + "@jimp/plugin-print": "1.6.1", + "@jimp/plugin-quantize": "1.6.1", + "@jimp/plugin-resize": "1.6.1", + "@jimp/plugin-rotate": "1.6.1", + "@jimp/plugin-threshold": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "devOptional": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/jpeg-js": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz", + "integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==", + "license": "BSD-3-Clause" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "devOptional": true, + "license": "MIT" + }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, "node_modules/lodash.snakecase": { @@ -1183,10 +2601,33 @@ "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", "license": "MIT" }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/lru.min": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", + "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", + "devOptional": true, + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, "node_modules/magic-bytes.js": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.12.1.tgz", - "integrity": "sha512-ThQLOhN86ZkJ7qemtVRGYM+gRgR8GEXNli9H/PMvpnZsE44Xfh3wx9kGJaldg314v85m+bFW6WBMaVHJc/c3zA==", + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz", + "integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==", "license": "MIT" }, "node_modules/make-dir": { @@ -1220,6 +2661,18 @@ "dev": true, "license": "ISC" }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -1294,6 +2747,40 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/mysql2": { + "version": "3.15.3", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz", + "integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.1", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.0", + "long": "^5.2.1", + "lru.min": "^1.0.0", + "named-placeholders": "^1.1.3", + "seq-queue": "^0.0.5", + "sqlstring": "^2.3.2" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/nan": { "version": "2.22.2", "resolved": "https://registry.npmjs.org/nan/-/nan-2.22.2.tgz", @@ -1329,98 +2816,437 @@ "abbrev": "1" }, "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": ">=6" + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npmlog": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", + "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "are-we-there-yet": "^2.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^3.0.0", + "set-blocking": "^2.0.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/omggif": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/omggif/-/omggif-1.0.10.tgz", + "integrity": "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==", + "license": "MIT" + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/parse-bmfont-ascii": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/parse-bmfont-ascii/-/parse-bmfont-ascii-1.0.6.tgz", + "integrity": "sha512-U4RrVsUFCleIOBsIGYOMKjn9PavsGOXxbvYGtMOEfnId0SVNsgehXh1DxUdVPLoxd5mvcEtvmKs2Mmf0Mpa1ZA==", + "license": "MIT" + }, + "node_modules/parse-bmfont-binary": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/parse-bmfont-binary/-/parse-bmfont-binary-1.0.6.tgz", + "integrity": "sha512-GxmsRea0wdGdYthjuUeWTMWPqm2+FAd4GI8vCvhgJsFnoGhTrLhXDDupwTo7rXVAgaLIGoVHDZS9p/5XbSqeWA==", + "license": "MIT" + }, + "node_modules/parse-bmfont-xml": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/parse-bmfont-xml/-/parse-bmfont-xml-1.1.6.tgz", + "integrity": "sha512-0cEliVMZEhrFDwMh4SxIyVJpqYoOWDJ9P895tFuS+XuNzI5UBmBk5U5O4KuJdTnZpSBI4LFA2+ZiJaiwfSwlMA==", + "license": "MIT", + "dependencies": { + "xml-parse-from-string": "^1.0.0", + "xml2js": "^0.5.0" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/pg": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz", + "integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.13.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.14.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz", + "integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz", + "integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pg-types/node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pixelmatch": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-5.3.0.tgz", + "integrity": "sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q==", + "license": "ISC", + "dependencies": { + "pngjs": "^6.0.0" + }, + "bin": { + "pixelmatch": "bin/pixelmatch" } }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, + "node_modules/pixelmatch/node_modules/pngjs": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz", + "integrity": "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==", "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=12.13.0" } }, - "node_modules/npmlog": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", - "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", - "deprecated": "This package is no longer supported.", - "license": "ISC", + "node_modules/pkg-types": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", + "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", + "devOptional": true, + "license": "MIT", "dependencies": { - "are-we-there-yet": "^2.0.0", - "console-control-strings": "^1.1.0", - "gauge": "^3.0.0", - "set-blocking": "^2.0.0" + "confbox": "^0.2.4", + "exsolve": "^1.0.8", + "pathe": "^2.0.3" } }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=14.19.0" } }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" + "node_modules/postgres": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", + "integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==", + "devOptional": true, + "license": "Unlicense", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/porsager" } }, - "node_modules/path-is-absolute": { + "node_modules/postgres-array": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz", + "integrity": "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/postgres-bytea": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, - "node_modules/path-parse": { + "node_modules/postgres-date": { "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, - "license": "MIT" - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", "license": "MIT", "engines": { - "node": ">=8.6" + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "engines": { + "node": ">=0.10.0" } }, "node_modules/prisma": { - "version": "4.16.2", - "resolved": "https://registry.npmjs.org/prisma/-/prisma-4.16.2.tgz", - "integrity": "sha512-SYCsBvDf0/7XSJyf2cHTLjLeTLVXYfqp7pG5eEVafFLeT0u/hLFz/9W196nDRGUOo1JfPatAEb+uEnTQImQC1g==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.8.0.tgz", + "integrity": "sha512-yfN4yrw7HV9kEJhoy1+jgah0jafEIQsf7uWouSsM8MvJtlubsk+kM7AIBWZ8+GJl74Yj3c+nbYqBkMOxtsZ3Lw==", "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@prisma/engines": "4.16.2" + "@prisma/config": "7.8.0", + "@prisma/dev": "0.24.3", + "@prisma/engines": "7.8.0", + "@prisma/studio-core": "0.27.3", + "mysql2": "3.15.3", + "postgres": "3.4.7" }, "bin": { - "prisma": "build/index.js", - "prisma2": "build/index.js" + "prisma": "build/index.js" }, "engines": { - "node": ">=14.17" + "node": "^20.19 || ^22.12 || >=24.0" + }, + "peerDependencies": { + "better-sqlite3": ">=9.0.0", + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "better-sqlite3": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/rc9": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-3.0.1.tgz", + "integrity": "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.6", + "destr": "^2.0.5" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "devOptional": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" } }, "node_modules/readable-stream": { @@ -1450,6 +3276,26 @@ "node": ">=8.10.0" } }, + "node_modules/remeda": { + "version": "2.33.4", + "resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz", + "integrity": "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==", + "devOptional": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/remeda" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", @@ -1471,6 +3317,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -1507,6 +3363,30 @@ ], "license": "MIT" }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "devOptional": true, + "license": "MIT", + "peer": true + }, "node_modules/semver": { "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", @@ -1519,18 +3399,56 @@ "node": ">=10" } }, + "node_modules/seq-queue": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", + "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==", + "devOptional": true + }, "node_modules/set-blocking": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", "license": "ISC" }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC" }, + "node_modules/simple-xml-to-json": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/simple-xml-to-json/-/simple-xml-to-json-1.2.7.tgz", + "integrity": "sha512-mz9VXphOxQWX3eQ/uXCtm6upltoN0DLx8Zb5T4TFC4FHB7S9FDPGre8CfLWqPWQQH/GrQYd2AXhhVM5LDpYx6Q==", + "license": "MIT", + "engines": { + "node": ">=20.12.2" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -1552,6 +3470,32 @@ "source-map": "^0.6.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/sqlstring": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", + "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "devOptional": true, + "license": "MIT" + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -1607,6 +3551,22 @@ "node": ">=0.10.0" } }, + "node_modules/strtok3": { + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", + "integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -1637,6 +3597,12 @@ "node": ">=10" } }, + "node_modules/tinycolor2": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", + "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -1650,6 +3616,24 @@ "node": ">=8.0" } }, + "node_modules/token-types": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", + "license": "MIT", + "dependencies": { + "@borewit/text-codec": "^0.2.1", + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -1785,10 +3769,10 @@ "license": "0BSD" }, "node_modules/typescript": { - "version": "5.8.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", - "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", - "dev": true, + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -1798,21 +3782,42 @@ "node": ">=14.17" } }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/undici": { - "version": "6.21.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.1.tgz", - "integrity": "sha512-q/1rj5D0/zayJB2FraXdaWxbhWiNKDvu8naDT2dl1yTlvJp4BLtOcp2a5BvgGNQpYYJzau7tf1WgKv3b+7mqpQ==", + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", + "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==", "license": "MIT", "engines": { "node": ">=18.17" } }, "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "license": "MIT" }, + "node_modules/utif2": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/utif2/-/utif2-4.1.0.tgz", + "integrity": "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.11" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -1826,6 +3831,21 @@ "dev": true, "license": "MIT" }, + "node_modules/valibot": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz", + "integrity": "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -1842,6 +3862,22 @@ "webidl-conversions": "^3.0.0" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/wide-align": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", @@ -1858,9 +3894,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.18.2", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz", - "integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -1878,11 +3914,38 @@ } } }, + "node_modules/xml-parse-from-string": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/xml-parse-from-string/-/xml-parse-from-string-1.0.1.tgz", + "integrity": "sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g==", + "license": "MIT" + }, + "node_modules/xml2js": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", + "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.4" @@ -1903,6 +3966,26 @@ "engines": { "node": ">=6" } + }, + "node_modules/zeptomatch": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.1.0.tgz", + "integrity": "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "grammex": "^3.1.11", + "graphmatch": "^1.1.0" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index c5c2cb4..7ef8c01 100644 --- a/package.json +++ b/package.json @@ -1,40 +1,45 @@ { "name": "arduino-bot", "description": "The new and improved custom discord bot to power the official Arduino discord server @ https://arduino.cc/discord", - "version": "0.3.0", + "version": "1.0.0", "main": "./dist/src/index.js", "scripts": { "start": "node ./dist/src/index.js", "build": "tsc -p .", - "dev": "tsnd --respawn --transpile-only --exit-child ." + "dev": "tsnd --respawn --transpile-only --exit-child .", + "postinstall": "prisma generate" }, "repository": { "type": "git", - "url": "git+https://github.com/max-bromberg/arduino-bot.git" + "url": "git+https://github.com/arduinodiscord/bot.git" }, "bugs": { - "url": "https://github.com/max-bromberg/arduino-bot/issues" + "url": "https://github.com/arduinodiscord/bot/issues" }, - "homepage": "https://github.com/max-bromberg/arduino-bot#readme", + "homepage": "https://github.com/arduinodiscord/bot#readme", "author": "Max Bromberg and Contributors", "license": "GPL-3.0-or-later", "dependencies": { - "@prisma/client": "^4.16.2", + "@prisma/adapter-pg": "^7.8.0", + "@prisma/client": "^7.8.0", "@sapphire/discord.js-utilities": "^7.3.3", - "@sapphire/framework": "^5.3.6", + "@sapphire/framework": "^5.5.0", "@sapphire/plugin-hmr": "^3.0.2", - "@sapphire/plugin-logger": "^4.0.2", + "@sapphire/plugin-logger": "^4.1.0", "@sapphire/plugin-subcommands": "^7.0.1", "@sapphire/type": "^2.6.0", "@sapphire/utilities": "^3.18.2", - "discord.js": "^14.19.3", - "dotenv": "^16.5.0" + "discord.js": "^14.26.4", + "dotenv": "^17.4.2", + "jimp": "^1.6.1", + "pg": "^8.21.0" }, "devDependencies": { - "@sapphire/ts-config": "^5.0.1", - "@types/node": "^20.14.2", - "prisma": "^4.16.2", + "@sapphire/ts-config": "^5.0.3", + "@types/node": "^25.9.3", + "@types/pg": "^8.20.0", + "prisma": "^7.8.0", "ts-node-dev": "^2.0.0", - "typescript": "^5.4.5" + "typescript": "^6.0.3" } } diff --git a/prisma.config.ts b/prisma.config.ts new file mode 100644 index 0000000..4bcdbf8 --- /dev/null +++ b/prisma.config.ts @@ -0,0 +1,14 @@ +import 'dotenv/config'; +import { defineConfig } from 'prisma/config'; + +// Prisma 7 moved the migration/introspection connection URL out of the schema +// and into this config file. The datasource is only required for CLI commands +// that touch the database (migrate, db pull); `prisma generate` does not need +// it, so we omit it entirely when DATABASE_URL is unset to keep offline +// generation (e.g. the postinstall hook) working. +const url = process.env.DATABASE_URL; + +export default defineConfig({ + schema: 'prisma/schema.prisma', + ...(url ? { datasource: { url } } : {}), +}); diff --git a/prisma/migrations/0_init/migration.sql b/prisma/migrations/0_init/migration.sql new file mode 100644 index 0000000..bba1aa5 --- /dev/null +++ b/prisma/migrations/0_init/migration.sql @@ -0,0 +1,55 @@ +-- CreateSchema +CREATE SCHEMA IF NOT EXISTS "public"; + +-- CreateEnum +CREATE TYPE "MemberAnalyticsEvent" AS ENUM ('join', 'leave'); + +-- CreateTable +CREATE TABLE "MemberAnalytics" ( + "time" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "event" "MemberAnalyticsEvent" NOT NULL, + "memberId" VARCHAR NOT NULL, + + CONSTRAINT "MemberAnalytics_pkey" PRIMARY KEY ("time") +); + +-- CreateTable +CREATE TABLE "MessageAnalytics" ( + "time" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "memberId" VARCHAR, + "channelId" VARCHAR, + + CONSTRAINT "MessageAnalytics_pk" PRIMARY KEY ("time") +); + +-- CreateTable +CREATE TABLE "CommandAnalytics" ( + "time" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "command" VARCHAR, + + CONSTRAINT "CommandAnalytics_pk" PRIMARY KEY ("time") +); + +-- CreateTable +CREATE TABLE "SpamSignature" ( + "signature" VARCHAR NOT NULL, + "kind" VARCHAR NOT NULL DEFAULT 'meta', + "addedBy" VARCHAR NOT NULL, + "reason" VARCHAR, + "createdAt" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "SpamSignature_pkey" PRIMARY KEY ("signature") +); + +-- CreateTable +CREATE TABLE "ModerationAction" ( + "id" TEXT NOT NULL, + "time" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "moderatorId" VARCHAR NOT NULL, + "targetId" VARCHAR NOT NULL, + "action" VARCHAR NOT NULL, + "reason" VARCHAR, + + CONSTRAINT "ModerationAction_pkey" PRIMARY KEY ("id") +); + diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..2fe25d8 --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1 @@ +provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d14cc32..25beb74 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -4,7 +4,6 @@ generator client { datasource db { provider = "postgresql" - url = env("DATABASE_URL") } model MemberAnalytics { @@ -28,3 +27,25 @@ enum MemberAnalyticsEvent { join leave } + +/// Blocklist of image fingerprints confirmed to be spam by a moderator. +/// New uploads matching one of these are auto-actioned. `kind` is "meta" for +/// exact metadata signatures or "phash" for perceptual hashes (matched by +/// Hamming distance). +model SpamSignature { + signature String @id @db.VarChar + kind String @default("meta") @db.VarChar + addedBy String @db.VarChar + reason String? @db.VarChar + createdAt DateTime @default(now()) @db.Timestamptz(6) +} + +/// Audit trail of automod-related moderation actions. +model ModerationAction { + id String @id @default(uuid()) + time DateTime @default(now()) @db.Timestamptz(6) + moderatorId String @db.VarChar + targetId String @db.VarChar + action String @db.VarChar + reason String? @db.VarChar +} diff --git a/src/commands/about.ts b/src/commands/about.ts index 672911b..b605fe0 100644 --- a/src/commands/about.ts +++ b/src/commands/about.ts @@ -1,7 +1,7 @@ import { ApplicationCommandRegistry, Command } from '@sapphire/framework'; import { EmbedBuilder } from 'discord.js'; import { version } from './../../package.json'; -import universalEmbed from '../index' +import universalEmbed from '../utils/embed'; export class AboutCommand extends Command { public constructor(context: Command.Context, options: Command.Options) { diff --git a/src/commands/openPosts.ts b/src/commands/openPosts.ts new file mode 100644 index 0000000..0ed96ae --- /dev/null +++ b/src/commands/openPosts.ts @@ -0,0 +1,74 @@ +import { ApplicationCommandRegistry, Command } from '@sapphire/framework'; +import { + EmbedBuilder, + MessageFlags, + TimestampStyles, + time, +} from 'discord.js'; +import { helpChannelIds } from '../utils/config'; +import { fetchOpenHelpPosts } from '../utils/helpPosts'; +import universalEmbed from '../utils/embed'; + +const MAX_LISTED = 15; + +/** + * `/openposts` β€” an ephemeral digest of currently-open (active, unsolved) help + * posts, oldest activity first, so helpers can pick up whatever's been waiting + * longest. Reuses the same open-post scan as the stale-post sweep. + */ +export class OpenPostsCommand extends Command { + public constructor(context: Command.Context, options: Command.Options) { + super(context, { + ...options, + name: 'openposts', + description: 'List open help posts waiting for an answer.', + }); + } + + public override registerApplicationCommands( + registry: ApplicationCommandRegistry + ) { + registry.registerChatInputCommand((builder) => + builder + .setName(this.name) + .setDescription(this.description) + .setDMPermission(false) + ); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + if (helpChannelIds.length === 0) + return interaction.reply({ + content: 'No help channels are configured.', + flags: MessageFlags.Ephemeral, + }); + + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + const posts = (await fetchOpenHelpPosts(interaction.client)).sort( + (a, b) => a.lastActivityAt - b.lastActivityAt + ); + + if (posts.length === 0) + return interaction.editReply({ + content: 'πŸŽ‰ No open help posts right now β€” all caught up!', + }); + + const lines = posts.slice(0, MAX_LISTED).map(({ thread, lastActivityAt }) => { + const url = `https://discord.com/channels/${thread.guildId}/${thread.id}`; + const when = time(Math.floor(lastActivityAt / 1000), TimestampStyles.RelativeTime); + return `β€’ [${thread.name}](${url}) β€” last activity ${when}`; + }); + + if (posts.length > MAX_LISTED) + lines.push(`…and ${posts.length - MAX_LISTED} more.`); + + const embed = new EmbedBuilder(universalEmbed) + .setTitle(`πŸ—‚οΈ Open help posts (${posts.length})`) + .setDescription(lines.join('\n')); + + return interaction.editReply({ embeds: [embed] }); + } +} diff --git a/src/commands/ping.ts b/src/commands/ping.ts index 3d184ba..90844b0 100644 --- a/src/commands/ping.ts +++ b/src/commands/ping.ts @@ -1,6 +1,6 @@ import { ApplicationCommandRegistry, Command } from '@sapphire/framework'; import { EmbedBuilder } from 'discord.js'; -import universalEmbed from '../index'; +import universalEmbed from '../utils/embed'; export class PingCommand extends Command { public constructor(context: Command.Context, options: Command.Options) { @@ -19,31 +19,29 @@ export class PingCommand extends Command { }); } - public override chatInputRun(interaction: Command.ChatInputCommandInteraction) { - const sent = await interaction.deferReply({ fetchReply: true }); - - const latency = sent.createdTimestamp - interaction.createdTimestamp; + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + const sent = await interaction.deferReply({ withResponse: true }); + const createdTimestamp = + sent.resource?.message?.createdTimestamp ?? Date.now(); + + const latency = createdTimestamp - interaction.createdTimestamp; const apiLatency = Math.round(this.container.client.ws.ping); - + const embed = new EmbedBuilder(universalEmbed) - .setDescription(`My latency is ${latency}ms `) + .setDescription(`My latency is ${latency}ms`) .addFields([ - - { - name: 'API Latency', - value: `${apiLatency}ms`, - inline: true - }, - { - name: 'Uptime', + { name: 'API Latency', value: `${apiLatency}ms`, inline: true }, + { + name: 'Uptime', value: this.formatUptime(process.uptime()), - inline: false - } + inline: false, + }, ]) - .setFooter({ text: 'Arduino server' }) - .setTimestap(); + .setFooter({ text: 'Arduino server' }) + .setTimestamp(); - return interaction.editReply({ embeds: [embed] }); } @@ -52,13 +50,13 @@ export class PingCommand extends Command { const hours = Math.floor((seconds % 86400) / 3600); const minutes = Math.floor((seconds % 3600) / 60); const secs = Math.floor(seconds % 60); - - const parts = []; + + const parts: string[] = []; if (days > 0) parts.push(`${days}d`); if (hours > 0) parts.push(`${hours}h`); if (minutes > 0) parts.push(`${minutes}m`); parts.push(`${secs}s`); - + return parts.join(' '); } } diff --git a/src/commands/requestInfo.ts b/src/commands/requestInfo.ts new file mode 100644 index 0000000..4e1b8bd --- /dev/null +++ b/src/commands/requestInfo.ts @@ -0,0 +1,61 @@ +import { ApplicationCommandRegistry, Command } from '@sapphire/framework'; +import { ApplicationCommandType, MessageFlags } from 'discord.js'; +import { resolveTag } from '../utils/resolveTag'; + +/** + * Right-click a message β†’ "Request more info" posts the `needinfo` checklist as + * a reply to that message, pinging its author. Saves helpers from typing out + * (or remembering) `/tag needinfo` for the most common ask. + */ +export class RequestInfoCommand extends Command { + public constructor(context: Command.Context, options: Command.Options) { + super(context, { ...options, name: 'Request more info' }); + } + + public override registerApplicationCommands( + registry: ApplicationCommandRegistry + ) { + registry.registerContextMenuCommand((builder) => + builder + .setName('Request more info') + .setType(ApplicationCommandType.Message) + .setDMPermission(false) + ); + } + + public override async contextMenuRun( + interaction: Command.ContextMenuCommandInteraction + ) { + if (!interaction.isMessageContextMenuCommand()) return; + + const target = interaction.targetMessage; + const payload = resolveTag('needinfo', target.author.id); + if (!payload?.content) + return interaction.reply({ + content: 'The needinfo tag is unavailable.', + flags: MessageFlags.Ephemeral, + }); + + const sent = await target + .reply({ + content: payload.content, + allowedMentions: { users: [target.author.id] }, + }) + .catch(() => null); + + // If replying to the original message failed (e.g. it was deleted), fall + // back to a normal channel message. + if (!sent && interaction.channel?.isSendable()) + await interaction.channel + .send({ + content: payload.content, + allowedMentions: { users: [target.author.id] }, + }) + .catch(() => null); + + return interaction.reply({ + content: 'βœ… Requested more info from the user.', + flags: MessageFlags.Ephemeral, + }); + } +} diff --git a/src/commands/say.ts b/src/commands/say.ts new file mode 100644 index 0000000..b810b07 --- /dev/null +++ b/src/commands/say.ts @@ -0,0 +1,132 @@ +import { ApplicationCommandRegistry, Command } from '@sapphire/framework'; +import { + ChannelType, + EmbedBuilder, + MessageFlags, + PermissionFlagsBits, +} from 'discord.js'; +import universalEmbed from '../utils/embed'; + +const MAX_FIELDS = 25; + +export class SayCommand extends Command { + public constructor(context: Command.Context, options: Command.Options) { + super(context, { + ...options, + name: 'say', + description: 'Send a custom embed to a channel as the bot (staff only).', + }); + } + + public override registerApplicationCommands( + registry: ApplicationCommandRegistry + ) { + registry.registerChatInputCommand((builder) => + builder + .setName(this.name) + .setDescription(this.description) + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) + .setDMPermission(false) + .addChannelOption((option) => + option + .setName('channel') + .setDescription('Channel to send the embed in') + .addChannelTypes( + ChannelType.GuildText, + ChannelType.GuildAnnouncement + ) + .setRequired(true) + ) + .addStringOption((option) => + option + .setName('title') + .setDescription('Embed title') + .setRequired(true) + ) + .addStringOption((option) => + option + .setName('description') + .setDescription('Embed description') + .setRequired(true) + ) + .addStringOption((option) => + option + .setName('fields') + .setDescription('Optional fields, one per line as: name | value') + ) + .addStringOption((option) => + option + .setName('thumbnail') + .setDescription('Optional thumbnail image URL') + ) + ); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + // Belt-and-braces: the command is also gated by default member permissions. + if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) + return interaction.reply({ + content: 'You need the Manage Server permission to use this.', + flags: MessageFlags.Ephemeral, + }); + + const picked = interaction.options.getChannel('channel', true); + const channel = await interaction.client.channels + .fetch(picked.id) + .catch(() => null); + if (!channel?.isSendable()) + return interaction.reply({ + content: 'I can\'t send messages in that channel.', + flags: MessageFlags.Ephemeral, + }); + + const embed = new EmbedBuilder(universalEmbed) + .setTitle(interaction.options.getString('title', true)) + .setDescription(interaction.options.getString('description', true)) + .setTimestamp(); + + const thumbnail = interaction.options.getString('thumbnail'); + if (thumbnail) { + if (!URL.canParse(thumbnail)) + return interaction.reply({ + content: 'The thumbnail must be a valid URL.', + flags: MessageFlags.Ephemeral, + }); + embed.setThumbnail(thumbnail); + } + + const fields = this.parseFields(interaction.options.getString('fields')); + if (fields.length > 0) embed.addFields(fields); + + try { + await channel.send({ embeds: [embed] }); + } catch (error) { + this.container.logger.error('/say failed to send:', error); + return interaction.reply({ + content: 'Something went wrong sending the embed.', + flags: MessageFlags.Ephemeral, + }); + } + + return interaction.reply({ + content: `βœ… Sent to <#${picked.id}>.`, + flags: MessageFlags.Ephemeral, + }); + } + + /** Parse newline-separated `name | value` pairs into embed fields. */ + private parseFields(raw: string | null): { name: string; value: string }[] { + if (!raw) return []; + return raw + .split('\n') + .map((line) => line.split('|')) + .filter((parts) => parts.length >= 2 && parts[0].trim() && parts[1].trim()) + .slice(0, MAX_FIELDS) + .map((parts) => ({ + name: parts[0].trim(), + value: parts.slice(1).join('|').trim(), + })); + } +} diff --git a/src/commands/solved.ts b/src/commands/solved.ts new file mode 100644 index 0000000..e9d596b --- /dev/null +++ b/src/commands/solved.ts @@ -0,0 +1,64 @@ +import { ApplicationCommandRegistry, Command } from '@sapphire/framework'; +import { EmbedBuilder, MessageFlags, PermissionFlagsBits } from 'discord.js'; +import universalEmbed from '../utils/embed'; +import { applySolved, canMarkSolved } from '../utils/solveThread'; + +export class SolvedCommand extends Command { + public constructor(context: Command.Context, options: Command.Options) { + super(context, { + ...options, + name: 'solved', + description: 'Mark the current help post/thread as solved.', + }); + } + + public override registerApplicationCommands( + registry: ApplicationCommandRegistry + ) { + registry.registerChatInputCommand((builder) => + builder + .setName(this.name) + .setDescription(this.description) + .setDMPermission(false) + .addUserOption((option) => + option + .setName('helper') + .setDescription('Credit the member who helped you') + ) + ); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + const channel = interaction.channel; + if (!channel?.isThread()) + return interaction.reply({ + content: 'Use this inside a help post or thread.', + flags: MessageFlags.Ephemeral, + }); + + const isStaff = + interaction.memberPermissions?.has(PermissionFlagsBits.ManageMessages) ?? + false; + const check = canMarkSolved(channel, interaction.user.id, isStaff); + if (!check.ok) + return interaction.reply({ + content: check.reason, + flags: MessageFlags.Ephemeral, + }); + + const helper = interaction.options.getUser('helper'); + const embed = new EmbedBuilder(universalEmbed) + .setTitle('βœ… Marked solved') + .setDescription( + helper + ? `Thanks for the help, <@${helper.id}>! πŸŽ‰` + : 'Glad it’s sorted! Closing this post.' + ); + + await interaction.reply({ embeds: [embed] }); + await applySolved(channel); + return undefined; + } +} diff --git a/src/commands/tag.ts b/src/commands/tag.ts index df633ec..923183f 100644 --- a/src/commands/tag.ts +++ b/src/commands/tag.ts @@ -1,8 +1,9 @@ import { ApplicationCommandRegistry, Command } from '@sapphire/framework'; -import { TextChannel, EmbedBuilder } from 'discord.js'; +import { EmbedBuilder, MessageFlags } from 'discord.js'; import { BOT_COMMANDS_CHANNEL_ID } from '../utils/config'; import tags from '../utils/tags'; -import universalEmbed from '../index'; +import { resolveTag } from '../utils/resolveTag'; +import universalEmbed from '../utils/embed'; export class TagCommand extends Command { public constructor(context: Command.Context, options: Command.Options) { @@ -56,108 +57,52 @@ export class TagCommand extends Command { } public override async chatInputRun( - interaction: Command.ChatInputCommandInteraction, + interaction: Command.ChatInputCommandInteraction ) { - const option = interaction.options.get('name'); - const tagName = option?.value as keyof typeof tags; + const tagName = interaction.options.getString('name', true); const user = interaction.options.getUser('user'); - const tag = tags[tagName]; - const botCommandsOnly = tag.botCommandsOnly !== false; // Defaults to true if missing - - // Role restriction check (uncomment and use if needed) - // if (tag.requiredRoles) { - // const member = await interaction.guild?.members.fetch(interaction.user.id); - // const hasRole = member?.roles.cache.some(role => - // tag.requiredRoles.includes(role.name) || tag.requiredRoles.includes(role.id) - // ); - // if (!hasRole) { - // return interaction.reply({ - // content: "You do not have permission to use this tag.", - // ephemeral: true, - // }); - // } - // } - // If tag is allowed in any channel, reply there - if (!botCommandsOnly) { - if (typeof tag === 'object' && tag.content) { - return interaction.reply({ - content: - typeof tag.content === 'function' - ? tag.content(user?.id) - : tag.content, - ephemeral: false, - }); - } else if (typeof tag === 'string') { - return interaction.reply({ content: tag, ephemeral: false }); - } - } - - // Tag not found - if (!tag) { + const tag = tags[tagName]; + const payload = resolveTag(tagName, user?.id); + if (!tag || !payload) return interaction.reply({ content: 'That tag does not exist.', - ephemeral: true, + flags: MessageFlags.Ephemeral, }); - } - // Fetch bot-commands channel + // Ping the requested user when the tag didn't already include a mention. + if (user && !payload.content) payload.content = `<@${user.id}>`; - const botCommandsChannel = await interaction.guild?.channels.fetch( - BOT_COMMANDS_CHANNEL_ID, - ); + // Tags default to bot-commands-channel-only unless they opt out. + if (tag.botCommandsOnly === false) return interaction.reply(payload); - if (!botCommandsChannel || !(botCommandsChannel instanceof TextChannel)) { + const botCommandsChannel = await interaction.client.channels + .fetch(BOT_COMMANDS_CHANNEL_ID) + .catch(() => null); + if (!botCommandsChannel?.isSendable()) return interaction.reply({ - content: 'Bot commands channel not found or is not a text channel.', - ephemeral: true, + content: 'The bot-commands channel is unavailable.', + flags: MessageFlags.Ephemeral, }); - } - let messagePayload: any = {}; + await botCommandsChannel.send(payload); - if (typeof tag === 'object') { - messagePayload = { ...tag }; - // If tag.content is a function, call it with user id - if (tag.content) { - messagePayload.content = - typeof tag.content === 'function' - ? tag.content(user?.id) - : tag.content; - } - if (user && !messagePayload.content) { - messagePayload.content = `<@${user.id}>`; - } - } else { - messagePayload.content = user ? `<@${user.id}> ${tag}` : tag; - } - - await botCommandsChannel.send(messagePayload); - - if (user) { - // Notify the user in the original channel (not ephemeral) + if (user) return interaction.reply({ content: `<@${user.id}> you've been tagged with standard helpful info.`, - ephemeral: false, embeds: [ new EmbedBuilder(universalEmbed) .setTitle('Your answer is in the Bot-Commands Channel...') - .setDescription(`See <#${BOT_COMMANDS_CHANNEL_ID}> for your info!`), //todo: 'info about <@$tagName}>!' would be better + .setDescription(`See <#${BOT_COMMANDS_CHANNEL_ID}> for your info!`), ], }); - } else { - // Ephemeral reply for normal tag - return interaction.reply({ - content: ``, - ephemeral: true, - embeds: [ - new EmbedBuilder(universalEmbed) - .setTitle('Requested info was sent in the Bot-Commands Channel') - .setDescription( - `See <#${BOT_COMMANDS_CHANNEL_ID}>... only you can see this message...`, - ), - ], - }); - } + return interaction.reply({ + embeds: [ + new EmbedBuilder(universalEmbed) + .setTitle('Requested info was sent in the Bot-Commands Channel') + .setDescription(`See <#${BOT_COMMANDS_CHANNEL_ID}> for your info!`), + ], + flags: MessageFlags.Ephemeral, + }); } } diff --git a/src/index.ts b/src/index.ts index 8951206..9a6693d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,7 @@ import { SapphireClient, Logger, LogLevel } from '@sapphire/framework'; -import { EmbedBuilder, GatewayIntentBits } from 'discord.js'; +import { ActivityType, GatewayIntentBits } from 'discord.js'; import { BOT_TOKEN } from './utils/config'; import { version } from '../package.json'; -// import './utils/db'; const logger = new Logger(LogLevel.Info); @@ -12,18 +11,14 @@ const client = new SapphireClient({ GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMembers, GatewayIntentBits.GuildPresences, + GatewayIntentBits.GuildInvites, GatewayIntentBits.MessageContent, GatewayIntentBits.DirectMessages, ], presence: { - activities: [{ - name: `/help | v${version}`, - type: 3 - }] - } -}) + activities: [{ name: `/tag β€’ v${version}`, type: ActivityType.Watching }], + }, +}); -logger.info('Attempting to connect to discord client...') -client.login(BOT_TOKEN) - -export default (new EmbedBuilder().setFooter({ text: 'Arduino Bot β€’ GPL-3.0 β€’ /tag' }).setColor('#dc5b05').toJSON()) \ No newline at end of file +logger.info('Attempting to connect to discord client...'); +void client.login(BOT_TOKEN); diff --git a/src/interaction-handlers/roleSelect.ts b/src/interaction-handlers/roleSelect.ts new file mode 100644 index 0000000..a16bef4 --- /dev/null +++ b/src/interaction-handlers/roleSelect.ts @@ -0,0 +1,85 @@ +import { + InteractionHandler, + InteractionHandlerTypes, +} from '@sapphire/framework'; +import { + EmbedBuilder, + MessageFlags, + type ButtonInteraction, + type GuildMember, +} from 'discord.js'; +import { + EVENT_NOTIFS_ROLE_ID, + ROLE_SELECT_MESSAGE_ID, + SERVER_UPDATE_NOTIFS_ROLE_ID, +} from '../utils/config'; +import universalEmbed from '../utils/embed'; + +interface RoleToggle { + roleId: string; + label: string; +} + +/** Self-assignable opt-in roles, keyed by the button's custom id. */ +function roleForButton(customId: string): RoleToggle | null { + if (customId === 'events' && EVENT_NOTIFS_ROLE_ID) + return { roleId: EVENT_NOTIFS_ROLE_ID, label: 'event notification' }; + if (customId === 'server_updates' && SERVER_UPDATE_NOTIFS_ROLE_ID) + return { + roleId: SERVER_UPDATE_NOTIFS_ROLE_ID, + label: 'server update notification', + }; + return null; +} + +/** + * Toggles opt-in notification roles when a member clicks a button on the + * configured role-select message. Disabled unless ROLE_SELECT_MESSAGE_ID and + * the corresponding role id are set. + */ +export class RoleSelectHandler extends InteractionHandler { + public constructor( + context: InteractionHandler.LoaderContext, + options: InteractionHandler.Options + ) { + super(context, { + ...options, + interactionHandlerType: InteractionHandlerTypes.Button, + }); + } + + public override parse(interaction: ButtonInteraction) { + if (!ROLE_SELECT_MESSAGE_ID) return this.none(); + if (interaction.message.id !== ROLE_SELECT_MESSAGE_ID) return this.none(); + const toggle = roleForButton(interaction.customId); + return toggle ? this.some(toggle) : this.none(); + } + + public async run(interaction: ButtonInteraction, toggle: RoleToggle) { + const member = interaction.member as GuildMember | null; + if (!member) + return interaction.reply({ + content: 'This only works inside the server.', + flags: MessageFlags.Ephemeral, + }); + + const had = member.roles.cache.has(toggle.roleId); + try { + if (had) await member.roles.remove(toggle.roleId); + else await member.roles.add(toggle.roleId); + } catch { + return interaction.reply({ + content: `I couldn't update your roles β€” please check my permissions or ask a moderator.`, + flags: MessageFlags.Ephemeral, + }); + } + + const embed = new EmbedBuilder(universalEmbed).setTitle( + `βœ… The ${toggle.label} role was ${had ? 'removed' : 'added'}.` + ); + return interaction.reply({ + embeds: [embed], + flags: MessageFlags.Ephemeral, + }); + } +} diff --git a/src/interaction-handlers/solvedButton.ts b/src/interaction-handlers/solvedButton.ts new file mode 100644 index 0000000..b31522a --- /dev/null +++ b/src/interaction-handlers/solvedButton.ts @@ -0,0 +1,70 @@ +import { + InteractionHandler, + InteractionHandlerTypes, +} from '@sapphire/framework'; +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + EmbedBuilder, + MessageFlags, + PermissionFlagsBits, + type ButtonInteraction, +} from 'discord.js'; +import universalEmbed from '../utils/embed'; +import { applySolved, canMarkSolved } from '../utils/solveThread'; + +/** Handles the "Mark Solved" button posted in help threads (forum or text). */ +export class SolvedButtonHandler extends InteractionHandler { + public constructor( + context: InteractionHandler.LoaderContext, + options: InteractionHandler.Options + ) { + super(context, { + ...options, + interactionHandlerType: InteractionHandlerTypes.Button, + }); + } + + public override parse(interaction: ButtonInteraction) { + return interaction.customId === 'solved' ? this.some() : this.none(); + } + + public async run(interaction: ButtonInteraction) { + const channel = interaction.channel; + if (!channel?.isThread()) + return interaction.reply({ + content: 'This button only works inside a help post.', + flags: MessageFlags.Ephemeral, + }); + + const isStaff = + interaction.memberPermissions?.has(PermissionFlagsBits.ManageMessages) ?? + false; + const check = canMarkSolved(channel, interaction.user.id, isStaff); + if (!check.ok) + return interaction.reply({ + content: check.reason, + flags: MessageFlags.Ephemeral, + }); + + const embed = new EmbedBuilder(universalEmbed) + .setTitle('βœ… Marked solved') + .setDescription(`Closed by <@${interaction.user.id}>.`); + await interaction.reply({ embeds: [embed] }); + + // Disable the button so it can't be clicked again, then close the thread. + const disabledRow = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('solved') + .setLabel('βœ… Solved') + .setStyle(ButtonStyle.Success) + .setDisabled(true) + ); + await interaction.message + .edit({ components: [disabledRow] }) + .catch(() => null); + await applySolved(channel); + return undefined; + } +} diff --git a/src/interaction-handlers/spamModeration.ts b/src/interaction-handlers/spamModeration.ts new file mode 100644 index 0000000..e3a1130 --- /dev/null +++ b/src/interaction-handlers/spamModeration.ts @@ -0,0 +1,186 @@ +import { + InteractionHandler, + InteractionHandlerTypes, + container, +} from '@sapphire/framework'; +import { + ActionRowBuilder, + ButtonBuilder, + ComponentType, + EmbedBuilder, + MessageFlags, + PermissionFlagsBits, + type ButtonComponent, + type ButtonInteraction, +} from 'discord.js'; +import { deleteIncident, getIncident } from '../utils/automod/incidents'; +import { addToBlocklist } from '../utils/automod/blocklist'; +import { + banMember, + deleteIncidentMessages, + logModerationAction, + timeoutMember, +} from '../utils/automod/console'; +import { clearUser } from '../utils/automod/tracker'; +import { clearFloodUser } from '../utils/automod/flood'; +import { clearCrosspostUser } from '../utils/automod/crosspost'; + +interface ParsedButton { + action: string; + incidentId: string; +} + +export class SpamModerationHandler extends InteractionHandler { + public constructor( + context: InteractionHandler.LoaderContext, + options: InteractionHandler.Options + ) { + super(context, { + ...options, + interactionHandlerType: InteractionHandlerTypes.Button, + }); + } + + public override parse(interaction: ButtonInteraction) { + if (!interaction.customId.startsWith('automod:')) return this.none(); + const [, action, incidentId] = interaction.customId.split(':'); + return this.some({ action, incidentId }); + } + + public async run(interaction: ButtonInteraction, parsed: ParsedButton) { + // Only staff (anyone who can delete messages) may action alerts. + if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageMessages)) + return interaction.reply({ + content: 'You need the Manage Messages permission to action spam alerts.', + flags: MessageFlags.Ephemeral, + }); + + const incident = getIncident(parsed.incidentId); + if (!incident || !interaction.guild) + return interaction.reply({ + content: + 'This alert has expired (the incident is no longer tracked, likely due to a bot restart). Please action the user manually.', + flags: MessageFlags.Ephemeral, + }); + + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + const { guild } = interaction; + const moderator = interaction.user; + let summary: string; + + switch (parsed.action) { + case 'confirm': { + const deleted = await deleteIncidentMessages( + container.client, + incident + ); + const timedOut = await timeoutMember( + guild, + incident.userId, + `Confirmed image spam by ${moderator.tag}` + ); + await addToBlocklist( + incident.signatures, + incident.hashes, + moderator.id, + 'confirmed image spam' + ); + clearUser(incident.userId); + clearFloodUser(incident.userId); + clearCrosspostUser(incident.userId); + const fingerprints = incident.signatures.length + incident.hashes.length; + const blocklisted = fingerprints + ? `, and blocklisted ${fingerprints} image fingerprint(s)` + : ''; + summary = `βœ… Confirmed spam β€” deleted ${deleted} message(s), ${ + timedOut ? 'timed out the user' : '**could not** time out the user' + }${blocklisted}.`; + break; + } + case 'timeout': { + const ok = await timeoutMember( + guild, + incident.userId, + `Automod review by ${moderator.tag}` + ); + summary = ok + ? '⏳ User timed out.' + : '⚠️ Could not time out the user (check role hierarchy and permissions).'; + break; + } + case 'ban': { + const ok = await banMember( + guild, + incident.userId, + `Automod review by ${moderator.tag}` + ); + clearUser(incident.userId); + clearFloodUser(incident.userId); + clearCrosspostUser(incident.userId); + summary = ok + ? 'πŸ”¨ User banned and recent messages purged.' + : '⚠️ Could not ban the user (check role hierarchy and permissions).'; + break; + } + case 'delete': { + const deleted = await deleteIncidentMessages( + container.client, + incident + ); + summary = `πŸ—‘οΈ Deleted ${deleted} message(s).`; + break; + } + case 'dismiss': { + clearUser(incident.userId); + clearFloodUser(incident.userId); + clearCrosspostUser(incident.userId); + summary = + 'πŸ‘Œ Marked as not spam. Cleared tracking for this user; no action taken.'; + break; + } + default: + summary = 'Unknown action.'; + } + + await logModerationAction( + moderator.id, + incident.userId, + parsed.action, + incident.reason + ); + deleteIncident(parsed.incidentId); + await this.resolveAlert(interaction, summary); + return interaction.editReply({ content: summary }); + } + + /** Disable the alert's buttons and stamp it with the resolution. */ + private async resolveAlert( + interaction: ButtonInteraction, + summary: string + ): Promise { + const disabledRows = interaction.message.components + .filter((row) => row.type === ComponentType.ActionRow) + .map((row) => { + const rebuilt = new ActionRowBuilder(); + for (const component of row.components) + if (component.type === ComponentType.Button) + rebuilt.addComponents( + ButtonBuilder.from(component as ButtonComponent).setDisabled(true) + ); + return rebuilt; + }); + + const original = interaction.message.embeds[0]; + const embed = (original ? EmbedBuilder.from(original) : new EmbedBuilder()) + .setColor(0x868e96) + .addFields({ + name: 'Resolution', + value: `${summary}\nActioned by <@${interaction.user.id}>`, + }); + + await interaction.message + .edit({ embeds: [embed], components: disabledRows }) + .catch(() => null); + } +} diff --git a/src/interaction-handlers/tagButton.ts b/src/interaction-handlers/tagButton.ts new file mode 100644 index 0000000..4df143c --- /dev/null +++ b/src/interaction-handlers/tagButton.ts @@ -0,0 +1,39 @@ +import { + InteractionHandler, + InteractionHandlerTypes, +} from '@sapphire/framework'; +import { MessageFlags, type ButtonInteraction } from 'discord.js'; +import { resolveTag } from '../utils/resolveTag'; + +/** + * Replies (ephemerally) with a tag when a `tag:` button is clicked β€” e.g. + * the "Learn How to Share Code" button on the `ask` tag opens the `codeblock` + * tag. This restores the legacy per-tag button-reply behaviour. + */ +export class TagButtonHandler extends InteractionHandler { + public constructor( + context: InteractionHandler.LoaderContext, + options: InteractionHandler.Options + ) { + super(context, { + ...options, + interactionHandlerType: InteractionHandlerTypes.Button, + }); + } + + public override parse(interaction: ButtonInteraction) { + if (!interaction.customId.startsWith('tag:')) return this.none(); + return this.some(interaction.customId.slice('tag:'.length)); + } + + public async run(interaction: ButtonInteraction, tagName: string) { + const payload = resolveTag(tagName); + if (!payload) + return interaction.reply({ + content: 'That tag no longer exists.', + flags: MessageFlags.Ephemeral, + }); + + return interaction.reply({ ...payload, flags: MessageFlags.Ephemeral }); + } +} diff --git a/src/listeners/crosspost.ts b/src/listeners/crosspost.ts new file mode 100644 index 0000000..fba86d1 --- /dev/null +++ b/src/listeners/crosspost.ts @@ -0,0 +1,50 @@ +import { Events, Listener, container } from '@sapphire/framework'; +import { EmbedBuilder, type Message } from 'discord.js'; +import { CROSSPOST_LOG_CHANNEL_ID, crosspostChannelIds } from '../utils/config'; +import universalEmbed from '../utils/embed'; + +/** + * Auto-publishes (crossposts) messages in configured announcement/feed channels + * and logs the result, mirroring the legacy bot's behaviour. Disabled unless + * CROSSPOST_CHANNEL_IDS is set. + */ +export class CrosspostListener extends Listener { + public constructor(context: Listener.Context, options: Listener.Options) { + super(context, { ...options, event: Events.MessageCreate }); + } + + public async run(message: Message) { + if (crosspostChannelIds.length === 0) return; + if (!message.inGuild()) return; + if (!crosspostChannelIds.includes(message.channelId)) return; + if (!message.crosspostable) return; + + const channelName = + 'name' in message.channel ? message.channel.name : message.channelId; + + try { + await message.crosspost(); + await this.log(`Auto-crossposted in #${channelName}`); + } catch (error) { + container.logger.error(`Auto-crosspost failed in #${channelName}:`, error); + await this.log( + `Failed to auto-crosspost in #${channelName}`, + 'Likely rate-limiting β€” check the logs for details.' + ); + } + } + + private async log(title: string, description?: string): Promise { + if (!CROSSPOST_LOG_CHANNEL_ID) return; + const channel = await container.client.channels + .fetch(CROSSPOST_LOG_CHANNEL_ID) + .catch(() => null); + if (!channel?.isSendable()) return; + + const embed = new EmbedBuilder(universalEmbed) + .setTitle(title) + .setTimestamp(); + if (description) embed.setDescription(description); + await channel.send({ embeds: [embed] }).catch(() => null); + } +} diff --git a/src/listeners/inviteCreate.ts b/src/listeners/inviteCreate.ts new file mode 100644 index 0000000..e6bbcfd --- /dev/null +++ b/src/listeners/inviteCreate.ts @@ -0,0 +1,14 @@ +import { Events, Listener } from '@sapphire/framework'; +import type { Invite } from 'discord.js'; +import { setInviteUses } from '../utils/inviteCache'; + +/** Seed newly-created invites into the cache so join attribution stays accurate. */ +export class InviteCreateListener extends Listener { + public constructor(context: Listener.Context, options: Listener.Options) { + super(context, { ...options, event: Events.InviteCreate }); + } + + public run(invite: Invite) { + setInviteUses(invite.code, invite.uses ?? 0); + } +} diff --git a/src/listeners/memberAdd.ts b/src/listeners/memberAdd.ts index 399e0e2..e86f713 100644 --- a/src/listeners/memberAdd.ts +++ b/src/listeners/memberAdd.ts @@ -1,6 +1,14 @@ -import { Events, Listener } from '@sapphire/framework'; -import type { GuildMember } from 'discord.js'; -// import { prisma } from '../utils/db'; +import { Events, Listener, container } from '@sapphire/framework'; +import { + EmbedBuilder, + TimestampStyles, + time, + type GuildMember, +} from 'discord.js'; +import { JOIN_LEAVE_LOG_CHANNEL_ID } from '../utils/config'; +import { getInviteUses, setInviteUses } from '../utils/inviteCache'; +import { getPrisma } from '../utils/db'; +import universalEmbed from '../utils/embed'; export class MemberAddListener extends Listener { public constructor(context: Listener.Context, options: Listener.Options) { @@ -8,8 +16,75 @@ export class MemberAddListener extends Listener { } public async run(member: GuildMember) { - // await prisma.memberAnalytics.create({ - // data: { event: 'join', memberId: member.user.id }, - // }); + await this.recordAnalytics(member.id); + await this.logJoin(member); + } + + private async recordAnalytics(memberId: string): Promise { + const prisma = getPrisma(); + if (!prisma) return; + try { + await prisma.memberAnalytics.create({ + data: { event: 'join', memberId }, + }); + } catch (error) { + container.logger.error('Recording join analytics failed:', error); + } + } + + private async logJoin(member: GuildMember): Promise { + if (!JOIN_LEAVE_LOG_CHANNEL_ID) return; + const channel = await container.client.channels + .fetch(JOIN_LEAVE_LOG_CHANNEL_ID) + .catch(() => null); + if (!channel?.isSendable()) return; + + const source = await this.resolveInviteSource(member); + const embed = new EmbedBuilder(universalEmbed) + .setTitle('πŸ“₯ Member joined') + .setAuthor({ + name: member.user.tag, + iconURL: member.displayAvatarURL(), + }) + .setThumbnail(member.displayAvatarURL()) + .setDescription(`<@${member.id}> β€” member #${member.guild.memberCount}`) + .addFields( + { + name: 'Account created', + value: time(member.user.createdAt, TimestampStyles.RelativeTime), + inline: true, + }, + { + name: 'Invite', + value: source ?? 'Unknown (Server Discovery or vanity URL)', + inline: true, + } + ) + .setFooter({ text: `ID: ${member.id}` }) + .setTimestamp(); + + await channel.send({ embeds: [embed] }).catch(() => null); + } + + /** + * Work out which invite was used by finding the one whose use count grew + * since the cached snapshot, then refresh the cache for every invite. + */ + private async resolveInviteSource( + member: GuildMember + ): Promise { + let source: string | null = null; + try { + const invites = await member.guild.invites.fetch(); + for (const invite of invites.values()) { + const previous = getInviteUses(invite.code) ?? 0; + if (!source && invite.uses !== null && invite.uses > previous) + source = `\`${invite.code}\` by ${invite.inviter?.tag ?? 'unknown'}`; + setInviteUses(invite.code, invite.uses ?? 0); + } + } catch (error) { + container.logger.warn('Could not resolve invite source on join:', error); + } + return source; } } diff --git a/src/listeners/memberRemove.ts b/src/listeners/memberRemove.ts index 0c9e6a9..f7172eb 100644 --- a/src/listeners/memberRemove.ts +++ b/src/listeners/memberRemove.ts @@ -1,15 +1,64 @@ -import { Events, Listener } from '@sapphire/framework'; -import type { GuildMember } from 'discord.js'; -// import { prisma } from '../utils/db'; +import { Events, Listener, container } from '@sapphire/framework'; +import { + EmbedBuilder, + TimestampStyles, + time, + type GuildMember, + type PartialGuildMember, +} from 'discord.js'; +import { JOIN_LEAVE_LOG_CHANNEL_ID } from '../utils/config'; +import { getPrisma } from '../utils/db'; +import universalEmbed from '../utils/embed'; export class MemberRemoveListener extends Listener { public constructor(context: Listener.Context, options: Listener.Options) { super(context, { ...options, event: Events.GuildMemberRemove }); } - public async run(member: GuildMember) { - // await prisma.memberAnalytics.create({ - // data: { event: 'leave', memberId: member.user.id }, - // }); + public async run(member: GuildMember | PartialGuildMember) { + await this.recordAnalytics(member.id); + await this.logLeave(member); + } + + private async recordAnalytics(memberId: string): Promise { + const prisma = getPrisma(); + if (!prisma) return; + try { + await prisma.memberAnalytics.create({ + data: { event: 'leave', memberId }, + }); + } catch (error) { + container.logger.error('Recording leave analytics failed:', error); + } + } + + private async logLeave( + member: GuildMember | PartialGuildMember + ): Promise { + if (!JOIN_LEAVE_LOG_CHANNEL_ID) return; + const channel = await container.client.channels + .fetch(JOIN_LEAVE_LOG_CHANNEL_ID) + .catch(() => null); + if (!channel?.isSendable()) return; + + const embed = new EmbedBuilder(universalEmbed) + .setTitle('πŸ“€ Member left') + .setAuthor({ + name: member.user.tag, + iconURL: member.displayAvatarURL(), + }) + .setDescription(`<@${member.id}>`) + .setFooter({ text: `ID: ${member.id}` }) + .setTimestamp(); + + // joinedAt is null for partials/uncached members. + if (member.joinedAt) + embed.addFields({ + name: 'Joined', + value: time(member.joinedAt, TimestampStyles.RelativeTime), + inline: true, + }); + + await channel.send({ embeds: [embed] }).catch(() => null); } } diff --git a/src/listeners/messageCreate.ts b/src/listeners/messageCreate.ts index 2f3dfac..fe40d71 100644 --- a/src/listeners/messageCreate.ts +++ b/src/listeners/messageCreate.ts @@ -1,6 +1,35 @@ -import { Events, Listener } from '@sapphire/framework'; -import type { Message } from 'discord.js'; -// import { prisma } from '../utils/db'; +import { Events, Listener, container } from '@sapphire/framework'; +import { PermissionFlagsBits, type GuildMember, type Message } from 'discord.js'; +import { SERVER_ID, MOD_LOG_CHANNEL_ID, automodConfig } from '../utils/config'; +import { imageSignatures } from '../utils/automod/signature'; +import { perceptualHashes } from '../utils/automod/phash'; +import { + recordImageMessage, + shouldAlert, + markAlerted, +} from '../utils/automod/tracker'; +import { + recordShortMessage, + shouldAlertFlood, + markFloodAlerted, +} from '../utils/automod/flood'; +import { + recordTextMessage, + shouldAlertCrosspost, + markCrosspostAlerted, + tokenize, +} from '../utils/automod/crosspost'; +import { isBlocklisted } from '../utils/automod/blocklist'; +import { + createIncident, + type Incident, + type IncidentLevel, +} from '../utils/automod/incidents'; +import { + buildAlertPayload, + deleteIncidentMessages, + timeoutMember, +} from '../utils/automod/console'; export class MessageCreateListener extends Listener { public constructor(context: Listener.Context, options: Listener.Options) { @@ -8,8 +37,238 @@ export class MessageCreateListener extends Listener { } public async run(message: Message) { - // await prisma.messageAnalytics.create({ - // data: { memberId: message.author.id, channelId: message.channel.id }, - // }); + if (!message.inGuild() || message.author.bot) return; + if (message.guildId !== SERVER_ID) return; + // Without a console channel there is nowhere to surface alerts. + if (!MOD_LOG_CHANNEL_ID) return; + if (message.member && this.isImmune(message.member)) return; + + // Text-flooding and cross-channel question spam run independently of the + // image automod below: they key off message text, not attachments. + await this.runFlood(message); + await this.runCrosspost(message); + + const signatures = imageSignatures(message); + if (signatures.length === 0) return; + + const now = Date.now(); + // Perceptual hashes are best-effort (network fetch + decode); the metadata + // signatures still cover anything that fails to hash. + const hashes = await perceptualHashes(message); + // New members get a stricter burst threshold so join-then-spam trips + // faster; tenure is useless for compromised veterans, who are instead + // caught by the fan-out/blocklist signals below. + const isNewMember = this.isNewMember(message.member, now); + const detection = recordImageMessage( + message.author.id, + { + at: now, + channelId: message.channelId, + messageId: message.id, + signatures, + hashes, + }, + isNewMember + ? { burstThreshold: automodConfig.newMemberBurstThreshold } + : {} + ); + + const { blocked, matched } = isBlocklisted(signatures, hashes); + + let level: IncidentLevel | null = null; + if (blocked) level = 'blocklist'; + else if (detection.level !== 'none') level = detection.level; + if (!level) return; + + if (!shouldAlert(message.author.id, now)) return; + markAlerted(message.author.id, now); + + // For blocklist-only hits the detector found no cluster, so the incident is + // just the current message; otherwise use the contributing events. + const messages = + detection.level === 'none' + ? [{ channelId: message.channelId, messageId: message.id }] + : detection.events.map((e) => ({ + channelId: e.channelId, + messageId: e.messageId, + })); + + const incident = createIncident({ + userId: message.author.id, + guildId: message.guildId, + level, + reason: + level === 'blocklist' + ? `Matched ${matched.length} known spam image${matched.length === 1 ? '' : 's'}` + : detection.reason, + messages, + // For a fresh detection, blocklist the fingerprints that triggered it. + // For a blocklist hit, reinforce with this message's fingerprints. + signatures: level === 'blocklist' ? signatures : detection.signatures, + hashes: level === 'blocklist' ? hashes : detection.hashes, + }); + + // Tiered enforcement: high-confidence signals act immediately; a + // same-channel burst only alerts and waits for a human. + const highConfidence = level === 'fanout' || level === 'blocklist'; + let autoActed = false; + if (highConfidence) { + const deleted = await deleteIncidentMessages( + container.client, + incident + ).catch(() => 0); + const timedOut = await timeoutMember( + message.guild, + message.author.id, + `Automod: ${incident.reason}` + ).catch(() => false); + autoActed = deleted > 0 || timedOut; + } + + await this.postAlert(message, incident, autoActed); + } + + /** + * Detect text flooding: many short messages from one user in quick + * succession. Only short, non-empty text counts β€” pure-image messages are + * left to the image automod above so the two detectors don't double up. + */ + private async runFlood(message: Message): Promise { + if (!automodConfig.floodEnabled) return; + + const content = message.content.trim(); + if (content.length === 0 || content.length > automodConfig.floodMaxChars) + return; + + const now = Date.now(); + const detection = recordShortMessage(message.author.id, { + at: now, + channelId: message.channelId, + messageId: message.id, + }); + if (!detection.flooded) return; + if (!shouldAlertFlood(message.author.id, now)) return; + markFloodAlerted(message.author.id, now); + + const incident = createIncident({ + userId: message.author.id, + guildId: message.guildId, + level: 'flood', + reason: detection.reason, + messages: detection.messages.map((m) => ({ + channelId: m.channelId, + messageId: m.messageId, + })), + // Flooding leaves no image fingerprints to blocklist. + signatures: [], + hashes: [], + }); + + // Flooding is usually a habit, not an attack, so we only auto-act when a + // server opts in; otherwise a human decides from the console. + let autoActed = false; + if (automodConfig.floodAutoTimeout) + autoActed = await timeoutMember( + message.guild, + message.author.id, + `Automod: ${incident.reason}` + ).catch(() => false); + + await this.postAlert(message, incident, autoActed); + } + + /** + * Detect a user fanning the same question across many channels β€” the classic + * new-joiner "ask everywhere at once" pattern. High-confidence near-identical + * repeats auto-delete the duplicate copies (keeping the first); the broader + * new-member spread signal only alerts. Tenure is read the same way as the + * image automod: new members are scrutinised harder. + */ + private async runCrosspost(message: Message): Promise { + if (!automodConfig.crosspostEnabled) return; + + const content = message.content.trim(); + if (content.length < automodConfig.crosspostMinChars) return; + + const now = Date.now(); + const detection = recordTextMessage( + message.author.id, + { + at: now, + channelId: message.channelId, + messageId: message.id, + tokens: tokenize(content), + }, + { isNewMember: this.isNewMember(message.member, now) } + ); + if (!detection) return; + if (!shouldAlertCrosspost(message.author.id, now)) return; + markCrosspostAlerted(message.author.id, now); + + const messages = detection.messages.map((m) => ({ + channelId: m.channelId, + messageId: m.messageId, + })); + const incident = createIncident({ + userId: message.author.id, + guildId: message.guildId, + level: 'crosspost', + reason: detection.reason, + messages, + // Cross-posting leaves no image fingerprints to blocklist. + signatures: [], + hashes: [], + }); + + // Auto-delete only genuine duplicates: for a near-identical fan-out, drop + // every copy but the first. The content-agnostic spread signal isn't a set + // of duplicates, so it only alerts and waits for a human. + let autoActed = false; + if (detection.kind === 'similar' && messages.length > 1) { + const deleted = await deleteIncidentMessages(container.client, { + ...incident, + messages: messages.slice(1), + }).catch(() => 0); + autoActed = deleted > 0; + } + + await this.postAlert(message, incident, autoActed); + } + + private isImmune(member: GuildMember): boolean { + if (member.permissions.has(PermissionFlagsBits.ManageMessages)) return true; + return automodConfig.immuneRoleIds.some((roleId) => + member.roles.cache.has(roleId) + ); + } + + /** Whether a member is still within the configured "new member" window. */ + private isNewMember(member: GuildMember | null, now: number): boolean { + return Boolean( + member?.joinedTimestamp && + now - member.joinedTimestamp < automodConfig.newMemberWindowMs + ); + } + + private async postAlert( + message: Message, + incident: Incident, + autoActed: boolean + ): Promise { + const channel = await container.client.channels + .fetch(MOD_LOG_CHANNEL_ID) + .catch(() => null); + if (!channel || !channel.isSendable()) { + container.logger.warn( + `Automod: MOD_LOG_CHANNEL_ID ${MOD_LOG_CHANNEL_ID} is not a sendable channel; cannot post alert.` + ); + return; + } + + const member = + message.member ?? + (await message.guild.members.fetch(message.author.id).catch(() => null)); + + await channel.send(buildAlertPayload(incident, member, autoActed)); } } diff --git a/src/listeners/messageLinkEmbed.ts b/src/listeners/messageLinkEmbed.ts new file mode 100644 index 0000000..15a7301 --- /dev/null +++ b/src/listeners/messageLinkEmbed.ts @@ -0,0 +1,50 @@ +import { Events, Listener } from '@sapphire/framework'; +import { EmbedBuilder, type Message } from 'discord.js'; +import universalEmbed from '../utils/embed'; + +// Matches https://discord.com/channels/// (and the +// canary/ptb subdomains). IDs are 17-20 digits to be future-proof. +const MESSAGE_LINK = + /https?:\/\/(?:canary\.|ptb\.)?discord\.com\/channels\/(\d{17,20})\/(\d{17,20})\/(\d{17,20})/g; + +/** + * When a user posts a link to another message in this server, re-posts that + * message's content inline as a quote embed for context. Same-guild only. + */ +export class MessageLinkEmbedListener extends Listener { + public constructor(context: Listener.Context, options: Listener.Options) { + super(context, { ...options, event: Events.MessageCreate }); + } + + public async run(message: Message) { + if (!message.inGuild() || message.author.bot) return; + + const matches = [...message.content.matchAll(MESSAGE_LINK)]; + if (matches.length === 0) return; + + for (const [, guildId, channelId, messageId] of matches) { + if (guildId !== message.guildId) continue; + + const channel = await message.client.channels + .fetch(channelId) + .catch(() => null); + if (!channel?.isTextBased() || channel.isDMBased()) continue; + + const linked = await channel.messages.fetch(messageId).catch(() => null); + if (!linked || (!linked.content && linked.embeds.length === 0)) continue; + + const embed = new EmbedBuilder(universalEmbed) + .setAuthor({ + name: `${linked.author.tag} said:`, + iconURL: linked.author.displayAvatarURL(), + }) + .setDescription(linked.content || '*[no text content]*') + .setFooter({ + text: `Quoted by ${message.author.tag} β€’ click the link for full context`, + }) + .setTimestamp(linked.createdAt); + + await message.channel.send({ embeds: [embed] }).catch(() => null); + } + } +} diff --git a/src/listeners/ready.ts b/src/listeners/ready.ts index 2408e55..f1b0807 100644 --- a/src/listeners/ready.ts +++ b/src/listeners/ready.ts @@ -1,15 +1,47 @@ import { Events, Listener } from '@sapphire/framework'; import type { Client } from 'discord.js'; +import { initDatabase } from '../utils/db'; +import { loadBlocklist } from '../utils/automod/blocklist'; +import { seedInviteCache } from '../utils/inviteCache'; +import { startStaleHelpSweep } from '../utils/staleHelpSweep'; +import { JOIN_LEAVE_LOG_CHANNEL_ID, SERVER_ID } from '../utils/config'; export class ReadyListener extends Listener { public constructor(context: Listener.Context, options: Listener.Options) { super(context, { ...options, once: true, event: Events.ClientReady }); } - public run({ user }: Client) { - const { username, id, discriminator } = user!; + public async run(client: Client) { + const { username, id, discriminator } = client.user; this.container.logger.info( `Logged in as ${username}#${discriminator} (${id})` ); + + // Connect persistence if configured; the bot runs in-memory otherwise. + await initDatabase(); + // Warm the automod blocklist from the database (no-op without one). + await loadBlocklist(); + // Seed the invite-use cache so join logging can attribute the source. + await this.fillInviteCache(client); + // Begin nudging/auto-archiving stale help posts (no-op unless configured). + startStaleHelpSweep(client); + } + + private async fillInviteCache(client: Client): Promise { + // Only needed when join logging is enabled; fetching invites requires the + // Manage Server permission, so failures are non-fatal. + if (!JOIN_LEAVE_LOG_CHANNEL_ID) return; + const guild = await client.guilds.fetch(SERVER_ID).catch(() => null); + if (!guild) return; + try { + const invites = await guild.invites.fetch(); + seedInviteCache(invites.values()); + this.container.logger.info(`Invite cache filled (${invites.size} invites).`); + } catch (error) { + this.container.logger.warn( + 'Could not fetch invites for join tracking (missing Manage Server permission?):', + error + ); + } } } diff --git a/src/listeners/tagSuggest.ts b/src/listeners/tagSuggest.ts new file mode 100644 index 0000000..918064a --- /dev/null +++ b/src/listeners/tagSuggest.ts @@ -0,0 +1,166 @@ +import { Events, Listener } from '@sapphire/framework'; +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + EmbedBuilder, + type Message, +} from 'discord.js'; +import { + SERVER_ID, + tagSuggestEnabled, + codeFormatSuggestEnabled, + askSuggestEnabled, + suggestIgnoreChannelIds, + suggestImmuneRoleIds, +} from '../utils/config'; +import tags, { type Tag, type TagSuggestion } from '../utils/tags'; +import universalEmbed from '../utils/embed'; + +interface Suggestion { + tag: string; + prompt: string; + /** Label for the button that reveals the tag. */ + label: string; +} + +// Keyword -> tag signatures, collected from the tags that declare a `suggest` +// rule. Co-locating the trigger with the tag keeps adding one a single edit. +const keywordSuggestions = Object.entries(tags) + .filter( + (entry): entry is [string, Tag & { suggest: TagSuggestion }] => + Boolean(entry[1].suggest) + ) + .map(([tag, t]) => ({ tag, pattern: t.suggest.pattern, prompt: t.suggest.prompt })); + +const COOLDOWN_MS = 5 * 60_000; +const lastSuggested = new Map(); + +const ARDUINO_CODE = + /\b(void\s+setup\s*\(|void\s+loop\s*\(|#include\s*[<"]|pinMode\s*\(|digital(Write|Read)\s*\(|analog(Write|Read)\s*\(|Serial\.(begin|print))/; + +/** + * Heuristic for code pasted as plain text. Conservative: an unmistakable + * Arduino signature in a multi-line paste, or a sizeable multi-line blob dense + * with code punctuation. Anything already in a code fence is left alone. + */ +function looksLikeUnformattedCode(content: string): boolean { + if (content.includes('```')) return false; + const lines = content.split('\n').length; + const semicolons = (content.match(/;/g) ?? []).length; + const braces = (content.match(/[{}]/g) ?? []).length; + if (ARDUINO_CODE.test(content) && (lines >= 4 || semicolons >= 2)) return true; + return lines >= 5 && semicolons >= 3 && braces >= 2; +} + +// Short messages that are a request to ask / a ping for attention rather than +// an actual question. Anchored and length-bounded to limit false positives. +const LOW_EFFORT_ASK = [ + /^(can|could|may) (i|someone|anyone|u|you)\b.{0,20}\b(help|ask)\b/i, + /^(can|may) i ask( a)?( quick)?( question)?\s*\??$/i, + /^(is\s+)?(any\s?(one|body)|some\s?(one|body))\s+(here|around|online|there|available)\s*\??$/i, + /\b(any\s?(one|body)|some\s?(one|body))\b.{0,30}\b(good with|know about|help with)\b/i, + /^(help|help me|need help|i need help|pls help|please help)\b[!.\s]*$/i, +]; + +function looksLikeLowEffortAsk(content: string): boolean { + const text = content.trim(); + if (text.length > 80) return false; + return LOW_EFFORT_ASK.some((p) => p.test(text)); +} + +/** Pick at most one suggestion, in priority order, honouring per-type toggles. */ +function detect(content: string): Suggestion | null { + if (tagSuggestEnabled) { + const match = keywordSuggestions.find((s) => s.pattern.test(content)); + if (match) + return { tag: match.tag, prompt: match.prompt, label: 'Show steps' }; + } + if (codeFormatSuggestEnabled && looksLikeUnformattedCode(content)) + return { + tag: 'codeblock', + prompt: 'That looks like **unformatted code**.', + label: 'How to format code', + }; + if (askSuggestEnabled && looksLikeLowEffortAsk(content)) + return { + tag: 'ask', + prompt: + 'No need to ask to ask β€” just **post your question with details** and someone will help.', + label: 'How to ask', + }; + return null; +} + +/** + * Watches messages and, when one matches a high-precision signature, offers the + * relevant tag via a single button (never the full answer). Three detectors β€” + * keyword/error signatures, unformatted code, and low-effort "can I ask" pings β€” + * share one reply, one priority order, and one per-user cooldown. + */ +export class TagSuggestListener extends Listener { + public constructor(context: Listener.Context, options: Listener.Options) { + super(context, { ...options, event: Events.MessageCreate }); + } + + public async run(message: Message) { + if (!tagSuggestEnabled && !codeFormatSuggestEnabled && !askSuggestEnabled) + return; + if (!message.inGuild() || message.author.bot) return; + if (message.guildId !== SERVER_ID) return; + if (message.content.length < 10) return; + + // Members holding a recognised role (Trusted and above) don't need suggestions. + // Self-assignable notification roles are intentionally excluded from + // SUGGEST_IMMUNE_ROLE_IDS so those members are still served suggestions. + if ( + suggestImmuneRoleIds.length > 0 && + suggestImmuneRoleIds.some((id) => message.member?.roles.cache.has(id)) + ) + return; + + // Respect the ignore-channel list. Check both the message's channel and, + // for threads, the parent channel so an entire forum can be suppressed. + if (suggestIgnoreChannelIds.length > 0) { + if (suggestIgnoreChannelIds.includes(message.channelId)) return; + const parentId = message.channel.isThread() + ? message.channel.parentId + : null; + if (parentId && suggestIgnoreChannelIds.includes(parentId)) return; + } + + const suggestion = detect(message.content); + if (!suggestion) return; + + const now = Date.now(); + const last = lastSuggested.get(message.author.id); + if (last && now - last < COOLDOWN_MS) return; + lastSuggested.set(message.author.id, now); + + const embed = new EmbedBuilder(universalEmbed).setDescription( + `πŸ’‘ ${suggestion.prompt} Tap below for the details.` + ); + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`tag:${suggestion.tag}`) + .setLabel(suggestion.label) + .setStyle(ButtonStyle.Primary) + ); + + await message + .reply({ + embeds: [embed], + components: [row], + allowedMentions: { repliedUser: false }, + }) + .catch(() => null); + } +} + +// Drop stale cooldown entries so the map can't grow unbounded. +const sweep = setInterval(() => { + const horizon = Date.now() - COOLDOWN_MS; + for (const [userId, at] of lastSuggested) + if (at < horizon) lastSuggested.delete(userId); +}, 10 * 60_000); +sweep.unref(); diff --git a/src/listeners/threadCreate.ts b/src/listeners/threadCreate.ts new file mode 100644 index 0000000..357f41c --- /dev/null +++ b/src/listeners/threadCreate.ts @@ -0,0 +1,95 @@ +import { Events, Listener } from '@sapphire/framework'; +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + EmbedBuilder, + type AnyThreadChannel, +} from 'discord.js'; +import { helpChannelIds, helpAssistConfig } from '../utils/config'; +import universalEmbed from '../utils/embed'; + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +// Concise auto-needinfo text. Kept inline (not in tags.ts) so the full +// /tag needinfo content β€” used by helpers explicitly β€” stays unchanged. +const AUTO_NEEDINFO_TEXT = [ + '**To help us help you, please share:**', + 'β€’ What you want it to do vs. what\'s actually happening', + 'β€’ A photo of your project and a wiring diagram', + 'β€’ Your code in a **code block** (\\`\\`\\`)', + 'β€’ Any error messages', + '', + 'Once your question is answered, click **Mark Solved** below. πŸ› οΈ', +].join('\n'); + +const SOLVED_ONLY_TEXT = + 'When your question is answered, the original poster or a moderator can click **Mark Solved** to close this post. Use `/solved helper:@user` to also thank whoever helped. πŸ› οΈ'; + +const solvedRow = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('solved') + .setLabel('βœ… Mark Solved') + .setStyle(ButtonStyle.Success) +); + +/** + * On a new help thread β€” a forum post, or a thread opened inside a text help + * channel β€” sends a single message with the Mark Solved button and, when the + * opening post is thin, an inline needinfo checklist in the same embed. + * Disabled unless a help channel is configured (HELP_CHANNEL_IDS). + */ +export class ThreadCreateListener extends Listener { + public constructor(context: Listener.Context, options: Listener.Options) { + super(context, { ...options, event: Events.ThreadCreate }); + } + + public async run(thread: AnyThreadChannel, newlyCreated: boolean) { + if (!newlyCreated) return; + if (helpChannelIds.length === 0) return; + if (!thread.parentId || !helpChannelIds.includes(thread.parentId)) return; + + const thin = + helpAssistConfig.autoNeedinfo && (await this.isThinPost(thread)); + + const embed = new EmbedBuilder(universalEmbed).setDescription( + thin ? AUTO_NEEDINFO_TEXT : SOLVED_ONLY_TEXT + ); + + await thread + .send({ embeds: [embed], components: [solvedRow] }) + .catch(() => null); + } + + /** + * Returns true when the opening post lacks enough substance to get meaningful + * help without prompting. Errs strongly on the side of NOT firing: + * + * - Combines the thread title (forum post title) with the body length, so a + * descriptive title alone can clear the threshold. + * - A code block, inline code, an image attachment, or any URL counts as + * substantive content regardless of character count. + * - The threshold (HELP_NEEDINFO_MIN_CHARS, default 120) applies to + * title + body combined, not body alone. + */ + private async isThinPost(thread: AnyThreadChannel): Promise { + // Forum starter messages can lag a moment behind ThreadCreate. + let starter = await thread.fetchStarterMessage().catch(() => null); + if (!starter) { + await delay(1500); + starter = await thread.fetchStarterMessage().catch(() => null); + } + if (!starter) return false; // can't judge β€” leave it alone + + const body = starter.content.trim(); + + if (starter.attachments.size > 0) return false; + if (body.includes('```')) return false; + if (/`[^`\n]+`/.test(body)) return false; // inline code + if (/https?:\/\/\S+/.test(body)) return false; // any URL + + const titleLen = thread.name.trim().length; + const combined = titleLen + body.length; + return combined < helpAssistConfig.needinfoMinChars; + } +} diff --git a/src/utils/automod/blocklist.ts b/src/utils/automod/blocklist.ts new file mode 100644 index 0000000..1495168 --- /dev/null +++ b/src/utils/automod/blocklist.ts @@ -0,0 +1,98 @@ +import { container } from '@sapphire/framework'; +import { automodConfig } from '../config'; +import { getPrisma } from '../db'; +import { hammingDistance } from './phash'; + +/** + * In-memory mirror of the blocklist, kept warm so lookups never touch the + * database on the hot path. Populated from the database on startup (when one is + * configured) and updated immediately whenever a moderator confirms spam, so + * the feature works fully in-memory when no database is present. + */ +const exactSignatures = new Set(); +const perceptualHashes = new Set(); + +export type FingerprintKind = 'meta' | 'phash'; + +export interface BlocklistMatch { + blocked: boolean; + /** Fingerprints from the input that matched the blocklist. */ + matched: string[]; +} + +/** Load the persisted blocklist into memory. Safe to call with no database. */ +export async function loadBlocklist(): Promise { + const prisma = getPrisma(); + if (!prisma) return; + try { + const rows = await prisma.spamSignature.findMany({ + select: { signature: true, kind: true }, + }); + for (const { signature, kind } of rows) + (kind === 'phash' ? perceptualHashes : exactSignatures).add(signature); + container.logger.info( + `Automod: loaded ${exactSignatures.size} signature(s) and ${perceptualHashes.size} perceptual hash(es) from the blocklist.` + ); + } catch (error) { + container.logger.error('Loading blocklist failed:', error); + } +} + +/** + * Check incoming fingerprints against the blocklist. Metadata signatures match + * exactly; perceptual hashes match within the configured Hamming distance. + */ +export function isBlocklisted( + signatures: string[], + hashes: string[] +): BlocklistMatch { + const matched = new Set(); + + for (const signature of signatures) + if (exactSignatures.has(signature)) matched.add(signature); + + for (const hash of hashes) + for (const known of perceptualHashes) + if (hammingDistance(hash, known) <= automodConfig.phashThreshold) { + matched.add(hash); + break; + } + + return { blocked: matched.size > 0, matched: [...matched] }; +} + +/** Add fingerprints to the blocklist (in-memory always; persisted if possible). */ +export async function addToBlocklist( + signatures: string[], + hashes: string[], + addedBy: string, + reason: string +): Promise { + for (const signature of signatures) exactSignatures.add(signature); + for (const hash of hashes) perceptualHashes.add(hash); + + const prisma = getPrisma(); + if (!prisma) return; + + const rows = [ + ...signatures.map((signature) => ({ + signature, + kind: 'meta' as FingerprintKind, + addedBy, + reason, + })), + ...hashes.map((signature) => ({ + signature, + kind: 'phash' as FingerprintKind, + addedBy, + reason, + })), + ]; + if (rows.length === 0) return; + + try { + await prisma.spamSignature.createMany({ data: rows, skipDuplicates: true }); + } catch (error) { + container.logger.error('Persisting blocklist fingerprints failed:', error); + } +} diff --git a/src/utils/automod/console.ts b/src/utils/automod/console.ts new file mode 100644 index 0000000..cde8b8a --- /dev/null +++ b/src/utils/automod/console.ts @@ -0,0 +1,201 @@ +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + EmbedBuilder, + TimestampStyles, + time, + type Client, + type Guild, + type GuildMember, + type MessageCreateOptions, +} from 'discord.js'; +import { container } from '@sapphire/framework'; +import { automodConfig } from '../config'; +import { getPrisma } from '../db'; +import type { Incident, IncidentLevel } from './incidents'; + +const LEVEL_COLOR: Record = { + fanout: 0xe03131, // high confidence β€” red + blocklist: 0xe03131, + burst: 0xf08c00, // needs review β€” amber + flood: 0xf08c00, // needs review β€” amber + crosspost: 0xe03131, // cross-channel β€” red +}; + +const LEVEL_LABEL: Record = { + fanout: 'Cross-channel fan-out', + blocklist: 'Known spam image', + burst: 'Image burst', + flood: 'Message flooding', + crosspost: 'Cross-channel question spam', +}; + +/** Headline shown at the top of an alert, by incident kind. */ +const LEVEL_TITLE: Record = { + fanout: '🚨 Possible image spam', + blocklist: '🚨 Possible image spam', + burst: '🚨 Possible image spam', + flood: '🚨 Possible message flooding', + crosspost: '🚨 Possible cross-channel question spam', +}; + +/** What an automatic action did, shown when the bot acted before a human. */ +const HIGH_CONFIDENCE_NOTE = + 'High-confidence signal: the messages were deleted and the user was timed out automatically. Review and escalate or reverse below.'; +const AUTO_ACTION_NOTE: Record = { + fanout: HIGH_CONFIDENCE_NOTE, + blocklist: HIGH_CONFIDENCE_NOTE, + burst: HIGH_CONFIDENCE_NOTE, + flood: 'The user was timed out automatically. Review and escalate or reverse below.', + crosspost: + 'The duplicate crossposts were deleted automatically (the first copy was kept). Review and escalate or reverse below.', +}; + +/** One moderation action button bound to an incident id. */ +function actionButton( + action: string, + label: string, + style: ButtonStyle, + incidentId: string +): ButtonBuilder { + return new ButtonBuilder() + .setCustomId(`automod:${action}:${incidentId}`) + .setLabel(label) + .setStyle(style); +} + +/** Build the alert message moderators see in the console channel. */ +export function buildAlertPayload( + incident: Incident, + member: GuildMember | null, + autoActed: boolean +): MessageCreateOptions { + const channelMentions = + [...new Set(incident.messages.map((m) => `<#${m.channelId}>`))].join(' ') || + 'β€”'; + + const jumpLinks = + incident.messages + .slice(0, 5) + .map( + (m, i) => + `[#${i + 1}](https://discord.com/channels/${incident.guildId}/${m.channelId}/${m.messageId})` + ) + .join(' β€’ ') || 'β€”'; + + const embed = new EmbedBuilder() + .setColor(LEVEL_COLOR[incident.level]) + .setTitle(LEVEL_TITLE[incident.level]) + .setDescription(`<@${incident.userId}> \`${incident.userId}\``) + .addFields( + { + name: 'Signal', + value: `**${LEVEL_LABEL[incident.level]}** β€” ${incident.reason}`, + }, + { name: 'Channels', value: channelMentions, inline: true }, + { name: 'Messages', value: String(incident.messages.length), inline: true } + ); + + if (member) { + embed + .setThumbnail(member.displayAvatarURL()) + .setFooter({ text: member.user.tag }) + .addFields( + { + name: 'Account created', + value: time(member.user.createdAt, TimestampStyles.RelativeTime), + inline: true, + }, + { + name: 'Joined server', + value: member.joinedAt + ? time(member.joinedAt, TimestampStyles.RelativeTime) + : 'unknown', + inline: true, + } + ); + } + + embed.addFields({ name: 'Jump to messages', value: jumpLinks }); + + if (autoActed) + embed.addFields({ + name: 'πŸ”’ Auto-action taken', + value: AUTO_ACTION_NOTE[incident.level], + }); + + const row1 = new ActionRowBuilder().addComponents( + actionButton('confirm', 'βœ… Confirm spam', ButtonStyle.Danger, incident.id), + actionButton('timeout', '⏳ Timeout', ButtonStyle.Secondary, incident.id), + actionButton('ban', 'πŸ”¨ Ban', ButtonStyle.Danger, incident.id) + ); + const row2 = new ActionRowBuilder().addComponents( + actionButton('delete', 'πŸ—‘οΈ Delete msgs', ButtonStyle.Secondary, incident.id), + actionButton('dismiss', 'πŸ‘Œ Not spam', ButtonStyle.Success, incident.id) + ); + + return { embeds: [embed], components: [row1, row2] }; +} + +/** Apply a timeout to a member. Returns false if blocked by hierarchy/perms. */ +export async function timeoutMember( + guild: Guild, + userId: string, + reason: string +): Promise { + const member = await guild.members.fetch(userId).catch(() => null); + if (!member || !member.moderatable) return false; + return member + .timeout(automodConfig.timeoutMs, reason) + .then(() => true) + .catch(() => false); +} + +/** Ban a member and scrub their last day of messages. */ +export async function banMember( + guild: Guild, + userId: string, + reason: string +): Promise { + return guild.members + .ban(userId, { reason, deleteMessageSeconds: 24 * 60 * 60 }) + .then(() => true) + .catch(() => false); +} + +/** Delete every message recorded on an incident. Returns the count deleted. */ +export async function deleteIncidentMessages( + client: Client, + incident: Incident +): Promise { + let deleted = 0; + for (const { channelId, messageId } of incident.messages) { + const channel = await client.channels.fetch(channelId).catch(() => null); + if (!channel || !channel.isTextBased() || channel.isDMBased()) continue; + const ok = await channel.messages + .delete(messageId) + .then(() => true) + .catch(() => false); + if (ok) deleted++; + } + return deleted; +} + +/** Best-effort audit log of a moderation action (no-op without a database). */ +export async function logModerationAction( + moderatorId: string, + targetId: string, + action: string, + reason: string +): Promise { + const prisma = getPrisma(); + if (!prisma) return; + try { + await prisma.moderationAction.create({ + data: { moderatorId, targetId, action, reason }, + }); + } catch (error) { + container.logger.error('Logging moderation action failed:', error); + } +} diff --git a/src/utils/automod/crosspost.ts b/src/utils/automod/crosspost.ts new file mode 100644 index 0000000..a0299ca --- /dev/null +++ b/src/utils/automod/crosspost.ts @@ -0,0 +1,155 @@ +import { automodConfig } from '../config'; + +/** A substantive text message recorded for cross-channel detection. */ +export interface TextMessage { + at: number; + channelId: string; + messageId: string; + /** Tokenised, normalised words of the message, for similarity comparison. */ + tokens: Set; +} + +/** Which signal tripped: a near-identical repeat, or a new-member shotgun. */ +export type CrosspostKind = 'similar' | 'spread'; + +export interface CrosspostDetection { + kind: CrosspostKind; + reason: string; + /** Contributing messages, oldest first (so the first is the one to keep). */ + messages: TextMessage[]; + /** Distinct channels involved. */ + channels: string[]; +} + +/** Per-user recent substantive messages, pruned to the crosspost window. */ +const userMessages = new Map(); +/** Last time we raised a crosspost alert for a user, for cooldown suppression. */ +const lastAlertAt = new Map(); + +const unique = (values: T[]): T[] => [...new Set(values)]; + +/** Lowercase, strip punctuation, collapse whitespace β€” then split into words. */ +export function tokenize(text: string): Set { + const normalized = text + .toLowerCase() + .replace(/[^\p{L}\p{N}\s]/gu, ' ') + .replace(/\s+/g, ' ') + .trim(); + return new Set(normalized.split(' ').filter(Boolean)); +} + +/** Jaccard overlap of two token sets, in [0, 1]. */ +function similarity(a: Set, b: Set): number { + if (a.size === 0 || b.size === 0) return 0; + let intersection = 0; + for (const token of a) if (b.has(token)) intersection++; + return intersection / (a.size + b.size - intersection); +} + +/** Whether two messages are "the same question" under the configured threshold. */ +export function isSimilar(a: TextMessage, b: TextMessage): boolean { + return similarity(a.tokens, b.tokens) >= automodConfig.crosspostSimilarityPct / 100; +} + +export interface CrosspostOptions { + /** Whether the author currently counts as a new member (enables `spread`). */ + isNewMember?: boolean; +} + +/** + * Record a substantive message and decide whether the user is fanning the same + * question across channels. The high-confidence `similar` signal (near-identical + * text in N+ channels) wins over the content-agnostic new-member `spread` + * signal. On a hit the buffer is cleared so the next incident builds afresh. + */ +export function recordTextMessage( + userId: string, + message: TextMessage, + options: CrosspostOptions = {} +): CrosspostDetection | null { + const horizon = message.at - automodConfig.crosspostWindowMs; + const messages = (userMessages.get(userId) ?? []).filter( + (m) => m.at >= horizon + ); + messages.push(message); + + const similar = detectSimilarFanout(messages); + const detection = similar ?? detectNewMemberSpread(messages, options); + + if (detection) { + userMessages.delete(userId); + return detection; + } + + userMessages.set(userId, messages); + return null; +} + +/** Near-identical text across `crosspostChannels`+ distinct channels. */ +function detectSimilarFanout(messages: TextMessage[]): CrosspostDetection | null { + for (const anchor of messages) { + const cluster = messages.filter((m) => isSimilar(m, anchor)); + const channels = unique(cluster.map((m) => m.channelId)); + if (channels.length >= automodConfig.crosspostChannels) { + const ordered = [...cluster].sort((a, b) => a.at - b.at); + return { + kind: 'similar', + reason: `Same question posted across ${channels.length} channels`, + messages: ordered, + channels, + }; + } + } + return null; +} + +/** + * A new member posting in `crosspostSpreadChannels`+ distinct channels, even + * when the wording differs enough to dodge the similarity check. + */ +function detectNewMemberSpread( + messages: TextMessage[], + options: CrosspostOptions +): CrosspostDetection | null { + if (!options.isNewMember) return null; + const channels = unique(messages.map((m) => m.channelId)); + if (channels.length < automodConfig.crosspostSpreadChannels) return null; + const ordered = [...messages].sort((a, b) => a.at - b.at); + return { + kind: 'spread', + reason: `New member posting across ${channels.length} channels at once`, + messages: ordered, + channels, + }; +} + +/** Whether enough time has passed since the last crosspost alert for this user. */ +export function shouldAlertCrosspost(userId: string, now: number): boolean { + const last = lastAlertAt.get(userId); + return !last || now - last >= automodConfig.alertCooldownMs; +} + +export function markCrosspostAlerted(userId: string, now: number): void { + lastAlertAt.set(userId, now); +} + +/** Forget a user's crosspost state (e.g. after a moderator resolves an alert). */ +export function clearCrosspostUser(userId: string): void { + userMessages.delete(userId); + lastAlertAt.delete(userId); +} + +// Periodically drop stale state so the maps don't grow unbounded. unref() +// keeps this timer from holding the process open. +const sweep = setInterval(() => { + const horizon = Date.now() - automodConfig.crosspostWindowMs; + for (const [userId, messages] of userMessages) { + const fresh = messages.filter((m) => m.at >= horizon); + if (fresh.length === 0) userMessages.delete(userId); + else userMessages.set(userId, fresh); + } + const cooldownHorizon = Date.now() - automodConfig.alertCooldownMs; + for (const [userId, at] of lastAlertAt) + if (at < cooldownHorizon) lastAlertAt.delete(userId); +}, 5 * 60_000); +sweep.unref(); diff --git a/src/utils/automod/flood.ts b/src/utils/automod/flood.ts new file mode 100644 index 0000000..47e2e38 --- /dev/null +++ b/src/utils/automod/flood.ts @@ -0,0 +1,97 @@ +import { automodConfig } from '../config'; + +/** A short message recorded for flood detection. */ +export interface FloodMessage { + at: number; + channelId: string; + messageId: string; +} + +export interface FloodDetection { + flooded: boolean; + reason: string; + /** The short messages that contributed to the verdict. */ + messages: FloodMessage[]; + /** Distinct channels involved. */ + channels: string[]; +} + +const NONE: FloodDetection = { + flooded: false, + reason: '', + messages: [], + channels: [], +}; + +/** Per-user recent short messages, pruned to the flood window. */ +const userMessages = new Map(); +/** Last time we raised a flood alert for a user, for cooldown suppression. */ +const lastAlertAt = new Map(); + +const unique = (values: T[]): T[] => [...new Set(values)]; + +/** + * Record a short message and evaluate whether the user is now flooding β€” i.e. + * sending too many tiny messages in quick succession instead of consolidating + * them into one. Only the caller's notion of "short" reaches here; this just + * counts how many landed inside the sliding window. + * + * On a positive verdict the user's buffer is cleared so the next flood has to + * build up from scratch (the alert cooldown still guards against re-alerting). + */ +export function recordShortMessage( + userId: string, + message: FloodMessage +): FloodDetection { + const horizon = message.at - automodConfig.floodWindowMs; + const messages = (userMessages.get(userId) ?? []).filter( + (m) => m.at >= horizon + ); + messages.push(message); + + if (messages.length >= automodConfig.floodThreshold) { + userMessages.delete(userId); + return { + flooded: true, + reason: `${messages.length} short messages in ${Math.round( + automodConfig.floodWindowMs / 1000 + )}s`, + messages, + channels: unique(messages.map((m) => m.channelId)), + }; + } + + userMessages.set(userId, messages); + return NONE; +} + +/** Whether enough time has passed since the last flood alert for this user. */ +export function shouldAlertFlood(userId: string, now: number): boolean { + const last = lastAlertAt.get(userId); + return !last || now - last >= automodConfig.alertCooldownMs; +} + +export function markFloodAlerted(userId: string, now: number): void { + lastAlertAt.set(userId, now); +} + +/** Forget a user's flood state (e.g. after a moderator resolves an alert). */ +export function clearFloodUser(userId: string): void { + userMessages.delete(userId); + lastAlertAt.delete(userId); +} + +// Periodically drop stale state so the maps don't grow unbounded. unref() +// keeps this timer from holding the process open. +const sweep = setInterval(() => { + const horizon = Date.now() - automodConfig.floodWindowMs; + for (const [userId, messages] of userMessages) { + const fresh = messages.filter((m) => m.at >= horizon); + if (fresh.length === 0) userMessages.delete(userId); + else userMessages.set(userId, fresh); + } + const cooldownHorizon = Date.now() - automodConfig.alertCooldownMs; + for (const [userId, at] of lastAlertAt) + if (at < cooldownHorizon) lastAlertAt.delete(userId); +}, 5 * 60_000); +sweep.unref(); diff --git a/src/utils/automod/incidents.ts b/src/utils/automod/incidents.ts new file mode 100644 index 0000000..1566ca0 --- /dev/null +++ b/src/utils/automod/incidents.ts @@ -0,0 +1,64 @@ +import { randomUUID } from 'node:crypto'; + +/** + * The kinds of incident the moderation console can surface. `burst`, `fanout` + * and `blocklist` come from the image-spam detector; `flood` from the + * text-flooding detector. + */ +export type IncidentLevel = + | 'burst' + | 'fanout' + | 'blocklist' + | 'flood' + | 'crosspost'; + +export interface IncidentMessage { + channelId: string; + messageId: string; +} + +export interface Incident { + id: string; + userId: string; + guildId: string; + level: IncidentLevel; + reason: string; + messages: IncidentMessage[]; + /** Metadata signatures to blocklist if a moderator confirms this is spam. */ + signatures: string[]; + /** Perceptual hashes to blocklist if a moderator confirms this is spam. */ + hashes: string[]; + createdAt: number; +} + +/** + * In-memory store of open incidents, keyed by a short id embedded in the + * moderation buttons' custom ids. Incidents are intentionally ephemeral: if + * the bot restarts, open alerts simply expire (their buttons report this). + */ +const incidents = new Map(); +const TTL_MS = 60 * 60 * 1000; + +export function createIncident( + data: Omit +): Incident { + const incident: Incident = { + ...data, + id: randomUUID().slice(0, 8), + createdAt: Date.now(), + }; + incidents.set(incident.id, incident); + return incident; +} + +export const getIncident = (id: string): Incident | undefined => + incidents.get(id); + +export const deleteIncident = (id: string): boolean => incidents.delete(id); + +const sweep = setInterval(() => { + const horizon = Date.now() - TTL_MS; + for (const [id, incident] of incidents) + if (incident.createdAt < horizon) incidents.delete(id); +}, 10 * 60_000); +sweep.unref(); diff --git a/src/utils/automod/phash.ts b/src/utils/automod/phash.ts new file mode 100644 index 0000000..7df62e5 --- /dev/null +++ b/src/utils/automod/phash.ts @@ -0,0 +1,159 @@ +import { Jimp, intToRGBA } from 'jimp'; +import type { Attachment, Message } from 'discord.js'; +import { container } from '@sapphire/framework'; +import { isImageAttachment } from './signature'; + +// pHash parameters: resize to DCT_INPUT_SIZEΓ—DCT_INPUT_SIZE, then keep the +// top-left DCT_COEFF_SIZEΓ—DCT_COEFF_SIZE low-frequency block (64 bits). +const DCT_INPUT_SIZE = 32; +const DCT_COEFF_SIZE = 8; + +/** Max image attachments per message we will fetch + hash. */ +const MAX_ATTACHMENTS = 4; +/** Per-fetch timeout. */ +const FETCH_TIMEOUT_MS = 4000; + +/** + * Pre-computed cosine table for the 1-D DCT: + * cosTable[k][n] = cos(Ο€/N * (n + 0.5) * k) for N = DCT_INPUT_SIZE + * Computed once at module load so per-image cost is multiplications only. + */ +const cosTable: number[][] = Array.from({ length: DCT_INPUT_SIZE }, (_, k) => + Array.from({ length: DCT_INPUT_SIZE }, (__, n) => + Math.cos((Math.PI / DCT_INPUT_SIZE) * (n + 0.5) * k) + ) +); + +/** 1-D DCT-II applied to a signal of length DCT_INPUT_SIZE. */ +function dct1d(signal: number[]): number[] { + return Array.from({ length: DCT_INPUT_SIZE }, (_, k) => { + let sum = 0; + for (let n = 0; n < DCT_INPUT_SIZE; n++) sum += signal[n] * cosTable[k][n]; + return sum; + }); +} + +/** + * 2-D DCT via two separable passes of the 1-D DCT: first across rows, then + * down columns. Input/output are row-major flat arrays of DCT_INPUT_SIZEΒ². + */ +function dct2d(pixels: number[]): number[] { + const N = DCT_INPUT_SIZE; + + // Pass 1: DCT each row + const tmp = new Array(N * N); + for (let r = 0; r < N; r++) { + const row = pixels.slice(r * N, r * N + N); + const t = dct1d(row); + for (let c = 0; c < N; c++) tmp[r * N + c] = t[c]; + } + + // Pass 2: DCT each column + const out = new Array(N * N); + for (let c = 0; c < N; c++) { + const col = Array.from({ length: N }, (_, r) => tmp[r * N + c]); + const t = dct1d(col); + for (let r = 0; r < N; r++) out[r * N + c] = t[r]; + } + + return out; +} + +/** + * Perceptual hash (pHash) of an image, as 16 hex chars (64 bits). + * + * 1. Resize to 32Γ—32 and greyscale. + * 2. Compute 2-D DCT. + * 3. Extract the top-left 8Γ—8 low-frequency block (64 coefficients). + * 4. Compute the mean of those values. + * 5. Bit i = 1 if coefficient i > mean, else 0. + * + * Near-identical images β€” re-encoded, recompressed, watermarked, or lightly + * resized/cropped β€” produce hashes with a small Hamming distance, which exact + * metadata signatures cannot detect. + */ +export async function pHashFromBuffer(buffer: Buffer): Promise { + const image = await Jimp.read(buffer); + image.resize({ w: DCT_INPUT_SIZE, h: DCT_INPUT_SIZE }).greyscale(); + + const pixels: number[] = []; + for (let y = 0; y < DCT_INPUT_SIZE; y++) + for (let x = 0; x < DCT_INPUT_SIZE; x++) + pixels.push(intToRGBA(image.getPixelColor(x, y)).r); + + const dct = dct2d(pixels); + + // Top-left 8Γ—8 low-frequency block + const low: number[] = []; + for (let r = 0; r < DCT_COEFF_SIZE; r++) + for (let c = 0; c < DCT_COEFF_SIZE; c++) + low.push(dct[r * DCT_INPUT_SIZE + c]); + + const mean = low.reduce((s, v) => s + v, 0) / low.length; + + // 64 bits β†’ 16 hex chars + let bits = ''; + for (const v of low) bits += v > mean ? '1' : '0'; + let hex = ''; + for (let i = 0; i < bits.length; i += 4) + hex += parseInt(bits.slice(i, i + 4), 2).toString(16); + return hex; +} + +/** Hamming distance between two equal-length hex hashes (lower = more similar). */ +export function hammingDistance(a: string, b: string): number { + if (a.length !== b.length) return Number.MAX_SAFE_INTEGER; + let distance = 0; + for (let i = 0; i < a.length; i++) { + let nibble = parseInt(a[i], 16) ^ parseInt(b[i], 16); + while (nibble) { + distance += nibble & 1; + nibble >>= 1; + } + } + return distance; +} + +/** + * Discord thumbnail proxy URL for an attachment. We request 64Γ—64 so Discord + * handles the expensive initial downscale and we receive a small buffer; Jimp + * then resizes it further to 32Γ—32 with better antialiasing than starting from + * a 32Γ—32 proxy directly. + */ +function thumbnailUrl(attachment: Attachment): string { + const base = attachment.proxyURL || attachment.url; + const separator = base.includes('?') ? '&' : '?'; + return `${base}${separator}width=64&height=64`; +} + +/** + * Perceptual hashes for every image attachment on a message. Best-effort: any + * attachment that fails to fetch or decode is skipped (the metadata signature + * still covers it). Never throws. + */ +export async function perceptualHashes(message: Message): Promise { + const images = [...message.attachments.values()] + .filter(isImageAttachment) + .slice(0, MAX_ATTACHMENTS); + + const hashes = await Promise.all( + images.map(async (attachment) => { + try { + const response = await fetch(thumbnailUrl(attachment), { + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + if (!response.ok) return null; + const buffer = Buffer.from(await response.arrayBuffer()); + return await pHashFromBuffer(buffer); + } catch (error) { + container.logger.debug( + `Automod: could not perceptual-hash attachment ${attachment.id}:`, + error + ); + return null; + } + }) + ); + + return hashes.filter((hash): hash is string => hash !== null); +} diff --git a/src/utils/automod/signature.ts b/src/utils/automod/signature.ts new file mode 100644 index 0000000..389f5cb --- /dev/null +++ b/src/utils/automod/signature.ts @@ -0,0 +1,38 @@ +import type { Attachment, Message } from 'discord.js'; + +/** + * A cheap, download-free fingerprint of an image attachment built from + * metadata Discord already gives us (content type, byte size, dimensions). + * + * Re-uploads of the *same* image file produce an identical signature, which is + * enough to spot the "same advert fanned across several channels" pattern + * without ever fetching image bytes. It is intentionally conservative: it can + * miss re-encoded/resized variants (a future perceptual-hash upgrade would + * catch those), but it never decodes untrusted media and costs nothing on the + * hot path. + */ +export function attachmentSignature(attachment: Attachment): string { + const type = attachment.contentType ?? 'image/unknown'; + const dimensions = + attachment.width && attachment.height + ? `${attachment.width}x${attachment.height}` + : 'na'; + return `${type}|${attachment.size}|${dimensions}`; +} + +const IMAGE_EXTENSION = /\.(png|jpe?g|gif|webp|bmp|tiff?|heic|avif)$/i; + +/** Whether an attachment is an image we should inspect. */ +export function isImageAttachment(attachment: Attachment): boolean { + if (attachment.contentType) + return attachment.contentType.startsWith('image/'); + // Fall back to the file extension when Discord omits the content type. + return IMAGE_EXTENSION.test(attachment.name ?? ''); +} + +/** Signatures for every image attachment on a message (empty if none). */ +export function imageSignatures(message: Message): string[] { + return [...message.attachments.values()] + .filter(isImageAttachment) + .map(attachmentSignature); +} diff --git a/src/utils/automod/tracker.ts b/src/utils/automod/tracker.ts new file mode 100644 index 0000000..cd2d74f --- /dev/null +++ b/src/utils/automod/tracker.ts @@ -0,0 +1,192 @@ +import { automodConfig } from '../config'; +import { hammingDistance } from './phash'; + +export interface ImageEvent { + at: number; + channelId: string; + messageId: string; + /** Cheap metadata fingerprints (one per image attachment). */ + signatures: string[]; + /** Perceptual hashes (one per image attachment, best-effort). */ + hashes: string[]; +} + +export type DetectionLevel = 'none' | 'burst' | 'fanout'; + +export interface Detection { + level: DetectionLevel; + reason: string; + /** The image events that contributed to this verdict. */ + events: ImageEvent[]; + /** Distinct channels involved. */ + channels: string[]; + /** Distinct metadata signatures involved. */ + signatures: string[]; + /** Distinct perceptual hashes involved. */ + hashes: string[]; +} + +const NONE: Detection = { + level: 'none', + reason: '', + events: [], + channels: [], + signatures: [], + hashes: [], +}; + +/** Per-user recent image events, pruned to the longest detection window. */ +const userEvents = new Map(); +/** Last time we raised an alert for a user, for cooldown suppression. */ +const lastAlertAt = new Map(); + +const retentionMs = () => + Math.max(automodConfig.burstWindowMs, automodConfig.fanoutWindowMs); + +const unique = (values: T[]): T[] => [...new Set(values)]; + +export interface DetectionOptions { + /** + * Override for the same-channel burst threshold. Used to apply a stricter + * threshold to new members. Falls back to the configured default. + */ + burstThreshold?: number; +} + +/** + * Record an image-bearing message and evaluate whether the user's recent + * activity now constitutes spam. Fan-out (same image across channels) takes + * precedence over a same-channel burst because it is the higher-confidence + * signal. + */ +export function recordImageMessage( + userId: string, + event: ImageEvent, + options: DetectionOptions = {} +): Detection { + const burstThreshold = options.burstThreshold ?? automodConfig.burstThreshold; + const horizon = event.at - retentionMs(); + const events = (userEvents.get(userId) ?? []).filter((e) => e.at >= horizon); + events.push(event); + userEvents.set(userId, events); + + // --- Fan-out: the same image seen across N+ distinct channels --- + const fanoutFrom = event.at - automodConfig.fanoutWindowMs; + const fanoutEvents = events.filter((e) => e.at >= fanoutFrom); + + const fanout = + detectExactFanout(fanoutEvents) ?? detectPerceptualFanout(fanoutEvents); + if (fanout) return fanout; + + // --- Burst: N+ image messages within the burst window --- + const burstFrom = event.at - automodConfig.burstWindowMs; + const burstEvents = events.filter((e) => e.at >= burstFrom); + if (burstEvents.length >= burstThreshold) { + return { + level: 'burst', + reason: `${burstEvents.length} image messages in ${Math.round( + automodConfig.burstWindowMs / 1000 + )}s`, + events: burstEvents, + channels: unique(burstEvents.map((e) => e.channelId)), + signatures: unique(burstEvents.flatMap((e) => e.signatures)), + hashes: unique(burstEvents.flatMap((e) => e.hashes)), + }; + } + + return NONE; +} + +/** Fan-out by exact metadata signature (catches byte-identical re-uploads). */ +function detectExactFanout(events: ImageEvent[]): Detection | null { + const channelsBySignature = new Map>(); + for (const e of events) + for (const signature of e.signatures) { + const channels = channelsBySignature.get(signature) ?? new Set(); + channels.add(e.channelId); + channelsBySignature.set(signature, channels); + } + + const fanned = [...channelsBySignature.entries()].filter( + ([, channels]) => channels.size >= automodConfig.fanoutChannels + ); + if (fanned.length === 0) return null; + + const signatures = fanned.map(([signature]) => signature); + const contributing = events.filter((e) => + e.signatures.some((s) => signatures.includes(s)) + ); + const channels = unique(contributing.map((e) => e.channelId)); + return { + level: 'fanout', + reason: `Identical image posted across ${channels.length} channels`, + events: contributing, + channels, + signatures, + hashes: unique(contributing.flatMap((e) => e.hashes)), + }; +} + +/** + * Fan-out by perceptual hash (catches re-encoded/resized copies of the same + * image that have different metadata signatures per channel). For each hash we + * cluster all near-duplicates within the configured Hamming distance and check + * whether that cluster spans enough distinct channels. + */ +function detectPerceptualFanout(events: ImageEvent[]): Detection | null { + const hashed = events.flatMap((e) => + e.hashes.map((hash) => ({ hash, event: e })) + ); + + for (const anchor of hashed) { + const cluster = hashed.filter( + ({ hash }) => + hammingDistance(hash, anchor.hash) <= automodConfig.phashThreshold + ); + const channels = unique(cluster.map(({ event }) => event.channelId)); + if (channels.length >= automodConfig.fanoutChannels) { + const contributing = unique(cluster.map(({ event }) => event)); + return { + level: 'fanout', + reason: `Near-identical image posted across ${channels.length} channels`, + events: contributing, + channels, + signatures: unique(contributing.flatMap((e) => e.signatures)), + hashes: unique(cluster.map(({ hash }) => hash)), + }; + } + } + return null; +} + +/** Whether enough time has passed since the last alert for this user. */ +export function shouldAlert(userId: string, now: number): boolean { + const last = lastAlertAt.get(userId); + return !last || now - last >= automodConfig.alertCooldownMs; +} + +export function markAlerted(userId: string, now: number): void { + lastAlertAt.set(userId, now); +} + +/** Forget a user's tracked state (e.g. after a moderator resolves an alert). */ +export function clearUser(userId: string): void { + userEvents.delete(userId); + lastAlertAt.delete(userId); +} + +// Periodically drop stale state so the maps don't grow unbounded for users +// who post one image and never return. unref() keeps this from holding the +// process open. +const sweep = setInterval(() => { + const horizon = Date.now() - retentionMs(); + for (const [userId, events] of userEvents) { + const fresh = events.filter((e) => e.at >= horizon); + if (fresh.length === 0) userEvents.delete(userId); + else userEvents.set(userId, fresh); + } + const cooldownHorizon = Date.now() - automodConfig.alertCooldownMs; + for (const [userId, at] of lastAlertAt) + if (at < cooldownHorizon) lastAlertAt.delete(userId); +}, 5 * 60_000); +sweep.unref(); diff --git a/src/utils/config.ts b/src/utils/config.ts index 56f3383..fe0c0c7 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -9,12 +9,196 @@ const missingEnv = requiredEnvironment.filter((req) => !process.env[req]); if (missingEnv.length > 0) throw new Error( - 'Missing required environment variables: \n- ' + - missingEnv.join('\n- ') + 'Missing required environment variables: \n- ' + missingEnv.join('\n- ') ); // This should be the server and the bot commands channel id's. export const { BOT_TOKEN = '', SERVER_ID = '420594746990526466', // Arduino Official Server BOT_COMMANDS_CHANNEL_ID = '451158319361556491', // Arduino Official Bot Channel + // Channel where automod posts spam alerts for moderators to action. + // Leave unset to disable the automod console entirely. + MOD_LOG_CHANNEL_ID = '', + // Optional: persistence for the spam-image blocklist. When unset the bot + // runs fully in-memory and the blocklist resets on restart. + DATABASE_URL = '', + // Server-management features. Each self-disables when its id is unset. + JOIN_LEAVE_LOG_CHANNEL_ID = '', // member join/leave + invite-source logging + CROSSPOST_LOG_CHANNEL_ID = '', // where auto-crosspost results are logged + ROLE_SELECT_MESSAGE_ID = '', // message whose buttons toggle opt-in roles + EVENT_NOTIFS_ROLE_ID = '', // role toggled by the "events" button + SERVER_UPDATE_NOTIFS_ROLE_ID = '', // role toggled by the "server_updates" button } = process.env; + +/** Parse a positive integer from the environment, falling back to a default. */ +const posInt = (value: string | undefined, fallback: number): number => { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback; +}; + +/** Parse a comma-separated list of ids from the environment. */ +const idList = (value: string | undefined): string[] => + (value ?? '') + .split(',') + .map((id) => id.trim()) + .filter((id) => id.length > 0); + +/** Announcement/feed channels whose messages are auto-published (crossposted). */ +export const crosspostChannelIds = idList(process.env.CROSSPOST_CHANNEL_IDS); + +/** + * Channels (and forum-channel parents) where tag/code/ask suggestions are + * suppressed. Useful for staff channels, announcements, or any channel where + * bot suggestions would be unwelcome. Comma-separated channel IDs. + */ +export const suggestIgnoreChannelIds = idList(process.env.SUGGEST_IGNORE_CHANNEL_IDS); + +/** + * Role IDs whose holders are never shown tag/code/ask suggestions. Set this to + * the IDs of Trusted, Knowledgeable, Helper, and any other recognised-member + * roles. Self-assignable notification roles and similar should NOT be listed + * here so those members still receive suggestions as normal. + */ +export const suggestImmuneRoleIds = idList(process.env.SUGGEST_IMMUNE_ROLE_IDS); + +/** + * Channels treated as "help" channels. Entries may be **forum** channels (each + * post is a thread) or **regular text** channels (threads opened inside them get + * the same treatment): new help threads get a "Mark Solved" button and, when + * thin, the needinfo checklist, and the stale-post sweep / `/openposts` track + * them. The /solved command works in any thread regardless of this list. + * + * Reads HELP_CHANNEL_IDS (preferred) and the legacy HELP_FORUM_CHANNEL_IDS, + * merged and de-duplicated, so existing configs keep working. + */ +export const helpChannelIds = [ + ...new Set([ + ...idList(process.env.HELP_CHANNEL_IDS), + ...idList(process.env.HELP_FORUM_CHANNEL_IDS), + ]), +]; + +/** Whether the keyword -> tag auto-suggester is active (on unless "false"). */ +export const tagSuggestEnabled = process.env.TAG_SUGGEST_ENABLED !== 'false'; + +/** Whether to nudge users who paste unformatted code toward the codeblock tag. */ +export const codeFormatSuggestEnabled = + process.env.CODE_FORMAT_SUGGEST_ENABLED !== 'false'; + +/** Whether to nudge "can I ask?" / "anyone here?" non-questions toward the ask tag. */ +export const askSuggestEnabled = process.env.ASK_SUGGEST_ENABLED !== 'false'; + +/** + * Help-channel quality-of-life knobs. The auto-needinfo and stale-post sweep + * only do anything when `helpChannelIds` is configured. + */ +export const helpAssistConfig = { + /** Auto-post the needinfo checklist when a new help post is too thin. */ + autoNeedinfo: process.env.HELP_AUTO_NEEDINFO === 'true', + /** + * Combined character threshold (forum title + post body) below which a new + * help post counts as "thin" for auto-needinfo. Posts that also contain a + * code block, inline code, image, or URL are never considered thin regardless + * of length. Default 120 β€” conservative to minimise false positives. + */ + needinfoMinChars: posInt(process.env.HELP_NEEDINFO_MIN_CHARS, 120), + /** Whether the stale-post nudge/auto-archive sweep runs. */ + staleSweepEnabled: process.env.HELP_STALE_SWEEP_ENABLED !== 'false', + /** + * Idle time (ms) before an open help post gets a "still need help?" nudge. + * Default 72h (3 days) β€” helpers often take a while to reach a post. + */ + staleNudgeMs: posInt(process.env.HELP_STALE_NUDGE_HOURS, 72) * 60 * 60 * 1000, + /** + * Idle time (ms) after a nudge, with no human reply, before auto-archiving. + * Default 168h (7 days) on top of the nudge wait, so nothing is archived from + * under a slow-but-active conversation. + */ + staleArchiveMs: posInt(process.env.HELP_STALE_ARCHIVE_HOURS, 168) * 60 * 60 * 1000, +}; + +/** + * Tunables for the image-spam detector. Every value is overridable via the + * environment so moderators can adjust thresholds without a redeploy. + */ +export const automodConfig = { + /** Number of image messages from one user within `burstWindowMs` to flag a burst. */ + burstThreshold: posInt(process.env.AUTOMOD_BURST_THRESHOLD, 3), + /** Sliding window (ms) for counting an image burst. */ + burstWindowMs: posInt(process.env.AUTOMOD_BURST_WINDOW_MS, 60_000), + /** Distinct channels the same image must appear in to flag a fan-out. */ + fanoutChannels: posInt(process.env.AUTOMOD_FANOUT_CHANNELS, 2), + /** + * Max Hamming distance (out of 64) for two perceptual hashes to count as the + * same image. Higher = more lenient/near-duplicate matching. Default 6. + */ + phashThreshold: posInt(process.env.AUTOMOD_PHASH_THRESHOLD, 6), + /** Sliding window (ms) for detecting cross-channel fan-out. */ + fanoutWindowMs: posInt(process.env.AUTOMOD_FANOUT_WINDOW_MS, 120_000), + /** How long (ms) auto-applied/console timeouts last. Default 1 hour. */ + timeoutMs: posInt(process.env.AUTOMOD_TIMEOUT_MS, 60 * 60 * 1000), + /** Minimum gap (ms) between alerts for the same user, to avoid alert spam. */ + alertCooldownMs: posInt(process.env.AUTOMOD_ALERT_COOLDOWN_MS, 30_000), + /** Roles whose members are never inspected or actioned by the automod. */ + immuneRoleIds: idList(process.env.AUTOMOD_IMMUNE_ROLE_IDS), + /** + * How long after joining (ms) a member is treated as "new", so their image + * posts are scrutinised harder. Default 72 hours. + */ + newMemberWindowMs: posInt(process.env.AUTOMOD_NEW_MEMBER_WINDOW_MS, 72 * 60 * 60 * 1000), + /** + * Stricter burst threshold applied to new members (catches join-then-spam + * faster). Should be <= burstThreshold. Default 2. + */ + newMemberBurstThreshold: posInt(process.env.AUTOMOD_NEW_MEMBER_BURST_THRESHOLD, 2), + + // --- Text-flooding detector --- + /** Whether the text-flooding detector is active (on unless "false"). */ + floodEnabled: process.env.AUTOMOD_FLOOD_ENABLED !== 'false', + /** + * Number of short messages from one user within `floodWindowMs` to flag + * flooding (a user fragmenting one thought across many tiny messages). + */ + floodThreshold: posInt(process.env.AUTOMOD_FLOOD_THRESHOLD, 5), + /** Sliding window (ms) for counting flood messages. Default 15s. */ + floodWindowMs: posInt(process.env.AUTOMOD_FLOOD_WINDOW_MS, 15_000), + /** + * A message counts toward flooding only if its trimmed content length is at + * most this. Longer messages are treated as normal conversation. Default 25. + */ + floodMaxChars: posInt(process.env.AUTOMOD_FLOOD_MAX_CHARS, 25), + /** + * When true, the bot also times the user out automatically on a flood hit. + * Off by default: flooding is usually a habit, not an attack, so by default + * we only alert moderators and let them decide. + */ + floodAutoTimeout: process.env.AUTOMOD_FLOOD_AUTO_TIMEOUT === 'true', + + // --- Cross-channel question-spam detector --- + /** Whether the cross-channel question-spam detector is active (on unless "false"). */ + crosspostEnabled: process.env.AUTOMOD_CROSSPOST_ENABLED !== 'false', + /** + * Distinct channels the same (or near-identical) message must span within + * `crosspostWindowMs` to flag a cross-channel repeat. Applies at any tenure. + */ + crosspostChannels: posInt(process.env.AUTOMOD_CROSSPOST_CHANNELS, 2), + /** + * Distinct channels a *new* member must post substantive messages in within + * the window to flag content-agnostic "shotgunning" (they reworded enough to + * dodge the similarity check but are clearly asking everywhere at once). + */ + crosspostSpreadChannels: posInt(process.env.AUTOMOD_CROSSPOST_SPREAD_CHANNELS, 3), + /** Sliding window (ms) for cross-channel detection. Default 2 minutes. */ + crosspostWindowMs: posInt(process.env.AUTOMOD_CROSSPOST_WINDOW_MS, 120_000), + /** + * Minimum trimmed length for a message to be considered a "question" worth + * tracking β€” keeps greetings/reactions ("hi", "ok") from tripping the + * detector. Default 12. + */ + crosspostMinChars: posInt(process.env.AUTOMOD_CROSSPOST_MIN_CHARS, 12), + /** + * Token-overlap (Jaccard) percentage at which two messages count as the + * "same" question. Higher = stricter. Default 80%. + */ + crosspostSimilarityPct: posInt(process.env.AUTOMOD_CROSSPOST_SIMILARITY_PCT, 80), +}; diff --git a/src/utils/db.ts b/src/utils/db.ts index 702e179..241123e 100644 --- a/src/utils/db.ts +++ b/src/utils/db.ts @@ -1,14 +1,44 @@ import { PrismaClient } from '@prisma/client'; -import { Logger, LogLevel } from '@sapphire/framework'; +import { PrismaPg } from '@prisma/adapter-pg'; +import { container } from '@sapphire/framework'; +import { DATABASE_URL } from './config'; -const logger = new Logger(LogLevel.Info); +/** + * The Prisma client, or `null` when no database is configured. The bot is + * designed to run fully in-memory when persistence is unavailable, so every + * consumer must treat a `null` client as "persistence disabled" rather than + * an error. + */ +let prisma: PrismaClient | null = null; -// export const prisma = new PrismaClient(); +export const getPrisma = (): PrismaClient | null => prisma; -// prisma -// .$connect() -// .catch((error: Error) => { -// logger.fatal(error.message); -// process.exit(1); -// }) -// .then(() => logger.info('Database connection success')); +/** + * Attempt to connect to the database. Safe to call when `DATABASE_URL` is + * unset (logs a notice and leaves the bot in in-memory mode) and when the + * connection fails (logs the error and continues without persistence). + * + * Prisma 7 connects through a driver adapter rather than an embedded engine, + * so we hand it a `pg` connection backed by `DATABASE_URL`. + */ +export async function initDatabase(): Promise { + if (!DATABASE_URL) { + container.logger.warn( + 'DATABASE_URL not set β€” running in-memory only. The spam-image blocklist will reset on restart.' + ); + return; + } + + const adapter = new PrismaPg(DATABASE_URL); + const client = new PrismaClient({ adapter }); + try { + await client.$connect(); + prisma = client; + container.logger.info('Database connection success'); + } catch (error) { + container.logger.error( + 'Database connection failed β€” continuing without persistence.', + error + ); + } +} diff --git a/src/utils/embed.ts b/src/utils/embed.ts new file mode 100644 index 0000000..96ec819 --- /dev/null +++ b/src/utils/embed.ts @@ -0,0 +1,16 @@ +import { EmbedBuilder } from 'discord.js'; + +/** + * Shared base embed (brand colour + footer) reused across the bot. Spread it + * into a fresh builder rather than mutating it: + * + * ```ts + * new EmbedBuilder(universalEmbed).setTitle('…') + * ``` + */ +export const universalEmbed = new EmbedBuilder() + .setFooter({ text: 'Arduino Bot β€’ GPL-3.0 β€’ /tag' }) + .setColor('#dc5b05') + .toJSON(); + +export default universalEmbed; diff --git a/src/utils/helpPosts.ts b/src/utils/helpPosts.ts new file mode 100644 index 0000000..09f0543 --- /dev/null +++ b/src/utils/helpPosts.ts @@ -0,0 +1,52 @@ +import type { Client, ThreadChannel } from 'discord.js'; +import { SERVER_ID, helpChannelIds } from './config'; +import { SOLVED_PREFIX } from './solveThread'; + +export interface OpenHelpPost { + thread: ThreadChannel; + /** Timestamp (ms) of the last message, falling back to thread creation. */ + lastActivityAt: number; + /** Whether that last message was posted by the bot (e.g. a prompt/nudge). */ + lastFromBot: boolean; +} + +/** Whether a thread has already been marked solved (βœ… title prefix). */ +export const isSolved = (thread: ThreadChannel): boolean => + thread.name.startsWith(SOLVED_PREFIX); + +/** + * Find the currently-open (active, unsolved) posts across the configured help + * channels, annotated with last-activity info. Works for threads under both + * forum and text help channels. Shared by the stale-post sweep and the + * `/openposts` digest. Best-effort: per-thread fetch failures are skipped. + */ +export async function fetchOpenHelpPosts( + client: Client +): Promise { + if (helpChannelIds.length === 0) return []; + + const guild = await client.guilds.fetch(SERVER_ID).catch(() => null); + if (!guild) return []; + + const active = await guild.channels.fetchActiveThreads().catch(() => null); + if (!active) return []; + + const posts: OpenHelpPost[] = []; + for (const thread of active.threads.values()) { + if (!thread.parentId || !helpChannelIds.includes(thread.parentId)) + continue; + if (thread.archived || isSolved(thread)) continue; + + const last = await thread.messages + .fetch({ limit: 1 }) + .then((messages) => messages.first() ?? null) + .catch(() => null); + + posts.push({ + thread, + lastActivityAt: last?.createdTimestamp ?? thread.createdTimestamp ?? 0, + lastFromBot: last?.author?.id === client.user?.id, + }); + } + return posts; +} diff --git a/src/utils/inviteCache.ts b/src/utils/inviteCache.ts new file mode 100644 index 0000000..4e887d8 --- /dev/null +++ b/src/utils/inviteCache.ts @@ -0,0 +1,20 @@ +/** + * In-memory snapshot of each invite's use count, used to work out which invite + * a new member joined with (by diffing against the live counts on join). + * Filled on ready, kept current by the inviteCreate and guildMemberAdd + * listeners. Not persisted β€” it is rebuilt from Discord on every startup. + */ +const inviteUses = new Map(); + +export const setInviteUses = (code: string, uses: number): void => { + inviteUses.set(code, uses); +}; + +export const getInviteUses = (code: string): number | undefined => + inviteUses.get(code); + +export const seedInviteCache = ( + invites: Iterable<{ code: string; uses: number | null }> +): void => { + for (const invite of invites) inviteUses.set(invite.code, invite.uses ?? 0); +}; diff --git a/src/utils/resolveTag.ts b/src/utils/resolveTag.ts new file mode 100644 index 0000000..b3a95d6 --- /dev/null +++ b/src/utils/resolveTag.ts @@ -0,0 +1,26 @@ +import type { ActionRowBuilder, ButtonBuilder, EmbedBuilder } from 'discord.js'; +import tags from './tags'; + +export interface TagPayload { + content?: string; + embeds?: EmbedBuilder[]; + components?: ActionRowBuilder[]; +} + +/** + * Resolve a tag name into a sendable message payload, evaluating templated + * (function) content with the optional user id. Returns null for unknown tags. + * Shared by the `/tag` command and the tag-button interaction handler. + */ +export function resolveTag(name: string, userId?: string): TagPayload | null { + const tag = tags[name]; + if (!tag) return null; + + const payload: TagPayload = {}; + if (tag.embeds) payload.embeds = tag.embeds; + if (tag.components) payload.components = tag.components; + if (tag.content) + payload.content = + typeof tag.content === 'function' ? tag.content(userId) : tag.content; + return payload; +} diff --git a/src/utils/solveThread.ts b/src/utils/solveThread.ts new file mode 100644 index 0000000..ac168df --- /dev/null +++ b/src/utils/solveThread.ts @@ -0,0 +1,36 @@ +import type { ThreadChannel } from 'discord.js'; + +export const SOLVED_PREFIX = 'βœ… '; + +export interface SolveCheck { + ok: boolean; + reason?: string; +} + +/** + * Whether `userId` may mark `thread` solved: the original poster always can, + * and so can staff (Manage Messages). Already-solved threads are rejected. + */ +export function canMarkSolved( + thread: ThreadChannel, + userId: string, + isStaff: boolean +): SolveCheck { + if (thread.name.startsWith(SOLVED_PREFIX)) + return { ok: false, reason: 'This post is already marked solved.' }; + if (thread.ownerId !== userId && !isStaff) + return { + ok: false, + reason: 'Only the person who opened this post (or a moderator) can mark it solved.', + }; + return { ok: true }; +} + +/** Prefix the thread title with βœ… and archive it. Idempotent and non-throwing. */ +export async function applySolved(thread: ThreadChannel): Promise { + const name = thread.name.startsWith(SOLVED_PREFIX) + ? thread.name + : (SOLVED_PREFIX + thread.name).slice(0, 100); + await thread.setName(name).catch(() => null); + await thread.setArchived(true).catch(() => null); +} diff --git a/src/utils/staleHelpSweep.ts b/src/utils/staleHelpSweep.ts new file mode 100644 index 0000000..3a85f54 --- /dev/null +++ b/src/utils/staleHelpSweep.ts @@ -0,0 +1,82 @@ +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + EmbedBuilder, + type Client, +} from 'discord.js'; +import { container } from '@sapphire/framework'; +import { helpChannelIds, helpAssistConfig } from './config'; +import { fetchOpenHelpPosts } from './helpPosts'; +import universalEmbed from './embed'; + +const SWEEP_INTERVAL_MS = 30 * 60_000; + +/** Threads we've nudged, so we can later auto-archive if still abandoned. */ +const nudged = new Map(); + +function nudgePayload() { + const embed = new EmbedBuilder(universalEmbed).setDescription( + "πŸ‘‹ This post has been quiet for a while. If you're sorted, tap **Mark Solved** to close it β€” otherwise reply with an update (what you've tried, your wiring/code) so a helper can jump back in." + ); + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('solved') + .setLabel('βœ… Mark Solved') + .setStyle(ButtonStyle.Success) + ); + return { embeds: [embed], components: [row] }; +} + +/** + * One pass: nudge open help posts idle past the nudge threshold, and archive + * those still untouched by a human a while after their nudge. A human reply + * after the nudge clears the state so the post is treated as live again. + */ +async function sweepOnce(client: Client): Promise { + const now = Date.now(); + const posts = await fetchOpenHelpPosts(client); + const live = new Set(posts.map((p) => p.thread.id)); + + for (const { thread, lastActivityAt, lastFromBot } of posts) { + const nudgedAt = nudged.get(thread.id); + + if (nudgedAt) { + const humanReplied = !lastFromBot && lastActivityAt > nudgedAt; + if (humanReplied) { + nudged.delete(thread.id); + } else if (now - nudgedAt >= helpAssistConfig.staleArchiveMs) { + await thread.setArchived(true, 'Auto-archived: no activity after nudge').catch(() => null); + nudged.delete(thread.id); + } + continue; + } + + if (now - lastActivityAt >= helpAssistConfig.staleNudgeMs) { + const sent = await thread.send(nudgePayload()).catch(() => null); + if (sent) nudged.set(thread.id, now); + } + } + + // Forget state for threads that are no longer open (solved/archived/deleted). + for (const id of nudged.keys()) if (!live.has(id)) nudged.delete(id); +} + +/** + * Start the periodic stale-help-post sweep. No-op unless help channels are + * configured and the sweep is enabled. The interval is unref'd so it never + * keeps the process alive on its own. + */ +export function startStaleHelpSweep(client: Client): void { + if (!helpAssistConfig.staleSweepEnabled) return; + if (helpChannelIds.length === 0) return; + + const run = () => + sweepOnce(client).catch((error) => + container.logger.error('Stale help-post sweep failed:', error) + ); + + // First pass shortly after startup, then on a fixed interval. + setTimeout(run, 60_000).unref(); + setInterval(run, SWEEP_INTERVAL_MS).unref(); +} diff --git a/src/utils/tags.ts b/src/utils/tags.ts index 766dd3e..2247887 100644 --- a/src/utils/tags.ts +++ b/src/utils/tags.ts @@ -4,9 +4,33 @@ import { ButtonBuilder, ButtonStyle, } from 'discord.js'; -import universalEmbed from '../index'; +import universalEmbed from './embed'; -export default { +/** + * Shape of a single tag. Every field is optional because tags vary: most are + * rich embeds, a few are plain (or templated) text, and some opt out of the + * bot-commands-channel-only behaviour. + */ +/** + * An optional auto-suggest rule for a tag. When a user's message matches + * `pattern`, the suggestion engine offers this tag via a single button with + * `prompt` as the lead-in. Co-locating the trigger with the tag means adding a + * suggestible tag is a one-place change. + */ +export interface TagSuggestion { + pattern: RegExp; + prompt: string; +} + +export interface Tag { + embeds?: EmbedBuilder[]; + components?: ActionRowBuilder[]; + content?: string | ((user?: string) => string); + botCommandsOnly?: boolean; + suggest?: TagSuggestion; +} + +const tags: Record = { ai: { embeds: [ new EmbedBuilder(universalEmbed) @@ -15,10 +39,10 @@ export default { ) .setTitle('🚫 NO AI ZONE 🚫') .setDescription( - "This server is a **NO AI** zone. We do not allow the use of AI-generated content, including but not limited to ChatGPT, Midjourney, DALL-E, and other similar tools. This includes asking for help with code, schematics, or any other content that can be generated by AI.\n\nWe believe in the value of human creativity and problem-solving skills. We encourage you to learn and grow by engaging with the community and seeking help from fellow members.\n\nIf you have questions or need assistance, please ask the community for help. We're here to support each other and learn together! \n\nThank you for respecting this rule and helping to maintain the integrity of our community by NOT useing ai to reply to users, and if users are useing AI for there project, do not assist!", + "This server is a **NO AI** zone. We do not allow the use of AI-generated content, including but not limited to ChatGPT, Midjourney, DALL-E, and other similar tools. This includes asking for help with code, schematics, or any other content that can be generated by AI.\n\nWe believe in the value of human creativity and problem-solving skills. We encourage you to learn and grow by engaging with the community and seeking help from fellow members.\n\nIf you have questions or need assistance, please ask the community for help. We're here to support each other and learn together! \n\nThank you for respecting this rule and helping to maintain the integrity of our community by NOT using AI to reply to users, and if users are using AI for their project, do not assist!", ) .setFooter({ - text: 'Help us keep this a NO AI zone!\n\nYou do them no favors by using AI, as it will stunt there growth and learning.', + text: 'Help us keep this a NO AI zone!\n\nYou do them no favors by using AI, as it will stunt their growth and learning.', }), ], }, @@ -55,7 +79,7 @@ export default { components: [ new ActionRowBuilder().addComponents( new ButtonBuilder() - .setCustomId('codeblock') + .setCustomId('tag:codeblock') .setLabel('Learn How to Share Code') .setStyle(ButtonStyle.Primary), ), @@ -63,6 +87,10 @@ export default { }, avrdude: { + suggest: { + pattern: /stk500|avrdude[:\s]|not in sync/i, + prompt: 'Looks like an **AVRDUDE upload error**.', + }, embeds: [ new EmbedBuilder(universalEmbed) .setTitle('Solving AVRDUDE Communication Errors (Try These in Order)') @@ -103,7 +131,7 @@ export default { name: '6. Is your cable faulty or capable of sending data?', value: "Some USB cables aren't capable of transferring data, and some may be faulty, so make sure to try a different one to see if it works! You should try plugging another device into the cable to see if data can pass through it.\n" + - 'APPLE computers sometimes have issues with there usb adapters. You need to try other adaptors or buy an official one for your MAC.', + 'APPLE computers sometimes have issues with their USB adapters. You need to try other adaptors or buy an official one for your MAC.', }, { name: '7. Is the power LED lit on your board?', @@ -111,15 +139,15 @@ export default { 'If it is, unplug and re-plug your board, then check for blinking LEDs. If only the Power LED or no LEDs light up, ask for further assistance (not for all boards).', }, { - name: '8. Do you have a Nano or similar Atmega 328p-based board?', + name: '8. Do you have a Nano or other ATmega328P-based board?', value: - 'If so, try using the old bootloader. In the Arduino IDE, go to Tools -> Processor and select 328p (old bootloader). This only applies to Nanos and Nano variants. *Boards this applies to will show the Processor option in the Tools menu. Otherwise, you can skip this step.*', + 'If so, try using the old bootloader. In the Arduino IDE, go to Tools β†’ Processor and select **ATmega328P (Old Bootloader)**. This is most common on Nanos and Nano-style clones, but some other 328P-based boards expose it too. *If the Processor option appears in your Tools menu, it is worth a try; if it does not, you can skip this step.*', }, { name: '9. Does your onboard LED blink when you press the reset button?', value: "Try pressing the reset button on your Arduino. If the onboard LED doesn't blink when you reset, you probably have a broken bootloader. You can check out [this tutorial](https://www.arduino.cc/en/Hacking/Bootloader?from=Tutorial.Bootloader) on how to burn the bootloader.\n\n" + - 'It it does, try unplugging the board, holding down the reset button, then plugging it back in while still holding the reset button. After a few seconds, release the reset button and try uploading your sketch again. This is called the "manual reset" method and can sometimes help with communication issues. You can also hold down the button untill its done compileing and tries to UPLOAD, then release it. This is called the "manual reset" method and can sometimes help with communication issues.', + 'If it does, try unplugging the board, holding down the reset button, then plugging it back in while still holding the reset button. After a few seconds, release the reset button and try uploading your sketch again. This is called the "manual reset" method and can sometimes help with communication issues. You can also hold down the button until its done compiling and tries to UPLOAD, then release it. This is called the "manual reset" method and can sometimes help with communication issues.', }, { name: "10. Is this a problem on your computer's side?", @@ -146,6 +174,11 @@ export default { }, debounce: { + suggest: { + pattern: + /debounc|button.*(bounc|multiple|several times|twice)|reading (multiple|several) (presses|times)/i, + prompt: 'Sounds like a **switch debouncing** problem.', + }, embeds: [ new EmbedBuilder(universalEmbed) .setTitle('πŸ”˜ Taming Bouncy Buttons: Understanding Debouncing') @@ -207,6 +240,11 @@ export default { }, espcomm: { + suggest: { + pattern: + /espcomm|esptool|failed to connect to esp|wrong boot mode|a fatal error occurred.*(packet|connect|timed out)/i, + prompt: 'Looks like an **ESP upload / connection** problem.', + }, embeds: [ new EmbedBuilder(universalEmbed) .setTitle( @@ -239,7 +277,7 @@ export default { { name: '6. Have you tried holding down the BOOT/IO0/FLASH button?', value: - 'Unhook the board, wait 10 secconds, then try holding down the BOOT/IO0/FLASH button. Keep holding it down untill **AFTER** its done compileing, and the IDE says "uploading".\n\nThen release the button. This puts the board into flash mode, and can sometimes help with communication issues.\n\nThe boards are so bad sometimes the buttons are labeled backwords, so try again with the other button if it has one.', + 'Unhook the board, wait 10 seconds, then try holding down the BOOT/IO0/FLASH button. Keep holding it down until **AFTER** its done compiling, and the IDE says "uploading".\n\nThen release the button. This puts the board into flash mode, and can sometimes help with communication issues.\n\nThe boards are so bad sometimes the buttons are labeled backwards, so try again with the other button if it has one.', }, { name: '7. Are there any problems with your wiring?', @@ -288,6 +326,11 @@ export default { }, hid: { + suggest: { + pattern: + /\bhid\b|keyboard\.h|mouse\.h|emulat(e|ing) (a )?(keyboard|mouse)|act as a (keyboard|mouse)/i, + prompt: 'Looks like a **USB HID (keyboard/mouse)** question.', + }, embeds: [ new EmbedBuilder({ ...universalEmbed }) .setTitle('Can Your Arduino Be Used as a Keyboard or Mouse?') @@ -298,7 +341,7 @@ export default { { name: 'Boards that are __NOT__ HID compliant', value: - 'Uno (R3 or older), Mega, Nano (328), and Pro Mini cannot be used as HID devices. Attemppting to do so will result in a bricked board.', + 'Uno (R3 or older), Mega, Nano (328), and Pro Mini cannot be used as HID devices. Attempting to do so will result in a bricked board.', }, { name: 'Boards that __ARE__ HID compliant', @@ -352,6 +395,11 @@ export default { }, levelShifter: { + suggest: { + pattern: + /level\s?shift|logic[- ]?level|3\.3\s?v?\s*(to|->|β†’)\s*5\s?v|5\s?v?\s*(to|->|β†’)\s*3\.3\s?v/i, + prompt: 'Sounds like a **logic-level / voltage-level** question.', + }, embeds: [ new EmbedBuilder(universalEmbed) .setTitle('Logic Level Shifters: Protecting Your 3.3V Modules') @@ -359,7 +407,7 @@ export default { { name: 'The Problem: Voltage Mismatch', value: - "Many popular Arduino boards, like the Uno and Mega operate at **5 Volts (5V)**. This means their digital pins operate at 5V for a 'HIGH' signal, and they expect 5v in return **3.3v will not be reconized**.\n\nHowever, a lot of modern modules and sensors (like the NRF24L01, ESP8266, SD cards) are designed to operate at **3.3 Volts (3.3V)**. Their **input or GPIO** pins are often **NOT 5V tolerant** and they can **NOT repibably send 5v to devices** either.\n\n Its like some one whispering softly to someone that is hard of hearing. They can not commonunicate consistantly. https://wiki.arduinodiscord.cc/hardwareGuides/logiclevel", + "Many popular Arduino boards, like the Uno and Mega operate at **5 Volts (5V)**. This means their digital pins operate at 5V for a 'HIGH' signal, and they expect 5v in return **3.3v will not be recognized**.\n\nHowever, a lot of modern modules and sensors (like the NRF24L01, ESP8266, SD cards) are designed to operate at **3.3 Volts (3.3V)**. Their **input or GPIO** pins are often **NOT 5V tolerant** and they can **NOT reliably send 5v to devices** either.\n\n It's like someone whispering softly to someone who is hard of hearing. They cannot communicate consistently. https://wiki.arduinodiscord.cc/hardwareGuides/logiclevel", }, { name: 'What Happens if You Connect 5V to a 3.3V Pin?', @@ -399,6 +447,11 @@ export default { }, libmissing: { + suggest: { + pattern: + /no such file or directory|fatal error:.*\.h|\.h: No such file|library.*(not found|is not installed|missing)/i, + prompt: 'Looks like a **missing library / header** error.', + }, embeds: [ new EmbedBuilder({ ...universalEmbed }) .setTitle('Solving Library Errors (Such as "yourlib.h not found")') @@ -457,19 +510,28 @@ export default { }, ninevolt: { + suggest: { + pattern: /\b9\s?v(olt)?\b.*batter|batter.*\b9\s?v(olt)?\b|smoke (alarm|detector) batter/i, + prompt: 'Heads up β€” this looks like the **9V battery** pitfall.', + }, embeds: [ new EmbedBuilder({ ...universalEmbed }) - .setTitle('Nine Volt usefullness') + .setTitle('Nine Volt usefulness') .setDescription('Not very useful') .addFields({ name: 'Dies quickly', value: - 'Nine-volt batteries are not very useful for powering Arduinos or other electronics. They have a low capacity and will die quickly 15 minutes or less, especially if you are using them to power an Arduino. They do not have enough power to drive motors or servos. They rarely have a usefull purpose in the Arduino world, and are not recommended for use with Arduinos, even though many kits come with them. https://odysee.com/@Maderdash:2/9vBattery:0', + 'Nine-volt batteries are not very useful for powering Arduinos or other electronics. They have a low capacity and will die quickly 15 minutes or less, especially if you are using them to power an Arduino. They do not have enough power to drive motors or servos. They rarely have a useful purpose in the Arduino world, and are not recommended for use with Arduinos, even though many kits come with them. https://odysee.com/@Maderdash:2/9vBattery:0', }), ], }, power: { + suggest: { + pattern: + /brown\s?out|not enough (power|current)|voltage drop|how (do i|to) power (my|the|a)|powering (my|the|a) (board|arduino|esp|nano|uno|mega)/i, + prompt: 'Looks like a **powering your board** question.', + }, embeds: [ new EmbedBuilder({ ...universalEmbed }) .setTitle('Powering an Arduino') @@ -487,7 +549,7 @@ export default { { name: '3. Powering the Arduino properly', value: - 'On most Arduino boards, there will be a pin marked **VIN**. This stands for Voltage Input. You can provide the maximum power rating for your board on this pin. An UNO will accept 7-12V. The voltage reagulator requires around 2v above what is trying to be supplied out. 5v plus 2v for the overhead = 7v+. Also the more voltage you supply to this pin, the less current you can get out of the board. Example: 7v you can get 400ma out of the reagulator. 12v you can get 100ma. 24v might over heat the reagulator useing just the board its self. If you have a regulated 5-volt power supply, you can sometimes use the 5V pin to power the Arduino. You should **NOT** connect batteries to the 5V or 3.3V pins. You can also power Arduinos via USB, this is conected directly to the 5v PIN on the board, or the barrel jack, that is conected directly to the VIN pin on some boards.', + 'On most Arduino boards, there will be a pin marked **VIN**. This stands for Voltage Input. You can provide the maximum power rating for your board on this pin. An UNO will accept 7-12V. The voltage regulator requires around 2v above what is trying to be supplied out. 5v plus 2v for the overhead = 7v+. Also the more voltage you supply to this pin, the less current you can get out of the board. Example: 7v you can get 400ma out of the regulator. 12v you can get 100ma. 24v might overheat the regulator using just the board itself. If you have a regulated 5-volt power supply, you can sometimes use the 5V pin to power the Arduino. You should **NOT** connect batteries to the 5V or 3.3V pins. You can also power Arduinos via USB, this is conected directly to the 5v PIN on the board, or the barrel jack, that is conected directly to the VIN pin on some boards.', }, { name: '4. The 3.3V pin', @@ -504,6 +566,11 @@ export default { }, pullup: { + suggest: { + pattern: + /pull[\s-]?up|pull[\s-]?down|floating (pin|input)|button.*(random|float|noisy)|reads? (randomly|high and low)/i, + prompt: 'Sounds like a **pull-up / floating input** issue.', + }, embeds: [ new EmbedBuilder({ ...universalEmbed }) .setTitle('What does pull-up (or pull-down) mean, and how do I use it?') @@ -610,3 +677,5 @@ export default { // requiredRoles: ['Admin', 'Moderator'], // Role names or IDs // }, }; + +export default tags; diff --git a/tsconfig.json b/tsconfig.json index 9a932b5..3e3203e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,6 @@ // --- Module Configuration --- "module": "Node16", "moduleResolution": "Node16", - "baseUrl": "./", // Allows for absolute paths from the root "target": "ES2022", // Updated to a more modern target "lib": ["ESNext"],