Compare commits

...

64 commits

Author SHA1 Message Date
7ea961e135
fix middlewares 2024-08-27 12:13:00 -04:00
14c30beb49
fix middlewares 2024-08-27 12:12:40 -04:00
c973278b5a
fix middlewares 2024-08-27 12:08:19 -04:00
25941e0437
fix middlewares 2024-08-27 11:57:45 -04:00
410a786497
fix app.Use 2024-08-27 11:49:49 -04:00
Declan Chidlow
83372c8d17 Bump Axios version 2024-08-27 18:06:59 +08:00
Declan Chidlow
2d4441ebbc Fix error detecting author of system messages 2024-08-27 17:39:22 +08:00
Declan Chidlow
b29a04e5b3 Split up botctl 2024-08-26 13:46:12 +08:00
Declan Chidlow
d299b47bfa Rename docker-compose to just compose 2024-08-23 16:20:30 +08:00
Declan Chidlow
93b0a67d07 Fix command file structure 2024-08-16 13:05:26 +08:00
Declan Chidlow
05c40d4b21 Fix some naming 2024-08-16 12:54:19 +08:00
Declan Chidlow
c4ac1f789f Update command documentation 2024-08-16 12:35:27 +08:00
Declan Chidlow
8a15e2a3a3 Update links 2024-08-15 15:38:57 +08:00
Declan Chidlow
3e5cf70f9e Add support for alternate instances 2024-08-15 15:22:06 +08:00
Declan Chidlow
2df936f0bb Bump Axios version 2024-08-09 14:27:23 +08:00
Declan Chidlow
df1b9275ef Consistent type imports and exports for bot 2024-08-09 13:51:43 +08:00
Declan Chidlow
345eec93fc Update bot tsconfig and apply knock on changes. Remove antispam enrollment notification for discover server owners. 2024-08-08 19:22:47 +08:00
Declan Chidlow
9eac9fffc8 Update libtsconfig 2024-08-08 17:43:57 +08:00
Declan Chidlow
c80cf77c08 Bump API deps 2024-08-08 17:06:23 +08:00
Declan Chidlow
acb97d9c48 Update docker compose to fix issues with api connecting 2024-08-07 12:51:11 +08:00
Declan Chidlow
b90aadfbba Remove now unused gitmodules 2024-08-07 12:34:25 +08:00
Declan Chidlow
a16a72e388 Fixed error error 2024-08-07 12:30:37 +08:00
Declan Chidlow
5ffe0a9434 Remove unneeded scripts 2024-08-07 12:30:02 +08:00
Declan Chidlow
74cc0d1e53 Fixed casing of 'as' and 'FROM' keywords 2024-08-07 11:47:11 +08:00
Declan Chidlow
0587d08437 Remove management panel (will be reimplemented) 2024-08-07 11:18:36 +08:00
Declan Chidlow
2c851516b2 I forget 2024-07-14 18:42:59 +08:00
Declan Chidlow
bc7c15271e Bump dependencies on api 2024-07-14 17:01:31 +08:00
Declan Chidlow
cf845aff4b Bump dependencies on bot 2024-07-14 16:58:47 +08:00
Declan Chidlow
c126b89a09 Bump dependencies on lib 2024-07-14 16:55:33 +08:00
Declan Chidlow
269765ee59 Bump dependencies on web frontend 2024-07-14 16:41:27 +08:00
Declan Chidlow
b57cac1639 Bump dependancies and remove depreciated Revolt React components 2024-07-14 16:16:29 +08:00
Declan Chidlow
6beeffe0fb Further remove bridge functionality 2024-07-14 15:43:17 +08:00
Declan Chidlow
bf6cff8f61 Changes, apparently 2024-07-14 11:04:02 +08:00
Declan Chidlow
530c3ac4ee Remove unnecessary image from web 2024-07-14 10:27:49 +08:00
Declan Chidlow
1e6d255697 Remove bridge metrics 2024-07-14 10:23:13 +08:00
Declan Chidlow
8d9bbc1893 De monk-ify 2024-07-13 20:58:10 +08:00
Declan Chidlow
bfd2ea2094 Update typescript configs 2024-07-13 20:18:12 +08:00
Declan Chidlow
13db769a22 Update bot tsconfig.json 2024-07-13 19:49:06 +08:00
Declan Chidlow
74c2a2025b Remove Vercel config 2024-07-13 19:39:08 +08:00
Declan Chidlow
adcf1b73cf Remove some now unused blacklist functionality 2024-07-13 19:26:04 +08:00
Declan Chidlow
3111c35b98 Update test command 2024-07-13 19:14:20 +08:00
Declan Chidlow
0a29694764 Remove more blacklist functionality 2024-07-13 17:42:37 +08:00
Declan Chidlow
d9d3726aa9 Better handle WS connection errors 2024-07-13 16:47:24 +08:00
Declan Chidlow
474986b990 Remove unused package.json bits 2024-07-13 12:22:40 +08:00
Declan Chidlow
f29c821fc7 Use npm's Revolt.js 2024-07-13 11:47:00 +08:00
Declan Chidlow
bd6712c750 Move bun ver to latest in docker 2024-07-13 11:34:19 +08:00
Declan Chidlow
626f544ea7 bump ws ver 2024-07-13 11:29:49 +08:00
Declan Chidlow
742797fab4 Move API to bun 2024-07-12 20:58:58 +08:00
Declan Chidlow
4bc049064b Update help 2024-07-12 19:09:54 +08:00
Declan Chidlow
0cd5a4e21c Update ping command 2024-07-12 18:24:48 +08:00
Declan Chidlow
01c4531129 Remove more Discord. Start migration to bun 2024-07-12 18:20:10 +08:00
Declan Chidlow
a74def6812 Update help 2024-07-12 17:19:31 +08:00
Declan Chidlow
e1f5927e89 remove depreciated docker-compose version 2024-07-12 16:41:37 +08:00
Declan Chidlow
efca4cce51 Cleanup .env and docker compose 2024-07-12 16:37:30 +08:00
Declan Chidlow
b81b3db842 Remove references to Discord 2024-07-12 16:32:02 +08:00
Declan Chidlow
84e09da269 Remove bridge functionality 2024-07-12 16:18:14 +08:00
Declan Chidlow
9f1634061a Update gitignore 2024-07-12 15:57:00 +08:00
Declan Chidlow
798da1d6e0 Update script shebangs 2024-07-12 15:52:36 +08:00
Declan Chidlow
69ad5cb96e Removed docker workflow 2024-07-12 15:49:51 +08:00
Declan Chidlow
df4823ee10 Update support link 2024-07-12 15:49:09 +08:00
Declan Chidlow
8286650a31 Slight repo cleanup 2024-06-16 12:13:48 +08:00
Declan Chidlow
c5547786ac
Merge branch 'sussycatgirl:master' into stable 2024-06-10 13:49:24 +08:00
Declan Chidlow
c32dbd4e3f
Create FUNDING.yml 2024-04-29 09:23:13 +08:00
Lea
c05acc34bf
Update README.md 2024-03-22 11:35:28 +01:00
159 changed files with 3339 additions and 17308 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

View file

@ -1,2 +0,0 @@
private/
db/

View file

@ -11,15 +11,12 @@
DB_PASS=
# Base URL of the Revolt API to connect to.
# Defaults to https://api.revolt.chat
API_URL=
# Defaults to https://revolt.chat/api
REVOLT_API_URL=
# Your bot account's token.
BOT_TOKEN=
# A Discord bot token, used for the bridge service.
BOT_TOKEN_DISCORD=
# The default prefix, can be overridden by server owners.
# Leave empty to keep default ("/")
BOT_PREFIX=
@ -44,10 +41,6 @@ INTERNAL_API_TOKEN=
# which gives them access to your server!
BOT_OWNERS=
# Optional: A Discord webhook URL which will be
# used to log certain events (bot started, etc).
LOG_WEBHOOK=
# Optional: If set, enables Prometheus metrics
# on the specified port (Under /metrics).
# Note that no authentication can be configured;
@ -55,10 +48,6 @@ LOG_WEBHOOK=
# BasicAuth or a different form of authentication.
BOT_METRICS_PORT=
# Same as above, but for the bridge service.
# Make sure the ports don't overlap!
BRIDGE_METRICS_PORT=
# Optional: Set this to a channel ID if you
# want Prometheus metrics to return `msg_ping`.
# The bot will regularly send a message in that
@ -71,11 +60,3 @@ BOT_METRICS_MSG_PING_CHANNEL=
PUBLIC_API_URL=http://localhost:9000
PUBLIC_WEB_URL=http://localhost:8080
# List of servers to use for bridging Revolt
# emojis to Discord. You will need at least 2
# servers. Revolt emojis will be automatically
# downloaded and synced to these servers.
# Please note that all other emojis will be deleted.
# Server IDs, divided by commas without spaces in between.
BRIDGE_EMOJI_SERVERS=

3
.github/FUNDING.yml vendored Normal file
View file

@ -0,0 +1,3 @@
github: DeclanChidlow
ko_fi: valence
custom: "https://vale.rocks/support"

View file

@ -1,174 +0,0 @@
name: Docker
on:
push:
branches:
- "master"
tags:
- "*"
workflow_dispatch:
jobs:
publish_api:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
-
name: Docker meta
id: meta
uses: docker/metadata-action@v4
with:
# list of Docker images to use as base name for tags
images: ghcr.io/sussycatgirl/automod-api
# generate Docker tags based on branch and short commit SHA
tags: |
type=ref,event=branch
type=sha
-
name: Set up QEMU
uses: docker/setup-qemu-action@v2
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
-
name: Login to DockerHub
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
-
name: Build and push - API
uses: docker/build-push-action@v3
with:
context: .
file: ./api/Dockerfile
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
publish_web:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
-
name: Docker meta
id: meta
uses: docker/metadata-action@v4
with:
# list of Docker images to use as base name for tags
images: ghcr.io/sussycatgirl/automod-web
# generate Docker tags based on branch and short commit SHA
tags: |
type=ref,event=branch
type=sha
-
name: Set up QEMU
uses: docker/setup-qemu-action@v2
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
-
name: Login to DockerHub
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
-
name: Build and push - Web
uses: docker/build-push-action@v3
with:
context: .
file: ./web/Dockerfile
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
publish_bridge:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
with:
submodules: recursive
-
name: Docker meta
id: meta
uses: docker/metadata-action@v4
with:
# list of Docker images to use as base name for tags
images: ghcr.io/sussycatgirl/automod-bridge
# generate Docker tags based on branch and short commit SHA
tags: |
type=ref,event=branch
type=sha
-
name: Set up QEMU
uses: docker/setup-qemu-action@v2
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
-
name: Login to DockerHub
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
-
name: Build and push - Bridge
uses: docker/build-push-action@v3
with:
context: .
file: ./bridge/Dockerfile
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
publish_bot:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
with:
submodules: recursive
-
name: Docker meta
id: meta
uses: docker/metadata-action@v4
with:
# list of Docker images to use as base name for tags
images: ghcr.io/sussycatgirl/automod-bot
# generate Docker tags based on branch and short commit SHA
tags: |
type=ref,event=branch
type=sha
-
name: Set up QEMU
uses: docker/setup-qemu-action@v2
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
-
name: Login to DockerHub
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
-
name: Build and push - Bot
uses: docker/build-push-action@v3
with:
context: .
file: ./bot/Dockerfile
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

28
.gitignore vendored
View file

@ -1,10 +1,22 @@
node_modules
dist
.env
yarn-error.log
docker-compose.yml
/db
.vercel
# IDE / editor specific ignores
.idea/
.vscode/
# For stuff like docker mounts
# Environment files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# JavaScript
node_modules
yarn-error.log
# Docker
compose.yml
# Misc
dist
/db
/private

4
.gitmodules vendored
View file

@ -1,4 +0,0 @@
[submodule "revolt.js"]
path = revolt.js
url = https://github.com/revoltchat/revolt.js
branch = insert/feat/store-rewrite

32
.vscode/launch.json vendored
View file

@ -1,32 +0,0 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"command": "cd bot && yarn dev",
"name": "Bot",
"request": "launch",
"type": "node-terminal",
},
{
"command": "cd api && yarn dev",
"name": "API",
"request": "launch",
"type": "node-terminal",
},
{
"command": "cd web && yarn dev",
"name": "Web",
"request": "launch",
"type": "node-terminal",
},
{
"command": "cd bridge && yarn dev",
"name": "Bridge",
"request": "launch",
"type": "node-terminal",
}
]
}

View file

@ -1,5 +0,0 @@
{
"editor.formatOnSave": false,
"editor.formatOnSaveMode": "modifications",
"prettier.tabWidth": 4
}

View file

