Compare commits
1 commit
stable
...
developmen
Author | SHA1 | Date | |
---|---|---|---|
|
f01ece22a3 |
159 changed files with 17289 additions and 3325 deletions
BIN
.assets/demo.gif
Normal file
BIN
.assets/demo.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.3 MiB |
2
.dockerignore
Normal file
2
.dockerignore
Normal file
|
@ -0,0 +1,2 @@
|
|||
private/
|
||||
db/
|
23
.env.example
23
.env.example
|
@ -11,12 +11,15 @@
|
|||
DB_PASS=
|
||||
|
||||
# Base URL of the Revolt API to connect to.
|
||||
# Defaults to https://revolt.chat/api
|
||||
REVOLT_API_URL=
|
||||
# Defaults to https://api.revolt.chat
|
||||
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=
|
||||
|
@ -41,6 +44,10 @@ 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;
|
||||
|
@ -48,6 +55,10 @@ BOT_OWNERS=
|
|||
# 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
|
||||
|
@ -60,3 +71,11 @@ 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
3
.github/FUNDING.yml
vendored
|
@ -1,3 +0,0 @@
|
|||
github: DeclanChidlow
|
||||
ko_fi: valence
|
||||
custom: "https://vale.rocks/support"
|
174
.github/workflows/docker.yml
vendored
Normal file
174
.github/workflows/docker.yml
vendored
Normal file
|
@ -0,0 +1,174 @@
|
|||
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 }}
|
24
.gitignore
vendored
24
.gitignore
vendored
|
@ -1,22 +1,10 @@
|
|||
# IDE / editor specific ignores
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# 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
|
||||
.env
|
||||
yarn-error.log
|
||||
docker-compose.yml
|
||||
/db
|
||||
.vercel
|
||||
|
||||
# For stuff like docker mounts
|
||||
/private
|
||||
|
|
4
.gitmodules
vendored
Normal file
4
.gitmodules
vendored
Normal file
|
@ -0,0 +1,4 @@
|
|||
[submodule "revolt.js"]
|
||||
path = revolt.js
|
||||
url = https://github.com/revoltchat/revolt.js
|
||||
branch = insert/feat/store-rewrite
|
32
.vscode/launch.json
vendored
Normal file
32
.vscode/launch.json
vendored
Normal file
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
// 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",
|
||||
}
|
||||
]
|
||||
}
|
5
.vscode/settings.json
vendored
Normal file
5
.vscode/settings.json
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"editor.formatOnSave": false,
|
||||
"editor.formatOnSaveMode": "modifications",
|
||||
"prettier.tabWidth": 4
|
||||
}
|
|
@ -9,11 +9,11 @@
|
|||
[![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
|
||||
Moderation bot for Revolt Chat
|
||||
</div>
|
||||
<br/>
|
||||
|
||||
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.
|
||||
AutoMod 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
|
||||
|
|
13
api/.gitignore
vendored
13
api/.gitignore
vendored
|
@ -1,6 +1,7 @@
|
|||
# IDE / editor specific ignores
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# JavaScript
|
||||
node_modules
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
|
|
786
api/.yarn/releases/yarn-3.2.1.cjs
vendored
Executable file
786
api/.yarn/releases/yarn-3.2.1.cjs
vendored
Executable file
File diff suppressed because one or more lines are too long
2
api/.yarnrc.yml
Normal file
2
api/.yarnrc.yml
Normal file
|
@ -0,0 +1,2 @@
|
|||
yarnPath: .yarn/releases/yarn-3.2.1.cjs
|
||||
nodeLinker: node-modules
|
|
@ -1,16 +1,19 @@
|
|||
FROM oven/bun:latest AS build
|
||||
FROM node:18 as build
|
||||
WORKDIR /build/app
|
||||
COPY api/package.json api/bun.lockb ./
|
||||
COPY api/package.json api/yarn.lock api/.yarnrc.yml ./
|
||||
COPY api/.yarn ./.yarn
|
||||
COPY lib ../lib
|
||||
RUN cd ../lib && bun install --frozen-lockfile && bun run build
|
||||
RUN bun install --frozen-lockfile
|
||||
RUN yarn --cwd ../lib --immutable
|
||||
RUN yarn --cwd ../lib build
|
||||
RUN yarn install --immutable
|
||||
COPY ./api .
|
||||
RUN bun run build
|
||||
RUN yarn build
|
||||
|
||||
FROM oven/bun:latest AS prod
|
||||
FROM node:18 as prod
|
||||
WORKDIR /app/api
|
||||
COPY --from=build /build/app/package.json /build/app/bun.lockb ./
|
||||
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
|
||||
RUN bun install --production --frozen-lockfile
|
||||
CMD ["bun", "start"]
|
||||
RUN yarn install --immutable
|
||||
CMD ["yarn", "start"]
|
||||
|
|
BIN
api/bun.lockb
BIN
api/bun.lockb
Binary file not shown.
|
@ -1,31 +1,35 @@
|
|||
{
|
||||
"name": "api",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"exports": "./index",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"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"
|
||||
"build": "rm -rf dist && tsc",
|
||||
"start": "node --experimental-specifier-resolution=node dist/index",
|
||||
"dev": "yarn build && yarn start"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/ws": "^8.5.12",
|
||||
"@types/express": "^4.17.13",
|
||||
"@types/monk": "^6.0.0",
|
||||
"@types/ws": "^8.2.2",
|
||||
"automod": "^0.1.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2",
|
||||
"log75": "^3.0.1",
|
||||
"mongodb": "^6.8.0",
|
||||
"redis": "^4.7.0",
|
||||
"dotenv": "^14.2.0",
|
||||
"express": "^4.17.3",
|
||||
"log75": "^2.2.0",
|
||||
"monk": "^7.3.4",
|
||||
"redis": "^4.2.0",
|
||||
"ulid": "^2.3.0",
|
||||
"ws": "^8.18.0"
|
||||
"ws": "^8.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.1.6",
|
||||
"bun-types": "latest"
|
||||
"typescript": "^4.5.5"
|
||||
},
|
||||
"module": "index.ts",
|
||||
"peerDependencies": {
|
||||
"typescript": "^4.9.5"
|
||||
"packageManager": "yarn@3.2.1",
|
||||
"resolutions": {
|
||||
"automod": "portal:../lib"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,44 +1,34 @@
|
|||
import { MongoClient, Db } from 'mongodb';
|
||||
import Monk, { IMonkManager } from 'monk';
|
||||
import Redis from 'redis';
|
||||
import { logger } from '.';
|
||||
|
||||
let db: Db;
|
||||
|
||||
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');
|
||||
export default (): IMonkManager => {
|
||||
let dburl = getDBUrl();
|
||||
let db = Monk(dburl);
|
||||
return db;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to connect to MongoDB: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const redis = Redis.createClient({ url: process.env['REDIS_URL'] });
|
||||
const redis = Redis.createClient({ url: process.env.REDIS_URL });
|
||||
|
||||
export { redis };
|
||||
export { redis }
|
||||
|
||||
// Checks if all required env vars were supplied, and returns the mongo db URL
|
||||
function getDBUrl(): string {
|
||||
const env = process.env;
|
||||
function getDBUrl() {
|
||||
let 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 new Error('Missing environment variables');
|
||||
throw '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 += `${env['DB_USERNAME'] ? '@' : ''}${env['DB_HOST']}`; // DB_HOST is assumed to contain the port
|
||||
dburl += `${process.env['DB_USERNAME'] ? '@' : ''}${env['DB_HOST']}`; // DB_HOST is assumed to contain the port
|
||||
dburl += `/${env['DB_NAME'] ?? 'automod'}`;
|
||||
|
||||
return dburl;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { Request, Response, NextFunction } from "express";
|
||||
import { Request, Response } from "express";
|
||||
import { app } from "..";
|
||||
|
||||
app.use('*', (_req: Request, res: Response, next: NextFunction) => {
|
||||
app.use('*', (req: Request, res: Response, next: () => void) => {
|
||||
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', '*');
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { Request, Response, NextFunction } from "express";
|
||||
import { Request, Response } from "express";
|
||||
import { app, logger } from "..";
|
||||
|
||||
app.use('*', (req: Request, _res: Response, next: NextFunction) => {
|
||||
app.use('*', (req: Request, res: Response, next: () => void) => {
|
||||
logger.debug(`${req.method} ${req.url}`);
|
||||
next();
|
||||
});
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { Request, Response, NextFunction } from "express";
|
||||
import { Request, Response } from "express";
|
||||
import { ulid } from "ulid";
|
||||
import { app, logger } from "..";
|
||||
import { redis } from "../db";
|
||||
|
@ -14,14 +14,16 @@ class RateLimiter {
|
|||
this.timeframe = limits.timeframe;
|
||||
}
|
||||
|
||||
middleware() {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
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 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
|
||||
|
@ -32,19 +34,14 @@ class RateLimiter {
|
|||
timeframe: this.timeframe,
|
||||
});
|
||||
} else {
|
||||
next();
|
||||
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) }
|
||||
}
|
||||
}
|
||||
|
||||
const globalRateLimiter = new RateLimiter('*', { limit: 20, timeframe: 1 });
|
||||
app.use('*', globalRateLimiter.middleware());
|
||||
app.use('*', (...args) => (new RateLimiter('*', { limit: 20, timeframe: 1 })).execute(...args));
|
||||
|
||||
export { RateLimiter };
|
||||
export { RateLimiter }
|
||||
|
|
|
@ -1,33 +1,19 @@
|
|||
import { Request, Response, NextFunction } from "express";
|
||||
import { Collection, Db } from "mongodb";
|
||||
import { app, SESSION_LIFETIME } from "..";
|
||||
import { Request, Response } from "express";
|
||||
import { FindOneResult } from "monk";
|
||||
import { app, db, SESSION_LIFETIME } from "..";
|
||||
|
||||
let sessionsCollection: Collection;
|
||||
|
||||
export function initializeSessionsMiddleware(db: Db) {
|
||||
sessionsCollection = db.collection('sessions');
|
||||
}
|
||||
|
||||
app.use('*', async (req: Request, _res: Response, next: NextFunction) => {
|
||||
app.use('*', async (req: Request, res: Response, next: () => void) => {
|
||||
next();
|
||||
|
||||
const user = req.header('x-auth-user');
|
||||
const token = req.header('x-auth-token');
|
||||
|
||||
if (!user || !token) return;
|
||||
|
||||
try {
|
||||
const session = await sessionsCollection.findOne({
|
||||
user,
|
||||
token,
|
||||
expires: { $gt: new Date() }
|
||||
});
|
||||
|
||||
const session: FindOneResult<any> = await db.get('sessions').findOne({ user, token, expires: { $gt: Date.now() } });
|
||||
if (session) {
|
||||
await sessionsCollection.updateOne(
|
||||
{ _id: session._id },
|
||||
{ $set: { expires: new Date(Date.now() + SESSION_LIFETIME) } }
|
||||
);
|
||||
}
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
await db.get('sessions').update({ _id: session._id }, { $set: { expires: Date.now() + SESSION_LIFETIME } });
|
||||
}
|
||||
} catch(e) { console.error(e) }
|
||||
});
|
||||
|
|
|
@ -1,16 +1,10 @@
|
|||
import { app } from '../..';
|
||||
import { app, db } from '../..';
|
||||
import { Request, Response } from 'express';
|
||||
import { badRequest, ensureObjectStructure, isAuthenticated, requireAuth, unauthorized } from '../../utils';
|
||||
import { botReq } from '../internal/ws';
|
||||
import { Collection, Db } from 'mongodb';
|
||||
import { FindOneResult } from 'monk';
|
||||
import { ulid } from 'ulid';
|
||||
|
||||
let serversCollection: Collection;
|
||||
|
||||
export function initializeAutomodAPI(database: Db) {
|
||||
serversCollection = database.collection('servers');
|
||||
}
|
||||
|
||||
type AntispamRule = {
|
||||
id: string;
|
||||
max_msg: number;
|
||||
|
@ -32,16 +26,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 = await serversCollection.findOne({ id: server });
|
||||
const serverConfig: FindOneResult<any> = await db.get('servers').findOne({ id: server });
|
||||
|
||||
const result = {
|
||||
antispam: (serverConfig?.['automodSettings']?.spam as AntispamRule[]|undefined)
|
||||
?.map(r => ({
|
||||
antispam: (serverConfig?.automodSettings?.spam as AntispamRule[]|undefined)
|
||||
?.map(r => ({ // Removing unwanted fields from response
|
||||
action: r.action,
|
||||
channels: r.channels,
|
||||
id: r.id,
|
||||
|
@ -63,29 +57,28 @@ app.patch('/dash/server/:server/automod/:ruleid', requireAuth({ permission: 2 })
|
|||
const body = req.body;
|
||||
if (!server || !ruleid) return badRequest(res);
|
||||
|
||||
const serverConfig = await serversCollection.findOne({ id: server });
|
||||
const antiSpamRules: AntispamRule[] = serverConfig?.['automodSettings']?.spam ?? [];
|
||||
const serverConfig: FindOneResult<any> = await db.get('servers').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.' });
|
||||
|
||||
const result = await serversCollection.updateOne(
|
||||
{ id: server, "automodSettings.spam.id": ruleid },
|
||||
{
|
||||
await db.get('servers').update({
|
||||
id: server
|
||||
}, {
|
||||
$set: {
|
||||
"automodSettings.spam.$": {
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
}, { arrayFilters: [ { "rulefilter.id": ruleid } ] });
|
||||
|
||||
return res.send({ success: result.modifiedCount > 0 });
|
||||
return res.send({ success: true });
|
||||
});
|
||||
|
||||
app.post('/dash/server/:server/automod', requireAuth({ permission: 2 }), async (req, res) => {
|
||||
|
@ -100,7 +93,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 {
|
||||
|
@ -116,9 +109,9 @@ app.post('/dash/server/:server/automod', requireAuth({ permission: 2 }), async (
|
|||
|
||||
const id = ulid();
|
||||
|
||||
const result = await serversCollection.updateOne(
|
||||
{ id: server },
|
||||
{
|
||||
await db.get('servers').update({
|
||||
id: server,
|
||||
}, {
|
||||
$push: {
|
||||
"automodSettings.spam": {
|
||||
id: id,
|
||||
|
@ -128,10 +121,9 @@ app.post('/dash/server/:server/automod', requireAuth({ permission: 2 }), async (
|
|||
message: rule.message ?? null,
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
res.status(200).send({ success: result.modifiedCount > 0, id: id });
|
||||
res.status(200).send({ success: true, id: id });
|
||||
});
|
||||
|
||||
app.delete('/dash/server/:server/automod/:ruleid', requireAuth({ permission: 2 }), async (req, res) => {
|
||||
|
@ -146,24 +138,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' });
|
||||
|
||||
let result;
|
||||
// todo: fix this shit idk if it works
|
||||
let queryRes;
|
||||
try {
|
||||
result = await serversCollection.updateOne(
|
||||
{ id: server },
|
||||
{
|
||||
queryRes = await db.get('servers').update({
|
||||
id: server
|
||||
}, {
|
||||
$pull: {
|
||||
"automodSettings.spam": { id: ruleid }
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
res.status(500).send({ error: e });
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.modifiedCount > 0) res.status(200).send({ success: true });
|
||||
if (queryRes.nModified > 0) res.status(200).send({ success: true });
|
||||
else res.status(404).send({ success: false, error: 'Rule not found' });
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -2,47 +2,49 @@ import crypto from 'crypto';
|
|||
import { app, SESSION_LIFETIME } from '..';
|
||||
import { Request, Response } from 'express';
|
||||
import { botReq } from './internal/ws';
|
||||
import { Collection, Db } from 'mongodb';
|
||||
import { db } from '..';
|
||||
import { FindOneResult } from 'monk';
|
||||
import { badRequest, isAuthenticated, requireAuth } from '../utils';
|
||||
|
||||
let pendingLoginsCollection: Collection;
|
||||
let sessionsCollection: Collection;
|
||||
|
||||
export function initializeAuthAPI(database: Db) {
|
||||
pendingLoginsCollection = database.collection('pending_logins');
|
||||
sessionsCollection = database.collection('sessions');
|
||||
}
|
||||
import { RateLimiter } from '../middlewares/ratelimit';
|
||||
|
||||
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) => requireAuth({ noAuthOnly: true }),
|
||||
(...args) => beginRatelimiter.execute(...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) => requireAuth({ noAuthOnly: true }),
|
||||
(...args) => completeRatelimiter.execute(...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 = await pendingLoginsCollection.findOne({
|
||||
const loginAttempt: FindOneResult<any> = await db.get('pending_logins').findOne({
|
||||
code: body.code,
|
||||
user: body.user,
|
||||
nonce: body.nonce,
|
||||
|
@ -51,24 +53,23 @@ app.post('/login/complete',
|
|||
});
|
||||
|
||||
if (!loginAttempt) return res.status(404).send({ error: 'The provided login info could not be found.' });
|
||||
if (!loginAttempt['confirmed']) {
|
||||
|
||||
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([
|
||||
sessionsCollection.insertOne({
|
||||
db.get('sessions').insert({
|
||||
user: body.user.toUpperCase(),
|
||||
token: sessionToken,
|
||||
nonce: body.nonce,
|
||||
invalid: false,
|
||||
expires: Date.now() + SESSION_LIFETIME,
|
||||
}),
|
||||
pendingLoginsCollection.updateOne(
|
||||
{ _id: loginAttempt._id },
|
||||
{ $set: { exchanged: true } }
|
||||
),
|
||||
db.get('pending_logins').update({ _id: loginAttempt._id }, { $set: { exchanged: true } }),
|
||||
]);
|
||||
|
||||
res.status(200).send({ success: true, user: body.user.toUpperCase(), token: sessionToken });
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import { app, db, logger } from '..';
|
||||
import { Response } from 'express';
|
||||
import { Request, Response } from 'express';
|
||||
import { botReq } from './internal/ws';
|
||||
import { WithId, Document, ObjectId } from 'mongodb';
|
||||
|
||||
let SERVER_COUNT = 0;
|
||||
|
||||
|
@ -9,7 +8,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);
|
||||
}
|
||||
|
@ -18,37 +17,21 @@ const fetchStats = async () => {
|
|||
fetchStats();
|
||||
setInterval(() => fetchStats(), 10000);
|
||||
|
||||
app.get('/stats', async (res: Response) => {
|
||||
app.get('/stats', async (req: Request, res: Response) => {
|
||||
res.send({
|
||||
servers: SERVER_COUNT,
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/stats/global_blacklist', async (res: Response) => {
|
||||
app.get('/stats/global_blacklist', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const dbConnection = await db;
|
||||
|
||||
const users = await dbConnection.collection('users').find({ globalBlacklist: true }).toArray();
|
||||
const users = await db.get('users').find({ globalBlacklist: true });
|
||||
|
||||
res.send({
|
||||
total: users.length,
|
||||
blacklist: users.map((u: WithId<Document>) => ({
|
||||
id: getId(u._id),
|
||||
reason: (u as any).blacklistReason || null
|
||||
})),
|
||||
blacklist: users.map(u => ({ id: u.id?.toUpperCase(), reason: u.blacklistReason || null })),
|
||||
});
|
||||
} catch(e) {
|
||||
console.error('Error fetching global blacklist:', e);
|
||||
res.status(500).send({ error: 'Internal server error' });
|
||||
console.error(''+e);
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,13 +1,8 @@
|
|||
import { Request, Response } from "express";
|
||||
import { Collection, Db } from "mongodb";
|
||||
import { FindOneResult } from "monk";
|
||||
import { db } from ".";
|
||||
import { botReq } from "./routes/internal/ws";
|
||||
|
||||
let sessionsCollection: Collection;
|
||||
|
||||
export function initializeSessionAuthentication(db: Db) {
|
||||
sessionsCollection = db.collection('sessions');
|
||||
}
|
||||
|
||||
class Session {
|
||||
user: string;
|
||||
token: string;
|
||||
|
@ -24,7 +19,9 @@ 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' });
|
||||
|
@ -35,13 +32,9 @@ 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 = await sessionsCollection.findOne<Session>({ user, token });
|
||||
const session: FindOneResult<Session> = await db.get('sessions').findOne({ 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) {
|
||||
|
@ -66,10 +59,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();
|
||||
|
|
|
@ -1,33 +1,100 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
// 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"]
|
||||
/* 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. */
|
||||
}
|
||||
}
|
||||
|
|
1113
api/yarn.lock
Normal file
1113
api/yarn.lock
Normal file
File diff suppressed because it is too large
Load diff
786
bot/.yarn/releases/yarn-3.2.1.cjs
vendored
Executable file
786
bot/.yarn/releases/yarn-3.2.1.cjs
vendored
Executable file
File diff suppressed because one or more lines are too long
894
bot/.yarn/releases/yarn-4.3.1.cjs
vendored
894
bot/.yarn/releases/yarn-4.3.1.cjs
vendored
File diff suppressed because one or more lines are too long
|
@ -1,3 +1,2 @@
|
|||
yarnPath: .yarn/releases/yarn-3.2.1.cjs
|
||||
nodeLinker: node-modules
|
||||
|
||||
yarnPath: .yarn/releases/yarn-4.3.1.cjs
|
||||
|
|
|
@ -1,22 +1,32 @@
|
|||
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
|
||||
|
||||
RUN yarn install
|
||||
# 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
|
||||
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"]
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"name": "revolt-automod",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": "./index.js",
|
||||
"scripts": {
|
||||
|
@ -8,27 +9,31 @@
|
|||
"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": "^1.7.5",
|
||||
"dayjs": "^1.11.11",
|
||||
"dotenv": "^16.4.5",
|
||||
"axios": "^0.22.0",
|
||||
"dayjs": "^1.10.7",
|
||||
"discord.js": "^13.3.1",
|
||||
"dotenv": "^10.0.0",
|
||||
"form-data": "^4.0.0",
|
||||
"log75": "^3.0.1",
|
||||
"log75": "^2.2.0",
|
||||
"monk": "^7.3.4",
|
||||
"prom-client": "^15.1.3",
|
||||
"revolt-api": "0.7.13",
|
||||
"revolt.js": "^7.0.3",
|
||||
"prom-client": "^14.0.1",
|
||||
"revolt-api": "0.6.4",
|
||||
"revolt.js": "^7.0.0",
|
||||
"ulid": "^2.3.0",
|
||||
"xlsx": "^0.18.5"
|
||||
"xlsx": "^0.17.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ws": "^8.5.11",
|
||||
"typescript": "^5.5.3"
|
||||
"typescript": "^4.4.3"
|
||||
},
|
||||
"packageManager": "yarn@4.3.1",
|
||||
"packageManager": "yarn@3.2.1",
|
||||
"resolutions": {
|
||||
"automod": "portal:../lib"
|
||||
"automod": "portal:../lib",
|
||||
"revolt.js": "portal:../revolt.js"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,8 +6,9 @@ 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 { parseUserOrId } from "../../util";
|
||||
import { getMutualServers, 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.`;
|
||||
|
@ -33,6 +34,9 @@ const SUBCOMMANDS: string[] = [
|
|||
'stats',
|
||||
'sudo',
|
||||
'userinfo',
|
||||
'blacklist',
|
||||
'unblacklist',
|
||||
'blacklistreason',
|
||||
'ignore',
|
||||
'unignore',
|
||||
];
|
||||
|
@ -58,10 +62,11 @@ 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`
|
||||
|
@ -91,6 +96,8 @@ 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;
|
||||
}
|
||||
|
||||
|
@ -138,6 +145,92 @@ 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.');
|
52
bot/src/bot/commands/admin/eval.ts
Normal file
52
bot/src/bot/commands/admin/eval.ts
Normal file
|
@ -0,0 +1,52 @@
|
|||
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);
|
||||
}
|
47
bot/src/bot/commands/admin/shell_eval.ts
Normal file
47
bot/src/bot/commands/admin/shell_eval.ts
Normal file
|
@ -0,0 +1,47 @@
|
|||
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;
|
|
@ -10,9 +10,9 @@ const SYNTAX = '/admin add @user; /admin remove @user; /admin list';
|
|||
export default {
|
||||
name: 'admin',
|
||||
aliases: [ 'admins', 'manager', 'managers' ],
|
||||
description: "Manage users with permission to modify the configuration.",
|
||||
description: 'Allow users to control the bot\'s configuration',
|
||||
syntax: SYNTAX,
|
||||
category: CommandCategory.Configuration,
|
||||
category: CommandCategory.Config,
|
||||
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.');
|
||||
|
|
359
bot/src/bot/commands/configuration/botctl.ts
Normal file
359
bot/src/bot/commands/configuration/botctl.ts
Normal file
|
@ -0,0 +1,359 @@
|
|||
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 }
|
361
bot/src/bot/commands/configuration/bridge.ts
Normal file
361
bot/src/bot/commands/configuration/bridge.ts
Normal file
|
@ -0,0 +1,361 @@
|
|||
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;
|
|
@ -1,157 +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: "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 };
|
|
@ -1,81 +0,0 @@
|
|||
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;
|
|
@ -1,5 +1,5 @@
|
|||
import type { FindOneResult } from "monk";
|
||||
import { dbs } from "../../..";
|
||||
import { FindOneResult } from "monk";
|
||||
import { client, 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.Miscellaneous,
|
||||
description: 'Log into the web dashboard',
|
||||
category: CommandCategory.Misc,
|
||||
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.vale.rocks'}).\n\n`
|
||||
+ `[here](${process.env.WEB_UI_URL || 'https://automod.janderedev.xyz'}).\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.vale.rocks'}>, \n` +
|
||||
`Otherwise, if this was you trying to log in from <${process.env.WEB_UI_URL || 'https://automod.janderedev.xyz'}>, \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!`
|
||||
),
|
|
@ -7,8 +7,8 @@ import { DEFAULT_PREFIX } from "../../modules/command_handler";
|
|||
export default {
|
||||
name: 'logout',
|
||||
aliases: null,
|
||||
description: 'Log out of web dashboard sessions.',
|
||||
category: CommandCategory.Miscellaneous,
|
||||
description: 'Log out of sessions created with /login',
|
||||
category: CommandCategory.Misc,
|
||||
run: async (message: MessageCommandContext, args: string[]) => {
|
||||
try {
|
||||
const code = args.shift();
|
|
@ -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.Configuration,
|
||||
category: CommandCategory.Config,
|
||||
run: async (message: MessageCommandContext, args: string[]) => {
|
||||
if (!await isBotManager(message)) return message.reply(NO_MANAGER_MSG);
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import SimpleCommand from "../../../struct/commands/SimpleCommand";
|
||||
import { dbs } from "../../..";
|
||||
import { client, 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: "Change AutoMod's prefix",
|
||||
description: 'Configure AutoMod\'s prefix',
|
||||
syntax: SYNTAX,
|
||||
category: CommandCategory.Configuration,
|
||||
category: CommandCategory.Config,
|
||||
run: async (message: MessageCommandContext, args: string[]) => {
|
||||
let config = await dbs.SERVERS.findOne({ id: message.channel!.serverId! });
|
||||
|
||||
|
|
14
bot/src/bot/commands/configuration/settings.ts
Normal file
14
bot/src/bot/commands/configuration/settings.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
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;
|
|
@ -1,41 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
|
@ -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.Configuration,
|
||||
category: CommandCategory.Config,
|
||||
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! };
|
||||
|
|
79
bot/src/bot/commands/misc/debug.ts
Normal file
79
bot/src/bot/commands/misc/debug.ts
Normal file
|
@ -0,0 +1,79 @@
|
|||
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;
|
||||
}
|
13
bot/src/bot/commands/misc/healthcheck.ts
Normal file
13
bot/src/bot/commands/misc/healthcheck.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
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;
|
117
bot/src/bot/commands/misc/help.ts
Normal file
117
bot/src/bot/commands/misc/help.ts
Normal file
|
@ -0,0 +1,117 @@
|
|||
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;
|
21
bot/src/bot/commands/misc/ping.ts
Normal file
21
bot/src/bot/commands/misc/ping.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
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;
|
24
bot/src/bot/commands/misc/test.ts
Normal file
24
bot/src/bot/commands/misc/test.ts
Normal 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: '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;
|
|
@ -1,112 +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: "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;
|
|
@ -1,20 +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: "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;
|
|
@ -1,15 +0,0 @@
|
|||
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;
|
|
@ -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: "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;
|
|
@ -1,12 +1,16 @@
|
|||
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: 'Manage or return a user\'s profile picture.',
|
||||
description: 'Get or clear someone\'s avatar',
|
||||
category: CommandCategory.Moderation,
|
||||
run: async (message: MessageCommandContext, args: string[]) => {
|
||||
try {
|
||||
|
|
|
@ -23,7 +23,7 @@ import {
|
|||
import Day from "dayjs";
|
||||
import RelativeTime from "dayjs/plugin/relativeTime";
|
||||
import CommandCategory from "../../../struct/commands/CommandCategory";
|
||||
import type { SendableEmbed } from "revolt-api";
|
||||
import { 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: "Bans a user from the server.",
|
||||
description: "Ban a member from the server",
|
||||
syntax: "/ban @username [10m|1h|...?] [reason?]",
|
||||
removeEmptyArgs: true,
|
||||
category: CommandCategory.Moderation,
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { User } from "revolt.js";
|
||||
import type { SendableEmbed } from "revolt-api";
|
||||
import { SendableEmbed } from "revolt-api";
|
||||
import { ulid } from "ulid";
|
||||
import { client } from "../../../";
|
||||
import Infraction from "automod/dist/types/antispam/Infraction";
|
||||
|
@ -17,6 +17,7 @@ import {
|
|||
getMembers,
|
||||
isModerator,
|
||||
NO_MANAGER_MSG,
|
||||
parseUser,
|
||||
parseUserOrId,
|
||||
sanitizeMessageContent,
|
||||
storeInfraction,
|
||||
|
@ -26,7 +27,7 @@ import {
|
|||
export default {
|
||||
name: "kick",
|
||||
aliases: ["yeet", "vent"],
|
||||
description: "Kick a user from the server.",
|
||||
description: "Kick a member from the server",
|
||||
syntax: "/kick @username [reason?]",
|
||||
removeEmptyArgs: true,
|
||||
category: CommandCategory.Moderation,
|
||||
|
|
|
@ -9,7 +9,7 @@ import { client } from "../../..";
|
|||
export default {
|
||||
name: 'nick',
|
||||
aliases: [ 'setnick' ],
|
||||
description: 'Manage a user\'s nickname.',
|
||||
description: 'Set or clear someone\'s nickname',
|
||||
category: CommandCategory.Moderation,
|
||||
run: async (message: MessageCommandContext, args: string[]) => {
|
||||
try {
|
||||
|
|
|
@ -11,7 +11,7 @@ const MAX_PURGE_AMOUNT = 100;
|
|||
export default {
|
||||
name: "purge",
|
||||
aliases: ["clear"],
|
||||
description: "Delete messages in bulk.",
|
||||
description: "Mass delete messages",
|
||||
syntax: SYNTAX,
|
||||
category: CommandCategory.Moderation,
|
||||
run: async (message: MessageCommandContext, args: string[]) => {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -8,7 +8,7 @@ import { isModerator, NO_MANAGER_MSG, parseUser, ULID_REGEX, USER_MENTION_REGEX
|
|||
export default {
|
||||
name: 'unban',
|
||||
aliases: [ 'pardon' ],
|
||||
description: "Removes a user's server ban.",
|
||||
description: 'Unbans a user',
|
||||
syntax: '/unban [@user or ID]',
|
||||
category: CommandCategory.Moderation,
|
||||
run: async (message: MessageCommandContext, args: string[]) => {
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { FindResult } from "monk";
|
||||
import { ulid } from "ulid";
|
||||
import { dbs } from "../../../";
|
||||
import { client, dbs } from "../../../";
|
||||
import CommandCategory from "../../../struct/commands/CommandCategory";
|
||||
import SimpleCommand from "../../../struct/commands/SimpleCommand";
|
||||
import MessageCommandContext from "../../../struct/MessageCommandContext";
|
||||
|
@ -19,7 +20,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 {
|
||||
|
@ -114,4 +115,4 @@ export default {
|
|||
}
|
||||
} as SimpleCommand;
|
||||
|
||||
export type { VoteEntry }
|
||||
export { VoteEntry }
|
||||
|
|
|
@ -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 type { SendableEmbed } from "revolt-api";
|
||||
import { 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: "Adds an infraction to a user's record.",
|
||||
description: 'add an infraction to an user\'s record',
|
||||
category: CommandCategory.Moderation,
|
||||
run: async (message, args, serverConfig) => {
|
||||
if (!await isModerator(message)) return message.reply(NO_MANAGER_MSG);
|
||||
|
|
|
@ -17,8 +17,8 @@ const GLOBAL_BLACKLIST_TEXT = (reason?: string) => `> :warning: This user has be
|
|||
|
||||
export default {
|
||||
name: 'warns',
|
||||
aliases: [ 'warnings', 'infractions' ],
|
||||
description: "Shows a user's infractions.",
|
||||
aliases: [ 'warnings', 'infractions', 'infraction' ],
|
||||
description: 'Show all user infractions',
|
||||
syntax: '/warns; /warns @username ["export-csv"]; /warns rm [ID]',
|
||||
category: CommandCategory.Moderation,
|
||||
run: async (message: MessageCommandContext, args: string[]) => {
|
||||
|
|
|
@ -45,6 +45,14 @@ 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"]);
|
||||
|
@ -52,6 +60,17 @@ 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 }
|
||||
|
|
|
@ -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;
|
||||
|
|
42
bot/src/bot/logging.ts
Normal file
42
bot/src/bot/logging.ts
Normal file
|
@ -0,0 +1,42 @@
|
|||
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,
|
||||
}
|
|
@ -1,15 +1,15 @@
|
|||
import { Message } from "revolt.js";
|
||||
import { ulid } from "ulid";
|
||||
import { dbs } from "../..";
|
||||
import { client, 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 { generateInfractionDMEmbed, isModerator, sendLogMessage, storeInfraction } from "../util";
|
||||
import { awaitClient, 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/filter";
|
||||
import { WORDLIST_DEFAULT_MESSAGE } from "../commands/configuration/botctl";
|
||||
|
||||
let msgCountStore: Map<string, { users: any }> = new Map();
|
||||
|
||||
|
@ -124,6 +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) {
|
||||
case 'WARN': {
|
||||
try {
|
||||
|
@ -148,10 +149,8 @@ async function wordFilterCheck(message: Message, config: ServerConfig) {
|
|||
}
|
||||
else logger.warn('Missing permission to DM user.');
|
||||
}
|
||||
break;
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
case 'DELETE': {
|
||||
|
@ -165,9 +164,7 @@ 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: {
|
||||
|
@ -180,8 +177,8 @@ async function wordFilterCheck(message: Message, config: ServerConfig) {
|
|||
`>${sanitizeMessageContent(message.content.substring(0, 1000)).trim().replace(/\n/g, '\n>')}`,
|
||||
color: '#ff557f',
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
}
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
|
@ -254,4 +251,51 @@ 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 }
|
||||
|
|
|
@ -3,8 +3,7 @@ import { User } from "revolt.js";
|
|||
import { client, dbs } from "../../..";
|
||||
import ServerConfig from "automod/dist/types/ServerConfig";
|
||||
import { getPermissionLevel } from "../../util";
|
||||
import type { WSResponse } from "../api_communication";
|
||||
import { wsEvents } from "../api_communication";
|
||||
import { wsEvents, WSResponse } from "../api_communication";
|
||||
|
||||
type ReqData = { user: string, server: string }
|
||||
type APIUser = { id: string, username?: string, avatarURL?: string }
|
||||
|
@ -96,4 +95,4 @@ wsEvents.on('req:getUserServerDetails', async (data: ReqData, cb: (data: WSRespo
|
|||
}
|
||||
});
|
||||
|
||||
export type { APIUser }
|
||||
export { APIUser }
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
import { User } from 'revolt.js';
|
||||
import { client } from '../../..';
|
||||
import { getMutualServers, getPermissionLevel } from '../../util';
|
||||
import type { WSResponse } from "../api_communication";
|
||||
import { wsEvents } from "../api_communication";
|
||||
import { wsEvents, WSResponse } from '../api_communication';
|
||||
|
||||
type ReqData = { user: string }
|
||||
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
import { User } from "revolt.js";
|
||||
import { client } from "../../..";
|
||||
import { getPermissionLevel, parseUser } from "../../util";
|
||||
import type { WSResponse } from "../api_communication";
|
||||
import { wsEvents } from "../api_communication";
|
||||
import type { APIUser } from "./server_details";
|
||||
import { wsEvents, WSResponse } from "../api_communication";
|
||||
import { APIUser } from "./server_details";
|
||||
|
||||
wsEvents.on('req:getPermissionLevel', async (data: { user: string, server: string }, cb: (data: WSResponse) => void) => {
|
||||
try {
|
||||
|
|
|
@ -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";
|
||||
|
@ -15,15 +15,12 @@ const wsEvents = new EventEmitter();
|
|||
const { API_WS_URL, API_WS_TOKEN } = process.env;
|
||||
const wsQueue: { [key: string]: string }[] = [];
|
||||
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.error("$API_WS_URL or $API_WS_TOKEN not found. Please set these environment variables.");
|
||||
} else {
|
||||
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();
|
||||
}
|
||||
|
@ -33,8 +30,7 @@ function connect() {
|
|||
client = new ws(API_WS_URL!, { headers: { authorization: API_WS_TOKEN! } });
|
||||
|
||||
client.once("open", () => {
|
||||
logger.info("WebSocket connected successfully");
|
||||
retryCount = 0;
|
||||
logger.debug("WS connected");
|
||||
if (wsQueue.length > 0) {
|
||||
logger.debug(`Attempting to send ${wsQueue.length} queued WS messages`);
|
||||
|
||||
|
@ -49,38 +45,37 @@ function connect() {
|
|||
|
||||
client.once("close", () => {
|
||||
client = undefined;
|
||||
retryConnection();
|
||||
logger.warn(`WS closed, reconnecting in 3 seconds`);
|
||||
setTimeout(connect, 3000);
|
||||
});
|
||||
|
||||
client.once("error", (err: Error) => {
|
||||
client.once('error', (err) => {
|
||||
client = undefined;
|
||||
logger.error(`WebSocket error: ${err.message}`);
|
||||
retryConnection();
|
||||
logger.warn(`WS: ${err}`);
|
||||
});
|
||||
|
||||
client.on("message", (msg: ws.Data) => {
|
||||
logger.debug(`[WS] [<] ${msg.toString("utf8")}`);
|
||||
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 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",
|
||||
},
|
||||
error: 'No event listeners available for event'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
} catch(e) { console.error(e) }
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -94,31 +89,19 @@ function wsSend(data: { [key: string]: any }) {
|
|||
}
|
||||
}
|
||||
|
||||
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) => {
|
||||
wsEvents.on('req:test', (data: any, res: (data: any) => void) => {
|
||||
res({ received: data });
|
||||
});
|
||||
|
||||
wsEvents.on("req:requestLogin", async (data: any, cb: (data: WSResponse) => void) => {
|
||||
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` });
|
||||
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 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();
|
||||
|
@ -133,11 +116,11 @@ wsEvents.on("req:requestLogin", async (data: any, cb: (data: WSResponse) => void
|
|||
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
|
||||
expires: Date.now() + (1000 * 60 * 15), // Expires in 15 minutes
|
||||
user: user.id,
|
||||
nonce: nonce,
|
||||
confirmed: false,
|
||||
|
@ -153,14 +136,13 @@ wsEvents.on("req:requestLogin", async (data: any, cb: (data: WSResponse) => void
|
|||
}
|
||||
});
|
||||
|
||||
wsEvents.on("req:stats", async (_data: any, cb: (data: { servers: number }) => void) => {
|
||||
wsEvents.on('req:stats', async (_data: any, cb: (data: { servers: number }) => void) => {
|
||||
const servers = bot.servers.size();
|
||||
cb({ servers });
|
||||
});
|
||||
|
||||
export { wsEvents, wsSend };
|
||||
export type { WSResponse };
|
||||
export { wsEvents, wsSend, WSResponse }
|
||||
|
||||
import("./api/servers");
|
||||
import("./api/server_details");
|
||||
import("./api/users");
|
||||
import('./api/servers');
|
||||
import('./api/server_details');
|
||||
import('./api/users');
|
||||
|
|
|
@ -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/owner/botadm";
|
||||
import { isSudo, updateSudoTimeout } from "../commands/admin/botadm";
|
||||
import { metrics } from "./metrics";
|
||||
|
||||
// thanks a lot esm
|
||||
|
@ -37,8 +37,6 @@ 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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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/owner/botadm";
|
||||
import { BLACKLIST_BAN_REASON, BLACKLIST_MESSAGE } from "../commands/admin/botadm";
|
||||
import logger from "../logger";
|
||||
import { storeInfraction } from "../util";
|
||||
import { DEFAULT_PREFIX } from "./command_handler";
|
||||
import type { SendableEmbed } from "revolt-api";
|
||||
import { SendableEmbed } from "revolt-api";
|
||||
import { UserSystemMessage } from "revolt.js";
|
||||
|
||||
const DM_SESSION_LIFETIME = 1000 * 60 * 60 * 24 * 30;
|
||||
|
|
|
@ -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?');
|
||||
|
|
|
@ -35,6 +35,13 @@ 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) {
|
||||
|
@ -56,6 +63,7 @@ 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)');
|
||||
|
@ -70,6 +78,11 @@ 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) {
|
||||
|
@ -113,6 +126,7 @@ client.on('messageDeleteBulk', async (messages) => {
|
|||
]);
|
||||
}
|
||||
|
||||
const sheet = Xlsx.utils.aoa_to_sheet(data);
|
||||
const csv = Xlsx.utils.sheet_to_csv(data);
|
||||
|
||||
let embed: LogMessage = {
|
||||
|
@ -122,6 +136,11 @@ 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);
|
||||
|
@ -149,6 +168,7 @@ async function logModAction(type: 'warn'|'kick'|'ban'|'votekick', server: Server
|
|||
+ `**Warn ID**: \`${infractionID}\`\n`
|
||||
+ (extraText ?? ''),
|
||||
color: embedColor,
|
||||
overrides: {},
|
||||
});
|
||||
}
|
||||
} catch(e) {
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { FindResult } from "monk";
|
||||
import { client, dbs } from "../..";
|
||||
import TempBan from "automod/dist/types/TempBan";
|
||||
import logger from "../logger";
|
||||
|
|
|
@ -7,14 +7,16 @@ 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/owner/botadm";
|
||||
import type { SendableEmbed } from "revolt-api";
|
||||
import { isSudo } from "./commands/admin/botadm";
|
||||
import { 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;
|
||||
|
@ -180,6 +182,58 @@ 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 {
|
||||
|
|
|
@ -9,14 +9,16 @@ 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 type { VoteEntry } from './bot/commands/moderation/votekick';
|
||||
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';
|
||||
|
||||
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);
|
||||
|
||||
|
@ -28,6 +30,9 @@ 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 }
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import * as Revolt from "revolt.js";
|
||||
import { IMonkManager } from 'monk';
|
||||
import logger from '../bot/logger';
|
||||
import type { ClientOptions } from "revolt.js";
|
||||
import { adminBotLog } from "../bot/logging";
|
||||
import { ClientOptions } from "revolt.js/src/Client";
|
||||
|
||||
class AutomodClient extends Revolt.Client {
|
||||
db: IMonkManager;
|
||||
|
@ -26,6 +27,7 @@ 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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -4,12 +4,6 @@ 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;
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
enum CommandCategory {
|
||||
Moderation = 'Moderation',
|
||||
Configuration = 'Configuration',
|
||||
Config = 'Config',
|
||||
Owner = 'Owner',
|
||||
Miscellaneous = 'Miscellaneous',
|
||||
Misc = 'Misc',
|
||||
None = 'None',
|
||||
}
|
||||
|
||||
|
|
|
@ -1,30 +1,100 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
// 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"
|
||||
/* 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. */
|
||||
}
|
||||
}
|
||||
|
|
932
bot/yarn.lock
932
bot/yarn.lock
File diff suppressed because it is too large
Load diff
7
bridge/.gitignore
vendored
Normal file
7
bridge/.gitignore
vendored
Normal file
|
@ -0,0 +1,7 @@
|
|||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
786
bridge/.yarn/releases/yarn-3.2.1.cjs
vendored
Executable file
786
bridge/.yarn/releases/yarn-3.2.1.cjs
vendored
Executable file
File diff suppressed because one or more lines are too long
2
bridge/.yarnrc.yml
Normal file
2
bridge/.yarnrc.yml
Normal file
|
@ -0,0 +1,2 @@
|
|||
yarnPath: .yarn/releases/yarn-3.2.1.cjs
|
||||
nodeLinker: node-modules
|
31
bridge/Dockerfile
Normal file
31
bridge/Dockerfile
Normal file
|
@ -0,0 +1,31 @@
|
|||
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"]
|
40
bridge/package.json
Normal file
40
bridge/package.json
Normal file
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
}
|
50
bridge/src/data_deletion.ts
Normal file
50
bridge/src/data_deletion.ts
Normal file
|
@ -0,0 +1,50 @@
|
|||
/**
|
||||
* 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));
|
||||
});
|
||||
})();
|
9
bridge/src/db.ts
Normal file
9
bridge/src/db.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
import Monk from 'monk';
|
||||
import { logger } from '.';
|
||||
|
||||
function getDb() {
|
||||
const db = Monk(process.env['DB_STRING']!);
|
||||
return db;
|
||||
}
|
||||
|
||||
export { getDb }
|
147
bridge/src/discord/bridgeEmojis.ts
Normal file
147
bridge/src/discord/bridgeEmojis.ts
Normal file
|
@ -0,0 +1,147 @@
|
|||
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 }
|
28
bridge/src/discord/client.ts
Normal file
28
bridge/src/discord/client.ts
Normal file
|
@ -0,0 +1,28 @@
|
|||
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 }
|
515
bridge/src/discord/commands.ts
Normal file
515
bridge/src/discord/commands.ts
Normal file
|
@ -0,0 +1,515 @@
|
|||
// 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
Loading…
Reference in a new issue