Merge branch 'master' into fixLocateUser
This commit is contained in:
commit
b1b4b85e94
65 changed files with 2573 additions and 2888 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -72,3 +72,4 @@ desktop.ini
|
|||
.cache
|
||||
npm-ls.txt
|
||||
npm-audit.txt
|
||||
.vscode/launch.json
|
||||
|
|
50
README.md
50
README.md
|
@ -1,28 +1,50 @@
|
|||
# Development
|
||||
# Zeppelin
|
||||
Zeppelin is a moderation bot for Discord, designed with large servers and reliability in mind.
|
||||
|
||||
**Main features include:**
|
||||
- Extensive automoderator features (automod)
|
||||
- Word filters, spam detection, etc.
|
||||
- Detailed moderator action tracking and notes (cases)
|
||||
- Customizable server logs
|
||||
- Tags/custom commands
|
||||
- Reaction roles
|
||||
- Tons of utility commands, including a granular member search
|
||||
- Full configuration via a web dashboard
|
||||
- Override specific settings and permissions on e.g. a per-user, per-channel, or per-permission-level basis
|
||||
- Bot-managed slowmodes
|
||||
- Automatically switches between native slowmodes (for 6h or less) and bot-enforced (for longer slowmodes)
|
||||
- Starboard
|
||||
- And more!
|
||||
|
||||
See https://zeppelin.gg/ for more details.
|
||||
|
||||
## Development
|
||||
These instructions are intended for bot development only.
|
||||
|
||||
👉 **No support is offered for self-hosting the bot!** 👈
|
||||
|
||||
## Running the bot
|
||||
### Running the bot
|
||||
1. `cd backend`
|
||||
2. `npm ci`
|
||||
3. Make a copy of `bot.env.example` called `bot.env`, fill in the values
|
||||
4. Run the desired start script:
|
||||
* `npm run start-bot-dev` to run the bot with `ts-node`
|
||||
* `npm run build` followed by `npm run start-bot-prod` to run the bot compiled
|
||||
* `npm run watch-bot` to run the bot with `ts-node` and restart on changes
|
||||
* `npm run build` followed by `npm run start-bot-dev` to run the bot in a **development** environment
|
||||
* `npm run build` followed by `npm run start-bot-prod` to run the bot in a **production** environment
|
||||
* `npm run watch` to watch files and run the **bot and api both** in a **development** environment
|
||||
with automatic restart on file changes
|
||||
5. When testing, make sure you have your test server in the `allowed_guilds` table or the guild's config won't be loaded at all
|
||||
|
||||
## Running the API server
|
||||
### Running the API server
|
||||
1. `cd backend`
|
||||
2. `npm ci`
|
||||
3. Make a copy of `api.env.example` called `api.env`, fill in the values
|
||||
4. Run the desired start script:
|
||||
* `npm run start-api-dev` to run the API server with `ts-node`
|
||||
* `npm run build` followed by `npm run start-api-prod` to run the API server compiled
|
||||
* `npm run watch-api` to run the API server with `ts-node` and restart on changes
|
||||
* `npm run build` followed by `npm run start-api-dev` to run the api in a **development** environment
|
||||
* `npm run build` followed by `npm run start-api-prod` to run the api in a **production** environment
|
||||
* `npm run watch` to watch files and run the **bot and api both** in a **development** environment
|
||||
with automatic restart on file changes
|
||||
|
||||
## Running the dashboard
|
||||
### Running the dashboard
|
||||
1. `cd dashboard`
|
||||
2. `npm ci`
|
||||
3. Make a copy of `.env.example` called `.env`, fill in the values
|
||||
|
@ -30,15 +52,15 @@ These instructions are intended for bot development only.
|
|||
* `npm run build` compiles the dashboard's static files to `dist/` which can then be served with any web server
|
||||
* `npm run watch` runs webpack's dev server that automatically reloads on changes
|
||||
|
||||
## Notes
|
||||
### Notes
|
||||
* Since we now use shared paths in `tsconfig.json`, the compiled files in `backend/dist/` have longer paths, e.g.
|
||||
`backend/dist/backend/src/index.js` instead of `backend/dist/index.js`. This is because the compiled shared files
|
||||
are placed in `backend/dist/shared`.
|
||||
* The `backend/register-tsconfig-prod-paths.js` module takes care of registering shared paths from `tsconfig.json` for
|
||||
`ts-node`, `ava`, and compiled `.js` files
|
||||
* The `backend/register-tsconfig-paths.js` module takes care of registering shared paths from `tsconfig.json` for
|
||||
`ava` and compiled `.js` files
|
||||
* To run the tests for the files in the `shared/` directory, you also need to run `npm ci` there
|
||||
|
||||
## Config format example
|
||||
### Config format example
|
||||
Configuration is stored in the database in the `configs` table
|
||||
|
||||
```yml
|
||||
|
|
|
@ -1,6 +0,0 @@
|
|||
{
|
||||
"watch": ["src", "../shared/src"],
|
||||
"ignore": ["src/migrations/*"],
|
||||
"ext": "ts",
|
||||
"exec": "node -r ts-node/register -r tsconfig-paths/register ./src/api/index.ts"
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
{
|
||||
"watch": ["src", "../shared/src"],
|
||||
"ignore": ["src/api/*", "src/migrations/*"],
|
||||
"ext": "ts",
|
||||
"exec": "node -r ts-node/register -r tsconfig-paths/register ./src/index.ts"
|
||||
}
|
|
@ -16,13 +16,9 @@ try {
|
|||
const moment = require('moment-timezone');
|
||||
moment.tz.setDefault('UTC');
|
||||
|
||||
const entities = process.env.NODE_ENV === 'production'
|
||||
? path.relative(process.cwd(), path.resolve(__dirname, 'dist/backend/src/data/entities/*.js'))
|
||||
: path.relative(process.cwd(), path.resolve(__dirname, 'src/data/entities/*.ts'));
|
||||
|
||||
const migrations = process.env.NODE_ENV === 'production'
|
||||
? path.relative(process.cwd(), path.resolve(__dirname, 'dist/backend/src/migrations/*.js'))
|
||||
: path.relative(process.cwd(), path.resolve(__dirname, 'src/migrations/*.ts'));
|
||||
const entities = path.relative(process.cwd(), path.resolve(__dirname, 'dist/backend/src/data/entities/*.js'));
|
||||
const migrations = path.relative(process.cwd(), path.resolve(__dirname, 'dist/backend/src/migrations/*.js'));
|
||||
const migrationsDir = path.relative(process.cwd(), path.resolve(__dirname, 'src/migrations'));
|
||||
|
||||
module.exports = {
|
||||
type: "mysql",
|
||||
|
@ -55,6 +51,6 @@ module.exports = {
|
|||
// Migrations
|
||||
migrations: [migrations],
|
||||
cli: {
|
||||
migrationsDir: path.dirname(migrations)
|
||||
migrationsDir,
|
||||
},
|
||||
};
|
||||
|
|
2254
backend/package-lock.json
generated
2254
backend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -4,18 +4,20 @@
|
|||
"description": "",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start-bot-dev": "node -r ts-node/register -r tsconfig-paths/register src/index.ts",
|
||||
"start-bot-prod": "cross-env NODE_ENV=production node -r ./register-tsconfig-prod-paths.js dist/backend/src/index.js",
|
||||
"watch-bot": "nodemon --config nodemon-bot.json",
|
||||
"watch": "cross-env NODE_ENV=development tsc-watch --onSuccess \"node start-dev.js\"",
|
||||
"build": "rimraf dist && tsc",
|
||||
"start-api-dev": "node -r ts-node/register -r tsconfig-paths/register src/api/index.ts",
|
||||
"start-api-prod": "cross-env NODE_ENV=production node -r ./register-tsconfig-prod-paths.js dist/backend/src/api/index.js",
|
||||
"watch-api": "nodemon --config nodemon-api.json",
|
||||
"format": "prettier --write \"./src/**/*.ts\"",
|
||||
"typeorm": "node -r ts-node/register -r tsconfig-paths/register ./node_modules/typeorm/cli.js",
|
||||
"migrate": "npm run typeorm -- migration:run",
|
||||
"migrate-rollback": "npm run typeorm -- migration:revert",
|
||||
"test": "ava"
|
||||
"start-bot-dev": "cross-env NODE_ENV=development node -r ./register-tsconfig-paths.js --unhandled-rejections=strict --inspect=127.0.0.1:9229 dist/backend/src/index.js",
|
||||
"start-bot-prod": "cross-env NODE_ENV=production node -r ./register-tsconfig-paths.js --unhandled-rejections=strict dist/backend/src/index.js",
|
||||
"start-api-dev": "cross-env NODE_ENV=development node -r ./register-tsconfig-paths.js --unhandled-rejections=strict --inspect=127.0.0.1:9239 dist/backend/src/api/index.js",
|
||||
"start-api-prod": "cross-env NODE_ENV=production node -r ./register-tsconfig-paths.js --unhandled-rejections=strict dist/backend/src/api/index.js",
|
||||
"typeorm": "node -r ./register-tsconfig-paths.js ./node_modules/typeorm/cli.js",
|
||||
"migrate-prod": "npm run typeorm -- migration:run",
|
||||
"migrate-dev": "npm run build && npm run typeorm -- migration:run",
|
||||
"migrate-rollback-prod": "npm run typeorm -- migration:revert",
|
||||
"migrate-rollback-dev": "npm run build && npm run typeorm -- migration:revert",
|
||||
"test": "npm run build && npm run run-tests",
|
||||
"run-tests": "ava",
|
||||
"test-watch": "tsc-watch --onSuccess \"npx ava\""
|
||||
},
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
|
@ -30,8 +32,8 @@
|
|||
"humanize-duration": "^3.15.0",
|
||||
"io-ts": "^2.0.0",
|
||||
"js-yaml": "^3.13.1",
|
||||
"knub": "^26.0.2",
|
||||
"knub-command-manager": "^6.1.0",
|
||||
"knub": "^28.0.0",
|
||||
"knub-command-manager": "^7.0.0",
|
||||
"last-commit-log": "^2.1.0",
|
||||
"lodash.chunk": "^4.2.0",
|
||||
"lodash.clonedeep": "^4.5.0",
|
||||
|
@ -49,6 +51,7 @@
|
|||
"seedrandom": "^3.0.1",
|
||||
"tlds": "^1.203.1",
|
||||
"tmp": "0.0.33",
|
||||
"transliteration": "^2.1.7",
|
||||
"tsconfig-paths": "^3.9.0",
|
||||
"typeorm": "^0.2.14",
|
||||
"uuid": "^3.3.2"
|
||||
|
@ -64,24 +67,21 @@
|
|||
"@types/passport": "^1.0.0",
|
||||
"@types/passport-oauth2": "^1.4.8",
|
||||
"@types/passport-strategy": "^0.2.35",
|
||||
"@types/safe-regex": "^1.1.2",
|
||||
"@types/tmp": "0.0.33",
|
||||
"ava": "^2.4.0",
|
||||
"nodemon": "^1.19.4",
|
||||
"rimraf": "^2.6.2",
|
||||
"ts-node": "^8.4.1",
|
||||
"source-map-support": "^0.5.16",
|
||||
"tsc-watch": "^4.0.0",
|
||||
"typescript": "^3.7.2"
|
||||
},
|
||||
"ava": {
|
||||
"compileEnhancements": false,
|
||||
"files": [
|
||||
"src/**/*.test.ts"
|
||||
],
|
||||
"extensions": [
|
||||
"ts"
|
||||
"dist/backend/src/**/*.test.js"
|
||||
],
|
||||
"require": [
|
||||
"ts-node/register",
|
||||
"tsconfig-paths/register"
|
||||
"./register-tsconfig-paths.js"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -53,6 +53,7 @@ export function initGuildsAPI(app: express.Express) {
|
|||
return res.status(400).json({ errors: [e.message] });
|
||||
}
|
||||
|
||||
// tslint:disable-next-line:no-console
|
||||
console.error("Error when loading YAML: " + e.message);
|
||||
return serverError(res, "Server error");
|
||||
}
|
||||
|
|
|
@ -55,8 +55,10 @@
|
|||
"MEMBER_MUTE_REJOIN": "⚠ Reapplied active mute for {userMention(member)} on rejoin",
|
||||
|
||||
"SCHEDULED_MESSAGE": "⏰ {userMention(author)} scheduled a message to be posted to {channelMention(channel)} on {date} at {time} (UTC)",
|
||||
"SCHEDULED_REPEATED_MESSAGE": "⏰ {userMention(author)} scheduled a message to be posted to {channelMention(channel)} on {date} at {time} (UTC), repeated {repeatDetails}",
|
||||
"REPEATED_MESSAGE": "⏰ {userMention(author)} scheduled a message to be posted to {channelMention(channel)} {repeatDetails}",
|
||||
"POSTED_SCHEDULED_MESSAGE": "\uD83D\uDCE8 Posted scheduled message (`{messageId}`) to {channelMention(channel)} as scheduled by {userMention(author)}",
|
||||
|
||||
"BOT_ALERT": "⚠ {tmplEval(body)}",
|
||||
"AUTOMOD_ACTION": "\uD83E\uDD16 Automod rule **{rule}** triggered by {userMention(user)}. Actions taken: **{actionsTaken}**\n{matchSummary}"
|
||||
"AUTOMOD_ACTION": "\uD83E\uDD16 Automod rule **{rule}** triggered by {userMention(user)}\n{matchSummary}\nActions taken: **{actionsTaken}**"
|
||||
}
|
||||
|
|
|
@ -50,6 +50,9 @@ export class GuildLogs extends EventEmitter {
|
|||
}
|
||||
|
||||
clearIgnoredLog(type: LogType, ignoreId: any) {
|
||||
this.ignoredLogs.splice(this.ignoredLogs.findIndex(info => type === info.type && ignoreId === info.ignoreId), 1);
|
||||
this.ignoredLogs.splice(
|
||||
this.ignoredLogs.findIndex(info => type === info.type && ignoreId === info.ignoreId),
|
||||
1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -50,13 +50,14 @@ export class GuildReactionRoles extends BaseGuildRepository {
|
|||
await this.reactionRoles.delete(criteria);
|
||||
}
|
||||
|
||||
async add(channelId: string, messageId: string, emoji: string, roleId: string) {
|
||||
async add(channelId: string, messageId: string, emoji: string, roleId: string, exclusive?: boolean) {
|
||||
await this.reactionRoles.insert({
|
||||
guild_id: this.guildId,
|
||||
channel_id: channelId,
|
||||
message_id: messageId,
|
||||
emoji,
|
||||
role_id: roleId,
|
||||
is_exclusive: Boolean(exclusive),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -34,13 +34,14 @@ export class GuildReminders extends BaseGuildRepository {
|
|||
});
|
||||
}
|
||||
|
||||
async add(userId: string, channelId: string, remindAt: string, body: string) {
|
||||
async add(userId: string, channelId: string, remindAt: string, body: string, created_at: string) {
|
||||
await this.reminders.insert({
|
||||
guild_id: this.guildId,
|
||||
user_id: userId,
|
||||
channel_id: channelId,
|
||||
remind_at: remindAt,
|
||||
body,
|
||||
created_at,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -38,4 +38,8 @@ export class GuildScheduledPosts extends BaseGuildRepository {
|
|||
guild_id: this.guildId,
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: number, data: Partial<ScheduledPost>) {
|
||||
await this.scheduledPosts.update(id, data);
|
||||
}
|
||||
}
|
||||
|
|
54
backend/src/data/GuildStarboardMessages.ts
Normal file
54
backend/src/data/GuildStarboardMessages.ts
Normal file
|
@ -0,0 +1,54 @@
|
|||
import { BaseGuildRepository } from "./BaseGuildRepository";
|
||||
import { getRepository, Repository } from "typeorm";
|
||||
import { StarboardMessage } from "./entities/StarboardMessage";
|
||||
|
||||
export class GuildStarboardMessages extends BaseGuildRepository {
|
||||
private allStarboardMessages: Repository<StarboardMessage>;
|
||||
|
||||
constructor(guildId) {
|
||||
super(guildId);
|
||||
this.allStarboardMessages = getRepository(StarboardMessage);
|
||||
}
|
||||
|
||||
async getStarboardMessagesForMessageId(messageId: string) {
|
||||
return this.allStarboardMessages
|
||||
.createQueryBuilder()
|
||||
.where("guild_id = :gid", { gid: this.guildId })
|
||||
.andWhere("message_id = :msgid", { msgid: messageId })
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async getStarboardMessagesForStarboardMessageId(starboardMessageId: string) {
|
||||
return this.allStarboardMessages
|
||||
.createQueryBuilder()
|
||||
.where("guild_id = :gid", { gid: this.guildId })
|
||||
.andWhere("starboard_message_id = :messageId", { messageId: starboardMessageId })
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async getMatchingStarboardMessages(starboardChannelId: string, sourceMessageId: string) {
|
||||
return this.allStarboardMessages
|
||||
.createQueryBuilder()
|
||||
.where("guild_id = :guildId", { guildId: this.guildId })
|
||||
.andWhere("message_id = :msgId", { msgId: sourceMessageId })
|
||||
.andWhere("starboard_channel_id = :channelId", { channelId: starboardChannelId })
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async createStarboardMessage(starboardId: string, messageId: string, starboardMessageId: string) {
|
||||
await this.allStarboardMessages.insert({
|
||||
message_id: messageId,
|
||||
starboard_message_id: starboardMessageId,
|
||||
starboard_channel_id: starboardId,
|
||||
guild_id: this.guildId,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteStarboardMessage(starboardMessageId: string, starboardChannelId: string) {
|
||||
await this.allStarboardMessages.delete({
|
||||
guild_id: this.guildId,
|
||||
starboard_message_id: starboardMessageId,
|
||||
starboard_channel_id: starboardChannelId,
|
||||
});
|
||||
}
|
||||
}
|
43
backend/src/data/GuildStarboardReactions.ts
Normal file
43
backend/src/data/GuildStarboardReactions.ts
Normal file
|
@ -0,0 +1,43 @@
|
|||
import { BaseGuildRepository } from "./BaseGuildRepository";
|
||||
import { Repository, getRepository } from "typeorm";
|
||||
import { StarboardReaction } from "./entities/StarboardReaction";
|
||||
|
||||
export class GuildStarboardReactions extends BaseGuildRepository {
|
||||
private allStarboardReactions: Repository<StarboardReaction>;
|
||||
|
||||
constructor(guildId) {
|
||||
super(guildId);
|
||||
this.allStarboardReactions = getRepository(StarboardReaction);
|
||||
}
|
||||
|
||||
async getAllReactionsForMessageId(messageId: string) {
|
||||
return this.allStarboardReactions
|
||||
.createQueryBuilder()
|
||||
.where("guild_id = :gid", { gid: this.guildId })
|
||||
.andWhere("message_id = :msgid", { msgid: messageId })
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async createStarboardReaction(messageId: string, reactorId: string) {
|
||||
await this.allStarboardReactions.insert({
|
||||
message_id: messageId,
|
||||
reactor_id: reactorId,
|
||||
guild_id: this.guildId,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteAllStarboardReactionsForMessageId(messageId: string) {
|
||||
await this.allStarboardReactions.delete({
|
||||
guild_id: this.guildId,
|
||||
message_id: messageId,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteStarboardReaction(messageId: string, reactorId: string) {
|
||||
await this.allStarboardReactions.delete({
|
||||
guild_id: this.guildId,
|
||||
reactor_id: reactorId,
|
||||
message_id: messageId,
|
||||
});
|
||||
}
|
||||
}
|
|
@ -1,84 +0,0 @@
|
|||
import { BaseGuildRepository } from "./BaseGuildRepository";
|
||||
import { getRepository, Repository } from "typeorm";
|
||||
import { Starboard } from "./entities/Starboard";
|
||||
import { StarboardMessage } from "./entities/StarboardMessage";
|
||||
|
||||
export class GuildStarboards extends BaseGuildRepository {
|
||||
private starboards: Repository<Starboard>;
|
||||
private starboardMessages: Repository<StarboardMessage>;
|
||||
|
||||
constructor(guildId) {
|
||||
super(guildId);
|
||||
this.starboards = getRepository(Starboard);
|
||||
this.starboardMessages = getRepository(StarboardMessage);
|
||||
}
|
||||
|
||||
getStarboardByChannelId(channelId): Promise<Starboard> {
|
||||
return this.starboards.findOne({
|
||||
where: {
|
||||
guild_id: this.guildId,
|
||||
channel_id: channelId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getStarboardsByEmoji(emoji): Promise<Starboard[]> {
|
||||
return this.starboards.find({
|
||||
where: {
|
||||
guild_id: this.guildId,
|
||||
emoji,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getStarboardMessageByStarboardIdAndMessageId(starboardId, messageId): Promise<StarboardMessage> {
|
||||
return this.starboardMessages.findOne({
|
||||
relations: this.getRelations(),
|
||||
where: {
|
||||
starboard_id: starboardId,
|
||||
message_id: messageId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getStarboardMessagesByMessageId(id): Promise<StarboardMessage[]> {
|
||||
return this.starboardMessages.find({
|
||||
relations: this.getRelations(),
|
||||
where: {
|
||||
message_id: id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async createStarboardMessage(starboardId, messageId, starboardMessageId): Promise<void> {
|
||||
await this.starboardMessages.insert({
|
||||
starboard_id: starboardId,
|
||||
message_id: messageId,
|
||||
starboard_message_id: starboardMessageId,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteStarboardMessage(starboardId, messageId): Promise<void> {
|
||||
await this.starboardMessages.delete({
|
||||
starboard_id: starboardId,
|
||||
message_id: messageId,
|
||||
});
|
||||
}
|
||||
|
||||
async create(channelId: string, channelWhitelist: string[], emoji: string, reactionsRequired: number): Promise<void> {
|
||||
await this.starboards.insert({
|
||||
guild_id: this.guildId,
|
||||
channel_id: channelId,
|
||||
channel_whitelist: channelWhitelist ? channelWhitelist.join(",") : null,
|
||||
emoji,
|
||||
reactions_required: reactionsRequired,
|
||||
});
|
||||
}
|
||||
|
||||
async delete(channelId: string): Promise<void> {
|
||||
await this.starboards.delete({
|
||||
guild_id: this.guildId,
|
||||
channel_id: channelId,
|
||||
});
|
||||
}
|
||||
}
|
30
backend/src/data/GuildStats.ts
Normal file
30
backend/src/data/GuildStats.ts
Normal file
|
@ -0,0 +1,30 @@
|
|||
import { BaseGuildRepository } from "./BaseGuildRepository";
|
||||
import { connection } from "./db";
|
||||
import { getRepository, Repository } from "typeorm";
|
||||
import { StatValue } from "./entities/StatValue";
|
||||
|
||||
export class GuildStats extends BaseGuildRepository {
|
||||
private stats: Repository<StatValue>;
|
||||
|
||||
constructor(guildId) {
|
||||
super(guildId);
|
||||
this.stats = getRepository(StatValue);
|
||||
}
|
||||
|
||||
async saveValue(source: string, key: string, value: number): Promise<void> {
|
||||
await this.stats.insert({
|
||||
guild_id: this.guildId,
|
||||
source,
|
||||
key,
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteOldValues(source: string, cutoff: string): Promise<void> {
|
||||
await this.stats
|
||||
.createQueryBuilder()
|
||||
.where("source = :source", { source })
|
||||
.andWhere("created_at < :cutoff", { cutoff })
|
||||
.delete();
|
||||
}
|
||||
}
|
|
@ -59,4 +59,7 @@ export enum LogType {
|
|||
|
||||
BOT_ALERT,
|
||||
AUTOMOD_ACTION,
|
||||
|
||||
SCHEDULED_REPEATED_MESSAGE,
|
||||
REPEATED_MESSAGE,
|
||||
}
|
||||
|
|
|
@ -19,7 +19,10 @@ export class ApiLogin {
|
|||
@Column()
|
||||
expires_at: string;
|
||||
|
||||
@ManyToOne(type => ApiUserInfo, userInfo => userInfo.logins)
|
||||
@ManyToOne(
|
||||
type => ApiUserInfo,
|
||||
userInfo => userInfo.logins,
|
||||
)
|
||||
@JoinColumn({ name: "user_id" })
|
||||
userInfo: ApiUserInfo;
|
||||
}
|
||||
|
|
|
@ -18,7 +18,10 @@ export class ApiPermissionAssignment {
|
|||
@Column("simple-array")
|
||||
permissions: string[];
|
||||
|
||||
@ManyToOne(type => ApiUserInfo, userInfo => userInfo.permissionAssignments)
|
||||
@ManyToOne(
|
||||
type => ApiUserInfo,
|
||||
userInfo => userInfo.permissionAssignments,
|
||||
)
|
||||
@JoinColumn({ name: "target_id" })
|
||||
userInfo: ApiUserInfo;
|
||||
}
|
||||
|
|
|
@ -20,9 +20,15 @@ export class ApiUserInfo {
|
|||
@Column()
|
||||
updated_at: string;
|
||||
|
||||
@OneToMany(type => ApiLogin, login => login.userInfo)
|
||||
@OneToMany(
|
||||
type => ApiLogin,
|
||||
login => login.userInfo,
|
||||
)
|
||||
logins: ApiLogin[];
|
||||
|
||||
@OneToMany(type => ApiPermissionAssignment, p => p.userInfo)
|
||||
@OneToMany(
|
||||
type => ApiPermissionAssignment,
|
||||
p => p.userInfo,
|
||||
)
|
||||
permissionAssignments: ApiPermissionAssignment[];
|
||||
}
|
||||
|
|
|
@ -29,6 +29,9 @@ export class Case {
|
|||
|
||||
@Column() pp_name: string;
|
||||
|
||||
@OneToMany(type => CaseNote, note => note.case)
|
||||
@OneToMany(
|
||||
type => CaseNote,
|
||||
note => note.case,
|
||||
)
|
||||
notes: CaseNote[];
|
||||
}
|
||||
|
|
|
@ -15,7 +15,10 @@ export class CaseNote {
|
|||
|
||||
@Column() created_at: string;
|
||||
|
||||
@ManyToOne(type => Case, theCase => theCase.notes)
|
||||
@ManyToOne(
|
||||
type => Case,
|
||||
theCase => theCase.notes,
|
||||
)
|
||||
@JoinColumn({ name: "case_id" })
|
||||
case: Case;
|
||||
}
|
||||
|
|
|
@ -19,4 +19,6 @@ export class ReactionRole {
|
|||
emoji: string;
|
||||
|
||||
@Column() role_id: string;
|
||||
|
||||
@Column() is_exclusive: boolean;
|
||||
}
|
||||
|
|
|
@ -15,4 +15,6 @@ export class Reminder {
|
|||
@Column() remind_at: string;
|
||||
|
||||
@Column() body: string;
|
||||
|
||||
@Column() created_at: string;
|
||||
}
|
||||
|
|
|
@ -22,5 +22,14 @@ export class ScheduledPost {
|
|||
|
||||
@Column() post_at: string;
|
||||
|
||||
/**
|
||||
* How often to post the message, in milliseconds
|
||||
*/
|
||||
@Column() repeat_interval: number;
|
||||
|
||||
@Column() repeat_until: string;
|
||||
|
||||
@Column() repeat_times: number;
|
||||
|
||||
@Column() enable_mentions: boolean;
|
||||
}
|
||||
|
|
|
@ -1,23 +0,0 @@
|
|||
import { Entity, Column, PrimaryColumn, OneToMany } from "typeorm";
|
||||
import { CaseNote } from "./CaseNote";
|
||||
import { StarboardMessage } from "./StarboardMessage";
|
||||
|
||||
@Entity("starboards")
|
||||
export class Starboard {
|
||||
@Column()
|
||||
@PrimaryColumn()
|
||||
id: number;
|
||||
|
||||
@Column() guild_id: string;
|
||||
|
||||
@Column() channel_id: string;
|
||||
|
||||
@Column() channel_whitelist: string;
|
||||
|
||||
@Column() emoji: string;
|
||||
|
||||
@Column() reactions_required: number;
|
||||
|
||||
@OneToMany(type => StarboardMessage, msg => msg.starboard)
|
||||
starboardMessages: StarboardMessage[];
|
||||
}
|
|
@ -1,23 +1,20 @@
|
|||
import { Entity, Column, PrimaryColumn, OneToMany, ManyToOne, JoinColumn, OneToOne } from "typeorm";
|
||||
import { Starboard } from "./Starboard";
|
||||
import { Case } from "./Case";
|
||||
import { SavedMessage } from "./SavedMessage";
|
||||
|
||||
@Entity("starboard_messages")
|
||||
export class StarboardMessage {
|
||||
@Column()
|
||||
@PrimaryColumn()
|
||||
starboard_id: number;
|
||||
message_id: string;
|
||||
|
||||
@Column()
|
||||
@PrimaryColumn()
|
||||
message_id: string;
|
||||
starboard_message_id: string;
|
||||
|
||||
@Column() starboard_message_id: string;
|
||||
@Column()
|
||||
starboard_channel_id: string;
|
||||
|
||||
@ManyToOne(type => Starboard, sb => sb.starboardMessages)
|
||||
@JoinColumn({ name: "starboard_id" })
|
||||
starboard: Starboard;
|
||||
@Column()
|
||||
guild_id: string;
|
||||
|
||||
@OneToOne(type => SavedMessage)
|
||||
@JoinColumn({ name: "message_id" })
|
||||
|
|
22
backend/src/data/entities/StarboardReaction.ts
Normal file
22
backend/src/data/entities/StarboardReaction.ts
Normal file
|
@ -0,0 +1,22 @@
|
|||
import { Entity, Column, PrimaryColumn, JoinColumn, OneToOne } from "typeorm";
|
||||
import { SavedMessage } from "./SavedMessage";
|
||||
|
||||
@Entity("starboard_reactions")
|
||||
export class StarboardReaction {
|
||||
@Column()
|
||||
@PrimaryColumn()
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
guild_id: string;
|
||||
|
||||
@Column()
|
||||
message_id: string;
|
||||
|
||||
@Column()
|
||||
reactor_id: string;
|
||||
|
||||
@OneToOne(type => SavedMessage)
|
||||
@JoinColumn({ name: "message_id" })
|
||||
message: SavedMessage;
|
||||
}
|
20
backend/src/data/entities/StatValue.ts
Normal file
20
backend/src/data/entities/StatValue.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
import { Entity, Column, PrimaryColumn, CreateDateColumn } from "typeorm";
|
||||
|
||||
@Entity("stats")
|
||||
export class StatValue {
|
||||
@Column()
|
||||
@PrimaryColumn()
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
guild_id: string;
|
||||
|
||||
@Column()
|
||||
source: string;
|
||||
|
||||
@Column() key: string;
|
||||
|
||||
@Column() value: number;
|
||||
|
||||
@Column() created_at: string;
|
||||
}
|
|
@ -24,33 +24,34 @@ const RECENT_DISCORD_ERROR_EXIT_THRESHOLD = 5;
|
|||
setInterval(() => (recentPluginErrors = Math.max(0, recentPluginErrors - 1)), 2500);
|
||||
setInterval(() => (recentDiscordErrors = Math.max(0, recentDiscordErrors - 1)), 2500);
|
||||
|
||||
function errorHandler(err) {
|
||||
// tslint:disable:no-console
|
||||
console.error(err);
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
const errorHandler = err => {
|
||||
// tslint:disable:no-console
|
||||
console.error(err);
|
||||
|
||||
if (err instanceof PluginError) {
|
||||
// Tolerate a few recent plugin errors before crashing
|
||||
if (++recentPluginErrors >= RECENT_PLUGIN_ERROR_EXIT_THRESHOLD) {
|
||||
console.error(`Exiting after ${RECENT_PLUGIN_ERROR_EXIT_THRESHOLD} plugin errors`);
|
||||
if (err instanceof PluginError) {
|
||||
// Tolerate a few recent plugin errors before crashing
|
||||
if (++recentPluginErrors >= RECENT_PLUGIN_ERROR_EXIT_THRESHOLD) {
|
||||
console.error(`Exiting after ${RECENT_PLUGIN_ERROR_EXIT_THRESHOLD} plugin errors`);
|
||||
process.exit(1);
|
||||
}
|
||||
} else if (err instanceof DiscordRESTError || err instanceof DiscordHTTPError) {
|
||||
// Discord API errors, usually safe to just log instead of crash
|
||||
// We still bail if we get a ton of them in a short amount of time
|
||||
if (++recentDiscordErrors >= RECENT_DISCORD_ERROR_EXIT_THRESHOLD) {
|
||||
console.error(`Exiting after ${RECENT_DISCORD_ERROR_EXIT_THRESHOLD} API errors`);
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
// On other errors, crash immediately
|
||||
process.exit(1);
|
||||
}
|
||||
} else if (err instanceof DiscordRESTError || err instanceof DiscordHTTPError) {
|
||||
// Discord API errors, usually safe to just log instead of crash
|
||||
// We still bail if we get a ton of them in a short amount of time
|
||||
if (++recentDiscordErrors >= RECENT_DISCORD_ERROR_EXIT_THRESHOLD) {
|
||||
console.error(`Exiting after ${RECENT_DISCORD_ERROR_EXIT_THRESHOLD} API errors`);
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
// On other errors, crash immediately
|
||||
process.exit(1);
|
||||
}
|
||||
// tslint:enable:no-console
|
||||
// tslint:enable:no-console
|
||||
};
|
||||
|
||||
process.on("uncaughtException", errorHandler);
|
||||
}
|
||||
|
||||
process.on("unhandledRejection", errorHandler);
|
||||
process.on("uncaughtException", errorHandler);
|
||||
|
||||
// Verify required Node.js version
|
||||
const REQUIRED_NODE_VERSION = "10.14.2";
|
||||
const requiredParts = REQUIRED_NODE_VERSION.split(".").map(v => parseInt(v, 10));
|
||||
|
|
|
@ -2,8 +2,14 @@ import { MigrationInterface, QueryRunner, TableColumn, TableIndex } from "typeor
|
|||
|
||||
export class AddTypeAndPermissionsToApiPermissions1573158035867 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<any> {
|
||||
await queryRunner.dropPrimaryKey("api_permissions");
|
||||
await queryRunner.dropIndex("api_permissions", "IDX_5e371749d4cb4a5191f35e26f6");
|
||||
try {
|
||||
await queryRunner.dropPrimaryKey("api_permissions");
|
||||
} catch (e) {} // tslint:disable-line
|
||||
|
||||
const table = await queryRunner.getTable("api_permissions");
|
||||
if (table.indices.length) {
|
||||
await queryRunner.dropIndex("api_permissions", table.indices[0]);
|
||||
}
|
||||
|
||||
await queryRunner.addColumn(
|
||||
"api_permissions",
|
||||
|
|
103
backend/src/migrations/1573248462469-MoveStarboardsToConfig.ts
Normal file
103
backend/src/migrations/1573248462469-MoveStarboardsToConfig.ts
Normal file
|
@ -0,0 +1,103 @@
|
|||
import { MigrationInterface, QueryRunner, Table, TableColumn, createQueryBuilder } from "typeorm";
|
||||
|
||||
export class MoveStarboardsToConfig1573248462469 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<any> {
|
||||
// Create the new column for the channels id
|
||||
const chanid_column = new TableColumn({
|
||||
name: "starboard_channel_id",
|
||||
type: "bigint",
|
||||
unsigned: true,
|
||||
});
|
||||
await queryRunner.addColumn("starboard_messages", chanid_column);
|
||||
|
||||
// Since we are removing the guild_id with the starboards table, we might want it here
|
||||
const guid_column = new TableColumn({
|
||||
name: "guild_id",
|
||||
type: "bigint",
|
||||
unsigned: true,
|
||||
});
|
||||
await queryRunner.addColumn("starboard_messages", guid_column);
|
||||
|
||||
// Migrate the old starboard_id to the new starboard_channel_id
|
||||
await queryRunner.query(`
|
||||
UPDATE starboard_messages AS sm
|
||||
JOIN starboards AS sb
|
||||
ON sm.starboard_id = sb.id
|
||||
SET sm.starboard_channel_id = sb.channel_id, sm.guild_id = sb.guild_id;
|
||||
`);
|
||||
|
||||
// Drop the starboard_id column as it is now obsolete
|
||||
await queryRunner.dropColumn("starboard_messages", "starboard_id");
|
||||
// Set new Primary Key
|
||||
await queryRunner.dropPrimaryKey("starboard_messages");
|
||||
await queryRunner.createPrimaryKey("starboard_messages", ["starboard_message_id"]);
|
||||
// Finally, drop the starboards channel as it is now obsolete
|
||||
await queryRunner.dropTable("starboards", true);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<any> {
|
||||
await queryRunner.dropColumn("starboard_messages", "starboard_channel_id");
|
||||
await queryRunner.dropColumn("starboard_messages", "guild_id");
|
||||
|
||||
const sbId = new TableColumn({
|
||||
name: "starboard_id",
|
||||
type: "int",
|
||||
unsigned: true,
|
||||
});
|
||||
await queryRunner.addColumn("starboard_messages", sbId);
|
||||
|
||||
await queryRunner.dropPrimaryKey("starboard_messages");
|
||||
await queryRunner.createPrimaryKey("starboard_messages", ["starboard_id", "message_id"]);
|
||||
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
name: "starboards",
|
||||
columns: [
|
||||
{
|
||||
name: "id",
|
||||
type: "int",
|
||||
unsigned: true,
|
||||
isGenerated: true,
|
||||
generationStrategy: "increment",
|
||||
isPrimary: true,
|
||||
},
|
||||
{
|
||||
name: "guild_id",
|
||||
type: "bigint",
|
||||
unsigned: true,
|
||||
},
|
||||
{
|
||||
name: "channel_id",
|
||||
type: "bigint",
|
||||
unsigned: true,
|
||||
},
|
||||
{
|
||||
name: "channel_whitelist",
|
||||
type: "text",
|
||||
isNullable: true,
|
||||
default: null,
|
||||
},
|
||||
{
|
||||
name: "emoji",
|
||||
type: "varchar",
|
||||
length: "64",
|
||||
},
|
||||
{
|
||||
name: "reactions_required",
|
||||
type: "smallint",
|
||||
unsigned: true,
|
||||
},
|
||||
],
|
||||
indices: [
|
||||
{
|
||||
columnNames: ["guild_id", "emoji"],
|
||||
},
|
||||
{
|
||||
columnNames: ["guild_id", "channel_id"],
|
||||
isUnique: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
import { MigrationInterface, QueryRunner, Table } from "typeorm";
|
||||
|
||||
export class CreateStarboardReactionsTable1573248794313 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<any> {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
name: "starboard_reactions",
|
||||
columns: [
|
||||
{
|
||||
name: "id",
|
||||
type: "int",
|
||||
isGenerated: true,
|
||||
generationStrategy: "increment",
|
||||
isPrimary: true,
|
||||
},
|
||||
{
|
||||
name: "guild_id",
|
||||
type: "bigint",
|
||||
unsigned: true,
|
||||
},
|
||||
{
|
||||
name: "message_id",
|
||||
type: "bigint",
|
||||
unsigned: true,
|
||||
},
|
||||
{
|
||||
name: "reactor_id",
|
||||
type: "bigint",
|
||||
unsigned: true,
|
||||
},
|
||||
],
|
||||
indices: [
|
||||
{
|
||||
columnNames: ["reactor_id", "message_id"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<any> {
|
||||
await queryRunner.dropTable("starboard_reactions", true, false, true);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
import { MigrationInterface, QueryRunner, TableColumn } from "typeorm";
|
||||
|
||||
export class AddIsExclusiveToReactionRoles1575145703039 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<any> {
|
||||
await queryRunner.addColumn(
|
||||
"reaction_roles",
|
||||
new TableColumn({
|
||||
name: "is_exclusive",
|
||||
type: "tinyint",
|
||||
unsigned: true,
|
||||
default: 0,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<any> {
|
||||
await queryRunner.dropColumn("reaction_roles", "is_exclusive");
|
||||
}
|
||||
}
|
59
backend/src/migrations/1575199835233-CreateStatsTable.ts
Normal file
59
backend/src/migrations/1575199835233-CreateStatsTable.ts
Normal file
|
@ -0,0 +1,59 @@
|
|||
import { MigrationInterface, QueryRunner, Table } from "typeorm";
|
||||
|
||||
export class CreateStatsTable1575199835233 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<any> {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
name: "stats",
|
||||
columns: [
|
||||
{
|
||||
name: "id",
|
||||
type: "bigint",
|
||||
unsigned: true,
|
||||
isPrimary: true,
|
||||
generationStrategy: "increment",
|
||||
},
|
||||
{
|
||||
name: "guild_id",
|
||||
type: "bigint",
|
||||
unsigned: true,
|
||||
},
|
||||
{
|
||||
name: "source",
|
||||
type: "varchar",
|
||||
length: "64",
|
||||
collation: "ascii_bin",
|
||||
},
|
||||
{
|
||||
name: "key",
|
||||
type: "varchar",
|
||||
length: "64",
|
||||
collation: "ascii_bin",
|
||||
},
|
||||
{
|
||||
name: "value",
|
||||
type: "integer",
|
||||
unsigned: true,
|
||||
},
|
||||
{
|
||||
name: "created_at",
|
||||
type: "datetime",
|
||||
default: "NOW()",
|
||||
},
|
||||
],
|
||||
indices: [
|
||||
{
|
||||
columnNames: ["guild_id", "source", "key"],
|
||||
},
|
||||
{
|
||||
columnNames: ["created_at"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<any> {
|
||||
await queryRunner.dropTable("stats");
|
||||
}
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
import { MigrationInterface, QueryRunner, TableColumn } from "typeorm";
|
||||
|
||||
export class AddRepeatColumnsToScheduledPosts1575230079526 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<any> {
|
||||
await queryRunner.addColumns("scheduled_posts", [
|
||||
new TableColumn({
|
||||
name: "repeat_interval",
|
||||
type: "integer",
|
||||
unsigned: true,
|
||||
isNullable: true,
|
||||
}),
|
||||
new TableColumn({
|
||||
name: "repeat_until",
|
||||
type: "datetime",
|
||||
isNullable: true,
|
||||
}),
|
||||
new TableColumn({
|
||||
name: "repeat_times",
|
||||
type: "integer",
|
||||
unsigned: true,
|
||||
isNullable: true,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<any> {
|
||||
await queryRunner.dropColumn("scheduled_posts", "repeat_interval");
|
||||
await queryRunner.dropColumn("scheduled_posts", "repeat_until");
|
||||
await queryRunner.dropColumn("scheduled_posts", "repeat_times");
|
||||
}
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
import { MigrationInterface, QueryRunner, TableColumn } from "typeorm";
|
||||
|
||||
export class CreateReminderCreatedAtField1578445483917 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<any> {
|
||||
await queryRunner.addColumn(
|
||||
"reminders",
|
||||
new TableColumn({
|
||||
name: "created_at",
|
||||
type: "datetime",
|
||||
isNullable: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<any> {
|
||||
await queryRunner.dropColumn("reminders", "created_at");
|
||||
}
|
||||
}
|
|
@ -2,6 +2,8 @@ import { PluginInfo, trimPluginDescription, ZeppelinPlugin } from "./ZeppelinPlu
|
|||
import * as t from "io-ts";
|
||||
import {
|
||||
convertDelayStringToMS,
|
||||
disableInlineCode,
|
||||
disableLinkPreviews,
|
||||
getEmojiInString,
|
||||
getInviteCodesInString,
|
||||
getRoleMentions,
|
||||
|
@ -12,12 +14,13 @@ import {
|
|||
noop,
|
||||
SECONDS,
|
||||
stripObjectToScalars,
|
||||
tDeepPartial,
|
||||
tNullable,
|
||||
UnknownUser,
|
||||
verboseChannelMention,
|
||||
} from "../utils";
|
||||
import { configUtils, CooldownManager } from "knub";
|
||||
import { Invite, Member, TextChannel } from "eris";
|
||||
import { Member, TextChannel } from "eris";
|
||||
import escapeStringRegexp from "escape-string-regexp";
|
||||
import { SimpleCache } from "../SimpleCache";
|
||||
import { Queue } from "../Queue";
|
||||
|
@ -32,6 +35,7 @@ import { GuildLogs } from "../data/GuildLogs";
|
|||
import { SavedMessage } from "../data/entities/SavedMessage";
|
||||
import moment from "moment-timezone";
|
||||
import { renderTemplate } from "../templateFormatter";
|
||||
import { transliterate } from "transliteration";
|
||||
import Timeout = NodeJS.Timeout;
|
||||
|
||||
type MessageInfo = { channelId: string; messageId: string };
|
||||
|
@ -46,23 +50,26 @@ type TextTriggerWithMultipleMatchTypes = {
|
|||
};
|
||||
|
||||
interface TriggerMatchResult {
|
||||
trigger: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface MessageTextTriggerMatchResult extends TriggerMatchResult {
|
||||
interface MessageTextTriggerMatchResult<T = any> extends TriggerMatchResult {
|
||||
type: "message" | "embed";
|
||||
str: string;
|
||||
userId: string;
|
||||
messageInfo: MessageInfo;
|
||||
matchedValue: T;
|
||||
}
|
||||
|
||||
interface OtherTextTriggerMatchResult extends TriggerMatchResult {
|
||||
interface OtherTextTriggerMatchResult<T = any> extends TriggerMatchResult {
|
||||
type: "username" | "nickname" | "visiblename" | "customstatus";
|
||||
str: string;
|
||||
userId: string;
|
||||
matchedValue: T;
|
||||
}
|
||||
|
||||
type TextTriggerMatchResult = MessageTextTriggerMatchResult | OtherTextTriggerMatchResult;
|
||||
type TextTriggerMatchResult<T = any> = MessageTextTriggerMatchResult<T> | OtherTextTriggerMatchResult<T>;
|
||||
|
||||
interface TextSpamTriggerMatchResult extends TriggerMatchResult {
|
||||
type: "textspam";
|
||||
|
@ -93,13 +100,16 @@ type AnyTriggerMatchResult =
|
|||
| OtherSpamTriggerMatchResult;
|
||||
|
||||
/**
|
||||
* TRIGGERS
|
||||
* CONFIG SCHEMA FOR TRIGGERS
|
||||
*/
|
||||
|
||||
const MatchWordsTrigger = t.type({
|
||||
words: t.array(t.string),
|
||||
case_sensitive: t.boolean,
|
||||
only_full_words: t.boolean,
|
||||
normalize: t.boolean,
|
||||
loose_matching: t.boolean,
|
||||
loose_matching_threshold: t.number,
|
||||
match_messages: t.boolean,
|
||||
match_embeds: t.boolean,
|
||||
match_visible_names: t.boolean,
|
||||
|
@ -108,10 +118,12 @@ const MatchWordsTrigger = t.type({
|
|||
match_custom_status: t.boolean,
|
||||
});
|
||||
type TMatchWordsTrigger = t.TypeOf<typeof MatchWordsTrigger>;
|
||||
const defaultMatchWordsTrigger: TMatchWordsTrigger = {
|
||||
words: [],
|
||||
const defaultMatchWordsTrigger: Partial<TMatchWordsTrigger> = {
|
||||
case_sensitive: false,
|
||||
only_full_words: true,
|
||||
normalize: false,
|
||||
loose_matching: false,
|
||||
loose_matching_threshold: 4,
|
||||
match_messages: true,
|
||||
match_embeds: true,
|
||||
match_visible_names: false,
|
||||
|
@ -123,6 +135,7 @@ const defaultMatchWordsTrigger: TMatchWordsTrigger = {
|
|||
const MatchRegexTrigger = t.type({
|
||||
patterns: t.array(TSafeRegex),
|
||||
case_sensitive: t.boolean,
|
||||
normalize: t.boolean,
|
||||
match_messages: t.boolean,
|
||||
match_embeds: t.boolean,
|
||||
match_visible_names: t.boolean,
|
||||
|
@ -133,6 +146,7 @@ const MatchRegexTrigger = t.type({
|
|||
type TMatchRegexTrigger = t.TypeOf<typeof MatchRegexTrigger>;
|
||||
const defaultMatchRegexTrigger: Partial<TMatchRegexTrigger> = {
|
||||
case_sensitive: false,
|
||||
normalize: false,
|
||||
match_messages: true,
|
||||
match_embeds: true,
|
||||
match_visible_names: false,
|
||||
|
@ -220,7 +234,7 @@ const VoiceMoveSpamTrigger = BaseSpamTrigger;
|
|||
type TVoiceMoveSpamTrigger = t.TypeOf<typeof VoiceMoveSpamTrigger>;
|
||||
|
||||
/**
|
||||
* ACTIONS
|
||||
* CONFIG SCHEMA FOR ACTIONS
|
||||
*/
|
||||
|
||||
const CleanAction = t.boolean;
|
||||
|
@ -253,6 +267,9 @@ const ChangeNicknameAction = t.type({
|
|||
|
||||
const LogAction = t.boolean;
|
||||
|
||||
const AddRolesAction = t.array(t.string);
|
||||
const RemoveRolesAction = t.array(t.string);
|
||||
|
||||
/**
|
||||
* FULL CONFIG SCHEMA
|
||||
*/
|
||||
|
@ -287,6 +304,8 @@ const Rule = t.type({
|
|||
alert: tNullable(AlertAction),
|
||||
change_nickname: tNullable(ChangeNicknameAction),
|
||||
log: tNullable(LogAction),
|
||||
add_roles: tNullable(AddRolesAction),
|
||||
remove_roles: tNullable(RemoveRolesAction),
|
||||
}),
|
||||
cooldown: tNullable(t.string),
|
||||
});
|
||||
|
@ -297,6 +316,8 @@ const ConfigSchema = t.type({
|
|||
});
|
||||
type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
|
||||
|
||||
const PartialConfigSchema = tDeepPartial(ConfigSchema);
|
||||
|
||||
/**
|
||||
* DEFAULTS
|
||||
*/
|
||||
|
@ -361,6 +382,13 @@ const RECENT_NICKNAME_CHANGE_EXPIRY_TIME = 5 * MINUTES;
|
|||
|
||||
const inviteCache = new SimpleCache(10 * MINUTES);
|
||||
|
||||
/**
|
||||
* General plugin flow:
|
||||
* - When a message is posted:
|
||||
* 1. Run logRecentActionsForMessage() -- used for detecting spam
|
||||
* 2. Run matchRuleToMessage() for each automod rule. This checks if any triggers in the rule match the message.
|
||||
* 3. If a rule matched, run applyActionsOnMatch() for that rule/match
|
||||
*/
|
||||
export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||
public static pluginName = "automod";
|
||||
public static configSchema = ConfigSchema;
|
||||
|
@ -499,12 +527,10 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
protected archives: GuildArchives;
|
||||
protected guildLogs: GuildLogs;
|
||||
|
||||
protected static preprocessStaticConfig(config) {
|
||||
if (config.rules && typeof config.rules === "object") {
|
||||
protected static preprocessStaticConfig(config: t.TypeOf<typeof PartialConfigSchema>) {
|
||||
if (config.rules) {
|
||||
// Loop through each rule
|
||||
for (const [name, rule] of Object.entries(config.rules)) {
|
||||
if (rule == null || typeof rule !== "object") continue;
|
||||
|
||||
rule["name"] = name;
|
||||
|
||||
// If the rule doesn't have an explicitly set "enabled" property, set it to true
|
||||
|
@ -513,12 +539,11 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
}
|
||||
|
||||
// Loop through the rule's triggers
|
||||
if (rule["triggers"] != null && Array.isArray(rule["triggers"])) {
|
||||
if (rule["triggers"]) {
|
||||
for (const trigger of rule["triggers"]) {
|
||||
if (trigger == null || typeof trigger !== "object") continue;
|
||||
// Apply default config to the triggers used in this rule
|
||||
for (const [defaultTriggerName, defaultTrigger] of Object.entries(defaultTriggers)) {
|
||||
if (trigger[defaultTriggerName] && typeof trigger[defaultTriggerName] === "object") {
|
||||
if (trigger[defaultTriggerName]) {
|
||||
trigger[defaultTriggerName] = configUtils.mergeConfig({}, defaultTrigger, trigger[defaultTriggerName]);
|
||||
}
|
||||
}
|
||||
|
@ -526,7 +551,7 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
}
|
||||
|
||||
// Enable logging of automod actions by default
|
||||
if (rule["actions"] && typeof rule["actions"] === "object") {
|
||||
if (rule["actions"]) {
|
||||
if (rule["actions"]["log"] == null) {
|
||||
rule["actions"]["log"] = true;
|
||||
}
|
||||
|
@ -583,63 +608,92 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
clearInterval(this.recentNicknameChangesClearInterval);
|
||||
}
|
||||
|
||||
protected evaluateMatchWordsTrigger(trigger: TMatchWordsTrigger, str: string): boolean {
|
||||
/**
|
||||
* @return Matched word
|
||||
*/
|
||||
protected evaluateMatchWordsTrigger(trigger: TMatchWordsTrigger, str: string): null | string {
|
||||
if (trigger.normalize) {
|
||||
str = transliterate(str);
|
||||
}
|
||||
|
||||
const looseMatchingThreshold = Math.min(Math.max(trigger.loose_matching_threshold, 1), 64);
|
||||
|
||||
for (const word of trigger.words) {
|
||||
const pattern = trigger.only_full_words ? `\\b${escapeStringRegexp(word)}\\b` : escapeStringRegexp(word);
|
||||
// When performing loose matching, allow any amount of whitespace or up to looseMatchingThreshold number of other
|
||||
// characters between the matched characters. E.g. if we're matching banana, a loose match could also match b a n a n a
|
||||
let pattern = trigger.loose_matching
|
||||
? [...word].map(c => escapeStringRegexp(c)).join(`(?:\\s*|.{0,${looseMatchingThreshold})`)
|
||||
: escapeStringRegexp(word);
|
||||
|
||||
if (trigger.only_full_words) {
|
||||
pattern = `\\b${pattern}\\b`;
|
||||
}
|
||||
|
||||
const regex = new RegExp(pattern, trigger.case_sensitive ? "" : "i");
|
||||
const test = regex.test(str);
|
||||
if (test) return true;
|
||||
if (test) return word;
|
||||
}
|
||||
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
protected evaluateMatchRegexTrigger(trigger: TMatchRegexTrigger, str: string): boolean {
|
||||
/**
|
||||
* @return Matched regex pattern
|
||||
*/
|
||||
protected evaluateMatchRegexTrigger(trigger: TMatchRegexTrigger, str: string): null | string {
|
||||
if (trigger.normalize) {
|
||||
str = transliterate(str);
|
||||
}
|
||||
|
||||
// TODO: Time limit regexes
|
||||
for (const pattern of trigger.patterns) {
|
||||
const regex = new RegExp(pattern, trigger.case_sensitive ? "" : "i");
|
||||
const test = regex.test(str);
|
||||
if (test) return true;
|
||||
if (test) return regex.source;
|
||||
}
|
||||
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
protected async evaluateMatchInvitesTrigger(trigger: TMatchInvitesTrigger, str: string): Promise<boolean> {
|
||||
/**
|
||||
* @return Matched invite code
|
||||
*/
|
||||
protected async evaluateMatchInvitesTrigger(trigger: TMatchInvitesTrigger, str: string): Promise<null | string> {
|
||||
const inviteCodes = getInviteCodesInString(str);
|
||||
if (inviteCodes.length === 0) return false;
|
||||
if (inviteCodes.length === 0) return null;
|
||||
|
||||
const uniqueInviteCodes = Array.from(new Set(inviteCodes));
|
||||
|
||||
for (const code of uniqueInviteCodes) {
|
||||
if (trigger.include_invite_codes && trigger.include_invite_codes.includes(code)) {
|
||||
return true;
|
||||
return code;
|
||||
}
|
||||
if (trigger.exclude_invite_codes && !trigger.exclude_invite_codes.includes(code)) {
|
||||
return true;
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
const invites: Array<Invite | null> = await Promise.all(uniqueInviteCodes.map(code => this.resolveInvite(code)));
|
||||
|
||||
for (const invite of invites) {
|
||||
// Always match on unknown invites
|
||||
if (!invite) return true;
|
||||
for (const inviteCode of uniqueInviteCodes) {
|
||||
const invite = await this.resolveInvite(inviteCode);
|
||||
if (!invite) return inviteCode;
|
||||
|
||||
if (trigger.include_guilds && trigger.include_guilds.includes(invite.guild.id)) {
|
||||
return true;
|
||||
return inviteCode;
|
||||
}
|
||||
if (trigger.exclude_guilds && !trigger.exclude_guilds.includes(invite.guild.id)) {
|
||||
return true;
|
||||
return inviteCode;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
protected evaluateMatchLinksTrigger(trigger: TMatchLinksTrigger, str: string): boolean {
|
||||
/**
|
||||
* @return Matched link
|
||||
*/
|
||||
protected evaluateMatchLinksTrigger(trigger: TMatchLinksTrigger, str: string): null | string {
|
||||
const links = getUrlsInString(str, true);
|
||||
|
||||
for (const link of links) {
|
||||
const normalizedHostname = link.hostname.toLowerCase();
|
||||
|
||||
|
@ -647,10 +701,10 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
for (const domain of trigger.include_domains) {
|
||||
const normalizedDomain = domain.toLowerCase();
|
||||
if (normalizedDomain === normalizedHostname) {
|
||||
return true;
|
||||
return domain;
|
||||
}
|
||||
if (trigger.include_subdomains && normalizedHostname.endsWith(`.${domain}`)) {
|
||||
return true;
|
||||
return domain;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -659,25 +713,25 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
for (const domain of trigger.exclude_domains) {
|
||||
const normalizedDomain = domain.toLowerCase();
|
||||
if (normalizedDomain === normalizedHostname) {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
if (trigger.include_subdomains && normalizedHostname.endsWith(`.${domain}`)) {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
return link.toString();
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
protected matchTextSpamTrigger(
|
||||
recentActionType: RecentActionType,
|
||||
trigger: TBaseTextSpamTrigger,
|
||||
msg: SavedMessage,
|
||||
): TextSpamTriggerMatchResult {
|
||||
): Partial<TextSpamTriggerMatchResult> {
|
||||
const since = moment.utc(msg.posted_at).valueOf() - convertDelayStringToMS(trigger.within);
|
||||
const recentActions = trigger.per_channel
|
||||
? this.getMatchingRecentActions(recentActionType, `${msg.channel_id}-${msg.user_id}`, since)
|
||||
|
@ -699,69 +753,85 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
return null;
|
||||
}
|
||||
|
||||
protected async matchMultipleTextTypesOnMessage(
|
||||
protected async matchMultipleTextTypesOnMessage<T>(
|
||||
trigger: TextTriggerWithMultipleMatchTypes,
|
||||
msg: SavedMessage,
|
||||
cb,
|
||||
): Promise<TextTriggerMatchResult> {
|
||||
matchFn: (str: string) => T | Promise<T> | null,
|
||||
): Promise<Partial<TextTriggerMatchResult<T>>> {
|
||||
const messageInfo: MessageInfo = { channelId: msg.channel_id, messageId: msg.id };
|
||||
const member = this.guild.members.get(msg.user_id);
|
||||
|
||||
if (trigger.match_messages) {
|
||||
const str = msg.data.content;
|
||||
const match = await cb(str);
|
||||
if (match) return { type: "message", str, userId: msg.user_id, messageInfo };
|
||||
const matchResult = await matchFn(str);
|
||||
if (matchResult) {
|
||||
return { type: "message", str, userId: msg.user_id, messageInfo, matchedValue: matchResult };
|
||||
}
|
||||
}
|
||||
|
||||
if (trigger.match_embeds && msg.data.embeds && msg.data.embeds.length) {
|
||||
const str = JSON.stringify(msg.data.embeds[0]);
|
||||
const match = await cb(str);
|
||||
if (match) return { type: "embed", str, userId: msg.user_id, messageInfo };
|
||||
const matchResult = await matchFn(str);
|
||||
if (matchResult) {
|
||||
return { type: "embed", str, userId: msg.user_id, messageInfo, matchedValue: matchResult };
|
||||
}
|
||||
}
|
||||
|
||||
if (trigger.match_visible_names) {
|
||||
const str = member.nick || msg.data.author.username;
|
||||
const match = await cb(str);
|
||||
if (match) return { type: "visiblename", str, userId: msg.user_id };
|
||||
const matchResult = await matchFn(str);
|
||||
if (matchResult) {
|
||||
return { type: "visiblename", str, userId: msg.user_id, matchedValue: matchResult };
|
||||
}
|
||||
}
|
||||
|
||||
if (trigger.match_usernames) {
|
||||
const str = `${msg.data.author.username}#${msg.data.author.discriminator}`;
|
||||
const match = await cb(str);
|
||||
if (match) return { type: "username", str, userId: msg.user_id };
|
||||
const matchResult = await matchFn(str);
|
||||
if (matchResult) {
|
||||
return { type: "username", str, userId: msg.user_id, matchedValue: matchResult };
|
||||
}
|
||||
}
|
||||
|
||||
if (trigger.match_nicknames && member.nick) {
|
||||
const str = member.nick;
|
||||
const match = await cb(str);
|
||||
if (match) return { type: "nickname", str, userId: msg.user_id };
|
||||
const matchResult = await matchFn(str);
|
||||
if (matchResult) {
|
||||
return { type: "nickname", str, userId: msg.user_id, matchedValue: matchResult };
|
||||
}
|
||||
}
|
||||
|
||||
// type 4 = custom status
|
||||
if (trigger.match_custom_status && member.game && member.game.type === 4) {
|
||||
const str = member.game.state;
|
||||
const match = await cb(str);
|
||||
if (match) return { type: "customstatus", str, userId: msg.user_id };
|
||||
const matchResult = await matchFn(str);
|
||||
if (matchResult) {
|
||||
return { type: "customstatus", str, userId: msg.user_id, matchedValue: matchResult };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected async matchMultipleTextTypesOnMember(
|
||||
protected async matchMultipleTextTypesOnMember<T>(
|
||||
trigger: TextTriggerWithMultipleMatchTypes,
|
||||
member: Member,
|
||||
cb,
|
||||
): Promise<TextTriggerMatchResult> {
|
||||
matchFn: (str: string) => T | Promise<T> | null,
|
||||
): Promise<Partial<TextTriggerMatchResult<T>>> {
|
||||
if (trigger.match_usernames) {
|
||||
const str = `${member.user.username}#${member.user.discriminator}`;
|
||||
const match = await cb(str);
|
||||
if (match) return { type: "username", str, userId: member.id };
|
||||
const matchResult = await matchFn(str);
|
||||
if (matchResult) {
|
||||
return { type: "username", str, userId: member.id, matchedValue: matchResult };
|
||||
}
|
||||
}
|
||||
|
||||
if (trigger.match_nicknames && member.nick) {
|
||||
const str = member.nick;
|
||||
const match = await cb(str);
|
||||
if (match) return { type: "nickname", str, userId: member.id };
|
||||
const matchResult = await matchFn(str);
|
||||
if (matchResult) {
|
||||
return { type: "nickname", str, userId: member.id, matchedValue: matchResult };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
|
@ -781,63 +851,63 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
const match = await this.matchMultipleTextTypesOnMessage(trigger.match_words, msg, str => {
|
||||
return this.evaluateMatchWordsTrigger(trigger.match_words, str);
|
||||
});
|
||||
if (match) return match;
|
||||
if (match) return { ...match, trigger: "match_words" } as TextTriggerMatchResult;
|
||||
}
|
||||
|
||||
if (trigger.match_regex) {
|
||||
const match = await this.matchMultipleTextTypesOnMessage(trigger.match_regex, msg, str => {
|
||||
return this.evaluateMatchRegexTrigger(trigger.match_regex, str);
|
||||
});
|
||||
if (match) return match;
|
||||
if (match) return { ...match, trigger: "match_regex" } as TextTriggerMatchResult;
|
||||
}
|
||||
|
||||
if (trigger.match_invites) {
|
||||
const match = await this.matchMultipleTextTypesOnMessage(trigger.match_invites, msg, str => {
|
||||
return this.evaluateMatchInvitesTrigger(trigger.match_invites, str);
|
||||
});
|
||||
if (match) return match;
|
||||
if (match) return { ...match, trigger: "match_invites" } as TextTriggerMatchResult;
|
||||
}
|
||||
|
||||
if (trigger.match_links) {
|
||||
const match = await this.matchMultipleTextTypesOnMessage(trigger.match_links, msg, str => {
|
||||
return this.evaluateMatchLinksTrigger(trigger.match_links, str);
|
||||
});
|
||||
if (match) return match;
|
||||
if (match) return { ...match, trigger: "match_links" } as TextTriggerMatchResult;
|
||||
}
|
||||
|
||||
if (trigger.message_spam) {
|
||||
const match = this.matchTextSpamTrigger(RecentActionType.Message, trigger.message_spam, msg);
|
||||
if (match) return match;
|
||||
if (match) return { ...match, trigger: "message_spam" } as TextSpamTriggerMatchResult;
|
||||
}
|
||||
|
||||
if (trigger.mention_spam) {
|
||||
const match = this.matchTextSpamTrigger(RecentActionType.Mention, trigger.mention_spam, msg);
|
||||
if (match) return match;
|
||||
if (match) return { ...match, trigger: "mention_spam" } as TextSpamTriggerMatchResult;
|
||||
}
|
||||
|
||||
if (trigger.link_spam) {
|
||||
const match = this.matchTextSpamTrigger(RecentActionType.Link, trigger.link_spam, msg);
|
||||
if (match) return match;
|
||||
if (match) return { ...match, trigger: "link_spam" } as TextSpamTriggerMatchResult;
|
||||
}
|
||||
|
||||
if (trigger.attachment_spam) {
|
||||
const match = this.matchTextSpamTrigger(RecentActionType.Attachment, trigger.attachment_spam, msg);
|
||||
if (match) return match;
|
||||
if (match) return { ...match, trigger: "attachment_spam" } as TextSpamTriggerMatchResult;
|
||||
}
|
||||
|
||||
if (trigger.emoji_spam) {
|
||||
const match = this.matchTextSpamTrigger(RecentActionType.Emoji, trigger.emoji_spam, msg);
|
||||
if (match) return match;
|
||||
if (match) return { ...match, trigger: "emoji_spam" } as TextSpamTriggerMatchResult;
|
||||
}
|
||||
|
||||
if (trigger.line_spam) {
|
||||
const match = this.matchTextSpamTrigger(RecentActionType.Line, trigger.line_spam, msg);
|
||||
if (match) return match;
|
||||
if (match) return { ...match, trigger: "line_spam" } as TextSpamTriggerMatchResult;
|
||||
}
|
||||
|
||||
if (trigger.character_spam) {
|
||||
const match = this.matchTextSpamTrigger(RecentActionType.Character, trigger.character_spam, msg);
|
||||
if (match) return match;
|
||||
if (match) return { ...match, trigger: "character_spam" } as TextSpamTriggerMatchResult;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1047,90 +1117,23 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the actions of the specified rule on the matched message/member
|
||||
*/
|
||||
protected async applyActionsOnMatch(rule: TRule, matchResult: AnyTriggerMatchResult) {
|
||||
const actionsTaken = [];
|
||||
|
||||
let matchSummary = null;
|
||||
let caseExtraNote = null;
|
||||
|
||||
if (rule.cooldown) {
|
||||
let cooldownKey = rule.name + "-";
|
||||
|
||||
if (matchResult.type === "textspam") {
|
||||
cooldownKey += matchResult.channelId ? `${matchResult.channelId}-${matchResult.userId}` : matchResult.userId;
|
||||
} else if (matchResult.type === "message" || matchResult.type === "embed") {
|
||||
cooldownKey += matchResult.userId;
|
||||
} else if (
|
||||
matchResult.type === "username" ||
|
||||
matchResult.type === "nickname" ||
|
||||
matchResult.type === "visiblename" ||
|
||||
matchResult.type === "customstatus"
|
||||
) {
|
||||
cooldownKey += matchResult.userId;
|
||||
} else if (matchResult.type === "otherspam") {
|
||||
cooldownKey += matchResult.userId;
|
||||
} else {
|
||||
cooldownKey = null;
|
||||
}
|
||||
|
||||
if (cooldownKey) {
|
||||
if (this.cooldownManager.isOnCooldown(cooldownKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cooldownTime = convertDelayStringToMS(rule.cooldown, "s");
|
||||
if (cooldownTime) {
|
||||
this.cooldownManager.setCooldown(cooldownKey, cooldownTime);
|
||||
}
|
||||
}
|
||||
if (rule.cooldown && this.checkAndUpdateCooldown(rule, matchResult)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchResult.type === "textspam") {
|
||||
this.activateGracePeriod(matchResult);
|
||||
this.clearSpecificRecentActions(
|
||||
matchResult.actionType,
|
||||
matchResult.channelId ? `${matchResult.channelId}-${matchResult.userId}` : matchResult.userId,
|
||||
);
|
||||
}
|
||||
const matchSummary = this.getMatchSummary(matchResult);
|
||||
|
||||
// Match summary
|
||||
let matchedMessageIds = [];
|
||||
if (matchResult.type === "message" || matchResult.type === "embed") {
|
||||
matchedMessageIds = [matchResult.messageInfo.messageId];
|
||||
} else if (matchResult.type === "textspam" || matchResult.type === "raidspam") {
|
||||
matchedMessageIds = matchResult.messageInfos.map(m => m.messageId);
|
||||
}
|
||||
|
||||
if (matchedMessageIds.length > 1) {
|
||||
const savedMessages = await this.savedMessages.getMultiple(matchedMessageIds);
|
||||
const archiveId = await this.archives.createFromSavedMessages(savedMessages, this.guild);
|
||||
const baseUrl = this.knub.getGlobalConfig().url;
|
||||
const archiveUrl = this.archives.getUrl(baseUrl, archiveId);
|
||||
matchSummary = `Matched messages: <${archiveUrl}>`;
|
||||
} else if (matchedMessageIds.length === 1) {
|
||||
const message = await this.savedMessages.find(matchedMessageIds[0]);
|
||||
const channel = this.guild.channels.get(message.channel_id);
|
||||
const channelMention = channel ? verboseChannelMention(channel) : `\`#${message.channel_id}\``;
|
||||
matchSummary = `Matched message in ${channelMention} (originally posted at **${
|
||||
message.posted_at
|
||||
}**):\n${messageSummary(message)}`;
|
||||
}
|
||||
|
||||
if (matchResult.type === "username") {
|
||||
matchSummary = `Matched username: ${matchResult.str}`;
|
||||
} else if (matchResult.type === "nickname") {
|
||||
matchSummary = `Matched nickname: ${matchResult.str}`;
|
||||
} else if (matchResult.type === "visiblename") {
|
||||
matchSummary = `Matched visible name: ${matchResult.str}`;
|
||||
} else if (matchResult.type === "customstatus") {
|
||||
matchSummary = `Matched custom status: ${matchResult.str}`;
|
||||
}
|
||||
|
||||
caseExtraNote = `Matched automod rule "${rule.name}"`;
|
||||
let caseExtraNote = `Matched automod rule "${rule.name}"`;
|
||||
if (matchSummary) {
|
||||
caseExtraNote += `\n${matchSummary}`;
|
||||
}
|
||||
|
||||
const actionsTaken = [];
|
||||
|
||||
// Actions
|
||||
if (rule.actions.clean) {
|
||||
const messagesToDelete: Array<{ channelId: string; messageId: string }> = [];
|
||||
|
@ -1254,6 +1257,58 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
actionsTaken.push("nickname");
|
||||
}
|
||||
|
||||
if (rule.actions.add_roles) {
|
||||
const userIdsToChange = matchResult.type === "raidspam" ? matchResult.userIds : [matchResult.userId];
|
||||
for (const userId of userIdsToChange) {
|
||||
const member = await this.getMember(userId);
|
||||
if (!member) continue;
|
||||
|
||||
const memberRoles = new Set(member.roles);
|
||||
for (const roleId of rule.actions.add_roles) {
|
||||
memberRoles.add(roleId);
|
||||
}
|
||||
|
||||
if (memberRoles.size === member.roles.length) {
|
||||
// No role changes
|
||||
continue;
|
||||
}
|
||||
|
||||
const rolesArr = Array.from(memberRoles.values());
|
||||
await member.edit({
|
||||
roles: rolesArr,
|
||||
});
|
||||
member.roles = rolesArr; // Make sure we know of the new roles internally as well
|
||||
}
|
||||
|
||||
actionsTaken.push("add roles");
|
||||
}
|
||||
|
||||
if (rule.actions.remove_roles) {
|
||||
const userIdsToChange = matchResult.type === "raidspam" ? matchResult.userIds : [matchResult.userId];
|
||||
for (const userId of userIdsToChange) {
|
||||
const member = await this.getMember(userId);
|
||||
if (!member) continue;
|
||||
|
||||
const memberRoles = new Set(member.roles);
|
||||
for (const roleId of rule.actions.remove_roles) {
|
||||
memberRoles.delete(roleId);
|
||||
}
|
||||
|
||||
if (memberRoles.size === member.roles.length) {
|
||||
// No role changes
|
||||
continue;
|
||||
}
|
||||
|
||||
const rolesArr = Array.from(memberRoles.values());
|
||||
await member.edit({
|
||||
roles: rolesArr,
|
||||
});
|
||||
member.roles = rolesArr; // Make sure we know of the new roles internally as well
|
||||
}
|
||||
|
||||
actionsTaken.push("remove roles");
|
||||
}
|
||||
|
||||
// Don't wait for the rest before continuing to other automod items in the queue
|
||||
(async () => {
|
||||
const user = matchResult.type !== "raidspam" ? this.getUser(matchResult.userId) : new UnknownUser();
|
||||
|
@ -1261,6 +1316,15 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
const safeUser = stripObjectToScalars(user);
|
||||
const safeUsers = users.map(u => stripObjectToScalars(u));
|
||||
|
||||
const logData = {
|
||||
rule: rule.name,
|
||||
user: safeUser,
|
||||
users: safeUsers,
|
||||
actionsTaken: actionsTaken.length ? actionsTaken.join(", ") : "<none>",
|
||||
matchSummary,
|
||||
};
|
||||
const logMessage = this.getLogs().getLogMessage(LogType.AUTOMOD_ACTION, logData);
|
||||
|
||||
if (rule.actions.alert) {
|
||||
const channel = this.guild.channels.get(rule.actions.alert.channel);
|
||||
if (channel && channel instanceof TextChannel) {
|
||||
|
@ -1271,6 +1335,7 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
users: safeUsers,
|
||||
text,
|
||||
matchSummary,
|
||||
logMessage,
|
||||
});
|
||||
channel.createMessage(rendered);
|
||||
actionsTaken.push("alert");
|
||||
|
@ -1282,17 +1347,102 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
}
|
||||
|
||||
if (rule.actions.log) {
|
||||
this.getLogs().log(LogType.AUTOMOD_ACTION, {
|
||||
rule: rule.name,
|
||||
user: safeUser,
|
||||
users: safeUsers,
|
||||
actionsTaken: actionsTaken.length ? actionsTaken.join(", ") : "<none>",
|
||||
matchSummary,
|
||||
});
|
||||
this.getLogs().log(LogType.AUTOMOD_ACTION, logData);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the rule's on cooldown and bump its usage count towards the cooldown up
|
||||
* @return Whether the rule's on cooldown
|
||||
*/
|
||||
protected checkAndUpdateCooldown(rule: TRule, matchResult: AnyTriggerMatchResult): boolean {
|
||||
let cooldownKey = rule.name + "-";
|
||||
|
||||
if (matchResult.type === "textspam") {
|
||||
cooldownKey += matchResult.channelId ? `${matchResult.channelId}-${matchResult.userId}` : matchResult.userId;
|
||||
} else if (matchResult.type === "message" || matchResult.type === "embed") {
|
||||
cooldownKey += matchResult.userId;
|
||||
} else if (
|
||||
matchResult.type === "username" ||
|
||||
matchResult.type === "nickname" ||
|
||||
matchResult.type === "visiblename" ||
|
||||
matchResult.type === "customstatus"
|
||||
) {
|
||||
cooldownKey += matchResult.userId;
|
||||
} else if (matchResult.type === "otherspam") {
|
||||
cooldownKey += matchResult.userId;
|
||||
} else {
|
||||
cooldownKey = null;
|
||||
}
|
||||
|
||||
if (cooldownKey) {
|
||||
if (this.cooldownManager.isOnCooldown(cooldownKey)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const cooldownTime = convertDelayStringToMS(rule.cooldown, "s");
|
||||
if (cooldownTime) {
|
||||
this.cooldownManager.setCooldown(cooldownKey, cooldownTime);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a text summary for the match result for use in logs/alerts
|
||||
*/
|
||||
protected async getMatchSummary(matchResult: AnyTriggerMatchResult): Promise<string> {
|
||||
if (matchResult.type === "message" || matchResult.type === "embed") {
|
||||
const message = await this.savedMessages.find(matchResult.messageInfo.messageId);
|
||||
const channel = this.guild.channels.get(matchResult.messageInfo.channelId);
|
||||
const channelMention = channel ? verboseChannelMention(channel) : `\`#${message.channel_id}\``;
|
||||
|
||||
return trimPluginDescription(`
|
||||
Matched ${this.getMatchedValueText(matchResult)} in message in ${channelMention}:
|
||||
${messageSummary(message)}
|
||||
`);
|
||||
} else if (matchResult.type === "textspam" || matchResult.type === "raidspam") {
|
||||
const savedMessages = await this.savedMessages.getMultiple(matchResult.messageInfos.map(i => i.messageId));
|
||||
const archiveId = await this.archives.createFromSavedMessages(savedMessages, this.guild);
|
||||
const baseUrl = this.knub.getGlobalConfig().url;
|
||||
const archiveUrl = this.archives.getUrl(baseUrl, archiveId);
|
||||
|
||||
return trimPluginDescription(`
|
||||
Matched spam: ${disableLinkPreviews(archiveUrl)}
|
||||
`);
|
||||
} else if (matchResult.type === "username") {
|
||||
return `Matched ${this.getMatchedValueText(matchResult)} in username: ${matchResult.str}`;
|
||||
} else if (matchResult.type === "nickname") {
|
||||
return `Matched ${this.getMatchedValueText(matchResult)} in nickname: ${matchResult.str}`;
|
||||
} else if (matchResult.type === "visiblename") {
|
||||
return `Matched ${this.getMatchedValueText(matchResult)} in visible name: ${matchResult.str}`;
|
||||
} else if (matchResult.type === "customstatus") {
|
||||
return `Matched ${this.getMatchedValueText(matchResult)} in custom status: ${matchResult.str}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a formatted version of the matched value (word, regex pattern, link, etc.) for use in the match summary
|
||||
*/
|
||||
protected getMatchedValueText(matchResult: TextTriggerMatchResult): string | null {
|
||||
if (matchResult.trigger === "match_words") {
|
||||
return `word \`${disableInlineCode(matchResult.matchedValue)}\``;
|
||||
} else if (matchResult.trigger === "match_regex") {
|
||||
return `regex \`${disableInlineCode(matchResult.matchedValue)}\``;
|
||||
} else if (matchResult.trigger === "match_invites") {
|
||||
return `invite code \`${disableInlineCode(matchResult.matchedValue)}\``;
|
||||
} else if (matchResult.trigger === "match_links") {
|
||||
return `link \`${disableInlineCode(matchResult.matchedValue)}\``;
|
||||
}
|
||||
|
||||
return typeof matchResult.matchedValue === "string" ? `\`${disableInlineCode(matchResult.matchedValue)}\`` : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run automod actions on new messages
|
||||
*/
|
||||
protected onMessageCreate(msg: SavedMessage) {
|
||||
if (msg.is_bot) return;
|
||||
|
||||
|
@ -1311,6 +1461,7 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
const matchResult = await this.matchRuleToMessage(rule, msg);
|
||||
if (matchResult) {
|
||||
await this.applyActionsOnMatch(rule, matchResult);
|
||||
break; // Don't apply multiple rules to the same message
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
@ -91,7 +91,8 @@ export class LocatePlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
sendWhere(this.guild, member, msg.channel, `${msg.member.mention} |`);
|
||||
}
|
||||
|
||||
@d.command("vcalert", "<member:resolvedMember> <duration:delay> [reminder:string$]", {
|
||||
@d.command("vcalert", "<member:resolvedMember> <duration:delay> <reminder:string$>", {
|
||||
overloads: ["<member:resolvedMember> <duration:delay>", "<member:resolvedMember>"],
|
||||
aliases: ["vca"],
|
||||
extra: {
|
||||
info: {
|
||||
|
|
|
@ -356,9 +356,11 @@ export class LogsPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
async onMemberUpdate(_, member: Member, oldMember: Member) {
|
||||
if (!oldMember) return;
|
||||
|
||||
const logMember = stripObjectToScalars(member, ["user", "roles"]);
|
||||
|
||||
if (member.nick !== oldMember.nick) {
|
||||
this.guildLogs.log(LogType.MEMBER_NICK_CHANGE, {
|
||||
member,
|
||||
member: logMember,
|
||||
oldNick: oldMember.nick != null ? oldMember.nick : "<none>",
|
||||
newNick: member.nick != null ? member.nick : "<none>",
|
||||
});
|
||||
|
@ -379,7 +381,7 @@ export class LogsPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
this.guildLogs.log(
|
||||
LogType.MEMBER_ROLE_CHANGES,
|
||||
{
|
||||
member,
|
||||
member: logMember,
|
||||
addedRoles: addedRoles
|
||||
.map(roleId => this.guild.roles.get(roleId) || { id: roleId, name: `Unknown (${roleId})` })
|
||||
.map(r => r.name)
|
||||
|
@ -397,7 +399,7 @@ export class LogsPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
this.guildLogs.log(
|
||||
LogType.MEMBER_ROLE_ADD,
|
||||
{
|
||||
member,
|
||||
member: logMember,
|
||||
roles: addedRoles
|
||||
.map(roleId => this.guild.roles.get(roleId) || { id: roleId, name: `Unknown (${roleId})` })
|
||||
.map(r => r.name)
|
||||
|
@ -411,7 +413,7 @@ export class LogsPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
this.guildLogs.log(
|
||||
LogType.MEMBER_ROLE_REMOVE,
|
||||
{
|
||||
member,
|
||||
member: logMember,
|
||||
roles: removedRoles
|
||||
.map(roleId => this.guild.roles.get(roleId) || { id: roleId, name: `Unknown (${roleId})` })
|
||||
.map(r => r.name)
|
||||
|
|
|
@ -125,7 +125,10 @@ export class MessageSaverPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
await msg.channel.createMessage(`Saving pins from <#${args.channel.id}>...`);
|
||||
|
||||
const pins = await args.channel.getPins();
|
||||
const { savedCount, failed } = await this.saveMessagesToDB(args.channel, pins.map(m => m.id));
|
||||
const { savedCount, failed } = await this.saveMessagesToDB(
|
||||
args.channel,
|
||||
pins.map(m => m.id),
|
||||
);
|
||||
|
||||
if (failed.length) {
|
||||
msg.channel.createMessage(
|
||||
|
|
|
@ -197,7 +197,10 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
}
|
||||
|
||||
clearIgnoredEvent(type: IgnoredEventType, userId: any) {
|
||||
this.ignoredEvents.splice(this.ignoredEvents.findIndex(info => type === info.type && userId === info.userId), 1);
|
||||
this.ignoredEvents.splice(
|
||||
this.ignoredEvents.findIndex(info => type === info.type && userId === info.userId),
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
formatReasonWithAttachments(reason: string, attachments: Attachment[]) {
|
||||
|
|
|
@ -13,6 +13,10 @@ import {
|
|||
deactivateMentions,
|
||||
createChunkedMessage,
|
||||
stripObjectToScalars,
|
||||
isValidEmbed,
|
||||
MINUTES,
|
||||
StrictMessageContent,
|
||||
DAYS,
|
||||
} from "../utils";
|
||||
import { GuildSavedMessages } from "../data/GuildSavedMessages";
|
||||
import { ZeppelinPlugin } from "./ZeppelinPlugin";
|
||||
|
@ -23,6 +27,7 @@ import moment, { Moment } from "moment-timezone";
|
|||
import { GuildLogs } from "../data/GuildLogs";
|
||||
import { LogType } from "../data/LogType";
|
||||
import * as t from "io-ts";
|
||||
import humanizeDuration from "humanize-duration";
|
||||
|
||||
const ConfigSchema = t.type({
|
||||
can_post: t.boolean,
|
||||
|
@ -33,9 +38,13 @@ const fsp = fs.promises;
|
|||
|
||||
const COLOR_MATCH_REGEX = /^#?([0-9a-f]{6})$/;
|
||||
|
||||
const SCHEDULED_POST_CHECK_INTERVAL = 15 * SECONDS;
|
||||
const SCHEDULED_POST_CHECK_INTERVAL = 5 * SECONDS;
|
||||
const SCHEDULED_POST_PREVIEW_TEXT_LENGTH = 50;
|
||||
|
||||
const MIN_REPEAT_TIME = 5 * MINUTES;
|
||||
const MAX_REPEAT_TIME = Math.pow(2, 32);
|
||||
const MAX_REPEAT_UNTIL = moment().add(100, "years");
|
||||
|
||||
export class PostPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||
public static pluginName = "post";
|
||||
public static configSchema = ConfigSchema;
|
||||
|
@ -142,17 +151,25 @@ export class PostPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
}
|
||||
|
||||
protected parseScheduleTime(str): Moment {
|
||||
const dtMatch = str.match(/^\d{4}-\d{2}-\d{2} \d{1,2}:\d{1,2}(:\d{1,2})?$/);
|
||||
if (dtMatch) {
|
||||
const dt = moment(str, dtMatch[1] ? "YYYY-MM-DD H:m:s" : "YYYY-MM-DD H:m");
|
||||
return dt;
|
||||
const dt1 = moment(str, "YYYY-MM-DD HH:mm:ss");
|
||||
if (dt1 && dt1.isValid()) return dt1;
|
||||
|
||||
const dt2 = moment(str, "YYYY-MM-DD HH:mm");
|
||||
if (dt2 && dt2.isValid()) return dt2;
|
||||
|
||||
const date = moment(str, "YYYY-MM-DD");
|
||||
if (date && date.isValid()) return date;
|
||||
|
||||
const t1 = moment(str, "HH:mm:ss");
|
||||
if (t1 && t1.isValid()) {
|
||||
if (t1.isBefore(moment())) t1.add(1, "day");
|
||||
return t1;
|
||||
}
|
||||
|
||||
const tMatch = str.match(/^\d{1,2}:\d{1,2}(:\d{1,2})?$/);
|
||||
if (tMatch) {
|
||||
const dt = moment(str, tMatch[1] ? "H:m:s" : "H:m");
|
||||
if (dt.isBefore(moment())) dt.add(1, "day");
|
||||
return dt;
|
||||
const t2 = moment(str, "HH:mm");
|
||||
if (t2 && t2.isValid()) {
|
||||
if (t2.isBefore(moment())) t2.add(1, "day");
|
||||
return t2;
|
||||
}
|
||||
|
||||
const delayStringMS = convertDelayStringToMS(str, "m");
|
||||
|
@ -194,12 +211,205 @@ export class PostPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
}
|
||||
}
|
||||
|
||||
await this.scheduledPosts.delete(post.id);
|
||||
let shouldClear = true;
|
||||
|
||||
if (post.repeat_interval) {
|
||||
const nextPostAt = moment().add(post.repeat_interval, "ms");
|
||||
|
||||
if (post.repeat_until) {
|
||||
const repeatUntil = moment(post.repeat_until, DBDateFormat);
|
||||
if (nextPostAt.isSameOrBefore(repeatUntil)) {
|
||||
await this.scheduledPosts.update(post.id, {
|
||||
post_at: nextPostAt.format(DBDateFormat),
|
||||
});
|
||||
shouldClear = false;
|
||||
}
|
||||
} else if (post.repeat_times) {
|
||||
if (post.repeat_times > 1) {
|
||||
await this.scheduledPosts.update(post.id, {
|
||||
post_at: nextPostAt.format(DBDateFormat),
|
||||
repeat_times: post.repeat_times - 1,
|
||||
});
|
||||
shouldClear = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldClear) {
|
||||
await this.scheduledPosts.delete(post.id);
|
||||
}
|
||||
}
|
||||
|
||||
this.scheduledPostLoopTimeout = setTimeout(() => this.scheduledPostLoop(), SCHEDULED_POST_CHECK_INTERVAL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Since !post and !post_embed have a lot of overlap for post scheduling, repeating, etc., that functionality is abstracted out to here
|
||||
*/
|
||||
async actualPostCmd(
|
||||
msg: Message,
|
||||
targetChannel: Channel,
|
||||
content: StrictMessageContent,
|
||||
opts?: {
|
||||
"enable-mentions"?: boolean;
|
||||
schedule?: string;
|
||||
repeat?: number;
|
||||
"repeat-until"?: string;
|
||||
"repeat-times"?: number;
|
||||
},
|
||||
) {
|
||||
if (!(targetChannel instanceof TextChannel)) {
|
||||
msg.channel.createMessage(errorMessage("Channel is not a text channel"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (content == null && msg.attachments.length === 0) {
|
||||
msg.channel.createMessage(errorMessage("Message content or attachment required"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.repeat) {
|
||||
if (opts.repeat < MIN_REPEAT_TIME) {
|
||||
return this.sendErrorMessage(msg.channel, `Minimum time for -repeat is ${humanizeDuration(MIN_REPEAT_TIME)}`);
|
||||
}
|
||||
if (opts.repeat > MAX_REPEAT_TIME) {
|
||||
return this.sendErrorMessage(msg.channel, `Max time for -repeat is ${humanizeDuration(MAX_REPEAT_TIME)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// If this is a scheduled or repeated post, figure out the next post date
|
||||
let postAt;
|
||||
if (opts.schedule) {
|
||||
// Schedule the post to be posted later
|
||||
postAt = this.parseScheduleTime(opts.schedule);
|
||||
if (!postAt) {
|
||||
return this.sendErrorMessage(msg.channel, "Invalid schedule time");
|
||||
}
|
||||
} else if (opts.repeat) {
|
||||
postAt = moment().add(opts.repeat, "ms");
|
||||
}
|
||||
|
||||
// For repeated posts, make sure repeat-until or repeat-times is specified
|
||||
let repeatUntil: moment.Moment = null;
|
||||
let repeatTimes: number = null;
|
||||
let repeatDetailsStr: string = null;
|
||||
|
||||
if (opts["repeat-until"]) {
|
||||
repeatUntil = this.parseScheduleTime(opts["repeat-until"]);
|
||||
|
||||
// Invalid time
|
||||
if (!repeatUntil) {
|
||||
return this.sendErrorMessage(msg.channel, "Invalid time specified for -repeat-until");
|
||||
}
|
||||
if (repeatUntil.isBefore(moment())) {
|
||||
return this.sendErrorMessage(msg.channel, "You can't set -repeat-until in the past");
|
||||
}
|
||||
if (repeatUntil.isAfter(MAX_REPEAT_UNTIL)) {
|
||||
return this.sendErrorMessage(
|
||||
msg.channel,
|
||||
"Unfortunately, -repeat-until can only be at most 100 years into the future. Maybe 99 years would be enough?",
|
||||
);
|
||||
}
|
||||
} else if (opts["repeat-times"]) {
|
||||
repeatTimes = opts["repeat-times"];
|
||||
if (repeatTimes <= 0) {
|
||||
return this.sendErrorMessage(msg.channel, "-repeat-times must be 1 or more");
|
||||
}
|
||||
}
|
||||
|
||||
if (repeatUntil && repeatTimes) {
|
||||
return this.sendErrorMessage(msg.channel, "You can only use one of -repeat-until or -repeat-times at once");
|
||||
}
|
||||
|
||||
if (opts.repeat && !repeatUntil && !repeatTimes) {
|
||||
return this.sendErrorMessage(
|
||||
msg.channel,
|
||||
"You must specify -repeat-until or -repeat-times for repeated messages",
|
||||
);
|
||||
}
|
||||
|
||||
if (opts.repeat) {
|
||||
repeatDetailsStr = repeatUntil
|
||||
? `every ${humanizeDuration(opts.repeat)} until ${repeatUntil.format(DBDateFormat)}`
|
||||
: `every ${humanizeDuration(opts.repeat)}, ${repeatTimes} times in total`;
|
||||
}
|
||||
|
||||
// Save schedule/repeat information in DB
|
||||
if (postAt) {
|
||||
if (postAt < moment()) {
|
||||
return this.sendErrorMessage(msg.channel, "Post can't be scheduled to be posted in the past");
|
||||
}
|
||||
|
||||
await this.scheduledPosts.create({
|
||||
author_id: msg.author.id,
|
||||
author_name: `${msg.author.username}#${msg.author.discriminator}`,
|
||||
channel_id: targetChannel.id,
|
||||
content,
|
||||
attachments: msg.attachments,
|
||||
post_at: postAt.format(DBDateFormat),
|
||||
enable_mentions: opts["enable-mentions"],
|
||||
repeat_interval: opts.repeat,
|
||||
repeat_until: repeatUntil ? repeatUntil.format(DBDateFormat) : null,
|
||||
repeat_times: repeatTimes ?? null,
|
||||
});
|
||||
|
||||
if (opts.repeat) {
|
||||
this.logs.log(LogType.SCHEDULED_REPEATED_MESSAGE, {
|
||||
author: stripObjectToScalars(msg.author),
|
||||
channel: stripObjectToScalars(targetChannel),
|
||||
date: postAt.format("YYYY-MM-DD"),
|
||||
time: postAt.format("HH:mm:ss"),
|
||||
repeatInterval: humanizeDuration(opts.repeat),
|
||||
repeatDetails: repeatDetailsStr,
|
||||
});
|
||||
} else {
|
||||
this.logs.log(LogType.SCHEDULED_MESSAGE, {
|
||||
author: stripObjectToScalars(msg.author),
|
||||
channel: stripObjectToScalars(targetChannel),
|
||||
date: postAt.format("YYYY-MM-DD"),
|
||||
time: postAt.format("HH:mm:ss"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// When the message isn't scheduled for later, post it immediately
|
||||
if (!opts.schedule) {
|
||||
await this.postMessage(targetChannel, content, msg.attachments, opts["enable-mentions"]);
|
||||
}
|
||||
|
||||
if (opts.repeat) {
|
||||
this.logs.log(LogType.REPEATED_MESSAGE, {
|
||||
author: stripObjectToScalars(msg.author),
|
||||
channel: stripObjectToScalars(targetChannel),
|
||||
date: postAt.format("YYYY-MM-DD"),
|
||||
time: postAt.format("HH:mm:ss"),
|
||||
repeatInterval: humanizeDuration(opts.repeat),
|
||||
repeatDetails: repeatDetailsStr,
|
||||
});
|
||||
}
|
||||
|
||||
// Bot reply schenanigans
|
||||
let successMessage = opts.schedule
|
||||
? `Message scheduled to be posted in <#${targetChannel.id}> on ${postAt.format("YYYY-MM-DD [at] HH:mm:ss")} (UTC)`
|
||||
: `Message posted in <#${targetChannel.id}>`;
|
||||
|
||||
if (opts.repeat) {
|
||||
successMessage += `. Message will be automatically reposted every ${humanizeDuration(opts.repeat)}`;
|
||||
|
||||
if (repeatUntil) {
|
||||
successMessage += ` until ${repeatUntil.format("YYYY-MM-DD [at] HH:mm:ss")} (UTC)`;
|
||||
} else if (repeatTimes) {
|
||||
successMessage += `, ${repeatTimes} times in total`;
|
||||
}
|
||||
|
||||
successMessage += ".";
|
||||
}
|
||||
|
||||
if (targetChannel.id !== msg.channel.id || opts.schedule || opts.repeat) {
|
||||
this.sendSuccessMessage(msg.channel, successMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* COMMAND: Post a regular text message as the bot to the specified channel
|
||||
*/
|
||||
|
@ -207,66 +417,40 @@ export class PostPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
options: [
|
||||
{
|
||||
name: "enable-mentions",
|
||||
type: "bool",
|
||||
isSwitch: true,
|
||||
},
|
||||
{
|
||||
name: "schedule",
|
||||
type: "string",
|
||||
},
|
||||
{
|
||||
name: "repeat",
|
||||
type: "delay",
|
||||
},
|
||||
{
|
||||
name: "repeat-until",
|
||||
type: "string",
|
||||
},
|
||||
{
|
||||
name: "repeat-times",
|
||||
type: "number",
|
||||
},
|
||||
],
|
||||
})
|
||||
@d.permission("can_post")
|
||||
async postCmd(
|
||||
msg: Message,
|
||||
args: { channel: Channel; content?: string; "enable-mentions": boolean; schedule?: string },
|
||||
args: {
|
||||
channel: Channel;
|
||||
content?: string;
|
||||
"enable-mentions": boolean;
|
||||
schedule?: string;
|
||||
repeat?: number;
|
||||
"repeat-until"?: string;
|
||||
"repeat-times"?: number;
|
||||
},
|
||||
) {
|
||||
if (!(args.channel instanceof TextChannel)) {
|
||||
msg.channel.createMessage(errorMessage("Channel is not a text channel"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.content == null && msg.attachments.length === 0) {
|
||||
msg.channel.createMessage(errorMessage("Text content or attachment required"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.schedule) {
|
||||
// Schedule the post to be posted later
|
||||
const postAt = this.parseScheduleTime(args.schedule);
|
||||
if (!postAt) {
|
||||
return this.sendErrorMessage(msg.channel, "Invalid schedule time");
|
||||
}
|
||||
|
||||
if (postAt < moment()) {
|
||||
return this.sendErrorMessage(msg.channel, "Post can't be scheduled to be posted in the past");
|
||||
}
|
||||
|
||||
await this.scheduledPosts.create({
|
||||
author_id: msg.author.id,
|
||||
author_name: `${msg.author.username}#${msg.author.discriminator}`,
|
||||
channel_id: args.channel.id,
|
||||
content: { content: args.content },
|
||||
attachments: msg.attachments,
|
||||
post_at: postAt.format(DBDateFormat),
|
||||
enable_mentions: args["enable-mentions"],
|
||||
});
|
||||
this.sendSuccessMessage(
|
||||
msg.channel,
|
||||
`Message scheduled to be posted in <#${args.channel.id}> on ${postAt.format("YYYY-MM-DD [at] HH:mm:ss")} (UTC)`,
|
||||
);
|
||||
this.logs.log(LogType.SCHEDULED_MESSAGE, {
|
||||
author: stripObjectToScalars(msg.author),
|
||||
channel: stripObjectToScalars(args.channel),
|
||||
date: postAt.format("YYYY-MM-DD"),
|
||||
time: postAt.format("HH:mm:ss"),
|
||||
});
|
||||
} else {
|
||||
// Post the message immediately
|
||||
await this.postMessage(args.channel, args.content, msg.attachments, args["enable-mentions"]);
|
||||
if (args.channel.id !== msg.channel.id) {
|
||||
this.sendSuccessMessage(msg.channel, `Message posted in <#${args.channel.id}>`);
|
||||
}
|
||||
}
|
||||
this.actualPostCmd(msg, args.channel, { content: args.content }, args);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -278,6 +462,19 @@ export class PostPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
{ name: "content", type: "string" },
|
||||
{ name: "color", type: "string" },
|
||||
{ name: "schedule", type: "string" },
|
||||
{ name: "raw", isSwitch: true, shortcut: "r" },
|
||||
{
|
||||
name: "repeat",
|
||||
type: "delay",
|
||||
},
|
||||
{
|
||||
name: "repeat-until",
|
||||
type: "string",
|
||||
},
|
||||
{
|
||||
name: "repeat-times",
|
||||
type: "number",
|
||||
},
|
||||
],
|
||||
})
|
||||
@d.permission("can_post")
|
||||
|
@ -290,13 +487,12 @@ export class PostPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
content?: string;
|
||||
color?: string;
|
||||
schedule?: string;
|
||||
raw?: boolean;
|
||||
repeat?: number;
|
||||
"repeat-until"?: string;
|
||||
"repeat-times"?: number;
|
||||
},
|
||||
) {
|
||||
if (!(args.channel instanceof TextChannel)) {
|
||||
msg.channel.createMessage(errorMessage("Channel is not a text channel"));
|
||||
return;
|
||||
}
|
||||
|
||||
const content = args.content || args.maincontent;
|
||||
|
||||
if (!args.title && !content) {
|
||||
|
@ -315,59 +511,32 @@ export class PostPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
color = parseInt(colorMatch[1], 16);
|
||||
}
|
||||
|
||||
const embed: EmbedBase = {};
|
||||
let embed: EmbedBase = {};
|
||||
if (args.title) embed.title = args.title;
|
||||
if (content) embed.description = this.formatContent(content);
|
||||
if (color) embed.color = color;
|
||||
|
||||
if (args.schedule) {
|
||||
// Schedule the post to be posted later
|
||||
const postAt = this.parseScheduleTime(args.schedule);
|
||||
if (!postAt) {
|
||||
return this.sendErrorMessage(msg.channel, "Invalid schedule time");
|
||||
}
|
||||
if (content) {
|
||||
if (args.raw) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(content);
|
||||
} catch (e) {
|
||||
this.sendErrorMessage(msg.channel, "Syntax error in embed JSON");
|
||||
return;
|
||||
}
|
||||
|
||||
if (postAt < moment()) {
|
||||
return this.sendErrorMessage(msg.channel, "Post can't be scheduled to be posted in the past");
|
||||
}
|
||||
if (!isValidEmbed(parsed)) {
|
||||
this.sendErrorMessage(msg.channel, "Embed is not valid");
|
||||
return;
|
||||
}
|
||||
|
||||
await this.scheduledPosts.create({
|
||||
author_id: msg.author.id,
|
||||
author_name: `${msg.author.username}#${msg.author.discriminator}`,
|
||||
channel_id: args.channel.id,
|
||||
content: { embed },
|
||||
attachments: msg.attachments,
|
||||
post_at: postAt.format(DBDateFormat),
|
||||
});
|
||||
await this.sendSuccessMessage(
|
||||
msg.channel,
|
||||
`Embed scheduled to be posted in <#${args.channel.id}> on ${postAt.format("YYYY-MM-DD [at] HH:mm:ss")} (UTC)`,
|
||||
);
|
||||
this.logs.log(LogType.SCHEDULED_MESSAGE, {
|
||||
author: stripObjectToScalars(msg.author),
|
||||
channel: stripObjectToScalars(args.channel),
|
||||
date: postAt.format("YYYY-MM-DD"),
|
||||
time: postAt.format("HH:mm:ss"),
|
||||
});
|
||||
} else {
|
||||
const createdMsg = await args.channel.createMessage({ embed });
|
||||
this.savedMessages.setPermanent(createdMsg.id);
|
||||
|
||||
if (msg.channel.id !== args.channel.id) {
|
||||
await this.sendSuccessMessage(msg.channel, `Embed posted in <#${args.channel.id}>`);
|
||||
embed = Object.assign({}, embed, parsed);
|
||||
} else {
|
||||
embed.description = this.formatContent(content);
|
||||
}
|
||||
}
|
||||
|
||||
if (args.content) {
|
||||
const prefix = this.guildConfig.prefix || "!";
|
||||
msg.channel.createMessage(
|
||||
trimLines(`
|
||||
<@!${msg.author.id}> You can now specify an embed's content directly at the end of the command:
|
||||
\`${prefix}post_embed -title "Some title" content goes here\`
|
||||
The \`-content\` option will soon be removed in favor of this.
|
||||
`),
|
||||
);
|
||||
}
|
||||
this.actualPostCmd(msg, args.channel, { embed }, args);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -472,6 +641,16 @@ export class PostPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
const parts = [`\`#${i++}\` \`[${p.post_at}]\` ${previewText}${isTruncated ? "..." : ""}`];
|
||||
if (p.attachments.length) parts.push("*(with attachment)*");
|
||||
if (p.content.embed) parts.push("*(embed)*");
|
||||
if (p.repeat_until) {
|
||||
parts.push(`*(repeated every ${humanizeDuration(p.repeat_interval)} until ${p.repeat_until})*`);
|
||||
}
|
||||
if (p.repeat_times) {
|
||||
parts.push(
|
||||
`*(repeated every ${humanizeDuration(p.repeat_interval)}, ${p.repeat_times} more ${
|
||||
p.repeat_times === 1 ? "time" : "times"
|
||||
})*`,
|
||||
);
|
||||
}
|
||||
parts.push(`*(${p.author_name})*`);
|
||||
|
||||
return parts.join(" ");
|
||||
|
@ -480,7 +659,7 @@ export class PostPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
const finalMessage = trimLines(`
|
||||
${postLines.join("\n")}
|
||||
|
||||
Use \`scheduled_posts show <num>\` to view a scheduled post in full
|
||||
Use \`scheduled_posts <num>\` to view a scheduled post in full
|
||||
Use \`scheduled_posts delete <num>\` to delete a scheduled post
|
||||
`);
|
||||
createChunkedMessage(msg.channel, finalMessage);
|
||||
|
|
|
@ -268,9 +268,17 @@ export class ReactionRolesPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
* :zep_twitch: = 473086848831455234
|
||||
* :zep_ps4: = 543184300250759188
|
||||
*/
|
||||
@d.command("reaction_roles", "<messageId:string> <reactionRolePairs:string$>")
|
||||
@d.command("reaction_roles", "<messageId:string> <reactionRolePairs:string$>", {
|
||||
options: [
|
||||
{
|
||||
name: "exclusive",
|
||||
shortcut: "e",
|
||||
isSwitch: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
@d.permission("can_manage")
|
||||
async reactionRolesCmd(msg: Message, args: { messageId: string; reactionRolePairs: string }) {
|
||||
async reactionRolesCmd(msg: Message, args: { messageId: string; reactionRolePairs: string; exclusive?: boolean }) {
|
||||
const savedMessage = await this.savedMessages.find(args.messageId);
|
||||
if (!savedMessage) {
|
||||
msg.channel.createMessage(errorMessage("Unknown message"));
|
||||
|
@ -331,7 +339,7 @@ export class ReactionRolesPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
|
||||
// Save the new reaction roles to the database
|
||||
for (const pair of emojiRolePairs) {
|
||||
await this.reactionRoles.add(channel.id, targetMessage.id, pair[0], pair[1]);
|
||||
await this.reactionRoles.add(channel.id, targetMessage.id, pair[0], pair[1], args.exclusive);
|
||||
}
|
||||
|
||||
// Apply the reactions themselves
|
||||
|
@ -370,6 +378,14 @@ export class ReactionRolesPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
const matchingReactionRole = await this.reactionRoles.getByMessageAndEmoji(msg.id, emoji.id || emoji.name);
|
||||
if (!matchingReactionRole) return;
|
||||
|
||||
// If the reaction role is exclusive, remove any other roles in the message first
|
||||
if (matchingReactionRole.is_exclusive) {
|
||||
const messageReactionRoles = await this.reactionRoles.getForMessage(msg.id);
|
||||
for (const reactionRole of messageReactionRoles) {
|
||||
this.addMemberPendingRoleChange(userId, "-", reactionRole.role_id);
|
||||
}
|
||||
}
|
||||
|
||||
this.addMemberPendingRoleChange(userId, "+", matchingReactionRole.role_id);
|
||||
}
|
||||
|
||||
|
|
|
@ -70,9 +70,19 @@ export class RemindersPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
const channel = this.guild.channels.get(reminder.channel_id);
|
||||
if (channel && channel instanceof TextChannel) {
|
||||
try {
|
||||
await channel.createMessage(
|
||||
disableLinkPreviews(`<@!${reminder.user_id}> You asked me to remind you: ${reminder.body}`),
|
||||
);
|
||||
// Only show created at date if one exists
|
||||
if (moment(reminder.created_at).isValid()) {
|
||||
const target = moment();
|
||||
const diff = target.diff(moment(reminder.created_at, "YYYY-MM-DD HH:mm:ss"));
|
||||
const result = humanizeDuration(diff, { largest: 2, round: true });
|
||||
await channel.createMessage(
|
||||
disableLinkPreviews(
|
||||
`Reminder for <@!${reminder.user_id}>: ${reminder.body} \n\`Set at ${reminder.created_at} (${result} ago)\``,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
await channel.createMessage(disableLinkPreviews(`Reminder for <@!${reminder.user_id}>: ${reminder.body}`));
|
||||
}
|
||||
} catch (e) {
|
||||
// Probably random Discord internal server error or missing permissions or somesuch
|
||||
// Try again next round unless we've already tried to post this a bunch of times
|
||||
|
@ -127,7 +137,13 @@ export class RemindersPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
}
|
||||
|
||||
const reminderBody = args.reminder || `https://discordapp.com/channels/${this.guildId}/${msg.channel.id}/${msg.id}`;
|
||||
await this.reminders.add(msg.author.id, msg.channel.id, reminderTime.format("YYYY-MM-DD HH:mm:ss"), reminderBody);
|
||||
await this.reminders.add(
|
||||
msg.author.id,
|
||||
msg.channel.id,
|
||||
reminderTime.format("YYYY-MM-DD HH:mm:ss"),
|
||||
reminderBody,
|
||||
moment().format("YYYY-MM-DD HH:mm:ss"),
|
||||
);
|
||||
|
||||
const msUntilReminder = reminderTime.diff(now);
|
||||
const timeUntilReminder = humanizeDuration(msUntilReminder, { largest: 2, round: true });
|
||||
|
@ -152,7 +168,10 @@ export class RemindersPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
const lines = Array.from(reminders.entries()).map(([i, reminder]) => {
|
||||
const num = i + 1;
|
||||
const paddedNum = num.toString().padStart(longestNum, " ");
|
||||
return `\`${paddedNum}.\` \`${reminder.remind_at}\` ${reminder.body}`;
|
||||
const target = moment(reminder.remind_at, "YYYY-MM-DD HH:mm:ss");
|
||||
const diff = target.diff(moment());
|
||||
const result = humanizeDuration(diff, { largest: 2, round: true });
|
||||
return `\`${paddedNum}.\` \`${reminder.remind_at} (${result})\` ${reminder.body}`;
|
||||
});
|
||||
|
||||
createChunkedMessage(msg.channel, lines.join("\n"));
|
||||
|
|
146
backend/src/plugins/Roles.ts
Normal file
146
backend/src/plugins/Roles.ts
Normal file
|
@ -0,0 +1,146 @@
|
|||
import { ZeppelinPlugin, trimPluginDescription } from "./ZeppelinPlugin";
|
||||
import * as t from "io-ts";
|
||||
import { stripObjectToScalars, tNullable } from "../utils";
|
||||
import { decorators as d, IPluginOptions, logger, waitForReaction, waitForReply } from "knub";
|
||||
import { Attachment, Constants as ErisConstants, Guild, GuildChannel, Member, Message, TextChannel, User } from "eris";
|
||||
import { GuildLogs } from "../data/GuildLogs";
|
||||
import { LogType } from "../data/LogType";
|
||||
|
||||
const ConfigSchema = t.type({
|
||||
can_assign: t.boolean,
|
||||
assignable_roles: t.array(t.string),
|
||||
});
|
||||
type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
|
||||
|
||||
export class RolesPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||
public static pluginName = "roles";
|
||||
public static configSchema = ConfigSchema;
|
||||
|
||||
public static pluginInfo = {
|
||||
prettyName: "Roles",
|
||||
description: trimPluginDescription(`
|
||||
Enables authorised users to add and remove whitelisted roles with a command.
|
||||
`),
|
||||
};
|
||||
|
||||
protected logs: GuildLogs;
|
||||
|
||||
onLoad() {
|
||||
this.logs = new GuildLogs(this.guildId);
|
||||
}
|
||||
|
||||
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
||||
return {
|
||||
config: {
|
||||
can_assign: false,
|
||||
assignable_roles: [],
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
level: ">=50",
|
||||
config: {
|
||||
can_assign: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@d.command("addrole", "<member:member> [role:string$]", {
|
||||
extra: {
|
||||
info: {
|
||||
description: "Add a role to the specified member",
|
||||
},
|
||||
},
|
||||
})
|
||||
@d.permission("can_assign")
|
||||
async addRoleCmd(msg: Message, args: { member: Member; role: string }) {
|
||||
if (!this.canActOn(msg.member, args.member, true)) {
|
||||
return this.sendErrorMessage(msg.channel, "Cannot add roles to this user: insufficient permissions");
|
||||
}
|
||||
|
||||
const roleId = await this.resolveRoleId(args.role);
|
||||
if (!roleId) {
|
||||
return this.sendErrorMessage(msg.channel, "Invalid role id");
|
||||
}
|
||||
|
||||
const config = this.getConfigForMsg(msg);
|
||||
if (!config.assignable_roles.includes(roleId)) {
|
||||
return this.sendErrorMessage(msg.channel, "You cannot assign that role");
|
||||
}
|
||||
|
||||
// Sanity check: make sure the role is configured properly
|
||||
const role = (msg.channel as GuildChannel).guild.roles.get(roleId);
|
||||
if (!role) {
|
||||
this.logs.log(LogType.BOT_ALERT, {
|
||||
body: `Unknown role configured for 'roles' plugin: ${roleId}`,
|
||||
});
|
||||
return this.sendErrorMessage(msg.channel, "You cannot assign that role");
|
||||
}
|
||||
|
||||
if (args.member.roles.includes(roleId)) {
|
||||
return this.sendErrorMessage(msg.channel, "Member already has that role");
|
||||
}
|
||||
|
||||
this.logs.ignoreLog(LogType.MEMBER_ROLE_ADD, args.member.id);
|
||||
|
||||
await args.member.addRole(roleId);
|
||||
|
||||
this.logs.log(LogType.MEMBER_ROLE_ADD, {
|
||||
member: stripObjectToScalars(args.member, ["user", "roles"]),
|
||||
roles: role.name,
|
||||
mod: stripObjectToScalars(msg.author),
|
||||
});
|
||||
|
||||
this.sendSuccessMessage(msg.channel, "Role added to user!");
|
||||
}
|
||||
|
||||
@d.command("removerole", "<member:member> [role:string$]", {
|
||||
extra: {
|
||||
info: {
|
||||
description: "Remove a role from the specified member",
|
||||
},
|
||||
},
|
||||
})
|
||||
@d.permission("can_assign")
|
||||
async removeRoleCmd(msg: Message, args: { member: Member; role: string }) {
|
||||
if (!this.canActOn(msg.member, args.member, true)) {
|
||||
return this.sendErrorMessage(msg.channel, "Cannot remove roles from this user: insufficient permissions");
|
||||
}
|
||||
|
||||
const roleId = await this.resolveRoleId(args.role);
|
||||
if (!roleId) {
|
||||
return this.sendErrorMessage(msg.channel, "Invalid role id");
|
||||
}
|
||||
|
||||
const config = this.getConfigForMsg(msg);
|
||||
if (!config.assignable_roles.includes(roleId)) {
|
||||
return this.sendErrorMessage(msg.channel, "You cannot remove that role");
|
||||
}
|
||||
|
||||
// Sanity check: make sure the role is configured properly
|
||||
const role = (msg.channel as GuildChannel).guild.roles.get(roleId);
|
||||
if (!role) {
|
||||
this.logs.log(LogType.BOT_ALERT, {
|
||||
body: `Unknown role configured for 'roles' plugin: ${roleId}`,
|
||||
});
|
||||
return this.sendErrorMessage(msg.channel, "You cannot remove that role");
|
||||
}
|
||||
|
||||
if (!args.member.roles.includes(roleId)) {
|
||||
return this.sendErrorMessage(msg.channel, "Member doesn't have that role");
|
||||
}
|
||||
|
||||
this.logs.ignoreLog(LogType.MEMBER_ROLE_REMOVE, args.member.id);
|
||||
|
||||
await args.member.removeRole(roleId);
|
||||
|
||||
this.logs.log(LogType.MEMBER_ROLE_REMOVE, {
|
||||
member: stripObjectToScalars(args.member, ["user", "roles"]),
|
||||
roles: role.name,
|
||||
mod: stripObjectToScalars(msg.author),
|
||||
});
|
||||
|
||||
this.sendSuccessMessage(msg.channel, "Role removed from user!");
|
||||
}
|
||||
}
|
|
@ -212,7 +212,7 @@ export class SlowmodePlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
options: [
|
||||
{
|
||||
name: "force",
|
||||
type: "bool",
|
||||
isSwitch: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
|
|
@ -1,58 +1,162 @@
|
|||
import { decorators as d, waitForReply, utils as knubUtils, IBasePluginConfig, IPluginOptions } from "knub";
|
||||
import { ZeppelinPlugin } from "./ZeppelinPlugin";
|
||||
import { GuildStarboards } from "../data/GuildStarboards";
|
||||
import { GuildChannel, Message, TextChannel } from "eris";
|
||||
import { decorators as d, IPluginOptions } from "knub";
|
||||
import { ZeppelinPlugin, trimPluginDescription } from "./ZeppelinPlugin";
|
||||
import { Embed, EmbedBase, GuildChannel, Message, TextChannel } from "eris";
|
||||
import {
|
||||
customEmojiRegex,
|
||||
errorMessage,
|
||||
getEmojiInString,
|
||||
getUrlsInString,
|
||||
messageLink,
|
||||
noop,
|
||||
snowflakeRegex,
|
||||
successMessage,
|
||||
TDeepPartialProps,
|
||||
tNullable,
|
||||
tDeepPartial,
|
||||
UnknownUser,
|
||||
EMPTY_CHAR,
|
||||
} from "../utils";
|
||||
import { Starboard } from "../data/entities/Starboard";
|
||||
import path from "path";
|
||||
import moment from "moment-timezone";
|
||||
import { GuildSavedMessages } from "../data/GuildSavedMessages";
|
||||
import { SavedMessage } from "../data/entities/SavedMessage";
|
||||
import * as t from "io-ts";
|
||||
import { GuildStarboardMessages } from "../data/GuildStarboardMessages";
|
||||
import { StarboardMessage } from "../data/entities/StarboardMessage";
|
||||
import { GuildStarboardReactions } from "../data/GuildStarboardReactions";
|
||||
|
||||
const StarboardOpts = t.type({
|
||||
channel_id: t.string,
|
||||
stars_required: t.number,
|
||||
star_emoji: tNullable(t.array(t.string)),
|
||||
enabled: tNullable(t.boolean),
|
||||
});
|
||||
type TStarboardOpts = t.TypeOf<typeof StarboardOpts>;
|
||||
|
||||
const ConfigSchema = t.type({
|
||||
can_manage: t.boolean,
|
||||
boards: t.record(t.string, StarboardOpts),
|
||||
can_migrate: t.boolean,
|
||||
});
|
||||
type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
|
||||
|
||||
const PartialConfigSchema = tDeepPartial(ConfigSchema);
|
||||
|
||||
const defaultStarboardOpts: Partial<TStarboardOpts> = {
|
||||
star_emoji: ["⭐"],
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
export class StarboardPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||
public static pluginName = "starboard";
|
||||
public static showInDocs = false;
|
||||
public static configSchema = ConfigSchema;
|
||||
|
||||
protected starboards: GuildStarboards;
|
||||
public static pluginInfo = {
|
||||
prettyName: "Starboard",
|
||||
description: trimPluginDescription(`
|
||||
This plugin allows you to set up starboards on your server. Starboards are like user voted pins where messages with enough reactions get immortalized on a "starboard" channel.
|
||||
`),
|
||||
configurationGuide: trimPluginDescription(`
|
||||
### Note on emojis
|
||||
To specify emoji in the config, you need to use the emoji's "raw form".
|
||||
To obtain this, post the emoji with a backslash in front of it.
|
||||
|
||||
- Example with a default emoji: "\:star:" => "⭐"
|
||||
- Example with a custom emoji: "\:mrvnSmile:" => "<:mrvnSmile:543000534102310933>"
|
||||
|
||||
### Basic starboard
|
||||
Any message on the server that gets 5 star reactions will be posted into the starboard channel (604342689038729226).
|
||||
|
||||
~~~yml
|
||||
starboard:
|
||||
config:
|
||||
boards:
|
||||
basic:
|
||||
channel_id: "604342689038729226"
|
||||
stars_required: 5
|
||||
~~~
|
||||
|
||||
### Custom star emoji
|
||||
This is identical to the basic starboard above, but accepts two emoji: the regular star and a custom :mrvnSmile: emoji
|
||||
|
||||
~~~yml
|
||||
starboard:
|
||||
config:
|
||||
boards:
|
||||
basic:
|
||||
channel_id: "604342689038729226"
|
||||
star_emoji: ["⭐", "<:mrvnSmile:543000534102310933>"]
|
||||
stars_required: 5
|
||||
~~~
|
||||
|
||||
### Limit starboard to a specific channel
|
||||
This is identical to the basic starboard above, but only works from a specific channel (473087035574321152).
|
||||
|
||||
~~~yml
|
||||
starboard:
|
||||
config:
|
||||
boards:
|
||||
basic:
|
||||
enabled: false # The starboard starts disabled and is then enabled in a channel override below
|
||||
channel_id: "604342689038729226"
|
||||
stars_required: 5
|
||||
overrides:
|
||||
- channel: "473087035574321152"
|
||||
config:
|
||||
boards:
|
||||
basic:
|
||||
enabled: true
|
||||
~~~
|
||||
`),
|
||||
};
|
||||
|
||||
protected savedMessages: GuildSavedMessages;
|
||||
protected starboardMessages: GuildStarboardMessages;
|
||||
protected starboardReactions: GuildStarboardReactions;
|
||||
|
||||
private onMessageDeleteFn;
|
||||
|
||||
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
||||
return {
|
||||
config: {
|
||||
can_manage: false,
|
||||
can_migrate: false,
|
||||
boards: {},
|
||||
},
|
||||
|
||||
overrides: [
|
||||
{
|
||||
level: ">=100",
|
||||
config: {
|
||||
can_manage: true,
|
||||
can_migrate: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
protected static preprocessStaticConfig(config: t.TypeOf<typeof PartialConfigSchema>) {
|
||||
if (config.boards) {
|
||||
for (const [name, opts] of Object.entries(config.boards)) {
|
||||
config.boards[name] = Object.assign({}, defaultStarboardOpts, config.boards[name]);
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
protected getStarboardOptsForStarboardChannel(starboardChannel): TStarboardOpts[] {
|
||||
const config = this.getConfigForChannel(starboardChannel);
|
||||
|
||||
const configs = Object.values(config.boards).filter(opts => opts.channel_id === starboardChannel.id);
|
||||
configs.forEach(cfg => {
|
||||
if (cfg.enabled == null) cfg.enabled = defaultStarboardOpts.enabled;
|
||||
if (cfg.star_emoji == null) cfg.star_emoji = defaultStarboardOpts.star_emoji;
|
||||
if (cfg.stars_required == null) cfg.stars_required = defaultStarboardOpts.stars_required;
|
||||
});
|
||||
|
||||
return configs;
|
||||
}
|
||||
|
||||
onLoad() {
|
||||
this.starboards = GuildStarboards.getGuildInstance(this.guildId);
|
||||
this.savedMessages = GuildSavedMessages.getGuildInstance(this.guildId);
|
||||
this.starboardMessages = GuildStarboardMessages.getGuildInstance(this.guildId);
|
||||
this.starboardReactions = GuildStarboardReactions.getGuildInstance(this.guildId);
|
||||
|
||||
this.onMessageDeleteFn = this.onMessageDelete.bind(this);
|
||||
this.savedMessages.events.on("delete", this.onMessageDeleteFn);
|
||||
|
@ -62,143 +166,13 @@ export class StarboardPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
this.savedMessages.events.off("delete", this.onMessageDeleteFn);
|
||||
}
|
||||
|
||||
/**
|
||||
* An interactive setup for creating a starboard
|
||||
*/
|
||||
@d.command("starboard create")
|
||||
@d.permission("can_manage")
|
||||
async setupCmd(msg: Message) {
|
||||
const cancelMsg = () => msg.channel.createMessage("Cancelled");
|
||||
|
||||
msg.channel.createMessage(
|
||||
`⭐ Let's make a starboard! What channel should we use as the board? ("cancel" to cancel)`,
|
||||
);
|
||||
|
||||
let starboardChannel;
|
||||
do {
|
||||
const reply = await waitForReply(this.bot, msg.channel as TextChannel, msg.author.id, 60000);
|
||||
if (reply.content == null || reply.content === "cancel") return cancelMsg();
|
||||
|
||||
starboardChannel = knubUtils.resolveChannel(this.guild, reply.content || "");
|
||||
if (!starboardChannel) {
|
||||
msg.channel.createMessage("Invalid channel. Try again?");
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingStarboard = await this.starboards.getStarboardByChannelId(starboardChannel.id);
|
||||
if (existingStarboard) {
|
||||
msg.channel.createMessage("That channel already has a starboard. Try again?");
|
||||
starboardChannel = null;
|
||||
continue;
|
||||
}
|
||||
} while (starboardChannel == null);
|
||||
|
||||
msg.channel.createMessage(`Ok. Which emoji should we use as the trigger? ("cancel" to cancel)`);
|
||||
|
||||
let emoji;
|
||||
do {
|
||||
const reply = await waitForReply(this.bot, msg.channel as TextChannel, msg.author.id);
|
||||
if (reply.content == null || reply.content === "cancel") return cancelMsg();
|
||||
|
||||
const allEmojis = getEmojiInString(reply.content || "");
|
||||
if (!allEmojis.length) {
|
||||
msg.channel.createMessage("Invalid emoji. Try again?");
|
||||
continue;
|
||||
}
|
||||
|
||||
emoji = allEmojis[0];
|
||||
|
||||
const customEmojiMatch = emoji.match(customEmojiRegex);
|
||||
if (customEmojiMatch) {
|
||||
// <:name:id> to name:id, as Eris puts them in the message reactions object
|
||||
emoji = `${customEmojiMatch[1]}:${customEmojiMatch[2]}`;
|
||||
}
|
||||
} while (emoji == null);
|
||||
|
||||
msg.channel.createMessage(
|
||||
`And how many reactions are required to immortalize a message in the starboard? ("cancel" to cancel)`,
|
||||
);
|
||||
|
||||
let requiredReactions;
|
||||
do {
|
||||
const reply = await waitForReply(this.bot, msg.channel as TextChannel, msg.author.id);
|
||||
if (reply.content == null || reply.content === "cancel") return cancelMsg();
|
||||
|
||||
requiredReactions = parseInt(reply.content || "", 10);
|
||||
|
||||
if (Number.isNaN(requiredReactions)) {
|
||||
msg.channel.createMessage("Invalid number. Try again?");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof requiredReactions === "number") {
|
||||
if (requiredReactions <= 0) {
|
||||
msg.channel.createMessage("The number must be higher than 0. Try again?");
|
||||
continue;
|
||||
} else if (requiredReactions > 65536) {
|
||||
msg.channel.createMessage("The number must be smaller than 65536. Try again?");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} while (requiredReactions == null);
|
||||
|
||||
msg.channel.createMessage(
|
||||
`And finally, which channels can messages be starred in? "All" for any channel. ("cancel" to cancel)`,
|
||||
);
|
||||
|
||||
let channelWhitelist;
|
||||
do {
|
||||
const reply = await waitForReply(this.bot, msg.channel as TextChannel, msg.author.id);
|
||||
if (reply.content == null || reply.content === "cancel") return cancelMsg();
|
||||
|
||||
if (reply.content.toLowerCase() === "all") {
|
||||
channelWhitelist = null;
|
||||
break;
|
||||
}
|
||||
|
||||
channelWhitelist = reply.content.match(new RegExp(snowflakeRegex, "g"));
|
||||
|
||||
let hasInvalidChannels = false;
|
||||
for (const id of channelWhitelist) {
|
||||
const channel = this.guild.channels.get(id);
|
||||
if (!channel || !(channel instanceof TextChannel)) {
|
||||
msg.channel.createMessage(`Couldn't recognize channel <#${id}> (\`${id}\`). Try again?`);
|
||||
hasInvalidChannels = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hasInvalidChannels) continue;
|
||||
} while (channelWhitelist == null);
|
||||
|
||||
await this.starboards.create(starboardChannel.id, channelWhitelist, emoji, requiredReactions);
|
||||
|
||||
msg.channel.createMessage(successMessage("Starboard created!"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the starboard from the specified channel. The already-posted starboard messages are retained.
|
||||
*/
|
||||
@d.command("starboard delete", "<channelId:channelId>")
|
||||
@d.permission("can_manage")
|
||||
async deleteCmd(msg: Message, args: { channelId: string }) {
|
||||
const starboard = await this.starboards.getStarboardByChannelId(args.channelId);
|
||||
if (!starboard) {
|
||||
msg.channel.createMessage(errorMessage(`Channel <#${args.channelId}> doesn't have a starboard!`));
|
||||
return;
|
||||
}
|
||||
|
||||
await this.starboards.delete(starboard.channel_id);
|
||||
|
||||
msg.channel.createMessage(successMessage(`Starboard deleted from <#${args.channelId}>!`));
|
||||
}
|
||||
|
||||
/**
|
||||
* When a reaction is added to a message, check if there are any applicable starboards and if the reactions reach
|
||||
* the required threshold. If they do, post the message in the starboard channel.
|
||||
*/
|
||||
@d.event("messageReactionAdd")
|
||||
@d.lock("starboardReaction")
|
||||
async onMessageReactionAdd(msg: Message, emoji: { id: string; name: string }) {
|
||||
async onMessageReactionAdd(msg: Message, emoji: { id: string; name: string }, userId: string) {
|
||||
if (!msg.author) {
|
||||
// Message is not cached, fetch it
|
||||
try {
|
||||
|
@ -209,63 +183,80 @@ export class StarboardPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
}
|
||||
}
|
||||
|
||||
const emojiStr = emoji.id ? `${emoji.name}:${emoji.id}` : emoji.name;
|
||||
const applicableStarboards = await this.starboards.getStarboardsByEmoji(emojiStr);
|
||||
// No self-votes!
|
||||
if (msg.author.id === userId) return;
|
||||
|
||||
const user = await this.resolveUser(userId);
|
||||
if (user instanceof UnknownUser) return;
|
||||
if (user.bot) return;
|
||||
|
||||
const config = this.getConfigForMemberIdAndChannelId(userId, msg.channel.id);
|
||||
const applicableStarboards = Object.values(config.boards)
|
||||
.filter(board => board.enabled)
|
||||
// Can't star messages in the starboard channel itself
|
||||
.filter(board => board.channel_id !== msg.channel.id)
|
||||
// Matching emoji
|
||||
.filter(board => {
|
||||
return board.star_emoji.some((boardEmoji: string) => {
|
||||
if (emoji.id) {
|
||||
// Custom emoji
|
||||
const customEmojiMatch = boardEmoji.match(/^<?:.+?:(\d+)>?$/);
|
||||
if (customEmojiMatch) {
|
||||
return customEmojiMatch[1] === emoji.id;
|
||||
}
|
||||
|
||||
return boardEmoji === emoji.id;
|
||||
} else {
|
||||
// Unicode emoji
|
||||
return emoji.name === boardEmoji;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
for (const starboard of applicableStarboards) {
|
||||
// Can't star messages in the starboard channel itself
|
||||
if (msg.channel.id === starboard.channel_id) continue;
|
||||
// Save reaction into the database
|
||||
await this.starboardReactions.createStarboardReaction(msg.id, userId).catch(noop);
|
||||
|
||||
if (starboard.channel_whitelist) {
|
||||
const allowedChannelIds = starboard.channel_whitelist.split(",");
|
||||
if (!allowedChannelIds.includes(msg.channel.id)) continue;
|
||||
}
|
||||
// If the message has already been posted to this starboard, we don't need to do anything else
|
||||
const starboardMessages = await this.starboardMessages.getMatchingStarboardMessages(starboard.channel_id, msg.id);
|
||||
if (starboardMessages.length > 0) continue;
|
||||
|
||||
// If the message has already been posted to this starboard, we don't need to do anything else here
|
||||
const existingSavedMessage = await this.starboards.getStarboardMessageByStarboardIdAndMessageId(
|
||||
starboard.id,
|
||||
msg.id,
|
||||
);
|
||||
if (existingSavedMessage) return;
|
||||
|
||||
const reactionsCount = await this.countReactions(msg, emojiStr);
|
||||
|
||||
if (reactionsCount >= starboard.reactions_required) {
|
||||
await this.saveMessageToStarboard(msg, starboard);
|
||||
const reactions = await this.starboardReactions.getAllReactionsForMessageId(msg.id);
|
||||
const reactionsCount = reactions.length;
|
||||
if (reactionsCount >= starboard.stars_required) {
|
||||
await this.saveMessageToStarboard(msg, starboard.channel_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts the specific reactions in the message, ignoring the message author
|
||||
*/
|
||||
async countReactions(msg: Message, reaction) {
|
||||
let reactionsCount = (msg.reactions[reaction] && msg.reactions[reaction].count) || 0;
|
||||
@d.event("messageReactionRemove")
|
||||
async onStarboardReactionRemove(msg: Message, emoji: { id: string; name: string }, userId: string) {
|
||||
await this.starboardReactions.deleteStarboardReaction(msg.id, userId);
|
||||
}
|
||||
|
||||
// Ignore self-stars
|
||||
const reactors = await msg.getReaction(reaction);
|
||||
if (reactors.some(u => u.id === msg.author.id)) reactionsCount--;
|
||||
|
||||
return reactionsCount;
|
||||
@d.event("messageReactionRemoveAll")
|
||||
async onMessageReactionRemoveAll(msg: Message) {
|
||||
await this.starboardReactions.deleteAllStarboardReactionsForMessageId(msg.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves/posts a message to the specified starboard. The message is posted as an embed and image attachments are
|
||||
* included as the embed image.
|
||||
* Saves/posts a message to the specified starboard.
|
||||
* The message is posted as an embed and image attachments are included as the embed image.
|
||||
*/
|
||||
async saveMessageToStarboard(msg: Message, starboard: Starboard) {
|
||||
const channel = this.guild.channels.get(starboard.channel_id);
|
||||
async saveMessageToStarboard(msg: Message, starboardChannelId: string) {
|
||||
const channel = this.guild.channels.get(starboardChannelId);
|
||||
if (!channel) return;
|
||||
|
||||
const time = moment(msg.timestamp, "x").format("YYYY-MM-DD [at] HH:mm:ss [UTC]");
|
||||
|
||||
const embed: any = {
|
||||
const embed: EmbedBase = {
|
||||
footer: {
|
||||
text: `#${(msg.channel as GuildChannel).name} - ${time}`,
|
||||
text: `#${(msg.channel as GuildChannel).name}`,
|
||||
},
|
||||
author: {
|
||||
name: `${msg.author.username}#${msg.author.discriminator}`,
|
||||
},
|
||||
timestamp: new Date(msg.timestamp).toISOString(),
|
||||
};
|
||||
|
||||
if (msg.author.avatarURL) {
|
||||
|
@ -276,6 +267,7 @@ export class StarboardPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
embed.description = msg.content;
|
||||
}
|
||||
|
||||
// Include attachments
|
||||
if (msg.attachments.length) {
|
||||
const attachment = msg.attachments[0];
|
||||
const ext = path
|
||||
|
@ -285,87 +277,96 @@ export class StarboardPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
if (["jpeg", "jpg", "png", "gif", "webp"].includes(ext)) {
|
||||
embed.image = { url: attachment.url };
|
||||
}
|
||||
} else if (msg.content) {
|
||||
const links = getUrlsInString(msg.content);
|
||||
for (const link of links) {
|
||||
const parts = link
|
||||
.toString()
|
||||
.replace(/\/$/, "")
|
||||
.split(".");
|
||||
const ext = parts[parts.length - 1].toLowerCase();
|
||||
|
||||
if (
|
||||
(link.hostname === "i.imgur.com" || link.hostname === "cdn.discordapp.com") &&
|
||||
["jpeg", "jpg", "png", "gif", "webp"].includes(ext)
|
||||
) {
|
||||
embed.image = { url: link.toString() };
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const starboardMessage = await (channel as TextChannel).createMessage({
|
||||
content: `https://discordapp.com/channels/${this.guildId}/${msg.channel.id}/${msg.id}`,
|
||||
embed,
|
||||
});
|
||||
await this.starboards.createStarboardMessage(starboard.id, msg.id, starboardMessage.id);
|
||||
// Include any embed images in the original message
|
||||
if (msg.embeds.length && msg.embeds[0].image) {
|
||||
embed.image = msg.embeds[0].image;
|
||||
}
|
||||
|
||||
embed.fields = [{ name: EMPTY_CHAR, value: `[Jump to message](${messageLink(msg)})` }];
|
||||
|
||||
const starboardMessage = await (channel as TextChannel).createMessage({ embed });
|
||||
await this.starboardMessages.createStarboardMessage(channel.id, msg.id, starboardMessage.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a message from the specified starboard
|
||||
*/
|
||||
async removeMessageFromStarboard(msgId: string, starboard: Starboard) {
|
||||
const starboardMessage = await this.starboards.getStarboardMessageByStarboardIdAndMessageId(starboard.id, msgId);
|
||||
if (!starboardMessage) return;
|
||||
async removeMessageFromStarboard(msg: StarboardMessage) {
|
||||
await this.bot.deleteMessage(msg.starboard_channel_id, msg.starboard_message_id).catch(noop);
|
||||
}
|
||||
|
||||
await this.bot.deleteMessage(starboard.channel_id, starboardMessage.starboard_message_id).catch(noop);
|
||||
await this.starboards.deleteStarboardMessage(starboard.id, msgId);
|
||||
async removeMessageFromStarboardMessages(starboard_message_id: string, channel_id: string) {
|
||||
await this.starboardMessages.deleteStarboardMessage(starboard_message_id, channel_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* When a message is deleted, also delete it from any starboards it's been posted in.
|
||||
* Likewise, if a starboard message (i.e. the bot message in the starboard) is deleted, remove it from the database.
|
||||
* This function is called in response to GuildSavedMessages events.
|
||||
* TODO: When a message is removed from the starboard itself, i.e. the bot's embed is removed, also remove that message from the starboard_messages database table
|
||||
*/
|
||||
async onMessageDelete(msg: SavedMessage) {
|
||||
const starboardMessages = await this.starboards.with("starboard").getStarboardMessagesByMessageId(msg.id);
|
||||
if (!starboardMessages.length) return;
|
||||
|
||||
// Deleted source message
|
||||
const starboardMessages = await this.starboardMessages.getStarboardMessagesForMessageId(msg.id);
|
||||
for (const starboardMessage of starboardMessages) {
|
||||
if (!starboardMessage.starboard) continue;
|
||||
this.removeMessageFromStarboard(starboardMessage.message_id, starboardMessage.starboard);
|
||||
this.removeMessageFromStarboard(starboardMessage);
|
||||
}
|
||||
|
||||
// Deleted message from the starboard
|
||||
const deletedStarboardMessages = await this.starboardMessages.getStarboardMessagesForStarboardMessageId(msg.id);
|
||||
if (deletedStarboardMessages.length === 0) return;
|
||||
|
||||
for (const starboardMessage of deletedStarboardMessages) {
|
||||
this.removeMessageFromStarboardMessages(
|
||||
starboardMessage.starboard_message_id,
|
||||
starboardMessage.starboard_channel_id,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@d.command("starboard migrate_pins", "<pinChannelId:channelId> <starboardChannelId:channelId>")
|
||||
async migratePinsCmd(msg: Message, args: { pinChannelId: string; starboardChannelId }) {
|
||||
const starboard = await this.starboards.getStarboardByChannelId(args.starboardChannelId);
|
||||
@d.command("starboard migrate_pins", "<pinChannel:channel> <starboardName:string>", {
|
||||
extra: {
|
||||
info: {
|
||||
description:
|
||||
"Posts all pins from a channel to the specified starboard. The pins are NOT unpinned automatically.",
|
||||
},
|
||||
},
|
||||
})
|
||||
@d.permission("can_migrate")
|
||||
async migratePinsCmd(msg: Message, args: { pinChannel: GuildChannel; starboardName: string }) {
|
||||
const config = await this.getConfig();
|
||||
const starboard = config.boards[args.starboardName];
|
||||
if (!starboard) {
|
||||
msg.channel.createMessage(errorMessage("The specified channel doesn't have a starboard!"));
|
||||
this.sendErrorMessage(msg.channel, "Unknown starboard specified");
|
||||
return;
|
||||
}
|
||||
|
||||
const channel = (await this.guild.channels.get(args.pinChannelId)) as GuildChannel & TextChannel;
|
||||
if (!channel) {
|
||||
msg.channel.createMessage(errorMessage("Could not find the specified channel to migrate pins from!"));
|
||||
if (!(args.pinChannel instanceof TextChannel)) {
|
||||
this.sendErrorMessage(msg.channel, "Unknown/invalid pin channel id");
|
||||
return;
|
||||
}
|
||||
|
||||
msg.channel.createMessage(`Migrating pins from <#${channel.id}> to <#${args.starboardChannelId}>...`);
|
||||
const starboardChannel = this.guild.channels.get(starboard.channel_id);
|
||||
if (!starboardChannel || !(starboardChannel instanceof TextChannel)) {
|
||||
this.sendErrorMessage(msg.channel, "Starboard has an unknown/invalid channel id");
|
||||
return;
|
||||
}
|
||||
|
||||
const pins = await channel.getPins();
|
||||
msg.channel.createMessage(`Migrating pins from <#${args.pinChannel.id}> to <#${starboardChannel.id}>...`);
|
||||
|
||||
const pins = await args.pinChannel.getPins();
|
||||
pins.reverse(); // Migrate pins starting from the oldest message
|
||||
|
||||
for (const pin of pins) {
|
||||
const existingStarboardMessage = await this.starboards.getStarboardMessageByStarboardIdAndMessageId(
|
||||
starboard.id,
|
||||
const existingStarboardMessage = await this.starboardMessages.getMatchingStarboardMessages(
|
||||
starboardChannel.id,
|
||||
pin.id,
|
||||
);
|
||||
if (existingStarboardMessage) continue;
|
||||
|
||||
await this.saveMessageToStarboard(pin, starboard);
|
||||
if (existingStarboardMessage.length > 0) continue;
|
||||
await this.saveMessageToStarboard(pin, starboardChannel.id);
|
||||
}
|
||||
|
||||
msg.channel.createMessage(successMessage("Pins migrated!"));
|
||||
this.sendSuccessMessage(msg.channel, `Pins migrated from <#${args.pinChannel.id}> to <#${starboardChannel.id}>!`);
|
||||
}
|
||||
}
|
||||
|
|
155
backend/src/plugins/Stats.ts
Normal file
155
backend/src/plugins/Stats.ts
Normal file
|
@ -0,0 +1,155 @@
|
|||
import { ZeppelinPlugin } from "./ZeppelinPlugin";
|
||||
import * as t from "io-ts";
|
||||
import { convertDelayStringToMS, DAYS, HOURS, tAlphanumeric, tDateTime, tDeepPartial, tDelayString } from "../utils";
|
||||
import { IPluginOptions } from "knub";
|
||||
import moment from "moment-timezone";
|
||||
import { GuildStats } from "../data/GuildStats";
|
||||
import { Message } from "eris";
|
||||
import escapeStringRegexp from "escape-string-regexp";
|
||||
import { SavedMessage } from "../data/entities/SavedMessage";
|
||||
import { GuildSavedMessages } from "../data/GuildSavedMessages";
|
||||
|
||||
const tBaseSource = t.type({
|
||||
name: tAlphanumeric,
|
||||
track: t.boolean,
|
||||
retention_period: tDelayString,
|
||||
});
|
||||
|
||||
const tMemberMessagesSource = t.intersection([
|
||||
tBaseSource,
|
||||
t.type({
|
||||
type: t.literal("member_messages"),
|
||||
}),
|
||||
]);
|
||||
type TMemberMessagesSource = t.TypeOf<typeof tMemberMessagesSource>;
|
||||
|
||||
const tChannelMessagesSource = t.intersection([
|
||||
tBaseSource,
|
||||
t.type({
|
||||
type: t.literal("channel_messages"),
|
||||
}),
|
||||
]);
|
||||
type TChannelMessagesSource = t.TypeOf<typeof tChannelMessagesSource>;
|
||||
|
||||
const tKeywordsSource = t.intersection([
|
||||
tBaseSource,
|
||||
t.type({
|
||||
type: t.literal("keywords"),
|
||||
keywords: t.array(t.string),
|
||||
}),
|
||||
]);
|
||||
type TKeywordsSource = t.TypeOf<typeof tKeywordsSource>;
|
||||
|
||||
const tSource = t.union([tMemberMessagesSource, tChannelMessagesSource, tKeywordsSource]);
|
||||
type TSource = t.TypeOf<typeof tSource>;
|
||||
|
||||
const tConfigSchema = t.type({
|
||||
sources: t.record(tAlphanumeric, tSource),
|
||||
});
|
||||
|
||||
type TConfigSchema = t.TypeOf<typeof tConfigSchema>;
|
||||
const tPartialConfigSchema = tDeepPartial(tConfigSchema);
|
||||
|
||||
const DEFAULT_RETENTION_PERIOD = "4w";
|
||||
|
||||
export class StatsPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||
public static pluginName = "stats";
|
||||
public static configSchema = tConfigSchema;
|
||||
public static showInDocs = false;
|
||||
|
||||
protected stats: GuildStats;
|
||||
protected savedMessages: GuildSavedMessages;
|
||||
|
||||
private onMessageCreateFn;
|
||||
private cleanStatsInterval;
|
||||
|
||||
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
||||
return {
|
||||
config: {
|
||||
sources: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
protected static preprocessStaticConfig(config: t.TypeOf<typeof tPartialConfigSchema>) {
|
||||
// TODO: Limit min period, min period start date
|
||||
|
||||
if (config.sources) {
|
||||
for (const [key, source] of Object.entries(config.sources)) {
|
||||
source.name = key;
|
||||
|
||||
if (source.track == null) {
|
||||
source.track = true;
|
||||
}
|
||||
|
||||
if (source.retention_period == null) {
|
||||
source.retention_period = DEFAULT_RETENTION_PERIOD;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
protected onLoad() {
|
||||
this.stats = GuildStats.getGuildInstance(this.guildId);
|
||||
this.savedMessages = GuildSavedMessages.getGuildInstance(this.guildId);
|
||||
|
||||
this.onMessageCreateFn = this.savedMessages.events.on("create", msg => this.onMessageCreate(msg));
|
||||
|
||||
this.cleanOldStats();
|
||||
this.cleanStatsInterval = setInterval(() => this.cleanOldStats(), 1 * DAYS);
|
||||
}
|
||||
|
||||
protected onUnload() {
|
||||
this.savedMessages.events.off("create", this.onMessageCreateFn);
|
||||
clearInterval(this.cleanStatsInterval);
|
||||
}
|
||||
|
||||
protected async cleanOldStats() {
|
||||
const config = this.getConfig();
|
||||
for (const source of Object.values(config.sources)) {
|
||||
const cutoffMS = convertDelayStringToMS(source.retention_period);
|
||||
const cutoff = moment()
|
||||
.subtract(cutoffMS, "ms")
|
||||
.format("YYYY-MM-DD HH:mm:ss");
|
||||
await this.stats.deleteOldValues(source.name, cutoff);
|
||||
}
|
||||
}
|
||||
|
||||
protected saveMemberMessagesStats(source: TMemberMessagesSource, msg: SavedMessage) {
|
||||
this.stats.saveValue(source.name, msg.user_id, 1);
|
||||
}
|
||||
|
||||
protected saveChannelMessagesStats(source: TChannelMessagesSource, msg: SavedMessage) {
|
||||
this.stats.saveValue(source.name, msg.channel_id, 1);
|
||||
}
|
||||
|
||||
protected saveKeywordsStats(source: TKeywordsSource, msg: SavedMessage) {
|
||||
const content = msg.data.content;
|
||||
if (!content) return;
|
||||
|
||||
for (const keyword of source.keywords) {
|
||||
const regex = new RegExp(`\\b${escapeStringRegexp(keyword)}\\b`, "i");
|
||||
if (content.match(regex)) {
|
||||
this.stats.saveValue(source.name, "keyword", 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMessageCreate(msg: SavedMessage) {
|
||||
const config = this.getConfigForMemberIdAndChannelId(msg.user_id, msg.channel_id);
|
||||
for (const source of Object.values(config.sources)) {
|
||||
if (!source.track) continue;
|
||||
|
||||
if (source.type === "member_messages") {
|
||||
this.saveMemberMessagesStats(source, msg);
|
||||
} else if (source.type === "channel_messages") {
|
||||
this.saveChannelMessagesStats(source, msg);
|
||||
} else if (source.type === "keywords") {
|
||||
this.saveKeywordsStats(source, msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -183,8 +183,20 @@ export class TagsPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
msg.channel.createMessage(successMessage(`Tag set! Use it with: \`${prefix}${args.tag}\``));
|
||||
}
|
||||
|
||||
@d.command("tag", "<tag:string>")
|
||||
async tagSourceCmd(msg: Message, args: { tag: string }) {
|
||||
@d.command("tag", "<tag:string>", {
|
||||
options: [
|
||||
{
|
||||
name: "delete",
|
||||
shortcut: "d",
|
||||
isSwitch: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
async tagSourceCmd(msg: Message, args: { tag: string; delete?: boolean }) {
|
||||
if (args.delete) {
|
||||
return this.deleteTagCmd(msg, { tag: args.tag });
|
||||
}
|
||||
|
||||
const tag = await this.tags.find(args.tag);
|
||||
if (!tag) {
|
||||
msg.channel.createMessage(errorMessage("No tag with that name"));
|
||||
|
|
|
@ -29,6 +29,7 @@ import {
|
|||
get,
|
||||
getInviteCodesInString,
|
||||
isSnowflake,
|
||||
messageLink,
|
||||
MINUTES,
|
||||
multiSorter,
|
||||
noop,
|
||||
|
@ -56,6 +57,9 @@ import { getCurrentUptime } from "../uptime";
|
|||
import LCL from "last-commit-log";
|
||||
import * as t from "io-ts";
|
||||
import { ICommandDefinition } from "knub-command-manager";
|
||||
import path from "path";
|
||||
import escapeStringRegexp from "escape-string-regexp";
|
||||
import safeRegex from "safe-regex";
|
||||
|
||||
const ConfigSchema = t.type({
|
||||
can_roles: t.boolean,
|
||||
|
@ -71,16 +75,20 @@ const ConfigSchema = t.type({
|
|||
can_vcmove: t.boolean,
|
||||
can_help: t.boolean,
|
||||
can_about: t.boolean,
|
||||
can_context: t.boolean,
|
||||
});
|
||||
type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
|
||||
|
||||
const { performance } = require("perf_hooks");
|
||||
|
||||
const SEARCH_RESULTS_PER_PAGE = 15;
|
||||
const SEARCH_ID_RESULTS_PER_PAGE = 50;
|
||||
|
||||
const MAX_CLEAN_COUNT = 150;
|
||||
const MAX_CLEAN_TIME = 1 * DAYS;
|
||||
const CLEAN_COMMAND_DELETE_DELAY = 5000;
|
||||
const MEMBER_REFRESH_FREQUENCY = 10 * 60 * 1000; // How often to do a full member refresh when using !search or !roles --counts
|
||||
const SEARCH_EXPORT_LIMIT = 1_000_000;
|
||||
|
||||
const activeReloads: Map<string, TextChannel> = new Map();
|
||||
|
||||
|
@ -88,10 +96,14 @@ type MemberSearchParams = {
|
|||
query?: string;
|
||||
role?: string;
|
||||
voice?: boolean;
|
||||
bot?: boolean;
|
||||
sort?: string;
|
||||
"case-sensitive"?: boolean;
|
||||
regex?: boolean;
|
||||
};
|
||||
|
||||
class SearchError extends Error {}
|
||||
|
||||
export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||
public static pluginName = "utility";
|
||||
public static configSchema = ConfigSchema;
|
||||
|
@ -124,6 +136,7 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
can_vcmove: false,
|
||||
can_help: false,
|
||||
can_about: false,
|
||||
can_context: false,
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
|
@ -138,6 +151,7 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
can_nickname: true,
|
||||
can_vcmove: true,
|
||||
can_help: true,
|
||||
can_context: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
@ -177,7 +191,7 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
options: [
|
||||
{
|
||||
name: "counts",
|
||||
type: "bool",
|
||||
isSwitch: true,
|
||||
},
|
||||
{
|
||||
name: "sort",
|
||||
|
@ -320,18 +334,27 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
matchingMembers = matchingMembers.filter(m => m.voiceState.channelID != null);
|
||||
}
|
||||
|
||||
if (args.bot) {
|
||||
matchingMembers = matchingMembers.filter(m => m.bot);
|
||||
}
|
||||
|
||||
if (args.query) {
|
||||
const query = args["case-sensitive"] ? args.query.trimStart() : args.query.toLowerCase().trimStart();
|
||||
let queryRegex: RegExp;
|
||||
if (args.regex) {
|
||||
queryRegex = new RegExp(args.query.trimStart(), args["case-sensitive"] ? "" : "i");
|
||||
} else {
|
||||
queryRegex = new RegExp(escapeStringRegexp(args.query.trimStart()), args["case-sensitive"] ? "" : "i");
|
||||
}
|
||||
|
||||
if (!safeRegex(queryRegex)) {
|
||||
throw new SearchError("Unsafe/too complex regex (star depth is limited to 1)");
|
||||
}
|
||||
|
||||
matchingMembers = matchingMembers.filter(member => {
|
||||
const nick = args["case-sensitive"] ? member.nick : member.nick && member.nick.toLowerCase();
|
||||
if (member.nick && member.nick.match(queryRegex)) return true;
|
||||
|
||||
const fullUsername = args["case-sensitive"]
|
||||
? `${member.user.username}#${member.user.discriminator}`
|
||||
: `${member.user.username}#${member.user.discriminator}`.toLowerCase();
|
||||
|
||||
if (nick && nick.indexOf(query) !== -1) return true;
|
||||
if (fullUsername.indexOf(query) !== -1) return true;
|
||||
const fullUsername = `${member.user.username}#${member.user.discriminator}`;
|
||||
if (fullUsername.match(queryRegex)) return true;
|
||||
|
||||
return false;
|
||||
});
|
||||
|
@ -344,15 +367,18 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
matchingMembers.sort(sorter(m => BigInt(m.id), realSortDir));
|
||||
} else {
|
||||
matchingMembers.sort(
|
||||
multiSorter([[m => m.username.toLowerCase(), realSortDir], [m => m.discriminator, realSortDir]]),
|
||||
multiSorter([
|
||||
[m => m.username.toLowerCase(), realSortDir],
|
||||
[m => m.discriminator, realSortDir],
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
const lastPage = Math.ceil(matchingMembers.length / SEARCH_RESULTS_PER_PAGE);
|
||||
const lastPage = Math.max(1, Math.ceil(matchingMembers.length / perPage));
|
||||
page = Math.min(lastPage, Math.max(1, page));
|
||||
|
||||
const from = (page - 1) * SEARCH_RESULTS_PER_PAGE;
|
||||
const to = Math.min(from + SEARCH_RESULTS_PER_PAGE, matchingMembers.length);
|
||||
const from = (page - 1) * perPage;
|
||||
const to = Math.min(from + perPage, matchingMembers.length);
|
||||
|
||||
const pageMembers = matchingMembers.slice(from, to);
|
||||
|
||||
|
@ -371,15 +397,23 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
options: [
|
||||
{
|
||||
name: "page",
|
||||
shortcut: "p",
|
||||
type: "number",
|
||||
},
|
||||
{
|
||||
name: "role",
|
||||
shortcut: "r",
|
||||
type: "string",
|
||||
},
|
||||
{
|
||||
name: "voice",
|
||||
type: "bool",
|
||||
shortcut: "v",
|
||||
isSwitch: true,
|
||||
},
|
||||
{
|
||||
name: "bot",
|
||||
shortcut: "b",
|
||||
isSwitch: true,
|
||||
},
|
||||
{
|
||||
name: "sort",
|
||||
|
@ -395,6 +429,15 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
shortcut: "e",
|
||||
isSwitch: true,
|
||||
},
|
||||
{
|
||||
name: "ids",
|
||||
isSwitch: true,
|
||||
},
|
||||
{
|
||||
name: "regex",
|
||||
shortcut: "re",
|
||||
isSwitch: true,
|
||||
},
|
||||
],
|
||||
extra: {
|
||||
info: <CommandInfo>{
|
||||
|
@ -417,15 +460,18 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
msg: Message,
|
||||
args: {
|
||||
query?: string;
|
||||
role?: string;
|
||||
page?: number;
|
||||
role?: string;
|
||||
voice?: boolean;
|
||||
bot?: boolean;
|
||||
sort?: string;
|
||||
"case-sensitive"?: boolean;
|
||||
export?: boolean;
|
||||
ids?: boolean;
|
||||
regex?: boolean;
|
||||
},
|
||||
) {
|
||||
const formatSearchResultLines = (members: Member[]) => {
|
||||
const formatSearchResultList = (members: Member[]): string => {
|
||||
const longestId = members.reduce((longest, member) => Math.max(longest, member.id.length), 0);
|
||||
const lines = members.map(member => {
|
||||
const paddedId = member.id.padEnd(longestId, " ");
|
||||
|
@ -433,23 +479,38 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
if (member.nick) line += ` (${member.nick})`;
|
||||
return line;
|
||||
});
|
||||
return lines;
|
||||
return lines.join("\n");
|
||||
};
|
||||
|
||||
const formatSearchResultIdList = (members: Member[]): string => {
|
||||
return members.map(m => m.id).join(" ");
|
||||
};
|
||||
|
||||
// If we're exporting the results, we don't need all the fancy schmancy pagination stuff.
|
||||
// Just get the results and dump them in an archive.
|
||||
if (args.export) {
|
||||
const results = await this.performMemberSearch(args, 1, Infinity);
|
||||
let results;
|
||||
try {
|
||||
results = await this.performMemberSearch(args, 1, SEARCH_EXPORT_LIMIT);
|
||||
} catch (e) {
|
||||
if (e instanceof SearchError) {
|
||||
return this.sendErrorMessage(msg.channel, e.message);
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
|
||||
if (results.totalResults === 0) {
|
||||
return this.sendErrorMessage(msg.channel, "No results found");
|
||||
}
|
||||
|
||||
const resultLines = formatSearchResultLines(results.results);
|
||||
const resultList = args.ids ? formatSearchResultIdList(results.results) : formatSearchResultList(results.results);
|
||||
|
||||
const archiveId = await this.archives.create(
|
||||
trimLines(`
|
||||
Search results (total ${results.totalResults}):
|
||||
|
||||
${resultLines.join("\n")}
|
||||
${resultList}
|
||||
`),
|
||||
moment().add(1, "hour"),
|
||||
);
|
||||
|
@ -468,6 +529,8 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
let clearReactionsFn = null;
|
||||
let clearReactionsTimeout = null;
|
||||
|
||||
const perPage = args.ids ? SEARCH_ID_RESULTS_PER_PAGE : SEARCH_RESULTS_PER_PAGE;
|
||||
|
||||
const loadSearchPage = async page => {
|
||||
if (searching) return;
|
||||
searching = true;
|
||||
|
@ -482,23 +545,37 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
searchMsgPromise.then(m => (originalSearchMsg = m));
|
||||
}
|
||||
|
||||
const searchResult = await this.performMemberSearch(args, page, SEARCH_RESULTS_PER_PAGE);
|
||||
let searchResult;
|
||||
try {
|
||||
searchResult = await this.performMemberSearch(args, page, perPage);
|
||||
} catch (e) {
|
||||
if (e instanceof SearchError) {
|
||||
return this.sendErrorMessage(msg.channel, e.message);
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
|
||||
if (searchResult.totalResults === 0) {
|
||||
return this.sendErrorMessage(msg.channel, "No results found");
|
||||
}
|
||||
|
||||
const resultWord = searchResult.totalResults === 1 ? "matching member" : "matching members";
|
||||
const headerText =
|
||||
searchResult.totalResults > SEARCH_RESULTS_PER_PAGE
|
||||
searchResult.totalResults > perPage
|
||||
? trimLines(`
|
||||
**Page ${searchResult.page}** (${searchResult.from}-${searchResult.to}) (total ${searchResult.totalResults})
|
||||
`)
|
||||
: `Found ${searchResult.totalResults} ${resultWord}`;
|
||||
const lines = formatSearchResultLines(searchResult.results);
|
||||
|
||||
const resultList = args.ids
|
||||
? formatSearchResultIdList(searchResult.results)
|
||||
: formatSearchResultList(searchResult.results);
|
||||
|
||||
const result = trimLines(`
|
||||
${headerText}
|
||||
\`\`\`js
|
||||
${lines.join("\n")}
|
||||
${resultList}
|
||||
\`\`\`
|
||||
`);
|
||||
|
||||
|
@ -506,7 +583,7 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
searchMsg.edit(result);
|
||||
|
||||
// Set up pagination reactions if needed. The reactions are cleared after a timeout.
|
||||
if (searchResult.totalResults > SEARCH_RESULTS_PER_PAGE) {
|
||||
if (searchResult.totalResults > perPage) {
|
||||
if (!hasReactions) {
|
||||
hasReactions = true;
|
||||
searchMsg.addReaction("⬅");
|
||||
|
@ -514,6 +591,7 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
searchMsg.addReaction("🔄");
|
||||
|
||||
const removeListenerFn = this.on("messageReactionAdd", (rMsg: Message, emoji, userId) => {
|
||||
if (rMsg.id !== searchMsg.id) return;
|
||||
if (userId !== msg.author.id) return;
|
||||
if (!["⬅", "➡", "🔄"].includes(emoji.name)) return;
|
||||
|
||||
|
@ -571,6 +649,8 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
count: savedMessages.length,
|
||||
archiveUrl,
|
||||
});
|
||||
|
||||
return { archiveUrl };
|
||||
}
|
||||
|
||||
@d.command("clean", "<count:number>", {
|
||||
|
@ -683,17 +763,19 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
|
||||
let responseMsg: Message;
|
||||
if (messagesToClean.length > 0) {
|
||||
await this.cleanMessages(targetChannel, messagesToClean, msg.author);
|
||||
const cleanResult = await this.cleanMessages(targetChannel, messagesToClean, msg.author);
|
||||
|
||||
let responseText = `Cleaned ${messagesToClean.length} ${messagesToClean.length === 1 ? "message" : "messages"}`;
|
||||
if (targetChannel.id !== msg.channel.id) responseText += ` in <#${targetChannel.id}>`;
|
||||
if (targetChannel.id !== msg.channel.id) {
|
||||
responseText += ` in <#${targetChannel.id}>\n${cleanResult.archiveUrl}`;
|
||||
}
|
||||
|
||||
responseMsg = await msg.channel.createMessage(successMessage(responseText));
|
||||
} else {
|
||||
responseMsg = await msg.channel.createMessage(errorMessage(`Found no messages to clean!`));
|
||||
}
|
||||
|
||||
if (targetChannel.id !== msg.channel.id) {
|
||||
if (targetChannel.id === msg.channel.id) {
|
||||
// Delete the !clean command and the bot response if a different channel wasn't specified
|
||||
// (so as not to spam the cleaned channel with the command itself)
|
||||
setTimeout(() => {
|
||||
|
@ -710,9 +792,16 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
basicUsage: "!info 106391128718245888",
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: "compact",
|
||||
shortcut: "c",
|
||||
isSwitch: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
@d.permission("can_info")
|
||||
async infoCmd(msg: Message, args: { user?: User | UnknownUser }) {
|
||||
async infoCmd(msg: Message, args: { user?: User | UnknownUser; compact?: boolean }) {
|
||||
const user = args.user || msg.author;
|
||||
|
||||
let member;
|
||||
|
@ -734,15 +823,40 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
embed.title = `${user.username}#${user.discriminator}`;
|
||||
embed.thumbnail = { url: user.avatarURL };
|
||||
|
||||
embed.fields.push({
|
||||
name: "User information",
|
||||
value:
|
||||
trimLines(`
|
||||
ID: **${user.id}**
|
||||
Profile: <@!${user.id}>
|
||||
Created: **${accountAge} ago (${createdAt.format("YYYY-MM-DD[T]HH:mm:ss")})**
|
||||
`) + embedPadding,
|
||||
});
|
||||
if (args.compact) {
|
||||
embed.fields.push({
|
||||
name: "User information",
|
||||
value: trimLines(`
|
||||
Profile: <@!${user.id}>
|
||||
Created: **${accountAge} ago (${createdAt.format("YYYY-MM-DD[T]HH:mm:ss")})**
|
||||
`),
|
||||
});
|
||||
if (member) {
|
||||
const joinedAt = moment(member.joinedAt);
|
||||
const joinAge = humanizeDuration(moment().valueOf() - member.joinedAt, {
|
||||
largest: 2,
|
||||
round: true,
|
||||
});
|
||||
embed.fields[0].value += `\nJoined: **${joinAge} ago (${joinedAt.format("YYYY-MM-DD[T]HH:mm:ss")})**`;
|
||||
} else {
|
||||
embed.fields.push({
|
||||
name: "!! USER IS NOT ON THE SERVER !!",
|
||||
value: embedPadding,
|
||||
});
|
||||
}
|
||||
msg.channel.createMessage({ embed });
|
||||
return;
|
||||
} else {
|
||||
embed.fields.push({
|
||||
name: "User information",
|
||||
value:
|
||||
trimLines(`
|
||||
ID: **${user.id}**
|
||||
Profile: <@!${user.id}>
|
||||
Created: **${accountAge} ago (${createdAt.format("YYYY-MM-DD[T]HH:mm:ss")})**
|
||||
`) + embedPadding,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
embed.title = `Unknown user`;
|
||||
}
|
||||
|
@ -782,7 +896,6 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
value: embedPadding,
|
||||
});
|
||||
}
|
||||
|
||||
const cases = (await this.cases.getByUserId(user.id)).filter(c => !c.is_hidden);
|
||||
|
||||
if (cases.length > 0) {
|
||||
|
@ -994,7 +1107,12 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
);
|
||||
|
||||
// Clean up test messages
|
||||
this.bot.deleteMessages(messages[0].channel.id, messages.map(m => m.id)).catch(noop);
|
||||
this.bot
|
||||
.deleteMessages(
|
||||
messages[0].channel.id,
|
||||
messages.map(m => m.id),
|
||||
)
|
||||
.catch(noop);
|
||||
}
|
||||
|
||||
@d.command("source", "<messageId:string>", {
|
||||
|
@ -1021,6 +1139,30 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
msg.channel.createMessage(`Message source: ${url}`);
|
||||
}
|
||||
|
||||
@d.command("context", "<channel:channel> <messageId:string>", {
|
||||
extra: {
|
||||
info: <CommandInfo>{
|
||||
description: "Get a link to the context of the specified message",
|
||||
basicUsage: "!context 94882524378968064 650391267720822785",
|
||||
},
|
||||
},
|
||||
})
|
||||
@d.permission("can_context")
|
||||
async contextCmd(msg: Message, args: { channel: Channel; messageId: string }) {
|
||||
if (!(args.channel instanceof TextChannel)) {
|
||||
this.sendErrorMessage(msg.channel, "Channel must be a text channel");
|
||||
return;
|
||||
}
|
||||
|
||||
const previousMessage = (await this.bot.getMessages(args.channel.id, 1, args.messageId))[0];
|
||||
if (!previousMessage) {
|
||||
this.sendErrorMessage(msg.channel, "Message context not found");
|
||||
return;
|
||||
}
|
||||
|
||||
msg.channel.createMessage(messageLink(this.guildId, previousMessage.channel.id, previousMessage.id));
|
||||
}
|
||||
|
||||
@d.command("vcmove", "<member:resolvedMember> <channel:string$>", {
|
||||
extra: {
|
||||
info: <CommandInfo>{
|
||||
|
@ -1192,8 +1334,25 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
const uptime = getCurrentUptime();
|
||||
const prettyUptime = humanizeDuration(uptime, { largest: 2, round: true });
|
||||
|
||||
const lcl = new LCL();
|
||||
const lastCommit = await lcl.getLastCommit();
|
||||
let lastCommit;
|
||||
|
||||
try {
|
||||
// From project root
|
||||
// FIXME: Store these paths properly somewhere
|
||||
const lcl = new LCL(path.resolve(__dirname, "..", "..", ".."));
|
||||
lastCommit = await lcl.getLastCommit();
|
||||
} catch (e) {} // tslint:disable-line:no-empty
|
||||
|
||||
let lastUpdate;
|
||||
let version;
|
||||
|
||||
if (lastCommit) {
|
||||
lastUpdate = moment(lastCommit.committer.date, "X").format("LL [at] H:mm [(UTC)]");
|
||||
version = lastCommit.shortHash;
|
||||
} else {
|
||||
lastUpdate = "?";
|
||||
version = "?";
|
||||
}
|
||||
|
||||
const shard = this.bot.shards.get(this.bot.guildShardMap[this.guildId]);
|
||||
|
||||
|
@ -1205,8 +1364,8 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
const basicInfoRows = [
|
||||
["Uptime", prettyUptime],
|
||||
["Last reload", `${lastReload} ago`],
|
||||
["Last update", moment(lastCommit.committer.date, "X").format("LL [at] H:mm [(UTC)]")],
|
||||
["Version", lastCommit.shortHash],
|
||||
["Last update", lastUpdate],
|
||||
["Version", version],
|
||||
["API latency", `${shard.latency}ms`],
|
||||
];
|
||||
|
||||
|
|
|
@ -12,14 +12,16 @@ import {
|
|||
resolveMember,
|
||||
resolveUser,
|
||||
resolveUserId,
|
||||
tDeepPartial,
|
||||
trimEmptyStartEndLines,
|
||||
trimIndents,
|
||||
UnknownUser,
|
||||
resolveRoleId,
|
||||
} from "../utils";
|
||||
import { Invite, Member, User } from "eris";
|
||||
import DiscordRESTError from "eris/lib/errors/DiscordRESTError"; // tslint:disable-line
|
||||
import { performance } from "perf_hooks";
|
||||
import { decodeAndValidateStrict, StrictValidationError } from "../validatorUtils";
|
||||
import { decodeAndValidateStrict, StrictValidationError, validate } from "../validatorUtils";
|
||||
import { SimpleCache } from "../SimpleCache";
|
||||
|
||||
const SLOW_RESOLVE_THRESHOLD = 1500;
|
||||
|
@ -52,8 +54,8 @@ export interface CommandInfo {
|
|||
export function trimPluginDescription(str) {
|
||||
const emptyLinesTrimmed = trimEmptyStartEndLines(str);
|
||||
const lines = emptyLinesTrimmed.split("\n");
|
||||
const lastLineIndentation = (lines[lines.length - 1].match(/^ +/g) || [""])[0].length;
|
||||
return trimIndents(emptyLinesTrimmed, lastLineIndentation);
|
||||
const firstLineIndentation = (lines[0].match(/^ +/g) || [""])[0].length;
|
||||
return trimIndents(emptyLinesTrimmed, firstLineIndentation);
|
||||
}
|
||||
|
||||
const inviteCache = new SimpleCache<Promise<Invite>>(10 * MINUTES, 200);
|
||||
|
@ -69,14 +71,14 @@ export class ZeppelinPlugin<TConfig extends {} = IBasePluginConfig> extends Plug
|
|||
throw new PluginRuntimeError(message, this.runtimePluginName, this.guildId);
|
||||
}
|
||||
|
||||
protected canActOn(member1, member2) {
|
||||
if (member1.id === member2.id || member2.id === this.bot.user.id) {
|
||||
protected canActOn(member1: Member, member2: Member, allowSameLevel = false) {
|
||||
if (member2.id === this.bot.user.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const ourLevel = this.getMemberLevel(member1);
|
||||
const memberLevel = this.getMemberLevel(member2);
|
||||
return ourLevel > memberLevel;
|
||||
return allowSameLevel ? ourLevel >= memberLevel : ourLevel > memberLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -121,6 +123,13 @@ export class ZeppelinPlugin<TConfig extends {} = IBasePluginConfig> extends Plug
|
|||
? options.overrides
|
||||
: (defaultOptions.overrides || []).concat(options.overrides || []);
|
||||
|
||||
// Before preprocessing the static config, do a loose check by checking the schema as deeply partial.
|
||||
// This way the preprocessing function can trust that if a property exists, its value will be the correct (partial) type.
|
||||
const initialLooseCheck = this.configSchema ? validate(tDeepPartial(this.configSchema), mergedConfig) : null;
|
||||
if (initialLooseCheck) {
|
||||
throw initialLooseCheck;
|
||||
}
|
||||
|
||||
mergedConfig = this.preprocessStaticConfig(mergedConfig);
|
||||
|
||||
const decodedConfig = this.configSchema ? decodeAndValidateStrict(this.configSchema, mergedConfig) : mergedConfig;
|
||||
|
@ -229,6 +238,16 @@ export class ZeppelinPlugin<TConfig extends {} = IBasePluginConfig> extends Plug
|
|||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a role from the passed string. The passed string can be a role ID, a role mention or a role name.
|
||||
* In the event of duplicate role names, this function will return the first one it comes across.
|
||||
* @param roleResolvable
|
||||
*/
|
||||
async resolveRoleId(roleResolvable: string): Promise<string | null> {
|
||||
const roleId = await resolveRoleId(this.bot, this.guildId, roleResolvable);
|
||||
return roleId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a member from the passed string. The passed string can be a user id, a user mention, a full username (with discrim), etc.
|
||||
* If the member is not found in the cache, it's fetched from the API.
|
||||
|
|
|
@ -27,6 +27,7 @@ import { LocatePlugin } from "./LocateUser";
|
|||
import { GuildConfigReloader } from "./GuildConfigReloader";
|
||||
import { ChannelArchiverPlugin } from "./ChannelArchiver";
|
||||
import { AutomodPlugin } from "./Automod";
|
||||
import { RolesPlugin } from "./Roles";
|
||||
|
||||
/**
|
||||
* Plugins available to be loaded for individual guilds
|
||||
|
@ -58,6 +59,7 @@ export const availablePlugins = [
|
|||
CompanionChannelPlugin,
|
||||
LocatePlugin,
|
||||
ChannelArchiverPlugin,
|
||||
RolesPlugin,
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
47
backend/src/utils.test.ts
Normal file
47
backend/src/utils.test.ts
Normal file
|
@ -0,0 +1,47 @@
|
|||
import { convertDelayStringToMS, convertMSToDelayString, getUrlsInString } from "./utils";
|
||||
|
||||
import test from "ava";
|
||||
|
||||
test("getUrlsInString(): detects full links", t => {
|
||||
const urls = getUrlsInString("foo https://google.com/ bar");
|
||||
t.is(urls.length, 1);
|
||||
t.is(urls[0].hostname, "google.com");
|
||||
});
|
||||
|
||||
test("getUrlsInString(): detects partial links", t => {
|
||||
const urls = getUrlsInString("foo google.com bar");
|
||||
t.is(urls.length, 1);
|
||||
t.is(urls[0].hostname, "google.com");
|
||||
});
|
||||
|
||||
test("getUrlsInString(): detects subdomains", t => {
|
||||
const urls = getUrlsInString("foo photos.google.com bar");
|
||||
t.is(urls.length, 1);
|
||||
t.is(urls[0].hostname, "photos.google.com");
|
||||
});
|
||||
|
||||
test("delay strings: basic support", t => {
|
||||
const delayString = "2w4d7h32m17s";
|
||||
const expected = 1_582_337_000;
|
||||
t.is(convertDelayStringToMS(delayString), expected);
|
||||
});
|
||||
|
||||
test("delay strings: default unit (minutes)", t => {
|
||||
t.is(convertDelayStringToMS("10"), 10 * 60 * 1000);
|
||||
});
|
||||
|
||||
test("delay strings: custom default unit", t => {
|
||||
t.is(convertDelayStringToMS("10", "s"), 10 * 1000);
|
||||
});
|
||||
|
||||
test("delay strings: reverse conversion", t => {
|
||||
const ms = 1_582_337_020;
|
||||
const expected = "2w4d7h32m17s20x";
|
||||
t.is(convertMSToDelayString(ms), expected);
|
||||
});
|
||||
|
||||
test("delay strings: reverse conversion (conservative)", t => {
|
||||
const ms = 1_209_600_000;
|
||||
const expected = "2w";
|
||||
t.is(convertMSToDelayString(ms), expected);
|
||||
});
|
|
@ -9,6 +9,7 @@ import {
|
|||
GuildAuditLogEntry,
|
||||
GuildChannel,
|
||||
Member,
|
||||
Message,
|
||||
MessageContent,
|
||||
TextableChannel,
|
||||
TextChannel,
|
||||
|
@ -27,6 +28,10 @@ import https from "https";
|
|||
import tmp from "tmp";
|
||||
import { logger, waitForReaction } from "knub";
|
||||
import { SavedMessage } from "./data/entities/SavedMessage";
|
||||
import { decodeAndValidateStrict, StrictValidationError } from "./validatorUtils";
|
||||
import { either } from "fp-ts/lib/Either";
|
||||
import safeRegex from "safe-regex";
|
||||
import moment from "moment-timezone";
|
||||
|
||||
const delayStringMultipliers = {
|
||||
w: 1000 * 60 * 60 * 24 * 7,
|
||||
|
@ -34,6 +39,7 @@ const delayStringMultipliers = {
|
|||
h: 1000 * 60 * 60,
|
||||
m: 1000 * 60,
|
||||
s: 1000,
|
||||
x: 1,
|
||||
};
|
||||
|
||||
export const MS = 1;
|
||||
|
@ -41,11 +47,141 @@ export const SECONDS = 1000 * MS;
|
|||
export const MINUTES = 60 * SECONDS;
|
||||
export const HOURS = 60 * MINUTES;
|
||||
export const DAYS = 24 * HOURS;
|
||||
export const WEEKS = 7 * 24 * HOURS;
|
||||
|
||||
export function tNullable<T extends t.Type<any, any, unknown>>(type: T) {
|
||||
export const EMPTY_CHAR = "\u200b";
|
||||
|
||||
export function tNullable<T extends t.Type<any, any>>(type: T) {
|
||||
return t.union([type, t.undefined, t.null], `Nullable<${type.name}>`);
|
||||
}
|
||||
|
||||
function typeHasProps(type: any): type is t.TypeC<any> {
|
||||
return type.props != null;
|
||||
}
|
||||
|
||||
function typeIsArray(type: any): type is t.ArrayC<any> {
|
||||
return type._tag === "ArrayType";
|
||||
}
|
||||
|
||||
export type TDeepPartial<T> = T extends t.InterfaceType<any>
|
||||
? TDeepPartialProps<T["props"]>
|
||||
: T extends t.DictionaryType<any, any>
|
||||
? t.DictionaryType<T["domain"], TDeepPartial<T["codomain"]>>
|
||||
: T extends t.UnionType<any[]>
|
||||
? t.UnionType<Array<TDeepPartial<T["types"][number]>>>
|
||||
: T extends t.IntersectionType<any>
|
||||
? t.IntersectionType<Array<TDeepPartial<T["types"][number]>>>
|
||||
: T extends t.ArrayType<any>
|
||||
? t.ArrayType<TDeepPartial<T["type"]>>
|
||||
: T;
|
||||
|
||||
// Based on t.PartialC
|
||||
export interface TDeepPartialProps<P extends t.Props>
|
||||
extends t.PartialType<
|
||||
P,
|
||||
{
|
||||
[K in keyof P]?: TDeepPartial<t.TypeOf<P[K]>>;
|
||||
},
|
||||
{
|
||||
[K in keyof P]?: TDeepPartial<t.OutputOf<P[K]>>;
|
||||
}
|
||||
> {}
|
||||
|
||||
export function tDeepPartial<T>(type: T): TDeepPartial<T> {
|
||||
if (type instanceof t.InterfaceType) {
|
||||
const newProps = {};
|
||||
for (const [key, prop] of Object.entries(type.props)) {
|
||||
newProps[key] = tDeepPartial(prop);
|
||||
}
|
||||
return t.partial(newProps) as TDeepPartial<T>;
|
||||
} else if (type instanceof t.DictionaryType) {
|
||||
return t.record(type.domain, tDeepPartial(type.codomain)) as TDeepPartial<T>;
|
||||
} else if (type instanceof t.UnionType) {
|
||||
return t.union(type.types.map(unionType => tDeepPartial(unionType))) as TDeepPartial<T>;
|
||||
} else if (type instanceof t.IntersectionType) {
|
||||
const types = type.types.map(intersectionType => tDeepPartial(intersectionType));
|
||||
return (t.intersection(types as [t.Mixed, t.Mixed]) as unknown) as TDeepPartial<T>;
|
||||
} else if (type instanceof t.ArrayType) {
|
||||
return t.array(tDeepPartial(type.type)) as TDeepPartial<T>;
|
||||
} else {
|
||||
return type as TDeepPartial<T>;
|
||||
}
|
||||
}
|
||||
|
||||
function tDeepPartialProp(prop: any) {
|
||||
if (typeHasProps(prop)) {
|
||||
return tDeepPartial(prop);
|
||||
} else if (typeIsArray(prop)) {
|
||||
return t.array(tDeepPartialProp(prop.type));
|
||||
} else {
|
||||
return prop;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors EmbedOptions from Eris
|
||||
*/
|
||||
export const tEmbed = t.type({
|
||||
title: tNullable(t.string),
|
||||
description: tNullable(t.string),
|
||||
url: tNullable(t.string),
|
||||
timestamp: tNullable(t.string),
|
||||
color: tNullable(t.number),
|
||||
footer: tNullable(
|
||||
t.type({
|
||||
text: t.string,
|
||||
icon_url: tNullable(t.string),
|
||||
proxy_icon_url: tNullable(t.string),
|
||||
}),
|
||||
),
|
||||
image: tNullable(
|
||||
t.type({
|
||||
url: tNullable(t.string),
|
||||
proxy_url: tNullable(t.string),
|
||||
width: tNullable(t.number),
|
||||
height: tNullable(t.number),
|
||||
}),
|
||||
),
|
||||
thumbnail: tNullable(
|
||||
t.type({
|
||||
url: tNullable(t.string),
|
||||
proxy_url: tNullable(t.string),
|
||||
width: tNullable(t.number),
|
||||
height: tNullable(t.number),
|
||||
}),
|
||||
),
|
||||
video: tNullable(
|
||||
t.type({
|
||||
url: tNullable(t.string),
|
||||
width: tNullable(t.number),
|
||||
height: tNullable(t.number),
|
||||
}),
|
||||
),
|
||||
provider: tNullable(
|
||||
t.type({
|
||||
name: t.string,
|
||||
url: tNullable(t.string),
|
||||
}),
|
||||
),
|
||||
fields: tNullable(
|
||||
t.array(
|
||||
t.type({
|
||||
name: tNullable(t.string),
|
||||
value: tNullable(t.string),
|
||||
inline: tNullable(t.boolean),
|
||||
}),
|
||||
),
|
||||
),
|
||||
author: tNullable(
|
||||
t.type({
|
||||
name: t.string,
|
||||
url: tNullable(t.string),
|
||||
width: tNullable(t.number),
|
||||
height: tNullable(t.number),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export function dropPropertiesByName(obj, propName) {
|
||||
if (obj.hasOwnProperty(propName)) delete obj[propName];
|
||||
for (const value of Object.values(obj)) {
|
||||
|
@ -55,6 +191,40 @@ export function dropPropertiesByName(obj, propName) {
|
|||
}
|
||||
}
|
||||
|
||||
export const tAlphanumeric = new t.Type<string, string>(
|
||||
"tAlphanumeric",
|
||||
(s): s is string => typeof s === "string",
|
||||
(from, to) =>
|
||||
either.chain(t.string.validate(from, to), s => {
|
||||
return s.match(/\W/) ? t.failure(from, to, "String must be alphanumeric") : t.success(s);
|
||||
}),
|
||||
s => s,
|
||||
);
|
||||
|
||||
export const tDateTime = new t.Type<string, string>(
|
||||
"tDateTime",
|
||||
(s): s is string => typeof s === "string",
|
||||
(from, to) =>
|
||||
either.chain(t.string.validate(from, to), s => {
|
||||
const parsed =
|
||||
s.length === 10 ? moment(s, "YYYY-MM-DD") : s.length === 19 ? moment(s, "YYYY-MM-DD HH:mm:ss") : null;
|
||||
|
||||
return parsed && parsed.isValid() ? t.success(s) : t.failure(from, to, "Invalid datetime");
|
||||
}),
|
||||
s => s,
|
||||
);
|
||||
|
||||
export const tDelayString = new t.Type<string, string>(
|
||||
"tDelayString",
|
||||
(s): s is string => typeof s === "string",
|
||||
(from, to) =>
|
||||
either.chain(t.string.validate(from, to), s => {
|
||||
const ms = convertDelayStringToMS(s);
|
||||
return ms === null ? t.failure(from, to, "Invalid delay string") : t.success(s);
|
||||
}),
|
||||
s => s,
|
||||
);
|
||||
|
||||
/**
|
||||
* Turns a "delay string" such as "1h30m" to milliseconds
|
||||
*/
|
||||
|
@ -79,8 +249,23 @@ export function convertDelayStringToMS(str, defaultUnit = "m"): number {
|
|||
return ms;
|
||||
}
|
||||
|
||||
export function convertMSToDelayString(ms: number): string {
|
||||
let result = "";
|
||||
let remaining = ms;
|
||||
for (const [abbr, multiplier] of Object.entries(delayStringMultipliers)) {
|
||||
if (multiplier <= remaining) {
|
||||
const amount = Math.floor(remaining / multiplier);
|
||||
result += `${amount}${abbr}`;
|
||||
remaining -= amount * multiplier;
|
||||
}
|
||||
|
||||
if (remaining === 0) break;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function successMessage(str) {
|
||||
return `👌 ${str}`;
|
||||
return `<:zep_check:650361014180904971> ${str}`;
|
||||
}
|
||||
|
||||
export function errorMessage(str) {
|
||||
|
@ -189,7 +374,7 @@ const urlRegex = /(\S+\.\S+)/g;
|
|||
const protocolRegex = /^[a-z]+:\/\//;
|
||||
|
||||
export function getUrlsInString(str: string, unique = false): url.URL[] {
|
||||
let matches = (str.match(urlRegex) || []).map(m => m[0]);
|
||||
let matches = str.match(urlRegex) || [];
|
||||
if (unique) matches = Array.from(new Set(matches));
|
||||
|
||||
return matches.reduce((urls, match) => {
|
||||
|
@ -216,15 +401,7 @@ export function getUrlsInString(str: string, unique = false): url.URL[] {
|
|||
|
||||
export function getInviteCodesInString(str: string): string[] {
|
||||
const inviteCodeRegex = /(?:discord.gg|discordapp.com\/invite)\/([a-z0-9]+)/gi;
|
||||
const inviteCodes = [];
|
||||
let match;
|
||||
|
||||
// tslint:disable-next-line
|
||||
while ((match = inviteCodeRegex.exec(str)) !== null) {
|
||||
inviteCodes.push(match[1]);
|
||||
}
|
||||
|
||||
return inviteCodes;
|
||||
return Array.from(str.matchAll(inviteCodeRegex)).map(m => m[1]);
|
||||
}
|
||||
|
||||
export const unicodeEmojiRegex = emojiRegex();
|
||||
|
@ -333,7 +510,7 @@ export function getRoleMentions(str: string) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Disables link previews in the given string by wrapping links in < >
|
||||
* Disable link previews in the given string by wrapping links in < >
|
||||
*/
|
||||
export function disableLinkPreviews(str: string): string {
|
||||
return str.replace(/(?<!<)(https?:\/\/\S+)/gi, "<$1>");
|
||||
|
@ -343,6 +520,17 @@ export function deactivateMentions(content: string): string {
|
|||
return content.replace(/@/g, "@\u200b");
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable inline code in the given string by replacing backticks/grave accents with acute accents
|
||||
* FIXME: Find a better way that keeps the grave accents? Can't use the code block approach here since it's just 1 character.
|
||||
*/
|
||||
export function disableInlineCode(content: string): string {
|
||||
return content.replace(/`/g, "\u00b4");
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable code blocks in the given string by adding invisible unicode characters between backticks
|
||||
*/
|
||||
export function disableCodeBlocks(content: string): string {
|
||||
return content.replace(/`/g, "`\u200b");
|
||||
}
|
||||
|
@ -768,6 +956,32 @@ export async function resolveMember(bot: Client, guild: Guild, value: string): P
|
|||
return null;
|
||||
}
|
||||
|
||||
export async function resolveRoleId(bot: Client, guildId: string, value: string) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Role mention
|
||||
const mentionMatch = value.match(/^<@&?(\d+)>$/);
|
||||
if (mentionMatch) {
|
||||
return mentionMatch[1];
|
||||
}
|
||||
|
||||
// Role name
|
||||
const roleList = await bot.getRESTGuildRoles(guildId);
|
||||
const role = roleList.filter(x => x.name.toLocaleLowerCase() === value.toLocaleLowerCase());
|
||||
if (role[0]) {
|
||||
return role[0].id;
|
||||
}
|
||||
|
||||
// Role ID
|
||||
const idMatch = value.match(/^\d+$/);
|
||||
if (idMatch) {
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export type StrictMessageContent = { content?: string; tts?: boolean; disableEveryone?: boolean; embed?: EmbedOptions };
|
||||
|
||||
export async function confirm(bot: Client, channel: TextableChannel, userId: string, content: MessageContent) {
|
||||
|
@ -805,3 +1019,28 @@ export function verboseUserName(user: User | UnknownUser): string {
|
|||
export function verboseChannelMention(channel: GuildChannel): string {
|
||||
return `<#${channel.id}> (**#${channel.name}**, \`${channel.id}\`)`;
|
||||
}
|
||||
|
||||
export function messageLink(message: Message): string;
|
||||
export function messageLink(guildId: string, channelId: string, messageId: string): string;
|
||||
export function messageLink(guildIdOrMessage: string | Message | null, channelId?: string, messageId?: string): string {
|
||||
let guildId;
|
||||
if (guildIdOrMessage == null) {
|
||||
// Full arguments without a guild id -> DM/Group chat
|
||||
guildId = "@me";
|
||||
} else if (guildIdOrMessage instanceof Message) {
|
||||
// Message object as the only argument
|
||||
guildId = (guildIdOrMessage.channel as GuildChannel).guild?.id ?? "@me";
|
||||
channelId = guildIdOrMessage.channel.id;
|
||||
messageId = guildIdOrMessage.id;
|
||||
} else {
|
||||
// Full arguments with all IDs
|
||||
guildId = guildIdOrMessage;
|
||||
}
|
||||
|
||||
return `https://discordapp.com/channels/${guildId}/${channelId}/${messageId}`;
|
||||
}
|
||||
|
||||
export function isValidEmbed(embed: any): boolean {
|
||||
const result = decodeAndValidateStrict(tEmbed, embed);
|
||||
return !(result instanceof StrictValidationError);
|
||||
}
|
||||
|
|
41
backend/src/validation.test.ts
Normal file
41
backend/src/validation.test.ts
Normal file
|
@ -0,0 +1,41 @@
|
|||
import { tDeepPartial } from "./utils";
|
||||
import * as t from "io-ts";
|
||||
import * as validatorUtils from "./validatorUtils";
|
||||
import test from "ava";
|
||||
import util from "util";
|
||||
|
||||
test("tDeepPartial works", ava => {
|
||||
const originalSchema = t.type({
|
||||
listOfThings: t.record(
|
||||
t.string,
|
||||
t.type({
|
||||
enabled: t.boolean,
|
||||
someValue: t.number,
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const deepPartialSchema = tDeepPartial(originalSchema);
|
||||
|
||||
const partialValidValue = {
|
||||
listOfThings: {
|
||||
myThing: {
|
||||
someValue: 5,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const partialErrorValue = {
|
||||
listOfThings: {
|
||||
myThing: {
|
||||
someValue: "test",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result1 = validatorUtils.validate(deepPartialSchema, partialValidValue);
|
||||
ava.is(result1, null);
|
||||
|
||||
const result2 = validatorUtils.validate(deepPartialSchema, partialErrorValue);
|
||||
ava.not(result2, null);
|
||||
});
|
|
@ -57,7 +57,7 @@ function getContextPath(context) {
|
|||
// tslint:enable
|
||||
|
||||
export class StrictValidationError extends Error {
|
||||
private errors;
|
||||
private readonly errors;
|
||||
|
||||
constructor(errors: string[]) {
|
||||
errors = Array.from(new Set(errors));
|
||||
|
@ -83,6 +83,17 @@ const report = fold((errors: any): StrictValidationError | void => {
|
|||
return new StrictValidationError(errorStrings);
|
||||
}, noop);
|
||||
|
||||
export function validate(schema: t.Type<any>, value: any): StrictValidationError | null {
|
||||
const validationResult = schema.decode(value);
|
||||
return pipe(
|
||||
validationResult,
|
||||
fold(
|
||||
err => report(validationResult),
|
||||
result => null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes and validates the given value against the given schema while also disallowing extra properties
|
||||
* See: https://github.com/gcanti/io-ts/issues/322
|
||||
|
|
18
backend/start-dev.js
Normal file
18
backend/start-dev.js
Normal file
|
@ -0,0 +1,18 @@
|
|||
/**
|
||||
* This file starts the bot and api processes in tandem.
|
||||
* Used with tsc-watch for restarting on watch.
|
||||
*/
|
||||
|
||||
const childProcess = require("child_process");
|
||||
|
||||
const cmd = process.platform === "win32"
|
||||
? "npm.cmd"
|
||||
: "npm";
|
||||
|
||||
childProcess.spawn(cmd, ["run", "start-bot-dev"], {
|
||||
stdio: [process.stdin, process.stdout, process.stderr],
|
||||
});
|
||||
|
||||
childProcess.spawn(cmd, ["run", "start-api-dev"], {
|
||||
stdio: [process.stdin, process.stdout, process.stderr],
|
||||
});
|
|
@ -6,9 +6,8 @@
|
|||
"allowSyntheticDefaultImports": true,
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"target": "esnext",
|
||||
"target": "es2018",
|
||||
"lib": [
|
||||
"es2017",
|
||||
"esnext"
|
||||
],
|
||||
"baseUrl": ".",
|
||||
|
@ -19,7 +18,8 @@
|
|||
"@shared/*": [
|
||||
"../shared/src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
"compilerOptions": {
|
||||
"moduleResolution": "node",
|
||||
"module": "esnext",
|
||||
"target": "esnext",
|
||||
"target": "es2018",
|
||||
"sourceMap": true,
|
||||
"noImplicitAny": false,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
|
|
6
package-lock.json
generated
6
package-lock.json
generated
|
@ -1218,9 +1218,9 @@
|
|||
}
|
||||
},
|
||||
"prettier": {
|
||||
"version": "1.18.2",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-1.18.2.tgz",
|
||||
"integrity": "sha512-OeHeMc0JhFE9idD4ZdtNibzY0+TPHSpSSb9h8FqtP+YnoZZ1sl8Vc9b1sasjfymH3SonAF4QcA2+mzHPhMvIiw==",
|
||||
"version": "1.19.1",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-1.19.1.tgz",
|
||||
"integrity": "sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew==",
|
||||
"dev": true
|
||||
},
|
||||
"pump": {
|
||||
|
|
|
@ -4,13 +4,13 @@
|
|||
"description": "",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "cd backend && npm run test && cd ../shared && npm run test"
|
||||
"format": "prettier --write \"./{backend,dashboard}/**/*.ts\""
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"husky": "^3.0.9",
|
||||
"lint-staged": "^9.4.2",
|
||||
"prettier": "^1.16.4",
|
||||
"prettier": "^1.19.1",
|
||||
"tslint": "^5.13.1",
|
||||
"tslint-config-prettier": "^1.18.0",
|
||||
"typescript": "^3.7.2"
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
"allowSyntheticDefaultImports": true,
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"target": "esnext",
|
||||
"target": "es2018",
|
||||
"lib": [
|
||||
"esnext"
|
||||
],
|
||||
|
|
Loading…
Add table
Reference in a new issue