@ -1,32 +1,27 @@
# AutoMod
<div align="center">
<h1>
AutoMod
[![Stars](https://img.shields.io/github/stars/DeclanChidlow/AutoMod?style=flat-square&logoColor=white)](https://github.com/DeclanChidlow/AutoMod/stargazers)
[![Forks](https://img.shields.io/github/forks/DeclanChidlow/AutoMod?style=flat-square&logoColor=white)](https://github.com/DeclanChidlow/AutoMod/network/members)
[![Pull Requests](https://img.shields.io/github/issues-pr/DeclanChidlow/AutoMod?style=flat-square&logoColor=white)](https://github.com/DeclanChidlow/AutoMod/pulls)
[![Issues](https://img.shields.io/github/issues/DeclanChidlow/AutoMod?style=flat-square&logoColor=white)](https://github.com/DeclanChidlow/AutoMod/issues)
[![Contributors](https://img.shields.io/github/contributors/DeclanChidlow/AutoMod?style=flat-square&logoColor=white)](https://github.com/DeclanChidlowRepo/Template/graphs/contributors)
[![Licence](https://img.shields.io/github/license/DeclanChidlow/AutoMod?style=flat-square&logoColor=white)](https://github.com/DeclanChidlow/AutoMod/blob/main/LICENCE)
</h1>
Moderation bot for Revolt
</div>
<br/>
The best moderation bot for Revolt! \
<sub><sup>Or so they say...</sup></sub>
[<img src="https://img.shields.io/badge/dynamic/json?labelColor=ff6e6d&color=15283f&label=Revolt%20Server&query=member_count&suffix=%20Members&url=https%3A%2F%2Fapi.revolt.chat%2Finvites%2Fjan&style=for-the-badge&cacheSeconds=60&logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA4AAAAOCAYAAAAfSC3RAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAABmJLR0QAAAAAAAD5Q7t/AAAACXBIWXMAAABgAAAAYADwa0LPAAABCUlEQVQoz6WQzUoCYRSGn/MhI4HuHCFwKzRbN5JkRes2dQddQV1G7WrhOvdC15CRGdQuMKOtC8cBowySj8nTIgxHG5V6d+fn4T3nhRhlvHIh45ULcXOZqk3W295R1UNg97ulDVTOgqfsBdQ+I2Aut75i086BKkdAPsbkWYRTZ2DPO53mhwEYppJFVSpzIIC8KpVhKlkEMPxR/wNlRLgsMN41AGrC3tJWCbo/qQLirm35gLsA84N2fRXQ8Y+qQnWhm0oV0Eg4Vuwx4M/ButYJT2ZSfWs1+4LuA++/QAMjsvf6cP0yAwL02lc3Bi0B9xPtOyNS8h8vbyPpxpxlXG9zAxENWvUGMJpe+ALe41VhoX4U6QAAACV0RVh0ZGF0ZTpjcmVhdGUAMjAyMi0wMy0yM1QxOTo0MDozOCswMDowMGtN3HYAAAAldEVYdGRhdGU6bW9kaWZ5ADIwMjItMDMtMjNUMTk6NDA6MzgrMDA6MDAaEGTKAAAAAElFTkSuQmCC" />](https://rvlt.gg/jan) <!-- @EnokiUN made this uwu -->
[<img src="https://img.shields.io/badge/dynamic/json?labelColor=ff6e6d&color=15283f&label=Add%20the%20bot&query=servers&suffix=%20Servers&url=https%3A%2F%2Fapi.automod.me%2Fstats&style=for-the-badge&cacheSeconds=60&logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA4AAAAOCAYAAAAfSC3RAAAAAXNSR0IArs4c6QAAAC9JREFUKJFjZMACRDXs/yPzX984yIiuhgmbRmLAENLIiB4QNLdxAPyITXA0AUAAAAZ+CZVOTi2mAAAAAElFTkSuQmCC" />](https://app.revolt.chat/bot/01FHGJ3NPP7XANQQH8C2BE44ZY)
AutoMod aims to protect your community from spam and malicious actors. It offers powerful moderation tools, an infraction system, a web interface and in the future will be able to automatically prevent raids and ban malicious actors.
<img src=".assets/demo.gif" />
AutoMod is a general purpose moderation bot for [Revolt](https://revolt.chat) that aims to protect your community from spam and malicious actors. It offers powerful moderation tools, an infraction system, and a web interface.
## Features
* Basic moderation features, including kick, ban, warn, purge, and timeout
* Infraction system, which logs all moderation events
* Configurable antispam rules
* Web interface for easy configuration
* Fully configurable bot prefix
* Permission system for moderation commands
- Basic moderation features: Kick/Ban/Tempban/Warn
- Infraction system which logs all moderation events
- Rudamentary configurable antispam rules (If user sends more than X messages per Y seconds, delete/warn/send message)
- Web interface for easy configuration
- Fully configurable bot prefix
- Permission system for moderation commands
- Votekick system to allow trusted users to remove users while no moderator is available*
- Act on messages based on regex matches (delete/warn/send message)*
- Log moderation events, message edits and deletions to a Revolt channel or Discord Webhook*
- Scan user profiles and alert moderators of suspicious users (Wordlist is private and provided by the bot admin)*
- And more is planned!
\* Technically a feature, but not yet configurable without database access. If you would like to set any of these up right now, feel free to get in contact with me.
## Donations
If you want to support this project, check out [this page](https://janderedev.xyz/donate). If you don't want to help financially, feel free to join [my server](https://rvlt.gg/jan) and provide mental support in the form of catgirls instead!
## Add the bot
What are you waiting for? Simply click [this link](https://app.revolt.chat/bot/01FHGJ3NPP7XANQQH8C2BE44ZY) to add the bot to your server.

13
api/.gitignore vendored
View file

@ -1,7 +1,6 @@
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# IDE / editor specific ignores
.idea/
.vscode/
# JavaScript
node_modules

File diff suppressed because one or more lines are too long

View file

@ -1,2 +0,0 @@
yarnPath: .yarn/releases/yarn-3.2.1.cjs
nodeLinker: node-modules

View file

@ -1,19 +1,16 @@
FROM node:18 as build
FROM oven/bun:latest AS build
WORKDIR /build/app
COPY api/package.json api/yarn.lock api/.yarnrc.yml ./
COPY api/.yarn ./.yarn
COPY api/package.json api/bun.lockb ./
COPY lib ../lib
RUN yarn --cwd ../lib --immutable
RUN yarn --cwd ../lib build
RUN yarn install --immutable
RUN cd ../lib && bun install --frozen-lockfile && bun run build
RUN bun install --frozen-lockfile
COPY ./api .
RUN yarn build
RUN bun run build
FROM node:18 as prod
FROM oven/bun:latest AS prod
WORKDIR /app/api
COPY --from=build /build/app/package.json /build/app/yarn.lock /build/app/.yarnrc.yml ./
COPY --from=build /build/app/.yarn ./.yarn
COPY --from=build /build/app/package.json /build/app/bun.lockb ./
COPY --from=build /build/app/dist ./dist
COPY --from=build /build/lib ../lib
RUN yarn install --immutable
CMD ["yarn", "start"]
RUN bun install --production --frozen-lockfile
CMD ["bun", "start"]

BIN
api/bun.lockb Executable file

Binary file not shown.

View file

@ -1,35 +1,31 @@
{
"name": "api",
"version": "1.0.0",
"description": "",
"exports": "./index",
"type": "module",
"scripts": {
"build": "rm -rf dist && tsc",
"start": "node --experimental-specifier-resolution=node dist/index",
"dev": "yarn build && yarn start"
"build": "rm -rf dist && bun build ./src/index.ts --outdir=dist --target=bun --sourcemap",
"start": "bun dist/index.js",
"dev": "bun run build && bun run start"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@types/express": "^4.17.13",
"@types/monk": "^6.0.0",
"@types/ws": "^8.2.2",
"@types/express": "^4.17.21",
"@types/ws": "^8.5.12",
"automod": "^0.1.0",
"dotenv": "^14.2.0",
"express": "^4.17.3",
"log75": "^2.2.0",
"monk": "^7.3.4",
"redis": "^4.2.0",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"log75": "^3.0.1",
"mongodb": "^6.8.0",
"redis": "^4.7.0",
"ulid": "^2.3.0",
"ws": "^8.4.2"
"ws": "^8.18.0"
},
"devDependencies": {
"typescript": "^4.5.5"
"@types/bun": "^1.1.6",
"bun-types": "latest"
},
"packageManager": "yarn@3.2.1",
"resolutions": {
"automod": "portal:../lib"
"module": "index.ts",
"peerDependencies": {
"typescript": "^4.9.5"
}
}

View file

@ -1,34 +1,44 @@
import Monk, { IMonkManager } from 'monk';
import { MongoClient, Db } from 'mongodb';
import Redis from 'redis';
import { logger } from '.';
export default (): IMonkManager => {
let dburl = getDBUrl();
let db = Monk(dburl);
return db;
};
let db: Db;
const redis = Redis.createClient({ url: process.env.REDIS_URL });
export default async function buildDBClient(): Promise<Db> {
if (db) return db;
const url = getDBUrl();
const client = new MongoClient(url);
try {
await client.connect();
db = client.db();
logger.info('Connected successfully to MongoDB');
return db;
} catch (error) {
logger.error(`Failed to connect to MongoDB: ${error}`);
throw error;
}
}
export { redis }
const redis = Redis.createClient({ url: process.env['REDIS_URL'] });
export { redis };
// Checks if all required env vars were supplied, and returns the mongo db URL
function getDBUrl() {
let env = process.env;
function getDBUrl(): string {
const env = process.env;
if (env['DB_URL']) return env['DB_URL'];
if (!env['DB_HOST']) {
logger.error(`Environment variable 'DB_HOST' not set, unable to connect to database`);
logger.error(`Specify either 'DB_URL' or 'DB_HOST', 'DB_USERNAME', 'DB_PASS' and 'DB_NAME'`);
throw 'Missing environment variables';
throw new Error('Missing environment variables');
}
// mongodb://username:password@hostname:port/dbname
let dburl = 'mongodb://';
if (env['DB_USERNAME']) dburl += env['DB_USERNAME'];
if (env['DB_PASS']) dburl += `:${env['DB_PASS']}`;
dburl += `${process.env['DB_USERNAME'] ? '@' : ''}${env['DB_HOST']}`; // DB_HOST is assumed to contain the port
dburl += `${env['DB_USERNAME'] ? '@' : ''}${env['DB_HOST']}`; // DB_HOST is assumed to contain the port
dburl += `/${env['DB_NAME'] ?? 'automod'}`;
return dburl;
}

View file

@ -5,7 +5,7 @@ import buildDBClient, { redis } from './db';
config();
const PORT = Number(process.env.API_PORT || 9000);
const PORT = Number(process.env['API_PORT'] || 9000);
const DEBUG = process.env.NODE_ENV != 'production';
const SESSION_LIFETIME = 1000 * 60 * 60 * 24 * 7;

View file

@ -1,7 +1,7 @@
import { Request, Response } from "express";
import { Request, Response, NextFunction } from "express";
import { app } from "..";
app.use('*', (req: Request, res: Response, next: () => void) => {
app.use('*', (_req: Request, res: Response, next: NextFunction) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, x-auth-user, x-auth-token');
res.header('Access-Control-Allow-Methods', '*');

View file

@ -1,7 +1,7 @@
import { Request, Response } from "express";
import { Request, Response, NextFunction } from "express";
import { app, logger } from "..";
app.use('*', (req: Request, res: Response, next: () => void) => {
app.use('*', (req: Request, _res: Response, next: NextFunction) => {
logger.debug(`${req.method} ${req.url}`);
next();
});

View file

@ -1,4 +1,4 @@
import { Request, Response } from "express";
import { Request, Response, NextFunction } from "express";
import { ulid } from "ulid";
import { app, logger } from "..";
import { redis } from "../db";
@ -14,34 +14,37 @@ class RateLimiter {
this.timeframe = limits.timeframe;
}
async execute(req: Request, res: Response, next: () => void) {
try {
const ip = req.ip;
const reqId = ulid();
// ratelimit:ip_address_base64:route_base64
const redisKey = `ratelimit:${Buffer.from(ip).toString('base64')}:${Buffer.from(this.route).toString('base64')}`;
const reqs = await redis.SCARD(redisKey);
if (reqs >= this.limit) {
logger.debug(`Ratelimiter: IP address exceeded ratelimit for ${this.route} [${this.limit}/${this.timeframe}]`);
res
.status(429)
.send({
error: 'You are being rate limited.',
limit: this.limit,
timeframe: this.timeframe,
});
} else {
next();
await redis.SADD(redisKey, reqId);
await redis.sendCommand([ 'EXPIREMEMBER', redisKey, reqId, this.timeframe.toString() ]);
middleware() {
return async (req: Request, res: Response, next: NextFunction) => {
try {
const ip = req.ip;
const reqId = ulid();
// ratelimit:ip_address_base64:route_base64
const redisKey = `ratelimit:${Buffer.from(ip!).toString('base64')}:${Buffer.from(this.route).toString('base64')}`;
const reqs = await redis.SCARD(redisKey);
if (reqs >= this.limit) {
logger.debug(`Ratelimiter: IP address exceeded ratelimit for ${this.route} [${this.limit}/${this.timeframe}]`);
res
.status(429)
.send({
error: 'You are being rate limited.',
limit: this.limit,
timeframe: this.timeframe,
});
} else {
await redis.SADD(redisKey, reqId);
await redis.sendCommand([ 'EXPIREMEMBER', redisKey, reqId, this.timeframe.toString() ]);
next();
}
} catch(e) {
console.error(e);
next(e);
}
} catch(e) { console.error(e) }
};
}
}
app.use('*', (...args) => (new RateLimiter('*', { limit: 20, timeframe: 1 })).execute(...args));
const globalRateLimiter = new RateLimiter('*', { limit: 20, timeframe: 1 });
app.use('*', globalRateLimiter.middleware());
export { RateLimiter }
export { RateLimiter };

View file

@ -1,19 +1,33 @@
import { Request, Response } from "express";
import { FindOneResult } from "monk";
import { app, db, SESSION_LIFETIME } from "..";
import { Request, Response, NextFunction } from "express";
import { Collection, Db } from "mongodb";
import { app, SESSION_LIFETIME } from "..";
app.use('*', async (req: Request, res: Response, next: () => void) => {
let sessionsCollection: Collection;
export function initializeSessionsMiddleware(db: Db) {
sessionsCollection = db.collection('sessions');
}
app.use('*', async (req: Request, _res: Response, next: NextFunction) => {
next();
const user = req.header('x-auth-user');
const token = req.header('x-auth-token');
if (!user || !token) return;
try {
const session: FindOneResult<any> = await db.get('sessions').findOne({ user, token, expires: { $gt: Date.now() } });
const session = await sessionsCollection.findOne({
user,
token,
expires: { $gt: new Date() }
});
if (session) {
await db.get('sessions').update({ _id: session._id }, { $set: { expires: Date.now() + SESSION_LIFETIME } });
await sessionsCollection.updateOne(
{ _id: session._id },
{ $set: { expires: new Date(Date.now() + SESSION_LIFETIME) } }
);
}
} catch(e) { console.error(e) }
} catch(e) {
console.error(e);
}
});

View file

@ -1,10 +1,16 @@
import { app, db } from '../..';
import { app } from '../..';
import { Request, Response } from 'express';
import { badRequest, ensureObjectStructure, isAuthenticated, requireAuth, unauthorized } from '../../utils';
import { botReq } from '../internal/ws';
import { FindOneResult } from 'monk';
import { Collection, Db } from 'mongodb';
import { ulid } from 'ulid';
let serversCollection: Collection;
export function initializeAutomodAPI(database: Db) {
serversCollection = database.collection('servers');
}
type AntispamRule = {
id: string;
max_msg: number;
@ -14,7 +20,7 @@ type AntispamRule = {
message: string | null;
}
app.get('/dash/server/:server/automod',requireAuth({ permission: 2 }) , async (req: Request, res: Response) => {
app.get('/dash/server/:server/automod', requireAuth({ permission: 2 }), async (req: Request, res: Response) => {
const user = await isAuthenticated(req, res, true);
if (!user) return;
@ -26,16 +32,16 @@ app.get('/dash/server/:server/automod',requireAuth({ permission: 2 }) , async (r
return res.status(response.statusCode ?? 500).send({ error: response.error });
}
if (!response.server) return res.status(404).send({ error: 'Server not found' });
if (!response['server']) return res.status(404).send({ error: 'Server not found' });
const permissionLevel: 0|1|2|3 = response.perms;
const permissionLevel: 0|1|2|3 = response['perms'];
if (permissionLevel < 1) return unauthorized(res, `Only moderators and bot managers may view this.`);
const serverConfig: FindOneResult<any> = await db.get('servers').findOne({ id: server });
const serverConfig = await serversCollection.findOne({ id: server });
const result = {
antispam: (serverConfig?.automodSettings?.spam as AntispamRule[]|undefined)
?.map(r => ({ // Removing unwanted fields from response
antispam: (serverConfig?.['automodSettings']?.spam as AntispamRule[]|undefined)
?.map(r => ({
action: r.action,
channels: r.channels,
id: r.id,
@ -57,28 +63,29 @@ app.patch('/dash/server/:server/automod/:ruleid', requireAuth({ permission: 2 })
const body = req.body;
if (!server || !ruleid) return badRequest(res);
const serverConfig: FindOneResult<any> = await db.get('servers').findOne({ id: server });
const antiSpamRules: AntispamRule[] = serverConfig.automodSettings?.spam ?? [];
const serverConfig = await serversCollection.findOne({ id: server });
const antiSpamRules: AntispamRule[] = serverConfig?.['automodSettings']?.spam ?? [];
const rule = antiSpamRules.find(r => r.id == ruleid);
if (!rule) return res.status(404).send({ error: 'No rule with this ID could be found.' });
await db.get('servers').update({
id: server
}, {
$set: {
"automodSettings.spam.$[rulefilter]": {
...rule,
action: Number(body.action ?? rule.action),
channels: body.channels ?? rule.channels,
message: body.message ?? rule.message,
max_msg: body.max_msg ?? rule.max_msg,
timeframe: body.timeframe ?? rule.timeframe,
} as AntispamRule
const result = await serversCollection.updateOne(
{ id: server, "automodSettings.spam.id": ruleid },
{
$set: {
"automodSettings.spam.$": {
...rule,
action: Number(body.action ?? rule.action),
channels: body.channels ?? rule.channels,
message: body.message ?? rule.message,
max_msg: body.max_msg ?? rule.max_msg,
timeframe: body.timeframe ?? rule.timeframe,
}
}
}
}, { arrayFilters: [ { "rulefilter.id": ruleid } ] });
);
return res.send({ success: true });
return res.send({ success: result.modifiedCount > 0 });
});
app.post('/dash/server/:server/automod', requireAuth({ permission: 2 }), async (req, res) => {
@ -93,7 +100,7 @@ app.post('/dash/server/:server/automod', requireAuth({ permission: 2 }), async (
return res.status(response.statusCode ?? 500).send({ error: response.error });
}
if (!response.server) return res.status(404).send({ error: 'Server not found' });
if (!response['server']) return res.status(404).send({ error: 'Server not found' });
let rule: any;
try {
@ -109,21 +116,22 @@ app.post('/dash/server/:server/automod', requireAuth({ permission: 2 }), async (
const id = ulid();
await db.get('servers').update({
id: server,
}, {
$push: {
"automodSettings.spam": {
id: id,
max_msg: rule.max_msg ?? 5,
timeframe: rule.timeframe ?? 3,
action: rule.action ?? 0,
message: rule.message ?? null,
const result = await serversCollection.updateOne(
{ id: server },
{
$push: {
"automodSettings.spam": {
id: id,
max_msg: rule.max_msg ?? 5,
timeframe: rule.timeframe ?? 3,
action: rule.action ?? 0,
message: rule.message ?? null,
}
}
}
});
);
res.status(200).send({ success: true, id: id });
res.status(200).send({ success: result.modifiedCount > 0, id: id });
});
app.delete('/dash/server/:server/automod/:ruleid', requireAuth({ permission: 2 }), async (req, res) => {
@ -138,24 +146,24 @@ app.delete('/dash/server/:server/automod/:ruleid', requireAuth({ permission: 2 }
return res.status(response.statusCode ?? 500).send({ error: response.error });
}
if (!response.server) return res.status(404).send({ error: 'Server not found' });
if (!response['server']) return res.status(404).send({ error: 'Server not found' });
// todo: fix this shit idk if it works
let queryRes;
let result;
try {
queryRes = await db.get('servers').update({
id: server
}, {
$pull: {
"automodSettings.spam": { id: ruleid }
result = await serversCollection.updateOne(
{ id: server },
{
$pull: {
"automodSettings.spam": { id: ruleid }
}
}
});
);
} catch(e) {
console.error(e);
res.status(500).send({ error: e });
return;
}
if (queryRes.nModified > 0) res.status(200).send({ success: true });
if (result.modifiedCount > 0) res.status(200).send({ success: true });
else res.status(404).send({ success: false, error: 'Rule not found' });
});

View file

@ -8,7 +8,7 @@ import { EventEmitter } from 'events';
import { logger } from "../..";
import server from '../../server';
if (!process.env.BOT_API_TOKEN) {
if (!process.env['BOT_API_TOKEN']) {
logger.error(`$BOT_API_TOKEN is not set. This token is `
+ `required for the bot to communicate with the API.`);
process.exit(1);

View file

@ -2,49 +2,47 @@ import crypto from 'crypto';
import { app, SESSION_LIFETIME } from '..';
import { Request, Response } from 'express';
import { botReq } from './internal/ws';
import { db } from '..';
import { FindOneResult } from 'monk';
import { Collection, Db } from 'mongodb';
import { badRequest, isAuthenticated, requireAuth } from '../utils';
import { RateLimiter } from '../middlewares/ratelimit';
let pendingLoginsCollection: Collection;
let sessionsCollection: Collection;
export function initializeAuthAPI(database: Db) {
pendingLoginsCollection = database.collection('pending_logins');
sessionsCollection = database.collection('sessions');
}
class BeginReqBody {
user: string;
}
class CompleteReqBody {
user: string;
nonce: string;
code: string;
}
const beginRatelimiter = new RateLimiter('/login/begin', { limit: 10, timeframe: 300 });
const completeRatelimiter = new RateLimiter('/login/complete', { limit: 5, timeframe: 30 });
app.post('/login/begin',
(...args) => beginRatelimiter.execute(...args),
requireAuth({ noAuthOnly: true }),
(_args) => requireAuth({ noAuthOnly: true }),
async (req: Request, res: Response) => {
if (typeof await isAuthenticated(req) == 'string') return res.status(403).send({ error: 'You are already authenticated' });
const body = req.body as BeginReqBody;
if (!body.user || typeof body.user != 'string') return badRequest(res);
const r = await botReq('requestLogin', { user: body.user.toLowerCase() });
if (!r.success) return res.status(r.statusCode ?? 500).send(JSON.stringify({ error: r.error }, null, 4));
res.status(200).send({ success: true, nonce: r.nonce, code: r.code, uid: r.uid });
res.status(200).send({ success: true, nonce: r['nonce'], code: r['code'], uid: r['uid'] });
});
app.post('/login/complete',
(...args) => completeRatelimiter.execute(...args),
requireAuth({ noAuthOnly: true }),
(_args) => requireAuth({ noAuthOnly: true }),
async (req: Request, res: Response) => {
const body = req.body as CompleteReqBody;
if ((!body.user || typeof body.user != 'string') ||
(!body.nonce || typeof body.nonce != 'string') ||
(!body.code || typeof body.code != 'string')) return badRequest(res);
const loginAttempt: FindOneResult<any> = await db.get('pending_logins').findOne({
const loginAttempt = await pendingLoginsCollection.findOne({
code: body.code,
user: body.user,
nonce: body.nonce,
@ -52,24 +50,25 @@ app.post('/login/complete',
invalid: false,
});
if (!loginAttempt) return res.status(404).send({ error: 'The provided login info could not be found.' });
if (!loginAttempt.confirmed) {
if (!loginAttempt) return res.status(404).send({ error: 'The provided login info could not be found.' });
if (!loginAttempt['confirmed']) {
return res.status(400).send({ error: "This code is not yet valid." });
}
const sessionToken = crypto.randomBytes(48).toString('base64').replace(/=/g, '');
await Promise.all([
db.get('sessions').insert({
sessionsCollection.insertOne({
user: body.user.toUpperCase(),
token: sessionToken,
nonce: body.nonce,
invalid: false,
expires: Date.now() + SESSION_LIFETIME,
}),
db.get('pending_logins').update({ _id: loginAttempt._id }, { $set: { exchanged: true } }),
pendingLoginsCollection.updateOne(
{ _id: loginAttempt._id },
{ $set: { exchanged: true } }
),
]);
res.status(200).send({ success: true, user: body.user.toUpperCase(), token: sessionToken });

View file

@ -1,6 +1,7 @@
import { app, db, logger } from '..';
import { Request, Response } from 'express';
import { Response } from 'express';
import { botReq } from './internal/ws';
import { WithId, Document, ObjectId } from 'mongodb';
let SERVER_COUNT = 0;
@ -8,7 +9,7 @@ const fetchStats = async () => {
try {
const res = await botReq('stats');
if (!res.success) return logger.warn(`Failed to fetch bot stats: ${res.statusCode} / ${res.error}`);
if (res.servers) SERVER_COUNT = Number(res.servers);
if (res['servers']) SERVER_COUNT = Number(res['servers']);
} catch(e) {
console.error(e);
}
@ -17,21 +18,37 @@ const fetchStats = async () => {
fetchStats();
setInterval(() => fetchStats(), 10000);
app.get('/stats', async (req: Request, res: Response) => {
app.get('/stats', async (res: Response) => {
res.send({
servers: SERVER_COUNT,
});
});
app.get('/stats/global_blacklist', async (req: Request, res: Response) => {
try {
const users = await db.get('users').find({ globalBlacklist: true });
res.send({
total: users.length,
blacklist: users.map(u => ({ id: u.id?.toUpperCase(), reason: u.blacklistReason || null })),
});
} catch(e) {
console.error(''+e);
}
app.get('/stats/global_blacklist', async (res: Response) => {
try {
const dbConnection = await db;
const users = await dbConnection.collection('users').find({ globalBlacklist: true }).toArray();
res.send({
total: users.length,
blacklist: users.map((u: WithId<Document>) => ({
id: getId(u._id),
reason: (u as any).blacklistReason || null
})),
});
} catch(e) {
console.error('Error fetching global blacklist:', e);
res.status(500).send({ error: 'Internal server error' });
}
});
function getId(id: string | ObjectId | undefined): string | null {
if (typeof id === 'string') {
return id.toUpperCase();
} else if (id instanceof ObjectId) {
return id.toHexString().toUpperCase();
} else {
return null;
}
}

View file

@ -1,8 +1,13 @@
import { Request, Response } from "express";
import { FindOneResult } from "monk";
import { db } from ".";
import { Collection, Db } from "mongodb";
import { botReq } from "./routes/internal/ws";
let sessionsCollection: Collection;
export function initializeSessionAuthentication(db: Db) {
sessionsCollection = db.collection('sessions');
}
class Session {
user: string;
token: string;
@ -19,9 +24,7 @@ class Session {
async function isAuthenticated(req: Request, res?: Response, send401?: boolean): Promise<string|false> {
const user = req.header('x-auth-user');
const token = req.header('x-auth-token');
if (!user || !token) return false;
const info = await getSessionInfo(user, token);
if (res && send401 && !info.valid) {
res.status(401).send({ error: 'Unauthorized' });
@ -32,9 +35,13 @@ async function isAuthenticated(req: Request, res?: Response, send401?: boolean):
type SessionInfo = { exists: boolean, valid: boolean, nonce?: string }
async function getSessionInfo(user: string, token: string): Promise<SessionInfo> {
const session: FindOneResult<Session> = await db.get('sessions').findOne({ user, token });
const session = await sessionsCollection.findOne<Session>({ user, token });
return { exists: !!session, valid: !!(session && !session.invalid && session.expires > Date.now()), nonce: session?.nonce }
return {
exists: !!session,
valid: !!(session && !session.invalid && session.expires > Date.now()),
nonce: session?.nonce
}
}
function badRequest(res: Response, infoText?: string) {
@ -59,10 +66,10 @@ function requireAuth(config: RequireAuthConfig): (req: Request, res: Response, n
if (config.permission != undefined) {
if (!auth) return unauthorized(res, 'Authentication required for this route');
const server_id = req.params.serverid || req.params.server;
const server_id = req.params['serverid'] || req.params['server'];
const levelRes = await getPermissionLevel(auth, server_id);
if (!levelRes.success) return res.status(500).send({ error: 'Unknown server or other error' });
if (levelRes.level < config.permission) return unauthorized(res, 'Your permission level is too low');
if (levelRes['level'] < config.permission) return unauthorized(res, 'Your permission level is too low');
}
next();

View file

@ -1,100 +1,33 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig.json to read more about this file */
/* Projects */
// "incremental": true, /* Enable incremental compilation */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "es6", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */
// "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
/* Modules */
"module": "ES2020", /* Specify what module code is generated. */
"rootDir": "./src", /* Specify the root folder within your source files. */
"moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "resolveJsonModule": true, /* Enable importing .json files */
// "noResolve": true, /* Disallow `import`s, `require`s or `<reference>`s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */
/* Emit */
"declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
"sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */
"outDir": "./dist", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
/* Type Checking */
"strict": true, /* Enable all strict type-checking options. */
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */
// "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */
"strictPropertyInitialization": false, /* Check for class properties that are declared but not set in the constructor. */
// "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */
// "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
}
// Enable latest features
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"target": "ESNext",
"module": "ESNext",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "node",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
// Some stricter flags (enabled)
"noUnusedLocals": true,
"noUnusedParameters": true,
"noPropertyAccessFromIndexSignature": true,
// Additional improvements
"esModuleInterop": true,
"resolveJsonModule": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"useDefineForClassFields": true,
"types": ["bun-types"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

894
bot/.yarn/releases/yarn-4.3.1.cjs vendored Executable file

File diff suppressed because one or more lines are too long

View file

@ -1,2 +1,3 @@
yarnPath: .yarn/releases/yarn-3.2.1.cjs
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.3.1.cjs

View file

@ -1,32 +1,22 @@
FROM node:18 as build
FROM node:18 AS build
WORKDIR /build/app
COPY bot/package.json bot/yarn.lock bot/.yarnrc.yml ./
COPY bot/.yarn ./.yarn
COPY lib ../lib
COPY revolt.js ../revolt.js
RUN yarn --cwd ../lib --immutable
RUN yarn --cwd ../lib build
# pnpm's --dir doesn't work, so cool!
WORKDIR /build/revolt.js
RUN corepack enable
RUN corepack prepare pnpm@7.14.2 --activate
RUN pnpm install
RUN pnpm run build
WORKDIR /build/app
RUN yarn install --immutable
RUN yarn install
COPY ./bot .
RUN yarn build
FROM node:18 as prod
FROM node:18 AS prod
WORKDIR /app/bot
COPY --from=build /build/app/package.json /build/app/yarn.lock /build/app/.yarnrc.yml ./
COPY --from=build /build/app/.yarn ./.yarn
COPY --from=build /build/app/dist ./dist
COPY --from=build /build/lib ../lib
COPY --from=build /build/revolt.js ../revolt.js
RUN yarn install --immutable
CMD ["yarn", "start"]

View file

@ -1,39 +1,34 @@
{
"name": "revolt-automod",
"version": "1.0.0",
"description": "",
"type": "module",
"exports": "./index.js",
"scripts": {
"build": "rm -rf dist && tsc",
"start": "node --experimental-specifier-resolution=node dist/index",
"dev": "yarn build && yarn start"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@types/monk": "^6.0.0",
"automod": "^0.1.0",
"axios": "^0.22.0",
"dayjs": "^1.10.7",
"discord.js": "^13.3.1",
"dotenv": "^10.0.0",
"form-data": "^4.0.0",
"log75": "^2.2.0",
"monk": "^7.3.4",
"prom-client": "^14.0.1",
"revolt-api": "0.6.4",
"revolt.js": "^7.0.0",
"ulid": "^2.3.0",
"xlsx": "^0.17.3"
},
"devDependencies": {
"typescript": "^4.4.3"
},
"packageManager": "yarn@3.2.1",
"resolutions": {
"automod": "portal:../lib",
"revolt.js": "portal:../revolt.js"
}
"name": "revolt-automod",
"version": "1.0.0",
"type": "module",
"exports": "./index.js",
"scripts": {
"build": "rm -rf dist && tsc",
"start": "node --experimental-specifier-resolution=node dist/index",
"dev": "yarn build && yarn start"
},
"dependencies": {
"@types/monk": "^6.0.0",
"automod": "^0.1.0",
"axios": "^1.7.5",
"dayjs": "^1.11.11",
"dotenv": "^16.4.5",
"form-data": "^4.0.0",
"log75": "^3.0.1",
"monk": "^7.3.4",
"prom-client": "^15.1.3",
"revolt-api": "0.7.13",
"revolt.js": "^7.0.3",
"ulid": "^2.3.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@types/ws": "^8.5.11",
"typescript": "^5.5.3"
},
"packageManager": "yarn@4.3.1",
"resolutions": {
"automod": "portal:../lib"
}
}

View file

@ -1,52 +0,0 @@
import SimpleCommand from "../../../struct/commands/SimpleCommand";
import { inspect } from 'util';
import MessageCommandContext from "../../../struct/MessageCommandContext";
import CommandCategory from "../../../struct/commands/CommandCategory";
export default {
name: 'eval',
aliases: [ 'e' ],
description: 'Evaluate JS code',
restrict: 'BOTOWNER',
removeEmptyArgs: false,
category: CommandCategory.Owner,
run: async (message: MessageCommandContext, args: string[]) => {
let cmd = args.join(' ');
let m = await message.channel?.sendMessage(`Executing...`);
try {
let e = eval(cmd);
if (e instanceof Promise) {
await m?.edit({ content: `## **Promise**<pending>` });
e.then((res) => {
m?.edit({
content: `## **Promise**<resolved>\n\`\`\`js\n${render(res)}\n\`\`\``
});
})
.catch((res) => {
m?.edit({
content: `## **Promise**<rejected>\n\`\`\`js\n${render(res)}\n\`\`\``
});
});
} else {
message.channel?.sendMessage(`\`\`\`js\n${render(e)}\n\`\`\``);
}
} catch(e) {
m?.edit({ content: `## Execution failed\n\`\`\`js\n${render(e)}\n\`\`\`` });
}
}
} as SimpleCommand;
function removeSecrets(input: string): string {
if (process.env['DB_PASS']) input = input.replace(new RegExp(process.env['DB_PASS']!, 'gi'), '[Secret redacted]');
if (process.env['DB_URL']) input = input.replace(new RegExp(process.env['DB_URL']!, 'gi'), '[Secret redacted]');
input = input.replace(new RegExp(process.env['BOT_TOKEN']!, 'gi'), '[Secret redacted]');
return input;
}
function render(input: any): string {
return removeSecrets(inspect(input)).substr(0, 1960);
}

View file

@ -1,47 +0,0 @@
import SimpleCommand from "../../../struct/commands/SimpleCommand";
import { exec } from 'child_process';
import MessageCommandContext from "../../../struct/MessageCommandContext";
import CommandCategory from "../../../struct/commands/CommandCategory";
export default {
name: 'shell',
aliases: [ 'exec', 'sh' ],
description: 'Run code in a shell',
restrict: 'BOTOWNER',
removeEmptyArgs: false,
category: CommandCategory.Owner,
run: async (message: MessageCommandContext, args: string[]) => {
let cmd = args.join(' ');
let m = await message.channel?.sendMessage(`Executing...`);
try {
let editMsg = () => {
if (str != '' && str != oldStr) {
if (str.length > 2000) {
str = str.substr(str.length - 2000);
}
m?.edit({ content: str })
.catch(e => console.warn('Failed to edit message'));
}
}
let str = '', oldStr = '';
let e = exec(cmd);
let i = setInterval(editMsg, 1000);
e.stdout?.on('data', m => {
str += m;
});
e.on('exit', (code) => {
clearInterval(i);
str += `\n\n**Exit code:** ${code}`;
editMsg();
});
} catch(e) {
message.channel?.sendMessage(`${e}`);
}
}
} as SimpleCommand;

View file

@ -10,9 +10,9 @@ const SYNTAX = '/admin add @user; /admin remove @user; /admin list';
export default {
name: 'admin',
aliases: [ 'admins', 'manager', 'managers' ],
description: 'Allow users to control the bot\'s configuration',
description: "Manage users with permission to modify the configuration.",
syntax: SYNTAX,
category: CommandCategory.Config,
category: CommandCategory.Configuration,
run: async (message: MessageCommandContext, args: string[]) => {
if (!message.member?.hasPermission(message.member.server!, 'ManageServer'))
return message.reply('You need **ManageServer** permission to use this command.');

View file

@ -1,359 +0,0 @@
import ServerConfig from "automod/dist/types/ServerConfig";
import axios from "axios";
import FormData from "form-data";
import { client, dbs } from "../../..";
import CommandCategory from "../../../struct/commands/CommandCategory";
import SimpleCommand from "../../../struct/commands/SimpleCommand";
import MessageCommandContext from "../../../struct/MessageCommandContext";
import { checkMessageForFilteredWords } from "../../modules/antispam";
import { DEFAULT_PREFIX } from "../../modules/command_handler";
import { embed, EmbedColor, getDmChannel, isBotManager, NO_MANAGER_MSG, sanitizeMessageContent } from "../../util";
const WORDLIST_DEFAULT_MESSAGE = '<@{{user_id}}>, the message you sent contained a blocked word.';
export default {
name: 'botctl',
aliases: null,
description: 'Perform administrative actions',
category: CommandCategory.Config,
run: async (message: MessageCommandContext, args: string[]) => {
if (!await isBotManager(message)) return message.reply(NO_MANAGER_MSG);
try {
let action = args.shift();
switch(action) {
case 'ignore_blacklist': {
if (args[0] == 'yes') {
if (message.serverContext.discoverable) {
return message.reply('Your server is currently listed in server discovery. As part of Revolt\'s [Discover Guidelines](<https://support.revolt.chat/kb/safety/discover-guidelines>), all servers on Discover are enrolled to AutoMod\'s antispam features.');
}
await dbs.SERVERS.update({ id: message.serverContext.id }, { $set: { allowBlacklistedUsers: true } });
await message.reply('Globally blacklisted users will no longer get banned in this server. Previously banned users will need to be unbanned manually.');
} else if (args[0] == 'no') {
await dbs.SERVERS.update({ id: message.serverContext.id }, { $set: { allowBlacklistedUsers: false } });
await message.reply('Globally blacklisted users will now get banned in this server.');
} else {
await message.reply(`Please specify either 'yes' or 'no' to toggle this setting.`);
}
break;
}
case 'spam_detection': {
if (args[0] == 'on') {
await dbs.SERVERS.update({ id: message.serverContext.id }, { $set: { antispamEnabled: true } });
await message.reply('Spam detection is now enabled in this server.\nIf a user is wrongfully kicked '
+ 'or banned, please report it here: https://rvlt.gg/jan\n\n'
+ 'Please make sure to grant AutoMod permission to **Kick**, **Ban** and **Manage Messages**!');
} else if (args[0] == 'off') {
if (message.serverContext.discoverable) {
return message.reply('Your server is currently listed in server discovery. As part of Revolt\'s [Discover Guidelines](<https://support.revolt.chat/kb/safety/discover-guidelines>), all servers on Discover are enrolled to AutoMod\'s antispam features.');
}
await dbs.SERVERS.update({ id: message.serverContext.id }, { $set: { antispamEnabled: false } });
await message.reply('Spam detection is now disabled in this server.');
} else {
const cfg = await dbs.SERVERS.findOne({ id: message.serverContext.id });
await message.reply(`Spam detection is currently **${cfg?.antispamEnabled ? 'enabled' : 'disabled'}**. `
+ `Please specify either 'on' or 'off' to toggle this setting.`);
}
break;
}
case 'logs': {
if (!args[0]) {
return await message.reply(
`No category specified. Syntax: ${DEFAULT_PREFIX}botctl logs [category] [#channel]\n` +
`Categories: \`messageupdate\`, \`modaction\``,
);
}
if (!args[1]) {
return await message.reply('No target channel specified.');
}
let channelInput = args[1];
if (channelInput.startsWith('<#') && channelInput.endsWith('>')) {
channelInput = channelInput.substring(2, channelInput.length - 1);
}
const channel = client.channels.get(channelInput);
if (!channel) return message.reply('I can\'t find that channel.');
if (channel.serverId != message.channel?.serverId) return message.reply('That channel is not part of this server!');
if (!channel.havePermission('SendMessage')) return message.reply('I don\'t have permission to **send messages** in that channel.');
if (!channel.havePermission('SendEmbeds')) return message.reply('I don\'t have permission to **send embeds** in that channel.');
switch(args[0]?.toLowerCase()) {
case 'messageupdate': {
await dbs.SERVERS.update(
{ id: message.channel!.serverId! },
{
$set: {
'logs.messageUpdate.revolt': {
channel: channel.id,
type: 'EMBED',
},
},
$setOnInsert: {
id: message.channel!.serverId!,
}
},
{ upsert: true },
);
await message.reply(`Bound message update logs to <#${channel.id}>!`);
break;
}
case 'modaction': {
await dbs.SERVERS.update(
{ id: message.channel!.serverId! },
{
$set: {
'logs.modAction.revolt': {
channel: channel.id,
type: 'EMBED',
},
},
$setOnInsert: {
id: message.channel!.serverId!,
}
},
{ upsert: true },
);
await message.reply(`Bound moderation logs to <#${channel.id}>!`);
break;
}
default: {
return await message.reply('Unknown log category');
}
}
break;
}
case 'filter': {
const config = await dbs.SERVERS.findOne({ id: message.channel!.serverId! });
switch(args.shift()?.toLowerCase()) {
case 'enable': {
await dbs.SERVERS.update(
{ id: message.channel!.serverId! },
{ $set: { wordlistEnabled: true } },
{ upsert: true },
);
await message.reply({ embeds: [
embed(
`### Word filter enabled!\nThere are currently ${config?.wordlist?.length ?? 0} words on your list.`,
null,
EmbedColor.Success
),
] });
break;
}
case 'disable': {
await dbs.SERVERS.update(
{ id: message.channel!.serverId! },
{ $set: { wordlistEnabled: false } },
{ upsert: true },
);
await message.reply({ embeds: [
embed('Word filter disabled.', null, EmbedColor.SoftError),
] });
break;
}
case 'add': {
let strictness: any = 'HARD';
if (['soft', 'hard', 'strict'].includes(args[0].toLowerCase())) {
strictness = args.shift()!.toUpperCase() as any;
}
const word = args.join(' ').toLowerCase();
if (!word) return message.reply('You didn\'t provide a word to add to the list!');
if (config?.wordlist?.find(w => w.word == word)) return await message.reply('That word is already on the list!');
await dbs.SERVERS.update(
{ id: message.channel!.serverId! },
{ $push: { wordlist: { strictness, word } } },
{ upsert: true },
);
await message.reply({ embeds: [
embed(`Word added with strictness **${strictness}**.`, null, EmbedColor.Success),
] });
break;
}
case 'remove': {
const word = args.join(' ').toLowerCase();
if (!word) return message.reply('You need to provide the word to remove from the list.');
if (!config?.wordlist?.find(w => w.word == word)) return await message.reply('That word is not on the list.');
await dbs.SERVERS.update(
{ id: message.channel!.serverId! },
{ $pull: { wordlist: { word } } },
{ upsert: true },
);
await message.reply({ embeds: [
embed(`Word removed.`, null, EmbedColor.Success),
] });
break;
}
case 'show': {
const formData = new FormData();
formData.append(
`wordlist_${message.channel?.serverId}`,
config?.wordlist?.map(w => `${w.strictness}\t${w.word}`).join('\n') ?? '',
`wordlist_${message.channel?.serverId}.txt`
);
try {
const channel = await getDmChannel(message.authorId!);
const res = await axios.post(
`${client.configuration?.features.autumn.url}/attachments`,
formData,
{ headers: formData.getHeaders(), responseType: 'json' }
);
await channel.sendMessage({
attachments: [ (res.data as any).id ],
embeds: [ embed(
`Here's the current word list for **${message.channel?.server?.name}**.`,
'Word List',
EmbedColor.Success
) ],
});
await message.reply({ embeds: [
embed(`I have sent the current word list to your direct messages!`, null, EmbedColor.Success),
] });
} catch(e) {
console.error(e);
}
break;
}
case 'message': {
const msg = args.join(' ');
if (!msg) {
return await message.reply({ embeds: [
embed(
'This command lets you change the message the bot will send if a message is filtered.\n' +
'Note that this message will not be sent if the configured action is to log events only.\n' +
'The current message is:\n' +
`>${sanitizeMessageContent(config?.wordlistAction?.message ?? WORDLIST_DEFAULT_MESSAGE).trim().replace(/\n/g, '\n>')}\n` +
'`{{user_id}}` will be substituted for the target user\'s ID.',
'Warning message',
EmbedColor.Success
),
] });
}
await dbs.SERVERS.update(
{ id: message.channel!.serverId! },
{ $set: { wordlistAction: { action: config?.wordlistAction?.action ?? 'LOG', message: msg } } },
{ upsert: true },
);
await message.reply({ embeds: [
embed(
'Filter message set!',
null,
EmbedColor.Success
),
] });
break;
}
case 'action': {
let action: string;
switch(args[0]?.toLowerCase()) {
case 'log':
case 'delete':
case 'warn':
action = args[0].toUpperCase();
break;
default:
await message.reply({ embeds: [
embed(
'Please provide one of the following arguments:\n' +
'- **log** (Log the message in mod action log channel)\n' +
'- **delete** (Log and delete the message)\n' +
'- **warn** (Log and delete message, warn user)\n\n' +
`The currently configured action is **${config?.wordlistAction?.action ?? 'LOG'}**.`,
null,
EmbedColor.SoftError
),
] });
return;
}
await dbs.SERVERS.update(
{ id: message.channel!.serverId! },
{ $set: { wordlistAction: {
action: action as any,
message: config?.wordlistAction?.message ?? WORDLIST_DEFAULT_MESSAGE
} } },
{ upsert: true },
);
await message.reply({ embeds: [
embed(
`Filter action set to **${action}**. ` +
`Please make sure you configured a logging channel using **${DEFAULT_PREFIX}botctl logs**.`,
null,
EmbedColor.Success
),
] });
break;
}
case 'test': {
const match = checkMessageForFilteredWords(args.join(' '), config as ServerConfig);
await message.reply({ embeds: [
match
? embed('Your word list matches this test phrase!', 'Filter Test', EmbedColor.SoftError)
: embed('Your word list does not match this test phrase!', 'Filter Test', EmbedColor.Success)
] });
break;
}
default: {
await message.reply({ embeds: [
embed(
`### This command allows you to configure a manual word filter.\n` +
`- **${DEFAULT_PREFIX}botctl filter enable** - Enable the word filter.\n` +
`- **${DEFAULT_PREFIX}botctl filter disable** - Disable the word filter.\n` +
`- **${DEFAULT_PREFIX}botctl filter add [soft|hard|strict] [word]** - Add a word to the list. If omitted, defaults to 'hard'.\n` +
`- **${DEFAULT_PREFIX}botctl filter remove** - Remove a word from the list.\n` +
`- **${DEFAULT_PREFIX}botctl filter show** - Send the current filter list.\n` +
`- **${DEFAULT_PREFIX}botctl filter message [message]** - Set the message sent when a message is matched.\n` +
`- **${DEFAULT_PREFIX}botctl filter action [log|delete|warn]** - Configure the action taken on filtered messages.\n` +
`- **${DEFAULT_PREFIX}botctl filter test [phrase]** - Test whether a phrase matches your word list.\n` +
`More documentation can be found [here](https://github.com/sussycatgirl/automod/wiki/Word-Filter).`,
'Word filter',
),
embed(
`**Enabled:** ${!!config?.wordlistEnabled}` + (!config?.wordlistEnabled
? ''
: `\n**Action:** ${config?.wordlistAction?.action ?? 'LOG'}\n` +
`**Warning message:** ${config?.wordlistAction?.message}\n` +
`**Wordlist length:** ${config?.wordlist?.length ?? 0}`),
'Current configuration',
config?.wordlistEnabled ? EmbedColor.Success : EmbedColor.SoftError,
),
] });
break;
}
}
break;
}
case undefined:
case '':
message.reply(`### Available subcommands\n`
+ `- \`ignore_blacklist\` - Ignore the bot's global blacklist.\n`
+ `- \`spam_detection\` - Toggle automatic spam detection.\n`
+ `- \`logs\` - Configure log channels.\n`
+ `- \`filter\` - Configure word filter.\n`);
break
default:
message.reply(`Unknown option`);
}
} catch(e) {
console.error(''+e);
message.reply('Something went wrong: ' + e);
}
}
} as SimpleCommand;
export { WORDLIST_DEFAULT_MESSAGE }

View file

@ -1,361 +0,0 @@
import { Message } from "revolt.js";
import { ulid } from "ulid";
import { SendableEmbed } from "revolt-api";
import { CONFIG_KEYS } from "automod/dist/misc/bridge_config_keys";
import { dbs } from "../../..";
import CommandCategory from "../../../struct/commands/CommandCategory";
import SimpleCommand from "../../../struct/commands/SimpleCommand";
import MessageCommandContext from "../../../struct/MessageCommandContext";
import { DEFAULT_PREFIX } from "../../modules/command_handler";
import {
embed,
EmbedColor,
isBotManager,
isModerator,
NO_MANAGER_MSG,
} from "../../util";
const DISCORD_INVITE_URL =
"https://discord.com/api/oauth2/authorize?client_id=965692929643524136&permissions=536996864&scope=bot%20applications.commands"; // todo: read this from env or smth
export default {
name: "bridge",
aliases: null,
description: "Bridge a channel with Discord",
category: CommandCategory.Misc,
run: async (message: MessageCommandContext, args: string[]) => {
switch (args[0]?.toLowerCase()) {
case "link": {
if (!(await isBotManager(message)))
return message.reply(NO_MANAGER_MSG);
const count = await dbs.BRIDGE_CONFIG.count({
revolt: message.channelId,
});
if (count)
return message.reply(`This channel is already bridged.`);
// Invalidate previous bridge request
await dbs.BRIDGE_REQUESTS.remove({
revolt: message.channelId,
});
const reqId = ulid();
await dbs.BRIDGE_REQUESTS.insert({
id: reqId,
revolt: message.channelId,
expires: Date.now() + 1000 * 60 * 15,
});
let text =
`### Link request created.\n` +
`Request ID: \`${reqId}\`\n\n` +
`[Invite the bridge bot to your Discord server](<${DISCORD_INVITE_URL}>) ` +
`and run \`/bridge confirm ${reqId}\` in the channel you wish to link.\n` +
`This request expires in 15 minutes.`;
if (
!message.channel!.havePermission("Masquerade") ||
!message.channel!.havePermission("SendEmbeds") ||
!message.channel!.havePermission("UploadFiles")
) {
text +=
"\n\n> :warning: I currently don't have all required permissions in this " +
'channel for the bridge to work. Please make sure to grant the "Masquerade", ' +
'"Upload Files" and "Send Embeds" permission.';
}
await message.reply(text, false);
break;
}
case "unlink": {
if (!(await isBotManager(message)))
return message.reply(NO_MANAGER_MSG);
const res = await dbs.BRIDGE_CONFIG.remove({
revolt: message.channelId,
});
if (res.deletedCount) await message.reply(`Channel unlinked!`);
else
await message.reply(`Unable to unlink; no channel linked.`);
break;
}
case "unlink_all": {
if (!(await isBotManager(message)))
return message.reply(NO_MANAGER_MSG);
const query = {
revolt: { $in: Array.from(message.channel?.server?.channelIds.values() ?? []) },
};
if (args[1] == "CONFIRM") {
const res = await dbs.BRIDGE_CONFIG.remove(query);
if (res.deletedCount) {
await message.reply(
`All channels have been unlinked. (Count: **${res.deletedCount}**)`
);
} else {
await message.reply(
`No bridged channels found; nothing to delete.`
);
}
} else {
const res = await dbs.BRIDGE_CONFIG.count(query);
if (!res)
await message.reply(
`No bridged channels found; nothing to delete.`
);
else {
await message.reply(
`${res} bridged channels found. ` +
`Run \`${DEFAULT_PREFIX}bridge unlink_all CONFIRM\` to confirm deletion.`
);
}
}
break;
}
case "list": {
if (!(await isBotManager(message)))
return message.reply(NO_MANAGER_MSG);
const links = await dbs.BRIDGE_CONFIG.find({
revolt: { $in: Array.from(message.channel?.server?.channelIds.values() ?? []) },
});
await message.reply({
content: "#",
embeds: [
{
title: `Bridges in ${message.channel?.server?.name}`,
description:
`**${links.length}** bridged channels found.\n\n` +
links
.map(
(l) =>
`<#${l.revolt}> **->** ${l.discord}`
)
.join("\n"),
},
],
});
break;
}
case "info": {
try {
if (!message.replyIds) {
return await message.reply(
"Please run this command again while replying to a message."
);
}
if (
message.replyIds.length > 1 &&
!(await isModerator(message, false))
) {
return await message.reply(
"To avoid spam, only moderators are allowed to query bridge info for more than one message at a time."
);
}
const messages = (
await Promise.allSettled(
message.replyIds.map((m) =>
message.channel!.fetchMessage(m)
) || []
)
)
.filter((m) => m.status == "fulfilled")
.map(
(m) => (m as PromiseFulfilledResult<Message>).value
);
if (!messages.length) {
return await message.reply(
"Something went wrong; could not fetch the target message(s)."
);
}
const embeds: SendableEmbed[] = await Promise.all(
messages.map(async (msg) => {
const bridgeData =
await dbs.BRIDGED_MESSAGES.findOne({
"revolt.messageId": msg.id,
});
const embed: SendableEmbed = bridgeData
? {
url: msg.url,
title: `Message ${
bridgeData?.origin == "revolt"
? `by ${msg.author?.username}`
: "from Discord"
}`,
colour: "#7e96ff",
description:
`**Origin:** ${
bridgeData.origin == "revolt"
? "Revolt"
: "Discord"
}\n` +
`**Bridge Status:** ${
bridgeData.origin == "revolt"
? bridgeData.discord.messageId
? "Bridged"
: "Unbridged"
: bridgeData.revolt.messageId
? "Bridged"
: bridgeData.revolt.nonce
? "ID unknown"
: "Unbridged"
}\n` +
`### Bridge Data\n` +
`Origin: \`${bridgeData.origin}\`\n` +
`Discord ID: \`${bridgeData.discord.messageId}\`\n` +
`Revolt ID: \`${bridgeData.revolt.messageId}\`\n` +
`Revolt Nonce: \`${bridgeData.revolt.nonce}\`\n` +
`Discord Channel: \`${bridgeData.channels?.discord}\`\n` +
`Revolt Channel: \`${bridgeData.channels?.revolt}\``,
}
: {
url: msg.url,
title: `Message by ${msg.author?.username}`,
description:
"This message has not been bridged.",
colour: "#7e96ff",
};
return embed;
})
);
await message.reply({ embeds }, false);
} catch (e) {
console.error(e);
message.reply("" + e)?.catch(() => {});
}
break;
}
case "status": {
const link = await dbs.BRIDGE_CONFIG.findOne({
revolt: message.channelId,
});
if (!link)
return await message.reply({
embeds: [
embed(
"This channel is **not** bridged, and no message data is being sent to Discord.",
"Bridge status",
EmbedColor.Success
),
],
});
else
return await message.reply({
embeds: [
embed(
"This channel is bridged to Discord. Please refer to the [Privacy Policy](<https://github.com/sussycatgirl/automod/wiki/Privacy-Policy>) for more info.",
"Bridge Status",
EmbedColor.Success
),
],
});
}
case "config": {
if (!(await isBotManager(message)))
return message.reply(NO_MANAGER_MSG);
const [_, configKey, newVal]: (string | undefined)[] = args;
if (!configKey) {
return await message.reply({
embeds: [
{
title: "Bridge Configuration",
description:
`To modify a configuration option, run ${DEFAULT_PREFIX}bridge config <key> [true|false].\n\n` +
`**Available configuration keys:**` +
Object.keys(CONFIG_KEYS).map(
(key) => `\n- ${key}`
),
},
],
});
}
if (!Object.keys(CONFIG_KEYS).includes(configKey)) {
return await message.reply("Unknown configuration key.");
}
const key = CONFIG_KEYS[configKey as keyof typeof CONFIG_KEYS];
if (!newVal) {
const bridgeConfig = await dbs.BRIDGE_CONFIG.findOne({
revolt: message.channelId,
});
return await message.reply({
embeds: [
{
title: "Bridge Configuration: " + configKey,
description: `**${key.friendlyName}**\n${
key.description
}\n\nCurrent value: **${
!!bridgeConfig?.config?.[
configKey as keyof typeof CONFIG_KEYS
]
}**`,
},
],
});
}
if (newVal != "true" && newVal != "false") {
return await message.reply(
"Value needs to be either `true` or `false`."
);
}
await dbs.BRIDGE_CONFIG.update(
{ revolt: message.channelId },
{
$set: { [`config.${configKey}`]: newVal == "true" },
$setOnInsert: { revolt: message.channelId },
},
{ upsert: true }
);
return await message.reply(
`Configuration key **${configKey}** has been updated to **${newVal}**.`
);
}
case "help": {
await message.reply({
content: "#",
embeds: [
{
title: "Discord Bridge",
description:
`Bridges allow you to link your Revolt server to a Discord server ` +
`by relaying all messages.\n\n` +
`To link a channel, first run \`${DEFAULT_PREFIX}bridge link\` on Revolt. ` +
`This will provide you with a link ID.\n` +
`On Discord, first [add the Bridge bot to your server](<${DISCORD_INVITE_URL}>), ` +
`then run the command: \`/bridge confirm [ID]\`.\n\n` +
`You can list all bridges in a Revolt server by running \`${DEFAULT_PREFIX}bridge list\`\n\n` +
`To unlink a channel, run \`/bridge unlink\` from either Discord or Revolt. If you wish to ` +
`unbridge all channels in a Revolt server, run \`${DEFAULT_PREFIX}bridge unlink_all\`.\n` +
`To view bridge info about a particular message, run \`${DEFAULT_PREFIX}bridge info\` ` +
`while replying to the message.\n` +
`You can customize how the bridge behaves using \`${DEFAULT_PREFIX}bridge config\`.`,
},
],
});
break;
}
default: {
await message.reply(
`Run \`${DEFAULT_PREFIX}bridge help\` for help.`
);
}
}
},
} as SimpleCommand;

View file

@ -0,0 +1,157 @@
import ServerConfig from "automod/dist/types/ServerConfig";
import axios from "axios";
import FormData from "form-data";
import { client, dbs } from "../../..";
import CommandCategory from "../../../struct/commands/CommandCategory";
import SimpleCommand from "../../../struct/commands/SimpleCommand";
import MessageCommandContext from "../../../struct/MessageCommandContext";
import { checkMessageForFilteredWords } from "../../modules/antispam";
import { DEFAULT_PREFIX } from "../../modules/command_handler";
import { embed, EmbedColor, getDmChannel, isBotManager, NO_MANAGER_MSG, sanitizeMessageContent } from "../../util";
const WORDLIST_DEFAULT_MESSAGE = "<@{{user_id}}>, the message you sent contained a blocked word.";
export default {
name: "filter",
aliases: null,
description: "Filter messages.",
category: CommandCategory.Configuration,
run: async (message: MessageCommandContext, args: string[]) => {
if (!(await isBotManager(message))) return message.reply(NO_MANAGER_MSG);
const config = await dbs.SERVERS.findOne({ id: message.channel!.serverId! });
switch (args.shift()?.toLowerCase()) {
case "enable": {
await dbs.SERVERS.update({ id: message.channel!.serverId! }, { $set: { wordlistEnabled: true } }, { upsert: true });
await message.reply(`Word filtering is now **enabled** in this server.\nThere are currently ${config?.wordlist?.length ?? 0} words on your list.`);
break;
}
case "disable": {
await dbs.SERVERS.update({ id: message.channel!.serverId! }, { $set: { wordlistEnabled: false } }, { upsert: true });
await message.reply("Word filter is now **disabled** in this server.");
break;
}
case "add": {
let strictness: any = "HARD";
if (["soft", "hard", "strict"].includes(args[0].toLowerCase())) {
strictness = args.shift()!.toUpperCase() as any;
}
const word = args.join(" ").toLowerCase();
if (!word) return message.reply("You didn't provide a word to add to the list!");
if (config?.wordlist?.find((w) => w.word == word)) return await message.reply("That word is already on the list!");
await dbs.SERVERS.update({ id: message.channel!.serverId! }, { $push: { wordlist: { strictness, word } } }, { upsert: true });
await message.reply(`'${word}' added with strictness **${strictness}**.`);
break;
}
case "remove": {
const word = args.join(" ").toLowerCase();
if (!word) return message.reply("You need to provide the word to remove from the list.");
if (!config?.wordlist?.find((w) => w.word == word)) return await message.reply("That word is not on the list.");
await dbs.SERVERS.update({ id: message.channel!.serverId! }, { $pull: { wordlist: { word } } }, { upsert: true });
await message.reply(`Word removed successfully.`);
break;
}
case "list": {
const formData = new FormData();
formData.append(`wordlist_${message.channel?.serverId}`, config?.wordlist?.map((w) => `${w.strictness}\t${w.word}`).join("\n") ?? "", `wordlist_${message.channel?.serverId}.txt`);
try {
const channel = await getDmChannel(message.authorId!);
const res = await axios.post(`${client.configuration?.features.autumn.url}/attachments`, formData, { headers: formData.getHeaders(), responseType: "json" });
await channel.sendMessage({
embeds: [embed(`Here's the current word list for **${message.channel?.server?.name}**.`, "Word List", EmbedColor.Success)],
attachments: [(res.data as any).id],
});
await message.reply(`I have sent the current word list to your direct messages!`);
} catch (e) {
console.error(e);
}
break;
}
case "message": {
const msg = args.join(" ");
if (!msg) {
await message.reply(
"This command lets you change the message the bot will send if a message is filtered.\n" +
"Note that this message will not be sent if the configured action is to log events only.\n" +
"The current message is:\n" +
`>${sanitizeMessageContent(config?.wordlistAction?.message ?? WORDLIST_DEFAULT_MESSAGE)
.trim()
.replace(/\n/g, "\n>")}\n` +
"`{{user_id}}` will be substituted for the target user's ID.",
);
return;
}
await dbs.SERVERS.update({ id: message.channel!.serverId! }, { $set: { wordlistAction: { action: config?.wordlistAction?.action ?? "LOG", message: msg } } }, { upsert: true });
await message.reply("Filter message set!");
break;
}
case "action": {
let action: string;
switch (args[0]?.toLowerCase()) {
case "log":
case "delete":
case "warn":
action = args[0].toUpperCase();
break;
default:
await message.reply(
"Please provide one of the following arguments:\n" +
"- **log** (Log the message in mod action log channel)\n" +
"- **delete** (Log and delete the message)\n" +
"- **warn** (Log and delete message, warn user)\n\n" +
`The currently configured action is **${config?.wordlistAction?.action ?? "LOG"}**.`,
);
return;
}
await dbs.SERVERS.update(
{ id: message.channel!.serverId! },
{
$set: {
wordlistAction: {
action: action as any,
message: config?.wordlistAction?.message ?? WORDLIST_DEFAULT_MESSAGE,
},
},
},
{ upsert: true },
);
await message.reply(`Filter action set to **${action}**. ` + `Please make sure you configured a logging channel using \`${DEFAULT_PREFIX}logs\`.`);
break;
}
case "test": {
const match = checkMessageForFilteredWords(args.join(" "), config as ServerConfig);
await message.reply({
embeds: [
match
? embed("Your word list matches this test phrase!", "Filter Test", EmbedColor.SoftError)
: embed("Your word list does not match this test phrase!", "Filter Test", EmbedColor.Success),
],
});
break;
}
default: {
await message.reply(
`### This command allows you to configure a manual word filter.\n` +
`- **${DEFAULT_PREFIX}filter enable** - Enable the word filter.\n` +
`- **${DEFAULT_PREFIX}filter disable** - Disable the word filter.\n` +
`- **${DEFAULT_PREFIX}filter add [soft|hard|strict] [word]** - Add a word to the list. If omitted, defaults to 'hard'.\n` +
`- **${DEFAULT_PREFIX}filter remove** - Remove a word from the list.\n` +
`- **${DEFAULT_PREFIX}filter list** - Send the current filter list.\n` +
`- **${DEFAULT_PREFIX}filter message [message]** - Set the message sent when a message is matched.\n` +
`- **${DEFAULT_PREFIX}filter action [log|delete|warn]** - Configure the action taken on filtered messages.\n` +
`- **${DEFAULT_PREFIX}filter test [phrase]** - Test whether a phrase matches your word list.\n`,
);
break;
}
}
},
} as SimpleCommand;
export { WORDLIST_DEFAULT_MESSAGE };

View file

@ -0,0 +1,81 @@
import { client, dbs } from "../../..";
import CommandCategory from "../../../struct/commands/CommandCategory";
import SimpleCommand from "../../../struct/commands/SimpleCommand";
import MessageCommandContext from "../../../struct/MessageCommandContext";
import { DEFAULT_PREFIX } from "../../modules/command_handler";
import { isBotManager, NO_MANAGER_MSG } from "../../util";
export default {
name: "logs",
aliases: null,
description: "Configure log collection.",
category: CommandCategory.Configuration,
run: async (message: MessageCommandContext, args: string[]) => {
if (!(await isBotManager(message))) return message.reply(NO_MANAGER_MSG);
if (!args[0]) {
return await message.reply(`No category specified. Syntax: \`${DEFAULT_PREFIX}logs [category] [#channel]\`\n` + `Categories: \`messageupdate\`, \`modaction\``);
}
if (!args[1]) {
return await message.reply("No target channel specified.");
}
let channelInput = args[1];
if (channelInput.startsWith("<#") && channelInput.endsWith(">")) {
channelInput = channelInput.substring(2, channelInput.length - 1);
}
const channel = client.channels.get(channelInput);
if (!channel) return message.reply("I can't find that channel.");
if (channel.serverId != message.channel?.serverId) return message.reply("That channel is not part of this server!");
if (!channel.havePermission("SendMessage")) return message.reply("I don't have permission to **send messages** in that channel.");
if (!channel.havePermission("SendEmbeds")) return message.reply("I don't have permission to **send embeds** in that channel.");
switch (args[0]?.toLowerCase()) {
case "messageupdate": {
await dbs.SERVERS.update(
{ id: message.channel!.serverId! },
{
$set: {
"logs.messageUpdate.revolt": {
channel: channel.id,
type: "EMBED",
},
},
$setOnInsert: {
id: message.channel!.serverId!,
},
},
{ upsert: true },
);
await message.reply(`Bound message update logs to <#${channel.id}>!`);
break;
}
case "modaction": {
await dbs.SERVERS.update(
{ id: message.channel!.serverId! },
{
$set: {
"logs.modAction.revolt": {
channel: channel.id,
type: "EMBED",
},
},
$setOnInsert: {
id: message.channel!.serverId!,
},
},
{ upsert: true },
);
await message.reply(`Bound moderation logs to <#${channel.id}>!`);
break;
}
default: {
return await message.reply("Unknown log category");
}
}
},
} as SimpleCommand;

View file

@ -12,9 +12,9 @@ const SYNTAX = '/mod add @user; /mod remove @user; /mod list';
export default {
name: 'moderator',
aliases: [ 'moderators', 'mod', 'mods' ],
description: 'Allow users to moderate other users',
description: 'Allow users to moderate other users.',
syntax: SYNTAX,
category: CommandCategory.Config,
category: CommandCategory.Configuration,
run: async (message: MessageCommandContext, args: string[]) => {
if (!await isBotManager(message)) return message.reply(NO_MANAGER_MSG);

View file

@ -1,5 +1,5 @@
import SimpleCommand from "../../../struct/commands/SimpleCommand";
import { client, dbs } from "../../..";
import { dbs } from "../../..";
import { DEFAULT_PREFIX } from "../../modules/command_handler";
import { isBotManager, NO_MANAGER_MSG } from "../../util";
import MessageCommandContext from "../../../struct/MessageCommandContext";
@ -11,9 +11,9 @@ const MENTION_TEXT = 'You can also @mention me instead of using the prefix.';
export default {
name: 'prefix',
aliases: null,
description: 'Configure AutoMod\'s prefix',
description: "Change AutoMod's prefix",
syntax: SYNTAX,
category: CommandCategory.Config,
category: CommandCategory.Configuration,
run: async (message: MessageCommandContext, args: string[]) => {
let config = await dbs.SERVERS.findOne({ id: message.channel!.serverId! });

View file

@ -1,14 +0,0 @@
import CommandCategory from "../../../struct/commands/CommandCategory";
import SimpleCommand from "../../../struct/commands/SimpleCommand";
import MessageCommandContext from "../../../struct/MessageCommandContext";
export default {
name: 'settings',
aliases: [ 'setting' ],
description: 'Manage AutoMod\'s configuration',
category: CommandCategory.Config,
run: async (message: MessageCommandContext, args: string[]) => {
await message.reply(`Bot configuration can be managed from `
+ `[here](<${process.env.WEB_UI_URL || 'https://automod.janderedev.xyz'}/dashboard>).`);
}
} as SimpleCommand;

View file

@ -0,0 +1,41 @@
import { dbs } from "../../..";
import { DEFAULT_PREFIX } from "../../modules/command_handler";
import CommandCategory from "../../../struct/commands/CommandCategory";
import MessageCommandContext from "../../../struct/MessageCommandContext";
import { isBotManager, NO_MANAGER_MSG } from "../../util";
export default {
name: "spam",
aliases: "antispam",
description: "Manage antispam features.",
category: CommandCategory.Configuration,
run: async (message: MessageCommandContext, args: string[]) => {
if (!(await isBotManager(message))) return message.reply(NO_MANAGER_MSG);
const antispamEnabled = await dbs.SERVERS.findOne({ id: message.serverContext.id });
switch (args.shift()?.toLowerCase()) {
case "enable": {
await dbs.SERVERS.update({ id: message.serverContext.id }, { $set: { antispamEnabled: true } });
await message.reply("Spam detection is now **enabled** in this server.\n" + "Please ensure AutoMod has permission to Manage Messages");
break;
}
case "disable": {
if (message.serverContext.discoverable) {
return message.reply(
"Your server is currently listed in Discover. As part of [Revolt's Discover Guidelines](<https://support.revolt.chat/kb/safety/discover-guidelines>), all servers on Discover are automatically enrolled into AutoMod's antispam features.",
);
}
await dbs.SERVERS.update({ id: message.serverContext.id }, { $set: { antispamEnabled: false } });
await message.reply("Spam detection is now **disabled** in this server.");
break;
}
default: {
const status = antispamEnabled ? "enabled" : "disabled";
await message.reply(`Spam detection is currently **${status}**. ` + `Use \`${DEFAULT_PREFIX}spam ${antispamEnabled ? "disable" : "enable"}\` to toggle this setting.`);
break;
}
}
},
};

View file

@ -11,9 +11,9 @@ const SYNTAX = '/whitelist add @user; /whitelist remove @user; /whitelist list';
export default {
name: 'whitelist',
aliases: [],
description: 'Allow users or roles to bypass moderation rules',
description: 'Allow users or roles to bypass moderation rules.',
syntax: SYNTAX,
category: CommandCategory.Config,
category: CommandCategory.Configuration,
run: async (message: MessageCommandContext, args: string[]) => {
let config: ServerConfig|null = await dbs.SERVERS.findOne({ id: message.serverContext.id })
if (!config) config = { id: message.channel!.serverId! };

View file

@ -1,79 +0,0 @@
import { decodeTime } from "ulid";
import CommandCategory from "../../../struct/commands/CommandCategory";
import SimpleCommand from "../../../struct/commands/SimpleCommand";
import MessageCommandContext from "../../../struct/MessageCommandContext";
import { ULID_REGEX } from "../../util";
export default {
name: 'debug',
aliases: null,
description: 'Gives info helpful for development and debugging',
syntax: '/debug [ULID|Discord ID|(empty)]',
category: CommandCategory.Misc,
run: async (message: MessageCommandContext, args: string[]) => {
if (ULID_REGEX.test(args[0])) {
const ts = decodeTime(args[0]);
const tsSmall = Math.round(ts / 1000);
await message.reply(
`ULID: \`${args[0]}\`\n` +
`TS: \`${ts}\` (<t:${tsSmall}:F> / <t:${tsSmall}:R>)`,
false
);
} else if (validateSnowflake(args[0])) {
const date = convertSnowflakeToDate(args[0]);
const ts = date.getTime(),
tsSmall = Math.round(ts / 1000);
await message.reply(
`Discord Snowflake: \`${args[0]}\`\n` +
`TS: \`${ts}\` (<t:${tsSmall}:F> / <t:${tsSmall}:R>)`,
false
);
} else {
await message.reply(
`Server ID: ${message.channel?.serverId || 'None'}\n`
+ `Server context: ${message.serverContext.id} `
+ `(${message.serverContext.id == message.channel?.serverId ? 'This server' : message.serverContext.name})\n`
+ `Channel ID: ${message.channelId}\n`
+ `User ID: ${message.authorId}`,
false
);
}
}
} as SimpleCommand;
/* The below is yoinked from https://github.com/vegeta897/snow-stamp/blob/main/src/convert.js */
const DISCORD_EPOCH = 1420070400000;
// Converts a snowflake ID string into a JS Date object using the provided epoch (in ms), or Discord's epoch if not provided
function convertSnowflakeToDate(snowflake: string|bigint, epoch = DISCORD_EPOCH) {
// Convert snowflake to BigInt to extract timestamp bits
// https://discord.com/developers/docs/reference#snowflakes
const milliseconds = BigInt(snowflake) >> 22n;
return new Date(Number(milliseconds) + epoch);
}
// Validates a snowflake ID string and returns a JS Date object if valid
function validateSnowflake(snowflake: string, epoch?: number) {
if (isNaN(parseInt(snowflake))) return false;
if (parseInt(snowflake) < 4194304) {
//throw new Error(
// "That doesn't look like a snowflake. Snowflakes are much larger numbers."
//)
return false;
}
const timestamp = convertSnowflakeToDate(snowflake, epoch);
if (Number.isNaN(timestamp.getTime())) {
//throw new Error(
// "That doesn't look like a snowflake. Snowflakes have fewer digits."
//)
return false;
}
return timestamp;
}

View file

@ -1,13 +0,0 @@
import CommandCategory from "../../../struct/commands/CommandCategory";
import SimpleCommand from "../../../struct/commands/SimpleCommand";
import MessageCommandContext from "../../../struct/MessageCommandContext";
export default {
name: 'healthcheck',
aliases: null,
description: 'Health check',
category: CommandCategory.Misc,
run: async (message: MessageCommandContext, args: string[]) => {
const msg = await message.reply('Health check success: ' + args.join(' '));
}
} as SimpleCommand;

View file

@ -1,117 +0,0 @@
import Command from "../../../struct/commands/SimpleCommand";
import { commands, DEFAULT_PREFIX, ownerIDs } from "../../modules/command_handler";
import MessageCommandContext from "../../../struct/MessageCommandContext";
import CommandCategory from "../../../struct/commands/CommandCategory";
const categories: { [key in CommandCategory]: {
friendlyName: string,
description: string,
aliases: string[],
} } = {
[CommandCategory.Moderation]: {
friendlyName: 'Moderation',
description: 'Moderation-focused commands',
aliases: [ 'mod', 'mods' ],
},
[CommandCategory.Config]: {
friendlyName: 'Configuration',
description: 'Configure AutoMod',
aliases: [ 'conf', 'config' ],
},
[CommandCategory.Misc]: {
friendlyName: 'Misc',
description: 'Random stuff :yed:',
aliases: [ 'miscellaneous', 'weirdwordicantspell' ],
},
[CommandCategory.Owner]: {
friendlyName: 'Owner',
description: 'Owner-only commands for managing AutoMod',
aliases: [],
},
[CommandCategory.None]: {
friendlyName: 'Uncategorized',
description: 'Uncategorized commands',
aliases: [],
},
};
export default {
name: 'help',
aliases: null,
description: 'Help command.',
removeEmptyArgs: true,
category: CommandCategory.Misc,
run: async (message: MessageCommandContext, args: string[]) => {
const isBotOwner = ownerIDs.includes(message.authorId!);
const prefix = DEFAULT_PREFIX; // TODO: fetch prefix from server config
let searchInput = args.shift()?.toLowerCase();
if (!searchInput) {
let msg = `## AutoMod help\n` +
`Type **${prefix}help [category]** to view see all commands or **${prefix}help [command]** to learn more about a command.\n\n`
+ `### [Open Server Settings]`
+ `(<${process.env.WEB_UI_URL || 'https://automod.janderedev.xyz'}/dashboard/${message.channel?.serverId}>)\n\n`;
let total = 0;
for (const categoryName in CommandCategory) {
let cmdCount = commands.filter(
cmd => (cmd.category == categoryName) &&
(cmd.restrict == 'BOTOWNER' ? isBotOwner : true) // Ensure owner commands are only shown to bot owner
).length;
if (cmdCount > 0) {
total++;
const category = (categories as any)[categoryName];
msg += `**${category.friendlyName}**\n` +
` \u200b \u200b ↳ ${(category.description)} \u200b $\\big |$ \u200b **${cmdCount}** command${cmdCount == 1 ? '' : 's'}\n`;
}
}
msg += `\n##### Categories: ${total}`;
await message.reply(msg);
} else {
let [ categoryName, category ] = Object.entries(categories).find(
c => c[1].friendlyName.toLowerCase() == searchInput
|| c[0].toLowerCase() == searchInput
) || Object.entries(categories).find(
c => c[1].aliases.find(k => k.toLowerCase() == searchInput)
) || [];
if (category && !searchInput.startsWith(prefix)) {
let msg = `**AutoMod help** - Category: ${category.friendlyName}\n`
+ `${category.description}\n\n`
+ `Type **${prefix}help [command]** to learn more about a command.\n\n`;
let cmdList = commands.filter(c => (c.category || 'uncategorized') == categoryName);
if (cmdList.length > 0) {
for (const cmd of cmdList) {
msg += `**${prefix}${cmd.name}** \u200b $\\big |$ \u200b ${cmd.description}\n`;
msg += '\n';
}
msg += `##### Total: ${cmdList.length}`;
} else msg += `### This category is empty.`;
await message.reply(msg);
} else {
if (searchInput.startsWith(prefix)) searchInput = searchInput.substring(prefix.length);
let cmd = commands.find(c => c.name.toLowerCase() == searchInput)
|| commands.find(c => c.aliases && c.aliases.find(k => k.toLowerCase() == searchInput));
if (!cmd) {
return message.reply(`I can't find any command or category matching \`${searchInput}\`.`);
} else {
let msg = `**AutoMod help** - Command: ${cmd.name}\n`
+ `${cmd.description}\n\n`;
if (cmd.syntax) msg += `Syntax: \`${cmd.syntax}\`\n`;
msg += 'Aliases: ' + (cmd.aliases ? `\`${cmd.aliases.join(`\`, \``)}\`` : 'None') + '\n';
message.reply(msg);
}
}
}
}
} as Command;

View file

@ -1,21 +0,0 @@
import SimpleCommand from "../../../struct/commands/SimpleCommand";
import { client } from "../../..";
import MessageCommandContext from "../../../struct/MessageCommandContext";
import CommandCategory from "../../../struct/commands/CommandCategory";
export default {
name: 'ping',
aliases: null,
description: 'ping pong',
category: CommandCategory.Misc,
run: async (message: MessageCommandContext, args: string[]) => {
let now = Date.now();
message.reply(`Measuring...`)
?.catch(console.error)
.then(msg => {
if (msg) msg.edit({ content: `## Ping Pong!\n`
+ `WS: \`${client.events.ping() ?? '--'}ms\`\n`
+ `Msg: \`${Math.round(Date.now() - now)}ms\`` });
});
}
} as SimpleCommand;

View file

@ -1,24 +0,0 @@
import CommandCategory from "../../../struct/commands/CommandCategory";
import SimpleCommand from "../../../struct/commands/SimpleCommand";
import MessageCommandContext from "../../../struct/MessageCommandContext";
export default {
name: 'test',
aliases: [ 'testalias' ],
description: 'Test command',
category: CommandCategory.Misc,
run: (message: MessageCommandContext, args: string[]) => {
message.reply({
content: 'Beep boop.',
embeds: [
{
colour: "#ff0000",
description: "embed description",
title: "embed title",
url: "https://amogus.org",
icon_url: "https://amogus.org/amogus.png"
}
],
});
}
} as SimpleCommand;

View file

@ -0,0 +1,112 @@
import Command from "../../../struct/commands/SimpleCommand";
import { commands, DEFAULT_PREFIX, ownerIDs } from "../../modules/command_handler";
import MessageCommandContext from "../../../struct/MessageCommandContext";
import CommandCategory from "../../../struct/commands/CommandCategory";
const categories: {
[key in CommandCategory]: {
friendlyName: string;
description: string;
aliases: string[];
};
} = {
[CommandCategory.Moderation]: {
friendlyName: "Moderation",
description: "Commands for enforcing server rules.",
aliases: ["moderation", "mod"],
},
[CommandCategory.Configuration]: {
friendlyName: "Configuration",
description: "Commands for setting up and customizing settings.",
aliases: ["configuration", "config", "conf"],
},
[CommandCategory.Owner]: {
friendlyName: "Owner",
description: "Exclusive commands for the bot owner to manage and control AutoMod.",
aliases: ["owner"],
},
[CommandCategory.Miscellaneous]: {
friendlyName: "Miscellaneous",
description: "Additional commands not covered by other categories.",
aliases: ["miscellaneous", "misc"],
},
[CommandCategory.None]: {
friendlyName: "Uncategorized",
description: "Commands that haven't been assigned to a specific category.",
aliases: [],
},
};
export default {
name: "help",
aliases: null,
description: "Displays usage instructions.",
removeEmptyArgs: true,
category: CommandCategory.Miscellaneous,
run: async (message: MessageCommandContext, args: string[]) => {
const isBotOwner = ownerIDs.includes(message.authorId!);
const prefix = DEFAULT_PREFIX; // TODO: fetch prefix from server config
let searchInput = args.shift()?.toLowerCase();
if (!searchInput) {
let msg =
`## AutoMod Help\n` +
`Type \`${prefix}help [category]\` to view commands within a category, or \`${prefix}help [command]\` to learn more about a specific command.\n\n`;
let total = 0;
for (const categoryName in CommandCategory) {
let cmdCount = commands.filter((cmd) => cmd.category == categoryName && (cmd.restrict == "BOTOWNER" ? isBotOwner : true)).length;
if (cmdCount > 0) {
total++;
const category = (categories as any)[categoryName];
msg += `**${category.friendlyName}**\n` + ` \u200b \u200b ↳ ${category.description} \u200b $\\big |$ \u200b **${cmdCount}** command${cmdCount == 1 ? "" : "s"}\n`;
}
}
msg += `\n##### Categories: ${total}\n\n` +
`[Open Server Settings]` +
`(<${process.env['WEB_UI_URL'] || "https://automod.vale.rocks"}/dashboard/${message.channel?.serverId}>)`;
await message.reply(msg);
} else {
let [categoryName, category] =
Object.entries(categories).find((c) => c[1].friendlyName.toLowerCase() == searchInput || c[0].toLowerCase() == searchInput) ||
Object.entries(categories).find((c) => c[1].aliases.find((k) => k.toLowerCase() == searchInput)) ||
[];
if (category && !searchInput.startsWith(prefix)) {
let msg = `## AutoMod Help - ${category.friendlyName}\n` +
`${category.description}\n\n` +
`Type \`${prefix}help [command]\` to learn more about a specific command.\n\n`;
let cmdList = commands.filter((c) => (c.category || "uncategorized") == categoryName);
if (cmdList.length > 0) {
for (const cmd of cmdList) {
msg += `**${prefix}${cmd.name}** - ${cmd.description}`;
msg += "\n";
}
msg += `##### ${category.friendlyName} Commands: ${cmdList.length}`;
} else msg += `### This category is empty.`;
await message.reply(msg);
} else {
if (searchInput.startsWith(prefix)) searchInput = searchInput.substring(prefix.length);
let cmd = commands.find((c) => c.name.toLowerCase() == searchInput) || commands.find((c) => c.aliases && c.aliases.find((k) => k.toLowerCase() == searchInput));
if (!cmd) {
return message.reply(`I can't find any command or category matching \`${searchInput}\`.`);
} else {
let msg = `## AutoMod Help - ${cmd.name}\n` +
`${cmd.description}\n\n`;
if (cmd.syntax) msg += `Syntax: \`${cmd.syntax}\`\n`;
msg += "Aliases: " + (cmd.aliases ? `\`${cmd.aliases.join(`\`, \``)}\`` : "None") + "\n";
message.reply(msg);
}
}
}
},
} as Command;

View file

@ -1,5 +1,5 @@
import { FindOneResult } from "monk";
import { client, dbs } from "../../..";
import type { FindOneResult } from "monk";
import { dbs } from "../../..";
import CommandCategory from "../../../struct/commands/CommandCategory";
import SimpleCommand from "../../../struct/commands/SimpleCommand";
import MessageCommandContext from "../../../struct/MessageCommandContext";
@ -10,14 +10,14 @@ import { DEFAULT_PREFIX } from "../../modules/command_handler";
export default {
name: 'login',
aliases: null,
description: 'Log into the web dashboard',
category: CommandCategory.Misc,
description: 'Log into the web dashboard.',
category: CommandCategory.Miscellaneous,
run: async (message: MessageCommandContext, args: string[]) => {
try {
const code = args.shift();
if (!code) {
return message.reply(`If you're trying to log in, you can access the dashboard `
+ `[here](${process.env.WEB_UI_URL || 'https://automod.janderedev.xyz'}).\n\n`
+ `[here](${process.env['WEB_UI_URL'] || 'https://automod.vale.rocks'}).\n\n`
+ `If you already have a code, you can use \`${DEFAULT_PREFIX}login [Code]\`.`);
}
@ -41,7 +41,7 @@ export default {
`# If someone told you to run this, stop!\n` +
`This could give an attacker access to all servers you're using AutoMod in.\n` +
`If someone else told you to run this command, **block them and ignore this.**\n\n` +
`Otherwise, if this was you trying to log in from <${process.env.WEB_UI_URL || 'https://automod.janderedev.xyz'}>, \n` +
`Otherwise, if this was you trying to log in from <${process.env['WEB_UI_URL'] || 'https://automod.vale.rocks'}>, \n` +
`you can run this command again to continue.\n` +
`##### You're seeing this because this is the first time you're trying to log in. Stay safe!`
),

View file

@ -7,8 +7,8 @@ import { DEFAULT_PREFIX } from "../../modules/command_handler";
export default {
name: 'logout',
aliases: null,
description: 'Log out of sessions created with /login',
category: CommandCategory.Misc,
description: 'Log out of web dashboard sessions.',
category: CommandCategory.Miscellaneous,
run: async (message: MessageCommandContext, args: string[]) => {
try {
const code = args.shift();

View file

@ -0,0 +1,20 @@
import SimpleCommand from "../../../struct/commands/SimpleCommand";
import { client } from "../../..";
import MessageCommandContext from "../../../struct/MessageCommandContext";
import CommandCategory from "../../../struct/commands/CommandCategory";
export default {
name: "ping",
aliases: null,
description: "Checks response times.",
category: CommandCategory.Miscellaneous,
run: async (message: MessageCommandContext) => {
let now = Date.now();
message
.reply(`Measuring...`)
?.catch(console.error)
.then((msg) => {
if (msg) msg.edit({ content: `## Ping Pong!\n` + `WebSocket: \`${client.events.ping() ?? "--"}ms\`\n` + `Message: \`${Math.round(Date.now() - now)}ms\`` });
});
},
} as SimpleCommand;

View file

@ -0,0 +1,15 @@
import CommandCategory from "../../../struct/commands/CommandCategory";
import SimpleCommand from "../../../struct/commands/SimpleCommand";
import MessageCommandContext from "../../../struct/MessageCommandContext";
export default {
name: "support",
aliases: ["donate", "tip"],
description: "Financially support AutoMod development and hosting.",
category: CommandCategory.Miscellaneous,
run: async (message: MessageCommandContext) => {
message.reply({
content: "AutoMod is hosted and developed free of charge, but your financial support is greatly appreciated. You can support me via https://vale.rocks/support. Thank you so very much!",
});
},
} as SimpleCommand;

View file

@ -0,0 +1,24 @@
import CommandCategory from "../../../struct/commands/CommandCategory";
import SimpleCommand from "../../../struct/commands/SimpleCommand";
import MessageCommandContext from "../../../struct/MessageCommandContext";
export default {
name: "test",
aliases: ["testalias"],
description: "Tests that the bot works.",
category: CommandCategory.Miscellaneous,
run: async (message: MessageCommandContext) => {
message.reply({
content: "Beep boop.",
embeds: [
{
colour: "#58A551",
title: "Test Success!",
description: "You've successfully tested the bot. It works.",
url: "https://automod.vale.rocks",
icon_url: "https://autumn.revolt.chat/avatars/pYjK-QyMv92hy8GUM-b4IK1DMzYILys9s114khzzKY",
},
],
});
},
} as SimpleCommand;

View file

@ -1,16 +1,12 @@
import { ServerMember } from "revolt.js";
import axios from "axios";
import CommandCategory from "../../../struct/commands/CommandCategory";
import SimpleCommand from "../../../struct/commands/SimpleCommand";
import MessageCommandContext from "../../../struct/MessageCommandContext";
import { isModerator, NO_MANAGER_MSG, parseUser } from "../../util";
import AutomodClient from "../../../struct/AutomodClient";
import { client } from "../../..";
export default {
name: 'avatar',
aliases: [ 'pfp' ],
description: 'Get or clear someone\'s avatar',
description: 'Manage or return a user\'s profile picture.',
category: CommandCategory.Moderation,
run: async (message: MessageCommandContext, args: string[]) => {
try {

View file

@ -23,7 +23,7 @@ import {
import Day from "dayjs";
import RelativeTime from "dayjs/plugin/relativeTime";
import CommandCategory from "../../../struct/commands/CommandCategory";
import { SendableEmbed } from "revolt-api";
import type { SendableEmbed } from "revolt-api";
import { User } from "revolt.js";
import logger from "../../logger";
@ -32,7 +32,7 @@ Day.extend(RelativeTime);
export default {
name: "ban",
aliases: ["eject"],
description: "Ban a member from the server",
description: "Bans a user from the server.",
syntax: "/ban @username [10m|1h|...?] [reason?]",
removeEmptyArgs: true,
category: CommandCategory.Moderation,

View file

@ -1,5 +1,5 @@
import { User } from "revolt.js";
import { SendableEmbed } from "revolt-api";
import type { SendableEmbed } from "revolt-api";
import { ulid } from "ulid";
import { client } from "../../../";
import Infraction from "automod/dist/types/antispam/Infraction";
@ -17,7 +17,6 @@ import {
getMembers,
isModerator,
NO_MANAGER_MSG,
parseUser,
parseUserOrId,
sanitizeMessageContent,
storeInfraction,
@ -27,7 +26,7 @@ import {
export default {
name: "kick",
aliases: ["yeet", "vent"],
description: "Kick a member from the server",
description: "Kick a user from the server.",
syntax: "/kick @username [reason?]",
removeEmptyArgs: true,
category: CommandCategory.Moderation,

View file

@ -9,7 +9,7 @@ import { client } from "../../..";
export default {
name: 'nick',
aliases: [ 'setnick' ],
description: 'Set or clear someone\'s nickname',
description: 'Manage a user\'s nickname.',
category: CommandCategory.Moderation,
run: async (message: MessageCommandContext, args: string[]) => {
try {

View file

@ -11,7 +11,7 @@ const MAX_PURGE_AMOUNT = 100;
export default {
name: "purge",
aliases: ["clear"],
description: "Mass delete messages",
description: "Delete messages in bulk.",
syntax: SYNTAX,
category: CommandCategory.Moderation,
run: async (message: MessageCommandContext, args: string[]) => {

View file

@ -34,7 +34,7 @@ function parseTimeInput(input: string) {
export default {
name: 'timeout',
aliases: [ 'mute' ],
description: 'Set a timeout on a user',
description: 'Set a timeout on a user.',
category: CommandCategory.Moderation,
run: async (message: MessageCommandContext, args: string[]) => {
try {

View file

@ -8,7 +8,7 @@ import { isModerator, NO_MANAGER_MSG, parseUser, ULID_REGEX, USER_MENTION_REGEX
export default {
name: 'unban',
aliases: [ 'pardon' ],
description: 'Unbans a user',
description: "Removes a user's server ban.",
syntax: '/unban [@user or ID]',
category: CommandCategory.Moderation,
run: async (message: MessageCommandContext, args: string[]) => {

View file

@ -1,6 +1,5 @@
import { FindResult } from "monk";
import { ulid } from "ulid";
import { client, dbs } from "../../../";
import { dbs } from "../../../";
import CommandCategory from "../../../struct/commands/CommandCategory";
import SimpleCommand from "../../../struct/commands/SimpleCommand";
import MessageCommandContext from "../../../struct/MessageCommandContext";
@ -20,7 +19,7 @@ type VoteEntry = {
export default {
name: 'votekick',
aliases: [ 'voteban' ],
description: 'Allow trusted users to vote kick users',
description: 'Allow trusted users to vote kick users.',
category: CommandCategory.Moderation,
run: async (message: MessageCommandContext, args: string[]) => {
try {
@ -115,4 +114,4 @@ export default {
}
} as SimpleCommand;
export { VoteEntry }
export type { VoteEntry }

View file

@ -5,7 +5,7 @@ import { ulid } from "ulid";
import InfractionType from "automod/dist/types/antispam/InfractionType";
import { fetchUsername, logModAction } from "../../modules/mod_logs";
import CommandCategory from "../../../struct/commands/CommandCategory";
import { SendableEmbed } from "revolt-api";
import type { SendableEmbed } from "revolt-api";
import { User } from "revolt.js";
import logger from "../../logger";
@ -13,7 +13,7 @@ export default {
name: 'warn',
aliases: null,
removeEmptyArgs: false,
description: 'add an infraction to an user\'s record',
description: "Adds an infraction to a user's record.",
category: CommandCategory.Moderation,
run: async (message, args, serverConfig) => {
if (!await isModerator(message)) return message.reply(NO_MANAGER_MSG);

View file

@ -17,8 +17,8 @@ const GLOBAL_BLACKLIST_TEXT = (reason?: string) => `> :warning: This user has be
export default {
name: 'warns',
aliases: [ 'warnings', 'infractions', 'infraction' ],
description: 'Show all user infractions',
aliases: [ 'warnings', 'infractions' ],
description: "Shows a user's infractions.",
syntax: '/warns; /warns @username ["export-csv"]; /warns rm [ID]',
category: CommandCategory.Moderation,
run: async (message: MessageCommandContext, args: string[]) => {

View file

@ -6,9 +6,8 @@ import child_process from 'child_process';
import fs from 'fs';
import path from 'path';
import { User } from "revolt.js";
import { adminBotLog } from "../../logging";
import CommandCategory from "../../../struct/commands/CommandCategory";
import { getMutualServers, parseUserOrId } from "../../util";
import { parseUserOrId } from "../../util";
const BLACKLIST_BAN_REASON = `This user is globally blacklisted and has been banned automatically. If you wish to opt out of the global blacklist, run '/botctl ignore_blacklist yes'.`;
const BLACKLIST_MESSAGE = (username: string) => `\`@${username}\` has been banned automatically. Check the ban reason for more info.`;
@ -34,9 +33,6 @@ const SUBCOMMANDS: string[] = [
'stats',
'sudo',
'userinfo',
'blacklist',
'unblacklist',
'blacklistreason',
'ignore',
'unignore',
];
@ -62,11 +58,10 @@ export default {
+ `Users: \`${client.users.size()}\`\n`
+ `### Misc\n`
+ `Command count: \`${commands.length}\`\n`
+ `Environment: \`${process.env.NODE_ENV || 'testing'}\`\n`
+ `Environment: \`${process.env['NODE_ENV'] || 'testing'}\`\n`
+ `Commit hash: \`${await getCommitHash() || 'Unknown'}\`\n`
+ `### Packages\n`
+ `revolt.js: \`${pjson.dependencies['revolt.js']}\`\n`
+ `discord.js: \`${pjson.dependencies['discord.js']}\`\n`
+ `axios: \`${pjson.dependencies['axios']}\`\n`
+ `log75: \`${pjson.dependencies['log75']}\`\n`
+ `typescript: \`${pjson.devDependencies['typescript']}\`\n`
@ -96,8 +91,6 @@ export default {
const sentMsg = await message.reply(msg.replace('%emoji%', ':lock:'), false);
setTimeout(() => sentMsg?.edit({ content: msg.replace('%emoji%', ':unlock:') }).catch(()=>{}), 200);
await adminBotLog({ type: 'WARN', message: `@${message.author!.username} has enabled sudo mode.` });
break;
}
@ -145,92 +138,6 @@ export default {
break;
}
case 'blacklist': {
const target = await parseUserOrId(args.shift() || '');
if (!target) return message.reply('Specified user could not be found.');
if (target.id == message.authorId) return message.reply(`no`);
await dbs.USERS.update({
id: target.id,
}, {
$setOnInsert: { id: target.id },
$set: { globalBlacklist: true }
}, { upsert: true });
try {
// Ban the user from all shared servers (unless those who opted out)
if (target instanceof User) {
const msg = await message.reply(`User update stored.`);
let bannedServers = 0;
const mutuals = getMutualServers(target);
for (const server of mutuals) {
if (server.havePermission('BanMembers')) {
const config = await dbs.SERVERS.findOne({ id: server.id });
if (config?.allowBlacklistedUsers) continue;
try {
await server.banUser(target.id, {
reason: BLACKLIST_BAN_REASON,
});
bannedServers++;
if (server.systemMessages?.user_banned) {
const channel = server.channels.find(c => c!.id == server.systemMessages!.user_banned);
if (channel && channel.havePermission('SendMessage')) {
await channel.sendMessage(BLACKLIST_MESSAGE(target.username));
}
}
} catch(e) {
console.error(`Failed to ban in ${server.id}: ${e}`);
}
}
}
if (bannedServers) {
msg?.edit({ content: `User update stored. User has been banned from ${bannedServers} servers.` });
}
} else await message.reply(`User update stored. No servers are currently shared with this user.`);
} catch(e) {
console.error(''+e);
await message.reply(`Failed to ban target from mutual servers: ${e}\n`);
}
break;
}
case 'unblacklist': {
const target = await parseUserOrId(args.shift() || '');
if (!target) return message.reply('Specified user could not be found.');
await dbs.USERS.update({
id: target.id,
}, {
$setOnInsert: { id: target.id },
$set: { globalBlacklist: false }
}, { upsert: true });
await message.reply(`User update stored. Existing bans will not be lifted automatically.`);
break;
}
case 'blacklistreason': {
const target = await parseUserOrId(args.shift() || '');
if (!target) return message.reply('Specified user could not be found.');
await dbs.USERS.update({
id: target.id,
}, {
$setOnInsert: { id: target.id },
$set: { blacklistReason: args.join(' ') || undefined }
}, { upsert: true });
await message.reply(`User update stored.`);
break;
}
case 'ignore': {
const target = await parseUserOrId(args.shift() || '');
if (!target) return message.reply('Specified user could not be found.');

View file

@ -45,14 +45,6 @@ async function databaseMigrations() {
}
}
await setIndexes(dbs.BRIDGE_CONFIG, ["discord", "revolt"]);
await setIndexes(dbs.BRIDGE_REQUESTS, ["id", "revolt"]);
await setIndexes(dbs.BRIDGED_MESSAGES, [
"discord.messageId",
"revolt.messageId",
"revolt.nonce",
"origin",
]);
await setIndexes(dbs.INFRACTIONS, ["createdBy", "user", "server"]);
await setIndexes(dbs.PENDING_LOGINS, ["code", "user"]);
await setIndexes(dbs.SERVERS, ["id"]);
@ -60,17 +52,6 @@ async function databaseMigrations() {
await setIndexes(dbs.TEMPBANS, ["id", "until"]);
await setIndexes(dbs.USERS, ["id"]);
await setIndexes(dbs.VOTEKICKS, ["id", "server", "target"]);
// Migrate `disallowIfOptedOut` to `config.disallow_opt_out` on bridge_config
await dbs.BRIDGE_CONFIG.update(
{
disallowIfOptedOut: { $exists: true },
"config.disallow_opt_out": { $exists: false },
},
{
$rename: { disallowIfOptedOut: "config.disallow_opt_out" },
}
);
}
export { databaseMigrations }

View file

@ -1,6 +1,6 @@
import Log75, { LogLevel } from 'log75';
// Thanks to being forced to switch to ESM this broke somehow?
let logger: Log75 = new (Log75 as any).default(process.env.NODE_ENV == 'production' ? LogLevel.Standard : LogLevel.Debug);
let logger: Log75 = new (Log75 as any).default(process.env['NODE_ENV'] == 'production' ? LogLevel.Standard : LogLevel.Debug);
export default logger;

View file

@ -1,42 +0,0 @@
import { ColorResolvable, MessageEmbed, WebhookClient } from "discord.js";
import logger from "./logger";
import { client as bot } from '../index'
let client: WebhookClient|undefined;
if (process.env.LOG_WEBHOOK) {
try {
client = new WebhookClient({ url: process.env.LOG_WEBHOOK });
} catch(e) {
console.error(e);
}
}
async function adminBotLog(data: { message: string, type: 'INFO'|'WARN'|'ERROR' }) {
logger.info(`[${data.type}] Admin log: ${data.message}`);
try {
let color: ColorResolvable = '#ffffff';
switch(data.type) {
case 'INFO': color = '#00ff73'; break;
case 'WARN': color = '#ffc823'; break;
case 'ERROR': color = '#ff4208'; break;
}
let embed = new MessageEmbed()
.setDescription(data.message)
.setColor(color);
await client?.send({
embeds: [ embed ],
username: bot.user?.username,
avatarURL: bot.user?.avatarURL,
});
} catch(e) {
logger.error(`Failed to log: ${e}`);
}
}
export {
client,
adminBotLog,
}

View file

@ -1,15 +1,15 @@
import { Message } from "revolt.js";
import { ulid } from "ulid";
import { client, dbs } from "../..";
import { dbs } from "../..";
import AntispamRule from "automod/dist/types/antispam/AntispamRule";
import Infraction from "automod/dist/types/antispam/Infraction";
import InfractionType from "automod/dist/types/antispam/InfractionType";
import ModerationAction from "automod/dist/types/antispam/ModerationAction";
import logger from "../logger";
import { awaitClient, generateInfractionDMEmbed, isModerator, sendLogMessage, storeInfraction } from "../util";
import { generateInfractionDMEmbed, isModerator, sendLogMessage, storeInfraction } from "../util";
import { getDmChannel, sanitizeMessageContent } from "../util";
import ServerConfig from "automod/dist/types/ServerConfig";
import { WORDLIST_DEFAULT_MESSAGE } from "../commands/configuration/botctl";
import { WORDLIST_DEFAULT_MESSAGE } from "../commands/configuration/filter";
let msgCountStore: Map<string, { users: any }> = new Map();
@ -124,8 +124,7 @@ async function wordFilterCheck(message: Message, config: ServerConfig) {
console.log('Message matched word filter!');
// Lack of `break` is intended here
switch(config.wordlistAction?.action) {
switch (config.wordlistAction?.action) {
case 'WARN': {
try {
const infraction: Infraction = {
@ -145,12 +144,14 @@ async function wordFilterCheck(message: Message, config: ServerConfig) {
const dmChannel = await getDmChannel(message.author!);
if (dmChannel.havePermission('SendMessage') && dmChannel.havePermission('SendEmbeds')) {
await dmChannel.sendMessage({ embeds: [ embed ] });
await dmChannel.sendMessage({ embeds: [embed] });
}
else logger.warn('Missing permission to DM user.');
}
} catch(e) {
break;
} catch (e) {
console.error(e);
break;
}
}
case 'DELETE': {
@ -164,7 +165,9 @@ async function wordFilterCheck(message: Message, config: ServerConfig) {
await message.channel.sendMessage((config.wordlistAction.message || WORDLIST_DEFAULT_MESSAGE)
.replaceAll('{{user_id}}', message.authorId!));
}
break;
}
break;
}
case 'LOG':
default: {
@ -177,10 +180,10 @@ async function wordFilterCheck(message: Message, config: ServerConfig) {
`>${sanitizeMessageContent(message.content.substring(0, 1000)).trim().replace(/\n/g, '\n>')}`,
color: '#ff557f',
});
break;
}
}
} catch(e) {
} catch (e) {
console.error(e);
}
}
@ -251,51 +254,4 @@ function checkMessageForFilteredWords(message: string, config: ServerConfig): bo
return false;
}
// Scan all servers for the `discoverable` flag and notify their owners that antispam is forcefully enabled
const notifyPublicServers = async () => {
logger.info('Sending antispam notification to public servers');
const servers = Array.from(client.servers.values())
.filter(server => server.discoverable);
const res = await dbs.SERVERS.find({
id: { $in: servers.map(s => s.id) },
discoverAutospamNotify: { $in: [ undefined, false ] },
});
for (const serverConfig of res) {
try {
logger.info(`Sending notification to owner of server ${serverConfig.id}`);
if (serverConfig.discoverAutospamNotify) {
logger.warn('This server already received the message');
continue;
}
await dbs.SERVERS.update(
{ id: serverConfig.id },
{ $set: { discoverAutospamNotify: true, antispamEnabled: true, allowBlacklistedUsers: false } },
);
const server = client.servers.get(serverConfig.id);
const channel = await getDmChannel(server!.ownerId);
await channel.sendMessage(`Hi there,
It looks like your server, **${sanitizeMessageContent(server!.name).trim()}**, has been added to server discovery. Congratulations!
In order to keep Revolt free of spam, AutoMod enables spam protection by default on public servers.
You are receiving this message to inform you that said features have been enabled automatically in your server.
Please ensure that AutoMod has appropriate permissions to kick and ban users.
You may also want to set up a logging channel by running \`/botctl logs modaction #yourchannel\` to receive details about antispam events if you haven't done so already.
Thanks for being part of Revolt!`);
} catch(e) {
console.error(e);
}
}
}
// awaitClient().then(() => notifyPublicServers());
export { antispam, wordFilterCheck, checkMessageForFilteredWords }

View file

@ -3,7 +3,8 @@ import { User } from "revolt.js";
import { client, dbs } from "../../..";
import ServerConfig from "automod/dist/types/ServerConfig";
import { getPermissionLevel } from "../../util";
import { wsEvents, WSResponse } from "../api_communication";
import type { WSResponse } from "../api_communication";
import { wsEvents } from "../api_communication";
type ReqData = { user: string, server: string }
type APIUser = { id: string, username?: string, avatarURL?: string }
@ -95,4 +96,4 @@ wsEvents.on('req:getUserServerDetails', async (data: ReqData, cb: (data: WSRespo
}
});
export { APIUser }
export type { APIUser }

View file

@ -1,7 +1,8 @@
import { User } from 'revolt.js';
import { client } from '../../..';
import { getMutualServers, getPermissionLevel } from '../../util';
import { wsEvents, WSResponse } from '../api_communication';
import type { WSResponse } from "../api_communication";
import { wsEvents } from "../api_communication";
type ReqData = { user: string }

View file

@ -1,8 +1,9 @@
import { User } from "revolt.js";
import { client } from "../../..";
import { getPermissionLevel, parseUser } from "../../util";
import { wsEvents, WSResponse } from "../api_communication";
import { APIUser } from "./server_details";
import type { WSResponse } from "../api_communication";
import { wsEvents } from "../api_communication";
import type { APIUser } from "./server_details";
wsEvents.on('req:getPermissionLevel', async (data: { user: string, server: string }, cb: (data: WSResponse) => void) => {
try {

View file

@ -4,8 +4,8 @@
import ws from "ws";
import logger from "../logger";
import crypto from 'crypto';
import { client as bot, dbs } from '../..';
import crypto from "crypto";
import { client as bot, dbs } from "../..";
import { EventEmitter } from "events";
import { parseUser } from "../util";
import PendingLogin from "automod/dist/types/PendingLogin";
@ -14,135 +14,153 @@ import { ulid } from "ulid";
const wsEvents = new EventEmitter();
const { API_WS_URL, API_WS_TOKEN } = process.env;
const wsQueue: { [key: string]: string }[] = [];
let client: ws|undefined = undefined;
let client: ws | undefined = undefined;
let retryCount = 0;
const MAX_RETRIES = 10;
const INITIAL_RETRY_DELAY = 3000;
type WSResponse = { success: false, error: string, statusCode?: number } | { success: true, [key: string]: any }
type WSResponse = { success: false; error: string; statusCode?: number } | { success: true; [key: string]: any };
if (!API_WS_URL || !API_WS_TOKEN)
logger.info("$API_WS_URL or $API_WS_TOKEN not found.");
else {
logger.info(`$API_WS_URL and $API_WS_TOKEN set; Connecting to ${API_WS_URL}`);
connect();
if (!API_WS_URL || !API_WS_TOKEN) {
logger.error("$API_WS_URL or $API_WS_TOKEN not found. Please set these environment variables.");
} else {
logger.info(`$API_WS_URL and $API_WS_TOKEN set; Connecting to ${API_WS_URL}`);
connect();
}
function connect() {
if (client && client.readyState == ws.OPEN) client.close();
client = new ws(API_WS_URL!, { headers: { authorization: API_WS_TOKEN! } });
if (client && client.readyState == ws.OPEN) client.close();
client = new ws(API_WS_URL!, { headers: { authorization: API_WS_TOKEN! } });
client.once("open", () => {
logger.debug("WS connected");
if (wsQueue.length > 0) {
logger.debug(`Attempting to send ${wsQueue.length} queued WS messages`);
client.once("open", () => {
logger.info("WebSocket connected successfully");
retryCount = 0;
if (wsQueue.length > 0) {
logger.debug(`Attempting to send ${wsQueue.length} queued WS messages`);
while (wsQueue.length > 0) {
if (client?.readyState != ws.OPEN) break;
const data = JSON.stringify(wsQueue.shift());
logger.debug(`[WS] [FROM QUEUE] [>] ${data}`);
client.send(data);
}
}
});
while (wsQueue.length > 0) {
if (client?.readyState != ws.OPEN) break;
const data = JSON.stringify(wsQueue.shift());
logger.debug(`[WS] [FROM QUEUE] [>] ${data}`);
client.send(data);
}
}
});
client.once("close", () => {
client = undefined;
logger.warn(`WS closed, reconnecting in 3 seconds`);
setTimeout(connect, 3000);
});
client.once("close", () => {
client = undefined;
retryConnection();
});
client.once('error', (err) => {
client = undefined;
logger.warn(`WS: ${err}`);
});
client.once("error", (err: Error) => {
client = undefined;
logger.error(`WebSocket error: ${err.message}`);
retryConnection();
});
client.on('message', (msg) => {
logger.debug(`[WS] [<] ${msg.toString('utf8')}`);
try {
const jsonMsg = JSON.parse(msg.toString('utf8'));
wsEvents.emit('message', jsonMsg);
if (jsonMsg['nonce'] && jsonMsg['type']) {
const hasListeners = wsEvents.emit(`req:${jsonMsg.type}`, jsonMsg.data, (res: { [key: string]: any }) => {
wsSend({ nonce: jsonMsg.nonce, type: `response:${jsonMsg.nonce}`, data: res });
});
if (!hasListeners) {
wsSend({
nonce: jsonMsg.nonce,
type: `response:${jsonMsg.nonce}`,
data: {
success: false,
error: 'No event listeners available for event'
}
});
}
}
} catch(e) { console.error(e) }
});
client.on("message", (msg: ws.Data) => {
logger.debug(`[WS] [<] ${msg.toString("utf8")}`);
try {
const jsonMsg = JSON.parse(msg.toString("utf8"));
wsEvents.emit("message", jsonMsg);
if (jsonMsg["nonce"] && jsonMsg["type"]) {
const hasListeners = wsEvents.emit(`req:${jsonMsg.type}`, jsonMsg.data, (res: { [key: string]: any }) => {
wsSend({ nonce: jsonMsg.nonce, type: `response:${jsonMsg.nonce}`, data: res });
});
if (!hasListeners) {
wsSend({
nonce: jsonMsg.nonce,
type: `response:${jsonMsg.nonce}`,
data: {
success: false,
error: "No event listeners available for event",
},
});
}
}
} catch (e) {
console.error(e);
}
});
}
function wsSend(data: { [key: string]: any }) {
if (client && client.readyState == client.OPEN) {
logger.debug(`[WS] [>] ${JSON.stringify(data)}`);
client.send(JSON.stringify(data));
} else {
logger.debug(`[WS] [QUEUED] [>] ${JSON.stringify(data)}`);
wsQueue.push(data);
}
if (client && client.readyState == client.OPEN) {
logger.debug(`[WS] [>] ${JSON.stringify(data)}`);
client.send(JSON.stringify(data));
} else {
logger.debug(`[WS] [QUEUED] [>] ${JSON.stringify(data)}`);
wsQueue.push(data);
}
}
wsEvents.on('req:test', (data: any, res: (data: any) => void) => {
res({ received: data });
function retryConnection() {
if (retryCount >= MAX_RETRIES) {
logger.error(`Failed to connect after ${MAX_RETRIES} attempts. Please check your network and API_WS_URL.`);
return;
}
const delay = INITIAL_RETRY_DELAY * Math.pow(2, retryCount);
retryCount++;
logger.warn(`WebSocket disconnected. Attempting to reconnect in ${delay / 1000} seconds (Attempt ${retryCount} of ${MAX_RETRIES})`);
setTimeout(connect, delay);
}
wsEvents.on("req:test", (data: any, res: (data: any) => void) => {
res({ received: data });
});
wsEvents.on('req:requestLogin', async (data: any, cb: (data: WSResponse) => void) => {
try {
const user = await parseUser(data.user);
if (!user)
return cb({ success: false, statusCode: 404, error: `The specified user could not be found` });
wsEvents.on("req:requestLogin", async (data: any, cb: (data: WSResponse) => void) => {
try {
const user = await parseUser(data.user);
if (!user) return cb({ success: false, statusCode: 404, error: `The specified user could not be found` });
let code: string|null = null;
while (!code) {
const c = crypto.randomBytes(8).toString('hex');
const found = await dbs.PENDING_LOGINS.find({ code: c, user: user.id, confirmed: false });
if (found.length > 0) continue;
code = c.substring(0, 8).toUpperCase();
}
let code: string | null = null;
while (!code) {
const c = crypto.randomBytes(8).toString("hex");
const found = await dbs.PENDING_LOGINS.find({ code: c, user: user.id, confirmed: false });
if (found.length > 0) continue;
code = c.substring(0, 8).toUpperCase();
}
logger.info(`Attempted login for user ${user.id} with code ${code}`);
logger.info(`Attempted login for user ${user.id} with code ${code}`);
const nonce = ulid();
const nonce = ulid();
const [previousLogins, currentValidLogins] = await Promise.all([
dbs.PENDING_LOGINS.find({ user: user.id, confirmed: true }),
dbs.PENDING_LOGINS.find({ user: user.id, confirmed: false, expires: { $gt: Date.now() } }),
]);
const [previousLogins, currentValidLogins] = await Promise.all([
dbs.PENDING_LOGINS.find({ user: user.id, confirmed: true }),
dbs.PENDING_LOGINS.find({ user: user.id, confirmed: false, expires: { $gt: Date.now() } }),
]);
if (currentValidLogins.length >= 5) return cb({ success: false, statusCode: 403, error: 'Too many pending logins. Try again later.' });
if (currentValidLogins.length >= 5) return cb({ success: false, statusCode: 403, error: "Too many pending logins. Try again later." });
await dbs.PENDING_LOGINS.insert({
code,
expires: Date.now() + (1000 * 60 * 15), // Expires in 15 minutes
user: user.id,
nonce: nonce,
confirmed: false,
requirePhishingConfirmation: previousLogins.length == 0,
exchanged: false,
invalid: false,
} as PendingLogin);
await dbs.PENDING_LOGINS.insert({
code,
expires: Date.now() + 1000 * 60 * 15, // Expires in 15 minutes
user: user.id,
nonce: nonce,
confirmed: false,
requirePhishingConfirmation: previousLogins.length == 0,
exchanged: false,
invalid: false,
} as PendingLogin);
cb({ success: true, uid: user.id, nonce, code });
} catch(e) {
console.error(e);
cb({ success: false, error: `${e}` });
}
cb({ success: true, uid: user.id, nonce, code });
} catch (e) {
console.error(e);
cb({ success: false, error: `${e}` });
}
});
wsEvents.on('req:stats', async (_data: any, cb: (data: { servers: number }) => void) => {
const servers = bot.servers.size();
cb({ servers });
wsEvents.on("req:stats", async (_data: any, cb: (data: { servers: number }) => void) => {
const servers = bot.servers.size();
cb({ servers });
});
export { wsEvents, wsSend, WSResponse }
export { wsEvents, wsSend };
export type { WSResponse };
import('./api/servers');
import('./api/server_details');
import('./api/users');
import("./api/servers");
import("./api/server_details");
import("./api/users");

View file

@ -8,7 +8,7 @@ import checkCustomRules from "./custom_rules/custom_rules";
import MessageCommandContext from "../../struct/MessageCommandContext";
import { fileURLToPath } from 'url';
import { getOwnMemberInServer } from "../util";
import { isSudo, updateSudoTimeout } from "../commands/admin/botadm";
import { isSudo, updateSudoTimeout } from "../commands/owner/botadm";
import { metrics } from "./metrics";
// thanks a lot esm
@ -37,6 +37,8 @@ let commands: SimpleCommand[];
client.on('messageCreate', async msg => {
logger.debug(`Message -> ${msg.content}`);
if (msg.systemMessage !== undefined) return;
if (typeof msg.content != 'string' ||
msg.authorId == client.user?.id ||
!msg.channel?.server) return;

View file

@ -39,7 +39,7 @@ async function messageContentTrigger(message: Message, trigger: CustomRuleTrigge
let script = new VM.Script('matchedStrings = content.match(regex);', { timeout: 2 });
script.runInContext(ctx);
if (ctx.matchedStrings?.length) matched = true;
if (ctx['matchedStrings']?.length) matched = true;
} catch(e) {
console.error('Exception thrown while parsing RegEx: ' + e);
}

View file

@ -3,11 +3,11 @@ import crypto from "crypto";
import { client, dbs } from "../..";
import Infraction from "automod/dist/types/antispam/Infraction";
import InfractionType from "automod/dist/types/antispam/InfractionType";
import { BLACKLIST_BAN_REASON, BLACKLIST_MESSAGE } from "../commands/admin/botadm";
import { BLACKLIST_BAN_REASON, BLACKLIST_MESSAGE } from "../commands/owner/botadm";
import logger from "../logger";
import { storeInfraction } from "../util";
import { DEFAULT_PREFIX } from "./command_handler";
import { SendableEmbed } from "revolt-api";
import type { SendableEmbed } from "revolt-api";
import { UserSystemMessage } from "revolt.js";
const DM_SESSION_LIFETIME = 1000 * 60 * 60 * 24 * 30;

View file

@ -3,7 +3,7 @@ import http from 'http';
import logger from '../logger';
import { client } from '../..';
const PORT = Number(process.env.BOT_METRICS_PORT);
const PORT = Number(process.env['BOT_METRICS_PORT']);
prom.collectDefaultMetrics({ prefix: 'automod_' });
@ -45,11 +45,11 @@ if (!isNaN(PORT)) {
measureLatency();
setInterval(measureLatency, 10000);
if (process.env.BOT_METRICS_MSG_PING_CHANNEL) {
if (process.env['BOT_METRICS_MSG_PING_CHANNEL']) {
logger.info('BOT_METRICS_MSG_PING_CHANNEL is set, enabling message latency measuring');
const getMsgPing = async () => {
const channel = client.channels.get(process.env.BOT_METRICS_MSG_PING_CHANNEL!);
const channel = client.channels.get(process.env['BOT_METRICS_MSG_PING_CHANNEL']!);
try {
const now = Date.now();
const msg = await channel?.sendMessage('Ping?');

View file

@ -35,13 +35,6 @@ client.on('messageUpdate', async (message, oldMessage) => {
`[Jump to message](/server/${server.id}/channel/${channel.id}/${message.id})`,
fields: [],
color: "#829dff",
overrides: {
discord: {
description: `Author: @${
message.author?.username || message.authorId || "Unknown"
} | Channel: ${channel.name || channel.id}`,
},
},
};
if (attachFullMessage) {
@ -63,7 +56,6 @@ client.on('messageUpdate', async (message, oldMessage) => {
client.on('messageDelete', async (message) => {
try {
let channel = client.channels.get(message.channelId);
let author = message.authorId ? client.users.get(message.authorId) : null;
if (!channel) return;
let msgRaw = String(message.content ?? '(Unknown)');
@ -78,11 +70,6 @@ client.on('messageDelete', async (message) => {
+ `[\\[Jump to context\\]](/server/${channel.serverId}/channel/${channel.id}/${message.id})`,
fields: [],
color: '#ff6b6b',
overrides: {
discord: {
description: `Author: @${author?.username || message.authorId} | Channel: ${channel?.name || message.channelId}`
},
}
}
if (msg.length > 1000) {
@ -126,7 +113,6 @@ client.on('messageDeleteBulk', async (messages) => {
]);
}
const sheet = Xlsx.utils.aoa_to_sheet(data);
const csv = Xlsx.utils.sheet_to_csv(data);
let embed: LogMessage = {
@ -136,11 +122,6 @@ client.on('messageDeleteBulk', async (messages) => {
fields: [],
attachments: [{ name: 'messages.csv', content: Buffer.from(csv) }],
color: '#ff392b',
overrides: {
discord: {
description: `${messages.length} messages deleted in #${channel.name}`,
}
}
}
await sendLogMessage(config.logs.messageUpdate, embed);
@ -168,7 +149,6 @@ async function logModAction(type: 'warn'|'kick'|'ban'|'votekick', server: Server
+ `**Warn ID**: \`${infractionID}\`\n`
+ (extraText ?? ''),
color: embedColor,
overrides: {},
});
}
} catch(e) {

View file

@ -5,5 +5,5 @@ import logger from '../logger';
if (process.env['AUTOMOD_LOAD_SPAM_DETECTION']) {
logger.info('Importing spam detection');
import(path.join(process.cwd(), '..', 'private', 'automod-spam-detection', 'dist', 'index.js'))
.then(mod => mod.raidDetection(client as any, logger, client.db, process.env.REDIS_URL));
.then(mod => mod.raidDetection(client as any, logger, client.db, process.env['REDIS_URL']));
}

View file

@ -1,4 +1,3 @@
import { FindResult } from "monk";
import { client, dbs } from "../..";
import TempBan from "automod/dist/types/TempBan";
import logger from "../logger";

View file

@ -7,16 +7,14 @@ import axios from 'axios';
import { Server } from "revolt.js";
import LogConfig from "automod/dist/types/LogConfig";
import LogMessage from "automod/dist/types/LogMessage";
import { ColorResolvable, MessageEmbed } from "discord.js";
import logger from "./logger";
import { ulid } from "ulid";
import { Channel } from "revolt.js";
import { Message } from "revolt.js";
import { isSudo } from "./commands/admin/botadm";
import { SendableEmbed } from "revolt-api";
import { isSudo } from "./commands/owner/botadm";
import type { SendableEmbed } from "revolt-api";
import ServerConfig from "automod/dist/types/ServerConfig";
const NO_MANAGER_MSG = "🔒 Missing permission";
const NO_MANAGER_MSG = "Missing permission";
const ULID_REGEX = /^[0-9A-HJ-KM-NP-TV-Z]{26}$/i;
const USER_MENTION_REGEX = /^<@[0-9A-HJ-KM-NP-TV-Z]{26}>$/i;
const CHANNEL_MENTION_REGEX = /^<#[0-9A-HJ-KM-NP-TV-Z]{26}>$/i;
@ -182,58 +180,6 @@ async function uploadFile(file: any, filename: string): Promise<string> {
}
async function sendLogMessage(config: LogConfig, content: LogMessage) {
if (config.discord?.webhookUrl) {
let c = { ...content, ...content.overrides?.discord };
const embed = new MessageEmbed();
if (c.title) embed.setTitle(content.title);
if (c.description) embed.setDescription(c.description);
if (c.color?.match(/^#[0-9a-fA-F]+$/))
embed.setColor(c.color as ColorResolvable);
if (c.fields?.length) {
for (const field of c.fields) {
embed.addField(
field.title,
field.content.trim() || "\u200b",
field.inline
);
}
}
if (content.image) {
if (content.image.type == "THUMBNAIL")
embed.setThumbnail(content.image.url);
else if (content.image.type == "BIG")
embed.setImage(content.image.url);
}
if (content.attachments?.length) {
embed.setFooter(
`Attachments: ${content.attachments
.map((a) => a.name)
.join(", ")}`
);
}
let data = new FormData();
content.attachments?.forEach((a) => {
data.append(`files[${ulid()}]`, a.content, { filename: a.name });
});
data.append(
"payload_json",
JSON.stringify({ embeds: [embed.toJSON()] }),
{ contentType: "application/json" }
);
axios
.post(config.discord.webhookUrl, data, {
headers: data.getHeaders(),
})
.catch((e) =>
logger.error(`Failed to send log message (discord): ${e}`)
);
}
if (config.revolt?.channel) {
let c = { ...content, ...content.overrides?.revolt };
try {

View file

@ -9,16 +9,14 @@ import ServerConfig from 'automod/dist/types/ServerConfig';
import Infraction from 'automod/dist/types/antispam/Infraction';
import PendingLogin from 'automod/dist/types/PendingLogin';
import TempBan from 'automod/dist/types/TempBan';
import { VoteEntry } from './bot/commands/moderation/votekick';
import BridgeRequest from 'automod/dist/types/BridgeRequest';
import BridgeConfig from 'automod/dist/types/BridgeConfig';
import BridgedMessage from 'automod/dist/types/BridgedMessage';
import type { VoteEntry } from './bot/commands/moderation/votekick';
logger.info('Initializing client');
let db = MongoDB();
let client = new AutomodClient({
autoReconnect: true,
baseURL: process.env['REVOLT_API_URL'] || "https://revolt.chat/api",
}, db);
login(client);
@ -30,9 +28,6 @@ const dbs = {
SESSIONS: db.get('sessions'),
TEMPBANS: db.get<TempBan>('tempbans'),
VOTEKICKS: db.get<VoteEntry>('votekicks'),
BRIDGE_CONFIG: db.get<BridgeConfig>('bridge_config'),
BRIDGED_MESSAGES: db.get<BridgedMessage>('bridged_messages'),
BRIDGE_REQUESTS: db.get<BridgeRequest>('bridge_requests'),
}
export { client, dbs }

View file

@ -1,8 +1,7 @@
import * as Revolt from "revolt.js";
import { IMonkManager } from 'monk';
import logger from '../bot/logger';
import { adminBotLog } from "../bot/logging";
import { ClientOptions } from "revolt.js/src/Client";
import type { ClientOptions } from "revolt.js";
class AutomodClient extends Revolt.Client {
db: IMonkManager;
@ -27,7 +26,6 @@ let login = (client: Revolt.Client): Promise<void> => new Promise((resolve, reje
client.once('ready', () => {
logger.done(`Bot logged in as ${client.user?.username}!`);
adminBotLog({ message: 'Bot logged in', type: 'INFO' });
resolve();
});
});

View file

@ -4,6 +4,12 @@ import { Server } from "revolt.js";
class MessageCommandContext extends Message {
// The server to which the command should be applied.
serverContext: Server;
constructor(messageData: any, channelData: any, serverContext: Server) {
// Assuming `Message` expects `messageData` and `channelData` as its arguments.
super(messageData, channelData);
this.serverContext = serverContext;
}
}
export default MessageCommandContext;

View file

@ -1,9 +1,9 @@
enum CommandCategory {
Moderation = 'Moderation',
Config = 'Config',
Configuration = 'Configuration',
Owner = 'Owner',
Misc = 'Misc',
Miscellaneous = 'Miscellaneous',
None = 'None',
}
export default CommandCategory;
export default CommandCategory;

View file

@ -1,100 +1,30 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig.json to read more about this file */
/* Projects */
// "incremental": true, /* Enable incremental compilation */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "ESNext", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */
// "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
/* Modules */
"module": "ESNext", /* Specify what module code is generated. */
"rootDir": "./src", /* Specify the root folder within your source files. */
"moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "resolveJsonModule": true, /* Enable importing .json files */
// "noResolve": true, /* Disallow `import`s, `require`s or `<reference>`s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */
/* Emit */
"declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
"sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */
"outDir": "./dist", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
/* Type Checking */
"strict": true, /* Enable all strict type-checking options. */
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */
// "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */
"strictPropertyInitialization": false, /* Check for class properties that are declared but not set in the constructor. */
// "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */
// "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
// Enable latest features
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"target": "ESNext",
"module": "ESNext",
"moduleDetection": "force",
"allowJs": true,
// Bundler mode
"moduleResolution": "node",
// Best practices
"strict": true,
"skipLibCheck": true,
"strictPropertyInitialization": false,
"noFallthroughCasesInSwitch": true,
// Some stricter flags (enabled)
"noUnusedLocals": true,
"noUnusedParameters": true,
"noPropertyAccessFromIndexSignature": true,
"declaration": true,
"declarationMap": true,
// Additional improvements
"esModuleInterop": true,
"resolveJsonModule": true,
"forceConsistentCasingInFileNames": true,
"useDefineForClassFields": true,
"rootDir": "./src",
"outDir": "./dist"
}
}

File diff suppressed because it is too large Load diff

7
bridge/.gitignore vendored
View file

@ -1,7 +0,0 @@
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions

File diff suppressed because one or more lines are too long

View file

@ -1,2 +0,0 @@
yarnPath: .yarn/releases/yarn-3.2.1.cjs
nodeLinker: node-modules

View file

@ -1,31 +0,0 @@
FROM node:18 as build
WORKDIR /build/app
COPY bridge/package.json bridge/yarn.lock bridge/.yarnrc.yml ./
COPY bridge/.yarn ./.yarn
COPY lib ../lib
COPY revolt.js ../revolt.js
RUN yarn --cwd ../lib --immutable
RUN yarn --cwd ../lib build
WORKDIR /build/revolt.js
RUN corepack enable
RUN corepack prepare pnpm@7.14.2 --activate
RUN pnpm install
RUN pnpm run build
WORKDIR /build/app
RUN yarn install --immutable
COPY ./bridge .
RUN yarn build
FROM node:18 as prod
WORKDIR /app/bridge
COPY --from=build /build/app/package.json /build/app/yarn.lock /build/app/.yarnrc.yml ./
COPY --from=build /build/app/.yarn ./.yarn
COPY --from=build /build/app/dist ./dist
COPY --from=build /build/lib ../lib
COPY --from=build /build/revolt.js ../revolt.js
RUN yarn install --immutable
CMD ["yarn", "start"]

View file

@ -1,40 +0,0 @@
{
"name": "bridge",
"version": "1.0.0",
"description": "",
"type": "module",
"exports": "./index.js",
"scripts": {
"build": "rm -rf dist && tsc",
"start": "node --experimental-specifier-resolution=node dist/index",
"dev": "yarn build && yarn start"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@discordjs/rest": "^0.4.1",
"automod": "^0.1.0",
"axios": "^0.26.1",
"discord-api-types": "^0.31.2",
"discord.js": "^13.6.0",
"dotenv": "^16.0.0",
"form-data": "^4.0.0",
"json5": "^2.2.1",
"log75": "^2.2.0",
"monk": "^7.3.4",
"prom-client": "^14.0.1",
"revolt-api": "0.6.4",
"revolt.js": "^7.0.0",
"smart-replace": "^1.0.2",
"ulid": "^2.3.0"
},
"packageManager": "yarn@3.2.1",
"devDependencies": {
"typescript": "^4.7.4"
},
"resolutions": {
"automod": "portal:../lib",
"revolt.js": "portal:../revolt.js"
}
}

View file

@ -1,50 +0,0 @@
/**
* When executed, this file scans the entire `messages` database collection
* and deletes entries belonging to channels which do no longer have a bridge
* configuration associated. Reads mongo URI from $DB_STRING env var.
*/
import Mongo from 'mongodb';
if (!process.env.DB_STRING) {
console.error('$DB_STRING not provided.');
process.exit(1);
}
const mongo = new Mongo.MongoClient(process.env.DB_STRING);
(async () => {
await mongo.connect();
const client = mongo.db();
const messages = client.collection('bridged_messages');
const res = messages.aggregate([{
$lookup: {
from: 'bridge_config',
localField: 'channels.discord',
foreignField: 'discord',
as: 'bridgeConfig',
}
}]);
let buf: string[] = [];
const execute = async () => {
const ids = [ ...buf ];
buf.length = 0;
if (ids.length) {
console.log('Deleting ' + ids.length + ' entries');
await messages.deleteMany({ _id: { $in: ids } });
}
}
res.on('data', data => {
if (!data.bridgeConfig?.length) buf.push(data._id);
if (buf.length >= 500) execute();
});
res.on('end', () => {
execute().then(() => process.exit(0));
});
})();

View file

@ -1,9 +0,0 @@
import Monk from 'monk';
import { logger } from '.';
function getDb() {
const db = Monk(process.env['DB_STRING']!);
return db;
}
export { getDb }

View file

@ -1,147 +0,0 @@
import axios from "axios";
import { GuildEmoji } from "discord.js";
import JSON5 from 'json5';
import { BRIDGED_EMOJIS, logger } from "..";
import { client } from "./client";
const EMOJI_DICT_URL = 'https://raw.githubusercontent.com/revoltchat/revite/master/src/assets/emojis.ts';
const EMOJI_URL_BASE = 'https://dl.insrt.uk/projects/revolt/emotes/';
const EMOJI_SERVERS = process.env.EMOJI_SERVERS?.split(',') || [];
async function fetchEmojiList(): Promise<Record<string, string>> {
const file: string = (await axios.get(EMOJI_DICT_URL)).data;
const start = file.indexOf('...{') + 3;
const end = file.indexOf('},') + 1;
return JSON5.parse(
file.substring(start, end)
.replace(/^\s*[0-9]+:/gm, (match) => `"${match.replace(/(^\s+)|(:$)/g, '')}":`)
.trim()
);
}
const emojiUpdate = async () => {
try {
if (!EMOJI_SERVERS.length) return logger.info('$EMOJI_SERVERS not set, not bridging emojis.');
if (!client.readyAt) await new Promise(r => client.once('ready', r));
logger.info('Updating bridged emojis. Due to Discord rate limits, this can take a few hours to complete.');
const emojis = await fetchEmojiList();
logger.info(`Downloaded emoji list: ${Object.keys(emojis).length} emojis.`);
const servers = await Promise.all(EMOJI_SERVERS.map(id => client.guilds.fetch(id)));
await Promise.all(servers.map(server => server.emojis.fetch())); // Make sure all emojis are cached
const findFreeServer = (animated: boolean) => servers.find(
server => server.emojis.cache
.filter(e => e.animated == animated)
.size < 50
);
// Remove unknown emojis from servers
for (const server of servers) {
for (const emoji of server.emojis.cache) {
const dbEmoji = await BRIDGED_EMOJIS.findOne({
emojiid: emoji[1].id,
});
if (!dbEmoji) {
try {
logger.info('Found unknown emoji; deleting.');
await emoji[1].delete('Unknown emoji');
} catch(e) {
logger.warn('Failed to delete emoji: ' + e);
}
}
}
}
for (const emoji of Object.entries(emojis)) {
const dbEmoji = await BRIDGED_EMOJIS.findOne({
$or: [
{ name: emoji[0] },
{ originalFileUrl: emoji[1] },
],
});
if (!dbEmoji) {
// Upload to Discord
logger.debug('Uploading emoji: ' + emoji[1]);
const fileurl = EMOJI_URL_BASE + emoji[1].replace('custom:', '');
const server = findFreeServer(emoji[1].endsWith('.gif'));
if (!server) {
logger.warn('Could not find a server with free emoji slots for ' + emoji[1]);
continue;
}
let e: GuildEmoji;
try {
e = await server.emojis.create(fileurl, emoji[0], { reason: 'Bridged Emoji' });
} catch(e) {
logger.warn(emoji[0] + ': Failed to upload emoji: ' + e);
continue;
}
await BRIDGED_EMOJIS.insert({
animated: e.animated || false,
emojiid: e.id,
name: emoji[0],
originalFileUrl: fileurl,
server: e.guild.id,
});
}
else {
// Double check if emoji exists
let exists = false;
for (const server of servers) {
if (server.emojis.cache.find(e => e.id == dbEmoji.emojiid)) {
exists = true;
break;
}
}
if (!exists) {
logger.info(`Emoji ${emoji[0]} does not exist; reuploading.`);
await BRIDGED_EMOJIS.remove({ emojiid: dbEmoji.emojiid });
const fileurl = EMOJI_URL_BASE + emoji[1].replace('custom:', '');
const server = findFreeServer(emoji[1].endsWith('.gif'));
if (!server) {
logger.warn('Could not find a server with free emoji slots for ' + emoji[1]);
continue;
}
let e: GuildEmoji;
try {
e = await server.emojis.create(fileurl, emoji[0], { reason: 'Bridged Emoji' });
} catch(e) {
logger.warn(emoji[0] + ': Failed to upload emoji: ' + e);
continue;
}
await BRIDGED_EMOJIS.insert({
animated: e.animated || false,
emojiid: e.id,
name: emoji[0],
originalFileUrl: fileurl,
server: e.guild.id,
});
}
}
};
logger.done('Emoji update finished.');
} catch(e) {
logger.error('Updating bridged emojis failed');
console.error(e);
}
};
emojiUpdate();
setInterval(emojiUpdate, 1000 * 60 * 60 * 6); // Every 6h
export { fetchEmojiList }

View file

@ -1,28 +0,0 @@
import Discord from "discord.js";
import { logger } from "..";
const client = new Discord.Client({
intents: [
'GUILDS',
'GUILD_EMOJIS_AND_STICKERS',
'GUILD_MESSAGES',
'GUILD_WEBHOOKS',
],
partials: [
'MESSAGE', // Allows us to receive message updates for uncached messages
],
allowedMentions: { parse: [ ] }, // how the hell does this work
});
const login = () => new Promise((resolve: (value: Discord.Client) => void) => {
client.login(process.env['DISCORD_TOKEN']!);
client.once('ready', () => {
logger.info(`Discord: ${client.user?.username} ready - ${client.guilds.cache.size} servers`);
});
});
import('./events');
import('./commands');
import('./bridgeEmojis');
export { client, login }

View file

@ -1,515 +0,0 @@
// fuck slash commands
import { client } from "./client";
import { REST } from '@discordjs/rest';
import { Routes } from 'discord-api-types/v9';
import { BRIDGED_MESSAGES, BRIDGE_CONFIG, BRIDGE_REQUESTS, BRIDGE_USER_CONFIG, logger } from "..";
import { MessageEmbed, TextChannel } from "discord.js";
import { revoltFetchMessage, revoltFetchUser } from "../util";
import { client as revoltClient } from "../revolt/client";
import { CONFIG_KEYS } from "automod/dist/misc/bridge_config_keys";
const PRIVACY_POLICY_URL =
"https://github.com/sussycatgirl/automod/wiki/Privacy-Policy";
const COMMANDS: any[] = [
{
name: "bridge",
description: "Confirm or delete Revolt bridges",
type: 1, // Slash command
options: [
{
name: "confirm",
description: "Confirm a bridge initiated from Revolt",
type: 1, // Subcommand
options: [
{
name: "id",
description: "The bridge request ID",
required: true,
type: 3,
},
],
},
{
name: "unlink",
description: "Unbridge the current channel",
type: 1,
},
{
name: "help",
description: "Usage instructions",
type: 1,
},
{
name: "opt_out",
description: "Opt out of having your messages bridged",
type: 1,
options: [
{
name: "opt_out",
description:
"Whether you wish to opt out of having your messages bridged",
optional: true,
type: 5, // Boolean
},
],
},
{
name: "status",
description:
"Find out whether this channel is bridged to Revolt",
type: 1,
},
{
name: "config",
description: "Bridge configuration options for this channel",
type: 1,
options: [
{
name: "key",
description: "The configuration option to change",
type: 3, // String
required: true,
choices: Object.entries(CONFIG_KEYS).map((conf) => ({
name: conf[1].friendlyName,
value: conf[0],
})),
},
{
name: "value",
description:
"The new value for the option. Leave empty to get current state",
type: 5, // Boolean
required: false,
},
],
},
],
},
{
name: "Message Info",
description: "",
type: 3, // Message context menu
},
];
const rest = new REST({ version: "9" }).setToken(process.env["DISCORD_TOKEN"]!);
client.once("ready", async () => {
try {
logger.info(`Refreshing application commands.`);
if (process.env.NODE_ENV != "production" && process.env.DEV_GUILD) {
await rest.put(
Routes.applicationGuildCommands(
client.user!.id,
process.env.DEV_GUILD
),
{ body: COMMANDS }
);
logger.done(
`Application commands for ${process.env.DEV_GUILD} have been updated.`
);
} else {
await rest.put(Routes.applicationCommands(client.user!.id), {
body: COMMANDS,
});
logger.done(`Global application commands have been updated.`);
}
} catch (e) {
console.error(e);
}
});
client.on("interactionCreate", async (interaction) => {
try {
if (interaction.isCommand()) {
logger.debug(`Command received: /${interaction.commandName}`);
// The revolutionary Jan command handler
switch (interaction.commandName) {
case "bridge":
if (
!interaction.memberPermissions?.has("MANAGE_GUILD") &&
["confirm", "unlink"].includes(
interaction.options.getSubcommand(true)
)
) {
return await interaction.reply({
content: `\`MANAGE_GUILD\` permission is required for this.`,
ephemeral: true,
});
}
const ownPerms = (
interaction.channel as TextChannel
).permissionsFor(client.user!)!;
switch (interaction.options.getSubcommand(true)) {
case "confirm": {
if (!ownPerms.has("MANAGE_WEBHOOKS"))
return interaction.reply(
"Sorry, I lack permission to manage webhooks in this channel."
);
const id = interaction.options.getString(
"id",
true
);
const request = await BRIDGE_REQUESTS.findOne({
id: id,
});
if (!request || request.expires < Date.now())
return await interaction.reply("Unknown ID.");
const bridgedCount = await BRIDGE_CONFIG.count({
discord: interaction.channelId,
});
if (bridgedCount > 0)
return await interaction.reply(
"This channel is already bridged."
);
const webhook = await(
interaction.channel as TextChannel
).createWebhook("AutoMod Bridge", {
avatar: client.user?.avatarURL(),
});
await BRIDGE_REQUESTS.remove({ id: id });
await BRIDGE_CONFIG.insert({
discord: interaction.channelId,
revolt: request.revolt,
discordWebhook: {
id: webhook.id,
token: webhook.token || "",
},
});
return await interaction.reply(
`✅ Channel bridged!`
);
}
case "unlink": {
const res = await BRIDGE_CONFIG.findOneAndDelete({
discord: interaction.channelId,
});
if (res?._id) {
await interaction.reply("Channel unbridged.");
if (
ownPerms.has("MANAGE_WEBHOOKS") &&
res.discordWebhook
) {
try {
const hooks = await(
interaction.channel as TextChannel
).fetchWebhooks();
if (hooks.get(res?.discordWebhook?.id))
await hooks
.get(res?.discordWebhook?.id)
?.delete(
"Channel has been unbridged"
);
} catch (_) {}
}
} else
await interaction.reply(
"This channel is not bridged."
);
break;
}
case "config": {
const configKey = interaction.options.getString(
"key",
true
) as keyof typeof CONFIG_KEYS;
const newValue = interaction.options.getBoolean(
"value",
false
);
if (newValue == null) {
const currentState =
(
await BRIDGE_CONFIG.findOne({
discord: interaction.channelId,
})
)?.config?.[configKey] ?? false;
return await interaction.reply({
ephemeral: true,
embeds: [
new MessageEmbed()
.setAuthor({
name: "Bridge Configuration",
})
.setTitle(configKey)
.setDescription(
`${CONFIG_KEYS[configKey].description}\n\nCurrent state: \`${currentState}\``
)
.toJSON(),
],
});
}
await BRIDGE_CONFIG.update(
{ discord: interaction.channelId },
{
$set: { [`config.${configKey}`]: newValue },
$setOnInsert: {
discord: interaction.channelId,
},
},
{ upsert: true }
);
return await interaction.reply({
ephemeral: true,
content: `Option \`${configKey}\` has been updated to \`${newValue}\`.`,
});
}
case "help": {
const isPrivileged =
!!interaction.memberPermissions?.has(
"MANAGE_GUILD"
);
const INVITE_URL = `https://discord.com/api/oauth2/authorize?client_id=${client.user?.id}&permissions=536996864&scope=bot%20applications.commands`;
const embed = new MessageEmbed()
.setColor("#ff6e6d")
.setAuthor({
name: "AutoMod Revolt Bridge",
iconURL: client.user?.displayAvatarURL(),
});
embed.setDescription(
"[AutoMod](https://automod.me) is a utility and moderation bot for [Revolt](https://revolt.chat). " +
"This Discord bot allows you to link your Discord servers to your Revolt servers " +
"by mirroring messages between text channels."
);
embed.addField(
"Setting up a bridge",
isPrivileged
? "The bridge process is initialized by running the `/bridge link` command in the Revolt " +
"channel you wish to bridge.\n" +
"Afterwards you can run the `/bridge confirm` command in the correct Discord channel to finish the link."
: "You don't have `Manage Messages` permission - Please ask a moderator to configure the bridge."
);
embed.addField(
"Adding AutoMod to your server",
`You can add the Revolt bot to your server ` +
`[here](https://app.revolt.chat/bot/${revoltClient.user?.id} "Open Revolt"). To add the Discord counterpart, ` +
`click [here](${INVITE_URL} "Add Discord bot").`
);
embed.addField(
"Contact",
`If you have any questions regarding this bot or the Revolt counterpart, feel free to join ` +
`[this](https://discord.gg/4pZgvqgYJ8) Discord server or [this](https://rvlt.gg/jan) Revolt server.\n` +
`If you want to report a bug, suggest a feature or browse the source code, ` +
`feel free to do so [on GitHub](https://github.com/sussycatgirl/automod).\n` +
`For other inquiries, please contact \`contact@automod.me\`.\n\n` +
`Before using this bot, please read the [Privacy Policy](${PRIVACY_POLICY_URL})!`
);
await interaction.reply({
embeds: [embed],
ephemeral: true,
});
break;
}
case "opt_out": {
const optOut = interaction.options.getBoolean(
"opt_out",
false
);
if (optOut == null) {
const userConfig =
await BRIDGE_USER_CONFIG.findOne({
id: interaction.user.id,
});
if (userConfig?.optOut) {
return await interaction.reply({
ephemeral: true,
content:
"You are currently **opted out** of message bridging. " +
"Users on Revolt **will not** see your username, avatar or message content.",
});
} else {
return await interaction.reply({
ephemeral: true,
content:
"You are currently **not** opted out of message bridging. " +
"All your messages in a bridged channel will be sent to the associated Revolt channel.",
});
}
} else {
await BRIDGE_USER_CONFIG.update(
{ id: interaction.user.id },
{
$setOnInsert: {
id: interaction.user.id,
},
$set: { optOut },
},
{ upsert: true }
);
return await interaction.reply({
ephemeral: true,
content:
`You have **opted ${
optOut ? "out of" : "into"
}** message bridging. ` +
(optOut
? "Your username, avatar and message content will no longer be visible on Revolt.\n" +
"Please note that some servers may be configured to automatically delete your messages."
: "All your messages in a bridged channel will be sent to the associated Revolt channel."),
});
}
}
case "status": {
const bridgeConfig = await BRIDGE_CONFIG.findOne({
discord: interaction.channelId,
});
if (!bridgeConfig?.revolt) {
return await interaction.reply({
ephemeral: true,
content:
"This channel is **not** bridged. No message content data will be processed.",
});
} else {
return await interaction.reply({
ephemeral: true,
content:
"This channel is **bridged to Revolt**. Your messages will " +
"be processed and sent to [Revolt](<https://revolt.chat>) according to AutoMod's " +
`[Privacy Policy](<${PRIVACY_POLICY_URL}>).`,
});
}
break;
}
default:
await interaction.reply("Unknown subcommand");
}
break;
}
} else if (interaction.isMessageContextMenu()) {
logger.debug(
`Received context menu: ${interaction.targetMessage.id}`
);
switch (interaction.commandName) {
case "Message Info":
const message = interaction.targetMessage;
const bridgeInfo = await BRIDGED_MESSAGES.findOne({
"discord.messageId": message.id,
});
const messageUrl = `https://discord.com/channels/${interaction.guildId}/${interaction.channelId}/${message.id}`;
if (!bridgeInfo)
return await interaction.reply({
ephemeral: true,
embeds: [
new MessageEmbed()
.setAuthor({
name: "Message info",
url: messageUrl,
})
.setDescription(
"This message has not been bridged."
)
.setColor("#7e96ff"),
],
});
else {
const embed = new MessageEmbed();
embed.setColor("#7e96ff");
embed.setAuthor({
name: "Message info",
url: messageUrl,
});
embed.addField(
"Origin",
bridgeInfo.origin == "discord"
? "Discord"
: "Revolt",
true
);
if (bridgeInfo.origin == "discord") {
embed.addField(
"Bridge Status",
bridgeInfo.revolt.messageId
? "Bridged"
: bridgeInfo.revolt.nonce
? "ID unknown"
: "Unbridged",
true
);
} else {
embed.addField(
"Bridge Status",
bridgeInfo.discord.messageId
? "Bridged"
: "Unbridged",
true
);
if (bridgeInfo.channels?.revolt) {
const channel = await revoltClient.channels.get(
bridgeInfo.channels.revolt
);
const revoltMsg = await revoltFetchMessage(
bridgeInfo.revolt.messageId,
channel
);
if (revoltMsg) {
const author = await revoltFetchUser(
revoltMsg.authorId
);
embed.addField(
"Message Author",
`**@${author?.username}** (${revoltMsg.authorId})`
);
}
}
}
embed.addField(
"Bridge Data",
`Origin: \`${bridgeInfo.origin}\`\n` +
`Discord ID: \`${bridgeInfo.discord.messageId}\`\n` +
`Revolt ID: \`${bridgeInfo.revolt.messageId}\`\n` +
`Revolt Nonce: \`${bridgeInfo.revolt.nonce}\`\n` +
`Discord Channel: \`${bridgeInfo.channels?.discord}\`\n` +
`Revolt Channel: \`${bridgeInfo.channels?.revolt}\``
);
return await interaction.reply({
ephemeral: true,
embeds: [embed],
});
}
}
}
} catch (e) {
console.error(e);
if (interaction.isCommand())
interaction.reply("An error has occurred: " + e).catch(() => {});
}
});

Some files were not shown because too many files have changed in this diff Show more