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
|
.cache
|
||||||
npm-ls.txt
|
npm-ls.txt
|
||||||
npm-audit.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.
|
These instructions are intended for bot development only.
|
||||||
|
|
||||||
👉 **No support is offered for self-hosting the bot!** 👈
|
👉 **No support is offered for self-hosting the bot!** 👈
|
||||||
|
|
||||||
## Running the bot
|
### Running the bot
|
||||||
1. `cd backend`
|
1. `cd backend`
|
||||||
2. `npm ci`
|
2. `npm ci`
|
||||||
3. Make a copy of `bot.env.example` called `bot.env`, fill in the values
|
3. Make a copy of `bot.env.example` called `bot.env`, fill in the values
|
||||||
4. Run the desired start script:
|
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-dev` to run the bot in a **development** environment
|
||||||
* `npm run build` followed by `npm run start-bot-prod` to run the bot compiled
|
* `npm run build` followed by `npm run start-bot-prod` to run the bot in a **production** environment
|
||||||
* `npm run watch-bot` to run the bot with `ts-node` and restart on changes
|
* `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
|
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`
|
1. `cd backend`
|
||||||
2. `npm ci`
|
2. `npm ci`
|
||||||
3. Make a copy of `api.env.example` called `api.env`, fill in the values
|
3. Make a copy of `api.env.example` called `api.env`, fill in the values
|
||||||
4. Run the desired start script:
|
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-dev` to run the api in a **development** environment
|
||||||
* `npm run build` followed by `npm run start-api-prod` to run the API server compiled
|
* `npm run build` followed by `npm run start-api-prod` to run the api in a **production** environment
|
||||||
* `npm run watch-api` to run the API server with `ts-node` and restart on changes
|
* `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`
|
1. `cd dashboard`
|
||||||
2. `npm ci`
|
2. `npm ci`
|
||||||
3. Make a copy of `.env.example` called `.env`, fill in the values
|
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 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
|
* `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.
|
* 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
|
`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`.
|
are placed in `backend/dist/shared`.
|
||||||
* The `backend/register-tsconfig-prod-paths.js` module takes care of registering shared paths from `tsconfig.json` for
|
* The `backend/register-tsconfig-paths.js` module takes care of registering shared paths from `tsconfig.json` for
|
||||||
`ts-node`, `ava`, and compiled `.js` files
|
`ava` and compiled `.js` files
|
||||||
* To run the tests for the files in the `shared/` directory, you also need to run `npm ci` there
|
* 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
|
Configuration is stored in the database in the `configs` table
|
||||||
|
|
||||||
```yml
|
```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');
|
const moment = require('moment-timezone');
|
||||||
moment.tz.setDefault('UTC');
|
moment.tz.setDefault('UTC');
|
||||||
|
|
||||||
const entities = process.env.NODE_ENV === 'production'
|
const entities = path.relative(process.cwd(), path.resolve(__dirname, 'dist/backend/src/data/entities/*.js'));
|
||||||
? 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'));
|
||||||
: path.relative(process.cwd(), path.resolve(__dirname, 'src/data/entities/*.ts'));
|
const migrationsDir = path.relative(process.cwd(), path.resolve(__dirname, 'src/migrations'));
|
||||||
|
|
||||||
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'));
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
type: "mysql",
|
type: "mysql",
|
||||||
|
@ -55,6 +51,6 @@ module.exports = {
|
||||||
// Migrations
|
// Migrations
|
||||||
migrations: [migrations],
|
migrations: [migrations],
|
||||||
cli: {
|
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": "",
|
"description": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start-bot-dev": "node -r ts-node/register -r tsconfig-paths/register src/index.ts",
|
"watch": "cross-env NODE_ENV=development tsc-watch --onSuccess \"node start-dev.js\"",
|
||||||
"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",
|
|
||||||
"build": "rimraf dist && tsc",
|
"build": "rimraf dist && tsc",
|
||||||
"start-api-dev": "node -r ts-node/register -r tsconfig-paths/register src/api/index.ts",
|
"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-api-prod": "cross-env NODE_ENV=production node -r ./register-tsconfig-prod-paths.js dist/backend/src/api/index.js",
|
"start-bot-prod": "cross-env NODE_ENV=production node -r ./register-tsconfig-paths.js --unhandled-rejections=strict dist/backend/src/index.js",
|
||||||
"watch-api": "nodemon --config nodemon-api.json",
|
"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",
|
||||||
"format": "prettier --write \"./src/**/*.ts\"",
|
"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 ts-node/register -r tsconfig-paths/register ./node_modules/typeorm/cli.js",
|
"typeorm": "node -r ./register-tsconfig-paths.js ./node_modules/typeorm/cli.js",
|
||||||
"migrate": "npm run typeorm -- migration:run",
|
"migrate-prod": "npm run typeorm -- migration:run",
|
||||||
"migrate-rollback": "npm run typeorm -- migration:revert",
|
"migrate-dev": "npm run build && npm run typeorm -- migration:run",
|
||||||
"test": "ava"
|
"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": {
|
"dependencies": {
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
@ -30,8 +32,8 @@
|
||||||
"humanize-duration": "^3.15.0",
|
"humanize-duration": "^3.15.0",
|
||||||
"io-ts": "^2.0.0",
|
"io-ts": "^2.0.0",
|
||||||
"js-yaml": "^3.13.1",
|
"js-yaml": "^3.13.1",
|
||||||
"knub": "^26.0.2",
|
"knub": "^28.0.0",
|
||||||
"knub-command-manager": "^6.1.0",
|
"knub-command-manager": "^7.0.0",
|
||||||
"last-commit-log": "^2.1.0",
|
"last-commit-log": "^2.1.0",
|
||||||
"lodash.chunk": "^4.2.0",
|
"lodash.chunk": "^4.2.0",
|
||||||
"lodash.clonedeep": "^4.5.0",
|
"lodash.clonedeep": "^4.5.0",
|
||||||
|
@ -49,6 +51,7 @@
|
||||||
"seedrandom": "^3.0.1",
|
"seedrandom": "^3.0.1",
|
||||||
"tlds": "^1.203.1",
|
"tlds": "^1.203.1",
|
||||||
"tmp": "0.0.33",
|
"tmp": "0.0.33",
|
||||||
|
"transliteration": "^2.1.7",
|
||||||
"tsconfig-paths": "^3.9.0",
|
"tsconfig-paths": "^3.9.0",
|
||||||
"typeorm": "^0.2.14",
|
"typeorm": "^0.2.14",
|
||||||
"uuid": "^3.3.2"
|
"uuid": "^3.3.2"
|
||||||
|
@ -64,24 +67,21 @@
|
||||||
"@types/passport": "^1.0.0",
|
"@types/passport": "^1.0.0",
|
||||||
"@types/passport-oauth2": "^1.4.8",
|
"@types/passport-oauth2": "^1.4.8",
|
||||||
"@types/passport-strategy": "^0.2.35",
|
"@types/passport-strategy": "^0.2.35",
|
||||||
|
"@types/safe-regex": "^1.1.2",
|
||||||
"@types/tmp": "0.0.33",
|
"@types/tmp": "0.0.33",
|
||||||
"ava": "^2.4.0",
|
"ava": "^2.4.0",
|
||||||
"nodemon": "^1.19.4",
|
|
||||||
"rimraf": "^2.6.2",
|
"rimraf": "^2.6.2",
|
||||||
"ts-node": "^8.4.1",
|
"source-map-support": "^0.5.16",
|
||||||
|
"tsc-watch": "^4.0.0",
|
||||||
"typescript": "^3.7.2"
|
"typescript": "^3.7.2"
|
||||||
},
|
},
|
||||||
"ava": {
|
"ava": {
|
||||||
"compileEnhancements": false,
|
"compileEnhancements": false,
|
||||||
"files": [
|
"files": [
|
||||||
"src/**/*.test.ts"
|
"dist/backend/src/**/*.test.js"
|
||||||
],
|
|
||||||
"extensions": [
|
|
||||||
"ts"
|
|
||||||
],
|
],
|
||||||
"require": [
|
"require": [
|
||||||
"ts-node/register",
|
"./register-tsconfig-paths.js"
|
||||||
"tsconfig-paths/register"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -53,6 +53,7 @@ export function initGuildsAPI(app: express.Express) {
|
||||||
return res.status(400).json({ errors: [e.message] });
|
return res.status(400).json({ errors: [e.message] });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tslint:disable-next-line:no-console
|
||||||
console.error("Error when loading YAML: " + e.message);
|
console.error("Error when loading YAML: " + e.message);
|
||||||
return serverError(res, "Server error");
|
return serverError(res, "Server error");
|
||||||
}
|
}
|
||||||
|
|
|
@ -55,8 +55,10 @@
|
||||||
"MEMBER_MUTE_REJOIN": "⚠ Reapplied active mute for {userMention(member)} on rejoin",
|
"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_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)}",
|
"POSTED_SCHEDULED_MESSAGE": "\uD83D\uDCE8 Posted scheduled message (`{messageId}`) to {channelMention(channel)} as scheduled by {userMention(author)}",
|
||||||
|
|
||||||
"BOT_ALERT": "⚠ {tmplEval(body)}",
|
"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) {
|
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);
|
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({
|
await this.reactionRoles.insert({
|
||||||
guild_id: this.guildId,
|
guild_id: this.guildId,
|
||||||
channel_id: channelId,
|
channel_id: channelId,
|
||||||
message_id: messageId,
|
message_id: messageId,
|
||||||
emoji,
|
emoji,
|
||||||
role_id: roleId,
|
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({
|
await this.reminders.insert({
|
||||||
guild_id: this.guildId,
|
guild_id: this.guildId,
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
channel_id: channelId,
|
channel_id: channelId,
|
||||||
remind_at: remindAt,
|
remind_at: remindAt,
|
||||||
body,
|
body,
|
||||||
|
created_at,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -38,4 +38,8 @@ export class GuildScheduledPosts extends BaseGuildRepository {
|
||||||
guild_id: this.guildId,
|
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,
|
BOT_ALERT,
|
||||||
AUTOMOD_ACTION,
|
AUTOMOD_ACTION,
|
||||||
|
|
||||||
|
SCHEDULED_REPEATED_MESSAGE,
|
||||||
|
REPEATED_MESSAGE,
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,7 +19,10 @@ export class ApiLogin {
|
||||||
@Column()
|
@Column()
|
||||||
expires_at: string;
|
expires_at: string;
|
||||||
|
|
||||||
@ManyToOne(type => ApiUserInfo, userInfo => userInfo.logins)
|
@ManyToOne(
|
||||||
|
type => ApiUserInfo,
|
||||||
|
userInfo => userInfo.logins,
|
||||||
|
)
|
||||||
@JoinColumn({ name: "user_id" })
|
@JoinColumn({ name: "user_id" })
|
||||||
userInfo: ApiUserInfo;
|
userInfo: ApiUserInfo;
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,7 +18,10 @@ export class ApiPermissionAssignment {
|
||||||
@Column("simple-array")
|
@Column("simple-array")
|
||||||
permissions: string[];
|
permissions: string[];
|
||||||
|
|
||||||
@ManyToOne(type => ApiUserInfo, userInfo => userInfo.permissionAssignments)
|
@ManyToOne(
|
||||||
|
type => ApiUserInfo,
|
||||||
|
userInfo => userInfo.permissionAssignments,
|
||||||
|
)
|
||||||
@JoinColumn({ name: "target_id" })
|
@JoinColumn({ name: "target_id" })
|
||||||
userInfo: ApiUserInfo;
|
userInfo: ApiUserInfo;
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,9 +20,15 @@ export class ApiUserInfo {
|
||||||
@Column()
|
@Column()
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
|
||||||
@OneToMany(type => ApiLogin, login => login.userInfo)
|
@OneToMany(
|
||||||
|
type => ApiLogin,
|
||||||
|
login => login.userInfo,
|
||||||
|
)
|
||||||
logins: ApiLogin[];
|
logins: ApiLogin[];
|
||||||
|
|
||||||
@OneToMany(type => ApiPermissionAssignment, p => p.userInfo)
|
@OneToMany(
|
||||||
|
type => ApiPermissionAssignment,
|
||||||
|
p => p.userInfo,
|
||||||
|
)
|
||||||
permissionAssignments: ApiPermissionAssignment[];
|
permissionAssignments: ApiPermissionAssignment[];
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,6 +29,9 @@ export class Case {
|
||||||
|
|
||||||
@Column() pp_name: string;
|
@Column() pp_name: string;
|
||||||
|
|
||||||
@OneToMany(type => CaseNote, note => note.case)
|
@OneToMany(
|
||||||
|
type => CaseNote,
|
||||||
|
note => note.case,
|
||||||
|
)
|
||||||
notes: CaseNote[];
|
notes: CaseNote[];
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,7 +15,10 @@ export class CaseNote {
|
||||||
|
|
||||||
@Column() created_at: string;
|
@Column() created_at: string;
|
||||||
|
|
||||||
@ManyToOne(type => Case, theCase => theCase.notes)
|
@ManyToOne(
|
||||||
|
type => Case,
|
||||||
|
theCase => theCase.notes,
|
||||||
|
)
|
||||||
@JoinColumn({ name: "case_id" })
|
@JoinColumn({ name: "case_id" })
|
||||||
case: Case;
|
case: Case;
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,4 +19,6 @@ export class ReactionRole {
|
||||||
emoji: string;
|
emoji: string;
|
||||||
|
|
||||||
@Column() role_id: string;
|
@Column() role_id: string;
|
||||||
|
|
||||||
|
@Column() is_exclusive: boolean;
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,4 +15,6 @@ export class Reminder {
|
||||||
@Column() remind_at: string;
|
@Column() remind_at: string;
|
||||||
|
|
||||||
@Column() body: string;
|
@Column() body: string;
|
||||||
|
|
||||||
|
@Column() created_at: string;
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,5 +22,14 @@ export class ScheduledPost {
|
||||||
|
|
||||||
@Column() post_at: string;
|
@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;
|
@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 { Entity, Column, PrimaryColumn, OneToMany, ManyToOne, JoinColumn, OneToOne } from "typeorm";
|
||||||
import { Starboard } from "./Starboard";
|
|
||||||
import { Case } from "./Case";
|
|
||||||
import { SavedMessage } from "./SavedMessage";
|
import { SavedMessage } from "./SavedMessage";
|
||||||
|
|
||||||
@Entity("starboard_messages")
|
@Entity("starboard_messages")
|
||||||
export class StarboardMessage {
|
export class StarboardMessage {
|
||||||
@Column()
|
@Column()
|
||||||
@PrimaryColumn()
|
message_id: string;
|
||||||
starboard_id: number;
|
|
||||||
|
|
||||||
@Column()
|
@Column()
|
||||||
@PrimaryColumn()
|
@PrimaryColumn()
|
||||||
message_id: string;
|
starboard_message_id: string;
|
||||||
|
|
||||||
@Column() starboard_message_id: string;
|
@Column()
|
||||||
|
starboard_channel_id: string;
|
||||||
|
|
||||||
@ManyToOne(type => Starboard, sb => sb.starboardMessages)
|
@Column()
|
||||||
@JoinColumn({ name: "starboard_id" })
|
guild_id: string;
|
||||||
starboard: Starboard;
|
|
||||||
|
|
||||||
@OneToOne(type => SavedMessage)
|
@OneToOne(type => SavedMessage)
|
||||||
@JoinColumn({ name: "message_id" })
|
@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(() => (recentPluginErrors = Math.max(0, recentPluginErrors - 1)), 2500);
|
||||||
setInterval(() => (recentDiscordErrors = Math.max(0, recentDiscordErrors - 1)), 2500);
|
setInterval(() => (recentDiscordErrors = Math.max(0, recentDiscordErrors - 1)), 2500);
|
||||||
|
|
||||||
function errorHandler(err) {
|
if (process.env.NODE_ENV === "production") {
|
||||||
// tslint:disable:no-console
|
const errorHandler = err => {
|
||||||
console.error(err);
|
// tslint:disable:no-console
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
if (err instanceof PluginError) {
|
if (err instanceof PluginError) {
|
||||||
// Tolerate a few recent plugin errors before crashing
|
// Tolerate a few recent plugin errors before crashing
|
||||||
if (++recentPluginErrors >= RECENT_PLUGIN_ERROR_EXIT_THRESHOLD) {
|
if (++recentPluginErrors >= RECENT_PLUGIN_ERROR_EXIT_THRESHOLD) {
|
||||||
console.error(`Exiting after ${RECENT_PLUGIN_ERROR_EXIT_THRESHOLD} plugin errors`);
|
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);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
} else if (err instanceof DiscordRESTError || err instanceof DiscordHTTPError) {
|
// tslint:enable:no-console
|
||||||
// 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) {
|
process.on("uncaughtException", errorHandler);
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
process.on("unhandledRejection", errorHandler);
|
|
||||||
process.on("uncaughtException", errorHandler);
|
|
||||||
|
|
||||||
// Verify required Node.js version
|
// Verify required Node.js version
|
||||||
const REQUIRED_NODE_VERSION = "10.14.2";
|
const REQUIRED_NODE_VERSION = "10.14.2";
|
||||||
const requiredParts = REQUIRED_NODE_VERSION.split(".").map(v => parseInt(v, 10));
|
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 {
|
export class AddTypeAndPermissionsToApiPermissions1573158035867 implements MigrationInterface {
|
||||||
public async up(queryRunner: QueryRunner): Promise<any> {
|
public async up(queryRunner: QueryRunner): Promise<any> {
|
||||||
await queryRunner.dropPrimaryKey("api_permissions");
|
try {
|
||||||
await queryRunner.dropIndex("api_permissions", "IDX_5e371749d4cb4a5191f35e26f6");
|
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(
|
await queryRunner.addColumn(
|
||||||
"api_permissions",
|
"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 * as t from "io-ts";
|
||||||
import {
|
import {
|
||||||
convertDelayStringToMS,
|
convertDelayStringToMS,
|
||||||
|
disableInlineCode,
|
||||||
|
disableLinkPreviews,
|
||||||
getEmojiInString,
|
getEmojiInString,
|
||||||
getInviteCodesInString,
|
getInviteCodesInString,
|
||||||
getRoleMentions,
|
getRoleMentions,
|
||||||
|
@ -12,12 +14,13 @@ import {
|
||||||
noop,
|
noop,
|
||||||
SECONDS,
|
SECONDS,
|
||||||
stripObjectToScalars,
|
stripObjectToScalars,
|
||||||
|
tDeepPartial,
|
||||||
tNullable,
|
tNullable,
|
||||||
UnknownUser,
|
UnknownUser,
|
||||||
verboseChannelMention,
|
verboseChannelMention,
|
||||||
} from "../utils";
|
} from "../utils";
|
||||||
import { configUtils, CooldownManager } from "knub";
|
import { configUtils, CooldownManager } from "knub";
|
||||||
import { Invite, Member, TextChannel } from "eris";
|
import { Member, TextChannel } from "eris";
|
||||||
import escapeStringRegexp from "escape-string-regexp";
|
import escapeStringRegexp from "escape-string-regexp";
|
||||||
import { SimpleCache } from "../SimpleCache";
|
import { SimpleCache } from "../SimpleCache";
|
||||||
import { Queue } from "../Queue";
|
import { Queue } from "../Queue";
|
||||||
|
@ -32,6 +35,7 @@ import { GuildLogs } from "../data/GuildLogs";
|
||||||
import { SavedMessage } from "../data/entities/SavedMessage";
|
import { SavedMessage } from "../data/entities/SavedMessage";
|
||||||
import moment from "moment-timezone";
|
import moment from "moment-timezone";
|
||||||
import { renderTemplate } from "../templateFormatter";
|
import { renderTemplate } from "../templateFormatter";
|
||||||
|
import { transliterate } from "transliteration";
|
||||||
import Timeout = NodeJS.Timeout;
|
import Timeout = NodeJS.Timeout;
|
||||||
|
|
||||||
type MessageInfo = { channelId: string; messageId: string };
|
type MessageInfo = { channelId: string; messageId: string };
|
||||||
|
@ -46,23 +50,26 @@ type TextTriggerWithMultipleMatchTypes = {
|
||||||
};
|
};
|
||||||
|
|
||||||
interface TriggerMatchResult {
|
interface TriggerMatchResult {
|
||||||
|
trigger: string;
|
||||||
type: string;
|
type: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MessageTextTriggerMatchResult extends TriggerMatchResult {
|
interface MessageTextTriggerMatchResult<T = any> extends TriggerMatchResult {
|
||||||
type: "message" | "embed";
|
type: "message" | "embed";
|
||||||
str: string;
|
str: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
messageInfo: MessageInfo;
|
messageInfo: MessageInfo;
|
||||||
|
matchedValue: T;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OtherTextTriggerMatchResult extends TriggerMatchResult {
|
interface OtherTextTriggerMatchResult<T = any> extends TriggerMatchResult {
|
||||||
type: "username" | "nickname" | "visiblename" | "customstatus";
|
type: "username" | "nickname" | "visiblename" | "customstatus";
|
||||||
str: string;
|
str: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
|
matchedValue: T;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TextTriggerMatchResult = MessageTextTriggerMatchResult | OtherTextTriggerMatchResult;
|
type TextTriggerMatchResult<T = any> = MessageTextTriggerMatchResult<T> | OtherTextTriggerMatchResult<T>;
|
||||||
|
|
||||||
interface TextSpamTriggerMatchResult extends TriggerMatchResult {
|
interface TextSpamTriggerMatchResult extends TriggerMatchResult {
|
||||||
type: "textspam";
|
type: "textspam";
|
||||||
|
@ -93,13 +100,16 @@ type AnyTriggerMatchResult =
|
||||||
| OtherSpamTriggerMatchResult;
|
| OtherSpamTriggerMatchResult;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TRIGGERS
|
* CONFIG SCHEMA FOR TRIGGERS
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const MatchWordsTrigger = t.type({
|
const MatchWordsTrigger = t.type({
|
||||||
words: t.array(t.string),
|
words: t.array(t.string),
|
||||||
case_sensitive: t.boolean,
|
case_sensitive: t.boolean,
|
||||||
only_full_words: t.boolean,
|
only_full_words: t.boolean,
|
||||||
|
normalize: t.boolean,
|
||||||
|
loose_matching: t.boolean,
|
||||||
|
loose_matching_threshold: t.number,
|
||||||
match_messages: t.boolean,
|
match_messages: t.boolean,
|
||||||
match_embeds: t.boolean,
|
match_embeds: t.boolean,
|
||||||
match_visible_names: t.boolean,
|
match_visible_names: t.boolean,
|
||||||
|
@ -108,10 +118,12 @@ const MatchWordsTrigger = t.type({
|
||||||
match_custom_status: t.boolean,
|
match_custom_status: t.boolean,
|
||||||
});
|
});
|
||||||
type TMatchWordsTrigger = t.TypeOf<typeof MatchWordsTrigger>;
|
type TMatchWordsTrigger = t.TypeOf<typeof MatchWordsTrigger>;
|
||||||
const defaultMatchWordsTrigger: TMatchWordsTrigger = {
|
const defaultMatchWordsTrigger: Partial<TMatchWordsTrigger> = {
|
||||||
words: [],
|
|
||||||
case_sensitive: false,
|
case_sensitive: false,
|
||||||
only_full_words: true,
|
only_full_words: true,
|
||||||
|
normalize: false,
|
||||||
|
loose_matching: false,
|
||||||
|
loose_matching_threshold: 4,
|
||||||
match_messages: true,
|
match_messages: true,
|
||||||
match_embeds: true,
|
match_embeds: true,
|
||||||
match_visible_names: false,
|
match_visible_names: false,
|
||||||
|
@ -123,6 +135,7 @@ const defaultMatchWordsTrigger: TMatchWordsTrigger = {
|
||||||
const MatchRegexTrigger = t.type({
|
const MatchRegexTrigger = t.type({
|
||||||
patterns: t.array(TSafeRegex),
|
patterns: t.array(TSafeRegex),
|
||||||
case_sensitive: t.boolean,
|
case_sensitive: t.boolean,
|
||||||
|
normalize: t.boolean,
|
||||||
match_messages: t.boolean,
|
match_messages: t.boolean,
|
||||||
match_embeds: t.boolean,
|
match_embeds: t.boolean,
|
||||||
match_visible_names: t.boolean,
|
match_visible_names: t.boolean,
|
||||||
|
@ -133,6 +146,7 @@ const MatchRegexTrigger = t.type({
|
||||||
type TMatchRegexTrigger = t.TypeOf<typeof MatchRegexTrigger>;
|
type TMatchRegexTrigger = t.TypeOf<typeof MatchRegexTrigger>;
|
||||||
const defaultMatchRegexTrigger: Partial<TMatchRegexTrigger> = {
|
const defaultMatchRegexTrigger: Partial<TMatchRegexTrigger> = {
|
||||||
case_sensitive: false,
|
case_sensitive: false,
|
||||||
|
normalize: false,
|
||||||
match_messages: true,
|
match_messages: true,
|
||||||
match_embeds: true,
|
match_embeds: true,
|
||||||
match_visible_names: false,
|
match_visible_names: false,
|
||||||
|
@ -220,7 +234,7 @@ const VoiceMoveSpamTrigger = BaseSpamTrigger;
|
||||||
type TVoiceMoveSpamTrigger = t.TypeOf<typeof VoiceMoveSpamTrigger>;
|
type TVoiceMoveSpamTrigger = t.TypeOf<typeof VoiceMoveSpamTrigger>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ACTIONS
|
* CONFIG SCHEMA FOR ACTIONS
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const CleanAction = t.boolean;
|
const CleanAction = t.boolean;
|
||||||
|
@ -253,6 +267,9 @@ const ChangeNicknameAction = t.type({
|
||||||
|
|
||||||
const LogAction = t.boolean;
|
const LogAction = t.boolean;
|
||||||
|
|
||||||
|
const AddRolesAction = t.array(t.string);
|
||||||
|
const RemoveRolesAction = t.array(t.string);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* FULL CONFIG SCHEMA
|
* FULL CONFIG SCHEMA
|
||||||
*/
|
*/
|
||||||
|
@ -287,6 +304,8 @@ const Rule = t.type({
|
||||||
alert: tNullable(AlertAction),
|
alert: tNullable(AlertAction),
|
||||||
change_nickname: tNullable(ChangeNicknameAction),
|
change_nickname: tNullable(ChangeNicknameAction),
|
||||||
log: tNullable(LogAction),
|
log: tNullable(LogAction),
|
||||||
|
add_roles: tNullable(AddRolesAction),
|
||||||
|
remove_roles: tNullable(RemoveRolesAction),
|
||||||
}),
|
}),
|
||||||
cooldown: tNullable(t.string),
|
cooldown: tNullable(t.string),
|
||||||
});
|
});
|
||||||
|
@ -297,6 +316,8 @@ const ConfigSchema = t.type({
|
||||||
});
|
});
|
||||||
type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
|
type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
|
||||||
|
|
||||||
|
const PartialConfigSchema = tDeepPartial(ConfigSchema);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DEFAULTS
|
* DEFAULTS
|
||||||
*/
|
*/
|
||||||
|
@ -361,6 +382,13 @@ const RECENT_NICKNAME_CHANGE_EXPIRY_TIME = 5 * MINUTES;
|
||||||
|
|
||||||
const inviteCache = new SimpleCache(10 * 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> {
|
export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||||
public static pluginName = "automod";
|
public static pluginName = "automod";
|
||||||
public static configSchema = ConfigSchema;
|
public static configSchema = ConfigSchema;
|
||||||
|
@ -499,12 +527,10 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||||
protected archives: GuildArchives;
|
protected archives: GuildArchives;
|
||||||
protected guildLogs: GuildLogs;
|
protected guildLogs: GuildLogs;
|
||||||
|
|
||||||
protected static preprocessStaticConfig(config) {
|
protected static preprocessStaticConfig(config: t.TypeOf<typeof PartialConfigSchema>) {
|
||||||
if (config.rules && typeof config.rules === "object") {
|
if (config.rules) {
|
||||||
// Loop through each rule
|
// Loop through each rule
|
||||||
for (const [name, rule] of Object.entries(config.rules)) {
|
for (const [name, rule] of Object.entries(config.rules)) {
|
||||||
if (rule == null || typeof rule !== "object") continue;
|
|
||||||
|
|
||||||
rule["name"] = name;
|
rule["name"] = name;
|
||||||
|
|
||||||
// If the rule doesn't have an explicitly set "enabled" property, set it to true
|
// 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
|
// Loop through the rule's triggers
|
||||||
if (rule["triggers"] != null && Array.isArray(rule["triggers"])) {
|
if (rule["triggers"]) {
|
||||||
for (const trigger of 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
|
// Apply default config to the triggers used in this rule
|
||||||
for (const [defaultTriggerName, defaultTrigger] of Object.entries(defaultTriggers)) {
|
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]);
|
trigger[defaultTriggerName] = configUtils.mergeConfig({}, defaultTrigger, trigger[defaultTriggerName]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -526,7 +551,7 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enable logging of automod actions by default
|
// Enable logging of automod actions by default
|
||||||
if (rule["actions"] && typeof rule["actions"] === "object") {
|
if (rule["actions"]) {
|
||||||
if (rule["actions"]["log"] == null) {
|
if (rule["actions"]["log"] == null) {
|
||||||
rule["actions"]["log"] = true;
|
rule["actions"]["log"] = true;
|
||||||
}
|
}
|
||||||
|
@ -583,63 +608,92 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||||
clearInterval(this.recentNicknameChangesClearInterval);
|
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) {
|
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 regex = new RegExp(pattern, trigger.case_sensitive ? "" : "i");
|
||||||
const test = regex.test(str);
|
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
|
// TODO: Time limit regexes
|
||||||
for (const pattern of trigger.patterns) {
|
for (const pattern of trigger.patterns) {
|
||||||
const regex = new RegExp(pattern, trigger.case_sensitive ? "" : "i");
|
const regex = new RegExp(pattern, trigger.case_sensitive ? "" : "i");
|
||||||
const test = regex.test(str);
|
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);
|
const inviteCodes = getInviteCodesInString(str);
|
||||||
if (inviteCodes.length === 0) return false;
|
if (inviteCodes.length === 0) return null;
|
||||||
|
|
||||||
const uniqueInviteCodes = Array.from(new Set(inviteCodes));
|
const uniqueInviteCodes = Array.from(new Set(inviteCodes));
|
||||||
|
|
||||||
for (const code of uniqueInviteCodes) {
|
for (const code of uniqueInviteCodes) {
|
||||||
if (trigger.include_invite_codes && trigger.include_invite_codes.includes(code)) {
|
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)) {
|
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 inviteCode of uniqueInviteCodes) {
|
||||||
|
const invite = await this.resolveInvite(inviteCode);
|
||||||
for (const invite of invites) {
|
if (!invite) return inviteCode;
|
||||||
// Always match on unknown invites
|
|
||||||
if (!invite) return true;
|
|
||||||
|
|
||||||
if (trigger.include_guilds && trigger.include_guilds.includes(invite.guild.id)) {
|
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)) {
|
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);
|
const links = getUrlsInString(str, true);
|
||||||
|
|
||||||
for (const link of links) {
|
for (const link of links) {
|
||||||
const normalizedHostname = link.hostname.toLowerCase();
|
const normalizedHostname = link.hostname.toLowerCase();
|
||||||
|
|
||||||
|
@ -647,10 +701,10 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||||
for (const domain of trigger.include_domains) {
|
for (const domain of trigger.include_domains) {
|
||||||
const normalizedDomain = domain.toLowerCase();
|
const normalizedDomain = domain.toLowerCase();
|
||||||
if (normalizedDomain === normalizedHostname) {
|
if (normalizedDomain === normalizedHostname) {
|
||||||
return true;
|
return domain;
|
||||||
}
|
}
|
||||||
if (trigger.include_subdomains && normalizedHostname.endsWith(`.${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) {
|
for (const domain of trigger.exclude_domains) {
|
||||||
const normalizedDomain = domain.toLowerCase();
|
const normalizedDomain = domain.toLowerCase();
|
||||||
if (normalizedDomain === normalizedHostname) {
|
if (normalizedDomain === normalizedHostname) {
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
if (trigger.include_subdomains && normalizedHostname.endsWith(`.${domain}`)) {
|
if (trigger.include_subdomains && normalizedHostname.endsWith(`.${domain}`)) {
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return link.toString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected matchTextSpamTrigger(
|
protected matchTextSpamTrigger(
|
||||||
recentActionType: RecentActionType,
|
recentActionType: RecentActionType,
|
||||||
trigger: TBaseTextSpamTrigger,
|
trigger: TBaseTextSpamTrigger,
|
||||||
msg: SavedMessage,
|
msg: SavedMessage,
|
||||||
): TextSpamTriggerMatchResult {
|
): Partial<TextSpamTriggerMatchResult> {
|
||||||
const since = moment.utc(msg.posted_at).valueOf() - convertDelayStringToMS(trigger.within);
|
const since = moment.utc(msg.posted_at).valueOf() - convertDelayStringToMS(trigger.within);
|
||||||
const recentActions = trigger.per_channel
|
const recentActions = trigger.per_channel
|
||||||
? this.getMatchingRecentActions(recentActionType, `${msg.channel_id}-${msg.user_id}`, since)
|
? this.getMatchingRecentActions(recentActionType, `${msg.channel_id}-${msg.user_id}`, since)
|
||||||
|
@ -699,69 +753,85 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async matchMultipleTextTypesOnMessage(
|
protected async matchMultipleTextTypesOnMessage<T>(
|
||||||
trigger: TextTriggerWithMultipleMatchTypes,
|
trigger: TextTriggerWithMultipleMatchTypes,
|
||||||
msg: SavedMessage,
|
msg: SavedMessage,
|
||||||
cb,
|
matchFn: (str: string) => T | Promise<T> | null,
|
||||||
): Promise<TextTriggerMatchResult> {
|
): Promise<Partial<TextTriggerMatchResult<T>>> {
|
||||||
const messageInfo: MessageInfo = { channelId: msg.channel_id, messageId: msg.id };
|
const messageInfo: MessageInfo = { channelId: msg.channel_id, messageId: msg.id };
|
||||||
const member = this.guild.members.get(msg.user_id);
|
const member = this.guild.members.get(msg.user_id);
|
||||||
|
|
||||||
if (trigger.match_messages) {
|
if (trigger.match_messages) {
|
||||||
const str = msg.data.content;
|
const str = msg.data.content;
|
||||||
const match = await cb(str);
|
const matchResult = await matchFn(str);
|
||||||
if (match) return { type: "message", str, userId: msg.user_id, messageInfo };
|
if (matchResult) {
|
||||||
|
return { type: "message", str, userId: msg.user_id, messageInfo, matchedValue: matchResult };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (trigger.match_embeds && msg.data.embeds && msg.data.embeds.length) {
|
if (trigger.match_embeds && msg.data.embeds && msg.data.embeds.length) {
|
||||||
const str = JSON.stringify(msg.data.embeds[0]);
|
const str = JSON.stringify(msg.data.embeds[0]);
|
||||||
const match = await cb(str);
|
const matchResult = await matchFn(str);
|
||||||
if (match) return { type: "embed", str, userId: msg.user_id, messageInfo };
|
if (matchResult) {
|
||||||
|
return { type: "embed", str, userId: msg.user_id, messageInfo, matchedValue: matchResult };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (trigger.match_visible_names) {
|
if (trigger.match_visible_names) {
|
||||||
const str = member.nick || msg.data.author.username;
|
const str = member.nick || msg.data.author.username;
|
||||||
const match = await cb(str);
|
const matchResult = await matchFn(str);
|
||||||
if (match) return { type: "visiblename", str, userId: msg.user_id };
|
if (matchResult) {
|
||||||
|
return { type: "visiblename", str, userId: msg.user_id, matchedValue: matchResult };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (trigger.match_usernames) {
|
if (trigger.match_usernames) {
|
||||||
const str = `${msg.data.author.username}#${msg.data.author.discriminator}`;
|
const str = `${msg.data.author.username}#${msg.data.author.discriminator}`;
|
||||||
const match = await cb(str);
|
const matchResult = await matchFn(str);
|
||||||
if (match) return { type: "username", str, userId: msg.user_id };
|
if (matchResult) {
|
||||||
|
return { type: "username", str, userId: msg.user_id, matchedValue: matchResult };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (trigger.match_nicknames && member.nick) {
|
if (trigger.match_nicknames && member.nick) {
|
||||||
const str = member.nick;
|
const str = member.nick;
|
||||||
const match = await cb(str);
|
const matchResult = await matchFn(str);
|
||||||
if (match) return { type: "nickname", str, userId: msg.user_id };
|
if (matchResult) {
|
||||||
|
return { type: "nickname", str, userId: msg.user_id, matchedValue: matchResult };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// type 4 = custom status
|
// type 4 = custom status
|
||||||
if (trigger.match_custom_status && member.game && member.game.type === 4) {
|
if (trigger.match_custom_status && member.game && member.game.type === 4) {
|
||||||
const str = member.game.state;
|
const str = member.game.state;
|
||||||
const match = await cb(str);
|
const matchResult = await matchFn(str);
|
||||||
if (match) return { type: "customstatus", str, userId: msg.user_id };
|
if (matchResult) {
|
||||||
|
return { type: "customstatus", str, userId: msg.user_id, matchedValue: matchResult };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async matchMultipleTextTypesOnMember(
|
protected async matchMultipleTextTypesOnMember<T>(
|
||||||
trigger: TextTriggerWithMultipleMatchTypes,
|
trigger: TextTriggerWithMultipleMatchTypes,
|
||||||
member: Member,
|
member: Member,
|
||||||
cb,
|
matchFn: (str: string) => T | Promise<T> | null,
|
||||||
): Promise<TextTriggerMatchResult> {
|
): Promise<Partial<TextTriggerMatchResult<T>>> {
|
||||||
if (trigger.match_usernames) {
|
if (trigger.match_usernames) {
|
||||||
const str = `${member.user.username}#${member.user.discriminator}`;
|
const str = `${member.user.username}#${member.user.discriminator}`;
|
||||||
const match = await cb(str);
|
const matchResult = await matchFn(str);
|
||||||
if (match) return { type: "username", str, userId: member.id };
|
if (matchResult) {
|
||||||
|
return { type: "username", str, userId: member.id, matchedValue: matchResult };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (trigger.match_nicknames && member.nick) {
|
if (trigger.match_nicknames && member.nick) {
|
||||||
const str = member.nick;
|
const str = member.nick;
|
||||||
const match = await cb(str);
|
const matchResult = await matchFn(str);
|
||||||
if (match) return { type: "nickname", str, userId: member.id };
|
if (matchResult) {
|
||||||
|
return { type: "nickname", str, userId: member.id, matchedValue: matchResult };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
@ -781,63 +851,63 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||||
const match = await this.matchMultipleTextTypesOnMessage(trigger.match_words, msg, str => {
|
const match = await this.matchMultipleTextTypesOnMessage(trigger.match_words, msg, str => {
|
||||||
return this.evaluateMatchWordsTrigger(trigger.match_words, 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) {
|
if (trigger.match_regex) {
|
||||||
const match = await this.matchMultipleTextTypesOnMessage(trigger.match_regex, msg, str => {
|
const match = await this.matchMultipleTextTypesOnMessage(trigger.match_regex, msg, str => {
|
||||||
return this.evaluateMatchRegexTrigger(trigger.match_regex, 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) {
|
if (trigger.match_invites) {
|
||||||
const match = await this.matchMultipleTextTypesOnMessage(trigger.match_invites, msg, str => {
|
const match = await this.matchMultipleTextTypesOnMessage(trigger.match_invites, msg, str => {
|
||||||
return this.evaluateMatchInvitesTrigger(trigger.match_invites, 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) {
|
if (trigger.match_links) {
|
||||||
const match = await this.matchMultipleTextTypesOnMessage(trigger.match_links, msg, str => {
|
const match = await this.matchMultipleTextTypesOnMessage(trigger.match_links, msg, str => {
|
||||||
return this.evaluateMatchLinksTrigger(trigger.match_links, 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) {
|
if (trigger.message_spam) {
|
||||||
const match = this.matchTextSpamTrigger(RecentActionType.Message, trigger.message_spam, msg);
|
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) {
|
if (trigger.mention_spam) {
|
||||||
const match = this.matchTextSpamTrigger(RecentActionType.Mention, trigger.mention_spam, msg);
|
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) {
|
if (trigger.link_spam) {
|
||||||
const match = this.matchTextSpamTrigger(RecentActionType.Link, trigger.link_spam, msg);
|
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) {
|
if (trigger.attachment_spam) {
|
||||||
const match = this.matchTextSpamTrigger(RecentActionType.Attachment, trigger.attachment_spam, msg);
|
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) {
|
if (trigger.emoji_spam) {
|
||||||
const match = this.matchTextSpamTrigger(RecentActionType.Emoji, trigger.emoji_spam, msg);
|
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) {
|
if (trigger.line_spam) {
|
||||||
const match = this.matchTextSpamTrigger(RecentActionType.Line, trigger.line_spam, msg);
|
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) {
|
if (trigger.character_spam) {
|
||||||
const match = this.matchTextSpamTrigger(RecentActionType.Character, trigger.character_spam, msg);
|
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) {
|
protected async applyActionsOnMatch(rule: TRule, matchResult: AnyTriggerMatchResult) {
|
||||||
const actionsTaken = [];
|
if (rule.cooldown && this.checkAndUpdateCooldown(rule, matchResult)) {
|
||||||
|
return;
|
||||||
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 (matchResult.type === "textspam") {
|
const matchSummary = this.getMatchSummary(matchResult);
|
||||||
this.activateGracePeriod(matchResult);
|
|
||||||
this.clearSpecificRecentActions(
|
|
||||||
matchResult.actionType,
|
|
||||||
matchResult.channelId ? `${matchResult.channelId}-${matchResult.userId}` : matchResult.userId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Match summary
|
let caseExtraNote = `Matched automod rule "${rule.name}"`;
|
||||||
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}"`;
|
|
||||||
if (matchSummary) {
|
if (matchSummary) {
|
||||||
caseExtraNote += `\n${matchSummary}`;
|
caseExtraNote += `\n${matchSummary}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const actionsTaken = [];
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
if (rule.actions.clean) {
|
if (rule.actions.clean) {
|
||||||
const messagesToDelete: Array<{ channelId: string; messageId: string }> = [];
|
const messagesToDelete: Array<{ channelId: string; messageId: string }> = [];
|
||||||
|
@ -1254,6 +1257,58 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||||
actionsTaken.push("nickname");
|
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
|
// Don't wait for the rest before continuing to other automod items in the queue
|
||||||
(async () => {
|
(async () => {
|
||||||
const user = matchResult.type !== "raidspam" ? this.getUser(matchResult.userId) : new UnknownUser();
|
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 safeUser = stripObjectToScalars(user);
|
||||||
const safeUsers = users.map(u => stripObjectToScalars(u));
|
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) {
|
if (rule.actions.alert) {
|
||||||
const channel = this.guild.channels.get(rule.actions.alert.channel);
|
const channel = this.guild.channels.get(rule.actions.alert.channel);
|
||||||
if (channel && channel instanceof TextChannel) {
|
if (channel && channel instanceof TextChannel) {
|
||||||
|
@ -1271,6 +1335,7 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||||
users: safeUsers,
|
users: safeUsers,
|
||||||
text,
|
text,
|
||||||
matchSummary,
|
matchSummary,
|
||||||
|
logMessage,
|
||||||
});
|
});
|
||||||
channel.createMessage(rendered);
|
channel.createMessage(rendered);
|
||||||
actionsTaken.push("alert");
|
actionsTaken.push("alert");
|
||||||
|
@ -1282,17 +1347,102 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rule.actions.log) {
|
if (rule.actions.log) {
|
||||||
this.getLogs().log(LogType.AUTOMOD_ACTION, {
|
this.getLogs().log(LogType.AUTOMOD_ACTION, logData);
|
||||||
rule: rule.name,
|
|
||||||
user: safeUser,
|
|
||||||
users: safeUsers,
|
|
||||||
actionsTaken: actionsTaken.length ? actionsTaken.join(", ") : "<none>",
|
|
||||||
matchSummary,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) {
|
protected onMessageCreate(msg: SavedMessage) {
|
||||||
if (msg.is_bot) return;
|
if (msg.is_bot) return;
|
||||||
|
|
||||||
|
@ -1311,6 +1461,7 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||||
const matchResult = await this.matchRuleToMessage(rule, msg);
|
const matchResult = await this.matchRuleToMessage(rule, msg);
|
||||||
if (matchResult) {
|
if (matchResult) {
|
||||||
await this.applyActionsOnMatch(rule, 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} |`);
|
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"],
|
aliases: ["vca"],
|
||||||
extra: {
|
extra: {
|
||||||
info: {
|
info: {
|
||||||
|
|
|
@ -356,9 +356,11 @@ export class LogsPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||||
async onMemberUpdate(_, member: Member, oldMember: Member) {
|
async onMemberUpdate(_, member: Member, oldMember: Member) {
|
||||||
if (!oldMember) return;
|
if (!oldMember) return;
|
||||||
|
|
||||||
|
const logMember = stripObjectToScalars(member, ["user", "roles"]);
|
||||||
|
|
||||||
if (member.nick !== oldMember.nick) {
|
if (member.nick !== oldMember.nick) {
|
||||||
this.guildLogs.log(LogType.MEMBER_NICK_CHANGE, {
|
this.guildLogs.log(LogType.MEMBER_NICK_CHANGE, {
|
||||||
member,
|
member: logMember,
|
||||||
oldNick: oldMember.nick != null ? oldMember.nick : "<none>",
|
oldNick: oldMember.nick != null ? oldMember.nick : "<none>",
|
||||||
newNick: member.nick != null ? member.nick : "<none>",
|
newNick: member.nick != null ? member.nick : "<none>",
|
||||||
});
|
});
|
||||||
|
@ -379,7 +381,7 @@ export class LogsPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||||
this.guildLogs.log(
|
this.guildLogs.log(
|
||||||
LogType.MEMBER_ROLE_CHANGES,
|
LogType.MEMBER_ROLE_CHANGES,
|
||||||
{
|
{
|
||||||
member,
|
member: logMember,
|
||||||
addedRoles: addedRoles
|
addedRoles: addedRoles
|
||||||
.map(roleId => this.guild.roles.get(roleId) || { id: roleId, name: `Unknown (${roleId})` })
|
.map(roleId => this.guild.roles.get(roleId) || { id: roleId, name: `Unknown (${roleId})` })
|
||||||
.map(r => r.name)
|
.map(r => r.name)
|
||||||
|
@ -397,7 +399,7 @@ export class LogsPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||||
this.guildLogs.log(
|
this.guildLogs.log(
|
||||||
LogType.MEMBER_ROLE_ADD,
|
LogType.MEMBER_ROLE_ADD,
|
||||||
{
|
{
|
||||||
member,
|
member: logMember,
|
||||||
roles: addedRoles
|
roles: addedRoles
|
||||||
.map(roleId => this.guild.roles.get(roleId) || { id: roleId, name: `Unknown (${roleId})` })
|
.map(roleId => this.guild.roles.get(roleId) || { id: roleId, name: `Unknown (${roleId})` })
|
||||||
.map(r => r.name)
|
.map(r => r.name)
|
||||||
|
@ -411,7 +413,7 @@ export class LogsPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||||
this.guildLogs.log(
|
this.guildLogs.log(
|
||||||
LogType.MEMBER_ROLE_REMOVE,
|
LogType.MEMBER_ROLE_REMOVE,
|
||||||
{
|
{
|
||||||
member,
|
member: logMember,
|
||||||
roles: removedRoles
|
roles: removedRoles
|
||||||
.map(roleId => this.guild.roles.get(roleId) || { id: roleId, name: `Unknown (${roleId})` })
|
.map(roleId => this.guild.roles.get(roleId) || { id: roleId, name: `Unknown (${roleId})` })
|
||||||
.map(r => r.name)
|
.map(r => r.name)
|
||||||
|
|
|
@ -125,7 +125,10 @@ export class MessageSaverPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||||
await msg.channel.createMessage(`Saving pins from <#${args.channel.id}>...`);
|
await msg.channel.createMessage(`Saving pins from <#${args.channel.id}>...`);
|
||||||
|
|
||||||
const pins = await args.channel.getPins();
|
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) {
|
if (failed.length) {
|
||||||
msg.channel.createMessage(
|
msg.channel.createMessage(
|
||||||
|
|
|
@ -197,7 +197,10 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||||
}
|
}
|
||||||
|
|
||||||
clearIgnoredEvent(type: IgnoredEventType, userId: any) {
|
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[]) {
|
formatReasonWithAttachments(reason: string, attachments: Attachment[]) {
|
||||||
|
|
|
@ -13,6 +13,10 @@ import {
|
||||||
deactivateMentions,
|
deactivateMentions,
|
||||||
createChunkedMessage,
|
createChunkedMessage,
|
||||||
stripObjectToScalars,
|
stripObjectToScalars,
|
||||||
|
isValidEmbed,
|
||||||
|
MINUTES,
|
||||||
|
StrictMessageContent,
|
||||||
|
DAYS,
|
||||||
} from "../utils";
|
} from "../utils";
|
||||||
import { GuildSavedMessages } from "../data/GuildSavedMessages";
|
import { GuildSavedMessages } from "../data/GuildSavedMessages";
|
||||||
import { ZeppelinPlugin } from "./ZeppelinPlugin";
|
import { ZeppelinPlugin } from "./ZeppelinPlugin";
|
||||||
|
@ -23,6 +27,7 @@ import moment, { Moment } from "moment-timezone";
|
||||||
import { GuildLogs } from "../data/GuildLogs";
|
import { GuildLogs } from "../data/GuildLogs";
|
||||||
import { LogType } from "../data/LogType";
|
import { LogType } from "../data/LogType";
|
||||||
import * as t from "io-ts";
|
import * as t from "io-ts";
|
||||||
|
import humanizeDuration from "humanize-duration";
|
||||||
|
|
||||||
const ConfigSchema = t.type({
|
const ConfigSchema = t.type({
|
||||||
can_post: t.boolean,
|
can_post: t.boolean,
|
||||||
|
@ -33,9 +38,13 @@ const fsp = fs.promises;
|
||||||
|
|
||||||
const COLOR_MATCH_REGEX = /^#?([0-9a-f]{6})$/;
|
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 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> {
|
export class PostPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||||
public static pluginName = "post";
|
public static pluginName = "post";
|
||||||
public static configSchema = ConfigSchema;
|
public static configSchema = ConfigSchema;
|
||||||
|
@ -142,17 +151,25 @@ export class PostPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||||
}
|
}
|
||||||
|
|
||||||
protected parseScheduleTime(str): Moment {
|
protected parseScheduleTime(str): Moment {
|
||||||
const dtMatch = str.match(/^\d{4}-\d{2}-\d{2} \d{1,2}:\d{1,2}(:\d{1,2})?$/);
|
const dt1 = moment(str, "YYYY-MM-DD HH:mm:ss");
|
||||||
if (dtMatch) {
|
if (dt1 && dt1.isValid()) return dt1;
|
||||||
const dt = moment(str, dtMatch[1] ? "YYYY-MM-DD H:m:s" : "YYYY-MM-DD H:m");
|
|
||||||
return dt;
|
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})?$/);
|
const t2 = moment(str, "HH:mm");
|
||||||
if (tMatch) {
|
if (t2 && t2.isValid()) {
|
||||||
const dt = moment(str, tMatch[1] ? "H:m:s" : "H:m");
|
if (t2.isBefore(moment())) t2.add(1, "day");
|
||||||
if (dt.isBefore(moment())) dt.add(1, "day");
|
return t2;
|
||||||
return dt;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const delayStringMS = convertDelayStringToMS(str, "m");
|
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);
|
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
|
* COMMAND: Post a regular text message as the bot to the specified channel
|
||||||
*/
|
*/
|
||||||
|
@ -207,66 +417,40 @@ export class PostPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||||
options: [
|
options: [
|
||||||
{
|
{
|
||||||
name: "enable-mentions",
|
name: "enable-mentions",
|
||||||
type: "bool",
|
isSwitch: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "schedule",
|
name: "schedule",
|
||||||
type: "string",
|
type: "string",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "repeat",
|
||||||
|
type: "delay",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "repeat-until",
|
||||||
|
type: "string",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "repeat-times",
|
||||||
|
type: "number",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
@d.permission("can_post")
|
@d.permission("can_post")
|
||||||
async postCmd(
|
async postCmd(
|
||||||
msg: Message,
|
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)) {
|
this.actualPostCmd(msg, args.channel, { content: args.content }, args);
|
||||||
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}>`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -278,6 +462,19 @@ export class PostPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||||
{ name: "content", type: "string" },
|
{ name: "content", type: "string" },
|
||||||
{ name: "color", type: "string" },
|
{ name: "color", type: "string" },
|
||||||
{ name: "schedule", 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")
|
@d.permission("can_post")
|
||||||
|
@ -290,13 +487,12 @@ export class PostPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||||
content?: string;
|
content?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
schedule?: 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;
|
const content = args.content || args.maincontent;
|
||||||
|
|
||||||
if (!args.title && !content) {
|
if (!args.title && !content) {
|
||||||
|
@ -315,59 +511,32 @@ export class PostPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||||
color = parseInt(colorMatch[1], 16);
|
color = parseInt(colorMatch[1], 16);
|
||||||
}
|
}
|
||||||
|
|
||||||
const embed: EmbedBase = {};
|
let embed: EmbedBase = {};
|
||||||
if (args.title) embed.title = args.title;
|
if (args.title) embed.title = args.title;
|
||||||
if (content) embed.description = this.formatContent(content);
|
|
||||||
if (color) embed.color = color;
|
if (color) embed.color = color;
|
||||||
|
|
||||||
if (args.schedule) {
|
if (content) {
|
||||||
// Schedule the post to be posted later
|
if (args.raw) {
|
||||||
const postAt = this.parseScheduleTime(args.schedule);
|
let parsed;
|
||||||
if (!postAt) {
|
try {
|
||||||
return this.sendErrorMessage(msg.channel, "Invalid schedule time");
|
parsed = JSON.parse(content);
|
||||||
}
|
} catch (e) {
|
||||||
|
this.sendErrorMessage(msg.channel, "Syntax error in embed JSON");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (postAt < moment()) {
|
if (!isValidEmbed(parsed)) {
|
||||||
return this.sendErrorMessage(msg.channel, "Post can't be scheduled to be posted in the past");
|
this.sendErrorMessage(msg.channel, "Embed is not valid");
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await this.scheduledPosts.create({
|
embed = Object.assign({}, embed, parsed);
|
||||||
author_id: msg.author.id,
|
} else {
|
||||||
author_name: `${msg.author.username}#${msg.author.discriminator}`,
|
embed.description = this.formatContent(content);
|
||||||
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}>`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (args.content) {
|
this.actualPostCmd(msg, args.channel, { embed }, args);
|
||||||
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.
|
|
||||||
`),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -472,6 +641,16 @@ export class PostPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||||
const parts = [`\`#${i++}\` \`[${p.post_at}]\` ${previewText}${isTruncated ? "..." : ""}`];
|
const parts = [`\`#${i++}\` \`[${p.post_at}]\` ${previewText}${isTruncated ? "..." : ""}`];
|
||||||
if (p.attachments.length) parts.push("*(with attachment)*");
|
if (p.attachments.length) parts.push("*(with attachment)*");
|
||||||
if (p.content.embed) parts.push("*(embed)*");
|
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})*`);
|
parts.push(`*(${p.author_name})*`);
|
||||||
|
|
||||||
return parts.join(" ");
|
return parts.join(" ");
|
||||||
|
@ -480,7 +659,7 @@ export class PostPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||||
const finalMessage = trimLines(`
|
const finalMessage = trimLines(`
|
||||||
${postLines.join("\n")}
|
${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
|
Use \`scheduled_posts delete <num>\` to delete a scheduled post
|
||||||
`);
|
`);
|
||||||
createChunkedMessage(msg.channel, finalMessage);
|
createChunkedMessage(msg.channel, finalMessage);
|
||||||
|
|
|
@ -268,9 +268,17 @@ export class ReactionRolesPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||||
* :zep_twitch: = 473086848831455234
|
* :zep_twitch: = 473086848831455234
|
||||||
* :zep_ps4: = 543184300250759188
|
* :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")
|
@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);
|
const savedMessage = await this.savedMessages.find(args.messageId);
|
||||||
if (!savedMessage) {
|
if (!savedMessage) {
|
||||||
msg.channel.createMessage(errorMessage("Unknown message"));
|
msg.channel.createMessage(errorMessage("Unknown message"));
|
||||||
|
@ -331,7 +339,7 @@ export class ReactionRolesPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||||
|
|
||||||
// Save the new reaction roles to the database
|
// Save the new reaction roles to the database
|
||||||
for (const pair of emojiRolePairs) {
|
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
|
// 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);
|
const matchingReactionRole = await this.reactionRoles.getByMessageAndEmoji(msg.id, emoji.id || emoji.name);
|
||||||
if (!matchingReactionRole) return;
|
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);
|
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);
|
const channel = this.guild.channels.get(reminder.channel_id);
|
||||||
if (channel && channel instanceof TextChannel) {
|
if (channel && channel instanceof TextChannel) {
|
||||||
try {
|
try {
|
||||||
await channel.createMessage(
|
// Only show created at date if one exists
|
||||||
disableLinkPreviews(`<@!${reminder.user_id}> You asked me to remind you: ${reminder.body}`),
|
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) {
|
} catch (e) {
|
||||||
// Probably random Discord internal server error or missing permissions or somesuch
|
// 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
|
// 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}`;
|
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 msUntilReminder = reminderTime.diff(now);
|
||||||
const timeUntilReminder = humanizeDuration(msUntilReminder, { largest: 2, round: true });
|
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 lines = Array.from(reminders.entries()).map(([i, reminder]) => {
|
||||||
const num = i + 1;
|
const num = i + 1;
|
||||||
const paddedNum = num.toString().padStart(longestNum, " ");
|
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"));
|
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: [
|
options: [
|
||||||
{
|
{
|
||||||
name: "force",
|
name: "force",
|
||||||
type: "bool",
|
isSwitch: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,58 +1,162 @@
|
||||||
import { decorators as d, waitForReply, utils as knubUtils, IBasePluginConfig, IPluginOptions } from "knub";
|
import { decorators as d, IPluginOptions } from "knub";
|
||||||
import { ZeppelinPlugin } from "./ZeppelinPlugin";
|
import { ZeppelinPlugin, trimPluginDescription } from "./ZeppelinPlugin";
|
||||||
import { GuildStarboards } from "../data/GuildStarboards";
|
import { Embed, EmbedBase, GuildChannel, Message, TextChannel } from "eris";
|
||||||
import { GuildChannel, Message, TextChannel } from "eris";
|
|
||||||
import {
|
import {
|
||||||
customEmojiRegex,
|
|
||||||
errorMessage,
|
errorMessage,
|
||||||
getEmojiInString,
|
|
||||||
getUrlsInString,
|
getUrlsInString,
|
||||||
|
messageLink,
|
||||||
noop,
|
noop,
|
||||||
snowflakeRegex,
|
|
||||||
successMessage,
|
successMessage,
|
||||||
|
TDeepPartialProps,
|
||||||
|
tNullable,
|
||||||
|
tDeepPartial,
|
||||||
|
UnknownUser,
|
||||||
|
EMPTY_CHAR,
|
||||||
} from "../utils";
|
} from "../utils";
|
||||||
import { Starboard } from "../data/entities/Starboard";
|
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import moment from "moment-timezone";
|
import moment from "moment-timezone";
|
||||||
import { GuildSavedMessages } from "../data/GuildSavedMessages";
|
import { GuildSavedMessages } from "../data/GuildSavedMessages";
|
||||||
import { SavedMessage } from "../data/entities/SavedMessage";
|
import { SavedMessage } from "../data/entities/SavedMessage";
|
||||||
import * as t from "io-ts";
|
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({
|
const ConfigSchema = t.type({
|
||||||
can_manage: t.boolean,
|
boards: t.record(t.string, StarboardOpts),
|
||||||
|
can_migrate: t.boolean,
|
||||||
});
|
});
|
||||||
type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
|
type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
|
||||||
|
|
||||||
|
const PartialConfigSchema = tDeepPartial(ConfigSchema);
|
||||||
|
|
||||||
|
const defaultStarboardOpts: Partial<TStarboardOpts> = {
|
||||||
|
star_emoji: ["⭐"],
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
export class StarboardPlugin extends ZeppelinPlugin<TConfigSchema> {
|
export class StarboardPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||||
public static pluginName = "starboard";
|
public static pluginName = "starboard";
|
||||||
public static showInDocs = false;
|
|
||||||
public static configSchema = ConfigSchema;
|
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 savedMessages: GuildSavedMessages;
|
||||||
|
protected starboardMessages: GuildStarboardMessages;
|
||||||
|
protected starboardReactions: GuildStarboardReactions;
|
||||||
|
|
||||||
private onMessageDeleteFn;
|
private onMessageDeleteFn;
|
||||||
|
|
||||||
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
||||||
return {
|
return {
|
||||||
config: {
|
config: {
|
||||||
can_manage: false,
|
can_migrate: false,
|
||||||
|
boards: {},
|
||||||
},
|
},
|
||||||
|
|
||||||
overrides: [
|
overrides: [
|
||||||
{
|
{
|
||||||
level: ">=100",
|
level: ">=100",
|
||||||
config: {
|
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() {
|
onLoad() {
|
||||||
this.starboards = GuildStarboards.getGuildInstance(this.guildId);
|
|
||||||
this.savedMessages = GuildSavedMessages.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.onMessageDeleteFn = this.onMessageDelete.bind(this);
|
||||||
this.savedMessages.events.on("delete", this.onMessageDeleteFn);
|
this.savedMessages.events.on("delete", this.onMessageDeleteFn);
|
||||||
|
@ -62,143 +166,13 @@ export class StarboardPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||||
this.savedMessages.events.off("delete", this.onMessageDeleteFn);
|
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
|
* 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.
|
* the required threshold. If they do, post the message in the starboard channel.
|
||||||
*/
|
*/
|
||||||
@d.event("messageReactionAdd")
|
@d.event("messageReactionAdd")
|
||||||
@d.lock("starboardReaction")
|
@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) {
|
if (!msg.author) {
|
||||||
// Message is not cached, fetch it
|
// Message is not cached, fetch it
|
||||||
try {
|
try {
|
||||||
|
@ -209,63 +183,80 @@ export class StarboardPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const emojiStr = emoji.id ? `${emoji.name}:${emoji.id}` : emoji.name;
|
// No self-votes!
|
||||||
const applicableStarboards = await this.starboards.getStarboardsByEmoji(emojiStr);
|
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) {
|
for (const starboard of applicableStarboards) {
|
||||||
// Can't star messages in the starboard channel itself
|
// Save reaction into the database
|
||||||
if (msg.channel.id === starboard.channel_id) continue;
|
await this.starboardReactions.createStarboardReaction(msg.id, userId).catch(noop);
|
||||||
|
|
||||||
if (starboard.channel_whitelist) {
|
// If the message has already been posted to this starboard, we don't need to do anything else
|
||||||
const allowedChannelIds = starboard.channel_whitelist.split(",");
|
const starboardMessages = await this.starboardMessages.getMatchingStarboardMessages(starboard.channel_id, msg.id);
|
||||||
if (!allowedChannelIds.includes(msg.channel.id)) continue;
|
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 reactions = await this.starboardReactions.getAllReactionsForMessageId(msg.id);
|
||||||
const existingSavedMessage = await this.starboards.getStarboardMessageByStarboardIdAndMessageId(
|
const reactionsCount = reactions.length;
|
||||||
starboard.id,
|
if (reactionsCount >= starboard.stars_required) {
|
||||||
msg.id,
|
await this.saveMessageToStarboard(msg, starboard.channel_id);
|
||||||
);
|
|
||||||
if (existingSavedMessage) return;
|
|
||||||
|
|
||||||
const reactionsCount = await this.countReactions(msg, emojiStr);
|
|
||||||
|
|
||||||
if (reactionsCount >= starboard.reactions_required) {
|
|
||||||
await this.saveMessageToStarboard(msg, starboard);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
@d.event("messageReactionRemove")
|
||||||
* Counts the specific reactions in the message, ignoring the message author
|
async onStarboardReactionRemove(msg: Message, emoji: { id: string; name: string }, userId: string) {
|
||||||
*/
|
await this.starboardReactions.deleteStarboardReaction(msg.id, userId);
|
||||||
async countReactions(msg: Message, reaction) {
|
}
|
||||||
let reactionsCount = (msg.reactions[reaction] && msg.reactions[reaction].count) || 0;
|
|
||||||
|
|
||||||
// Ignore self-stars
|
@d.event("messageReactionRemoveAll")
|
||||||
const reactors = await msg.getReaction(reaction);
|
async onMessageReactionRemoveAll(msg: Message) {
|
||||||
if (reactors.some(u => u.id === msg.author.id)) reactionsCount--;
|
await this.starboardReactions.deleteAllStarboardReactionsForMessageId(msg.id);
|
||||||
|
|
||||||
return reactionsCount;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Saves/posts a message to the specified starboard. The message is posted as an embed and image attachments are
|
* Saves/posts a message to the specified starboard.
|
||||||
* included as the embed image.
|
* The message is posted as an embed and image attachments are included as the embed image.
|
||||||
*/
|
*/
|
||||||
async saveMessageToStarboard(msg: Message, starboard: Starboard) {
|
async saveMessageToStarboard(msg: Message, starboardChannelId: string) {
|
||||||
const channel = this.guild.channels.get(starboard.channel_id);
|
const channel = this.guild.channels.get(starboardChannelId);
|
||||||
if (!channel) return;
|
if (!channel) return;
|
||||||
|
|
||||||
const time = moment(msg.timestamp, "x").format("YYYY-MM-DD [at] HH:mm:ss [UTC]");
|
const time = moment(msg.timestamp, "x").format("YYYY-MM-DD [at] HH:mm:ss [UTC]");
|
||||||
|
|
||||||
const embed: any = {
|
const embed: EmbedBase = {
|
||||||
footer: {
|
footer: {
|
||||||
text: `#${(msg.channel as GuildChannel).name} - ${time}`,
|
text: `#${(msg.channel as GuildChannel).name}`,
|
||||||
},
|
},
|
||||||
author: {
|
author: {
|
||||||
name: `${msg.author.username}#${msg.author.discriminator}`,
|
name: `${msg.author.username}#${msg.author.discriminator}`,
|
||||||
},
|
},
|
||||||
|
timestamp: new Date(msg.timestamp).toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (msg.author.avatarURL) {
|
if (msg.author.avatarURL) {
|
||||||
|
@ -276,6 +267,7 @@ export class StarboardPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||||
embed.description = msg.content;
|
embed.description = msg.content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Include attachments
|
||||||
if (msg.attachments.length) {
|
if (msg.attachments.length) {
|
||||||
const attachment = msg.attachments[0];
|
const attachment = msg.attachments[0];
|
||||||
const ext = path
|
const ext = path
|
||||||
|
@ -285,87 +277,96 @@ export class StarboardPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||||
if (["jpeg", "jpg", "png", "gif", "webp"].includes(ext)) {
|
if (["jpeg", "jpg", "png", "gif", "webp"].includes(ext)) {
|
||||||
embed.image = { url: attachment.url };
|
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({
|
// Include any embed images in the original message
|
||||||
content: `https://discordapp.com/channels/${this.guildId}/${msg.channel.id}/${msg.id}`,
|
if (msg.embeds.length && msg.embeds[0].image) {
|
||||||
embed,
|
embed.image = msg.embeds[0].image;
|
||||||
});
|
}
|
||||||
await this.starboards.createStarboardMessage(starboard.id, msg.id, starboardMessage.id);
|
|
||||||
|
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
|
* Remove a message from the specified starboard
|
||||||
*/
|
*/
|
||||||
async removeMessageFromStarboard(msgId: string, starboard: Starboard) {
|
async removeMessageFromStarboard(msg: StarboardMessage) {
|
||||||
const starboardMessage = await this.starboards.getStarboardMessageByStarboardIdAndMessageId(starboard.id, msgId);
|
await this.bot.deleteMessage(msg.starboard_channel_id, msg.starboard_message_id).catch(noop);
|
||||||
if (!starboardMessage) return;
|
}
|
||||||
|
|
||||||
await this.bot.deleteMessage(starboard.channel_id, starboardMessage.starboard_message_id).catch(noop);
|
async removeMessageFromStarboardMessages(starboard_message_id: string, channel_id: string) {
|
||||||
await this.starboards.deleteStarboardMessage(starboard.id, msgId);
|
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.
|
* 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.
|
* 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) {
|
async onMessageDelete(msg: SavedMessage) {
|
||||||
const starboardMessages = await this.starboards.with("starboard").getStarboardMessagesByMessageId(msg.id);
|
// Deleted source message
|
||||||
if (!starboardMessages.length) return;
|
const starboardMessages = await this.starboardMessages.getStarboardMessagesForMessageId(msg.id);
|
||||||
|
|
||||||
for (const starboardMessage of starboardMessages) {
|
for (const starboardMessage of starboardMessages) {
|
||||||
if (!starboardMessage.starboard) continue;
|
this.removeMessageFromStarboard(starboardMessage);
|
||||||
this.removeMessageFromStarboard(starboardMessage.message_id, starboardMessage.starboard);
|
}
|
||||||
|
|
||||||
|
// 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>")
|
@d.command("starboard migrate_pins", "<pinChannel:channel> <starboardName:string>", {
|
||||||
async migratePinsCmd(msg: Message, args: { pinChannelId: string; starboardChannelId }) {
|
extra: {
|
||||||
const starboard = await this.starboards.getStarboardByChannelId(args.starboardChannelId);
|
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) {
|
if (!starboard) {
|
||||||
msg.channel.createMessage(errorMessage("The specified channel doesn't have a starboard!"));
|
this.sendErrorMessage(msg.channel, "Unknown starboard specified");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const channel = (await this.guild.channels.get(args.pinChannelId)) as GuildChannel & TextChannel;
|
if (!(args.pinChannel instanceof TextChannel)) {
|
||||||
if (!channel) {
|
this.sendErrorMessage(msg.channel, "Unknown/invalid pin channel id");
|
||||||
msg.channel.createMessage(errorMessage("Could not find the specified channel to migrate pins from!"));
|
|
||||||
return;
|
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
|
pins.reverse(); // Migrate pins starting from the oldest message
|
||||||
|
|
||||||
for (const pin of pins) {
|
for (const pin of pins) {
|
||||||
const existingStarboardMessage = await this.starboards.getStarboardMessageByStarboardIdAndMessageId(
|
const existingStarboardMessage = await this.starboardMessages.getMatchingStarboardMessages(
|
||||||
starboard.id,
|
starboardChannel.id,
|
||||||
pin.id,
|
pin.id,
|
||||||
);
|
);
|
||||||
if (existingStarboardMessage) continue;
|
if (existingStarboardMessage.length > 0) continue;
|
||||||
|
await this.saveMessageToStarboard(pin, starboardChannel.id);
|
||||||
await this.saveMessageToStarboard(pin, starboard);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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}\``));
|
msg.channel.createMessage(successMessage(`Tag set! Use it with: \`${prefix}${args.tag}\``));
|
||||||
}
|
}
|
||||||
|
|
||||||
@d.command("tag", "<tag:string>")
|
@d.command("tag", "<tag:string>", {
|
||||||
async tagSourceCmd(msg: Message, args: { 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);
|
const tag = await this.tags.find(args.tag);
|
||||||
if (!tag) {
|
if (!tag) {
|
||||||
msg.channel.createMessage(errorMessage("No tag with that name"));
|
msg.channel.createMessage(errorMessage("No tag with that name"));
|
||||||
|
|
|
@ -29,6 +29,7 @@ import {
|
||||||
get,
|
get,
|
||||||
getInviteCodesInString,
|
getInviteCodesInString,
|
||||||
isSnowflake,
|
isSnowflake,
|
||||||
|
messageLink,
|
||||||
MINUTES,
|
MINUTES,
|
||||||
multiSorter,
|
multiSorter,
|
||||||
noop,
|
noop,
|
||||||
|
@ -56,6 +57,9 @@ import { getCurrentUptime } from "../uptime";
|
||||||
import LCL from "last-commit-log";
|
import LCL from "last-commit-log";
|
||||||
import * as t from "io-ts";
|
import * as t from "io-ts";
|
||||||
import { ICommandDefinition } from "knub-command-manager";
|
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({
|
const ConfigSchema = t.type({
|
||||||
can_roles: t.boolean,
|
can_roles: t.boolean,
|
||||||
|
@ -71,16 +75,20 @@ const ConfigSchema = t.type({
|
||||||
can_vcmove: t.boolean,
|
can_vcmove: t.boolean,
|
||||||
can_help: t.boolean,
|
can_help: t.boolean,
|
||||||
can_about: t.boolean,
|
can_about: t.boolean,
|
||||||
|
can_context: t.boolean,
|
||||||
});
|
});
|
||||||
type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
|
type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
|
||||||
|
|
||||||
const { performance } = require("perf_hooks");
|
const { performance } = require("perf_hooks");
|
||||||
|
|
||||||
const SEARCH_RESULTS_PER_PAGE = 15;
|
const SEARCH_RESULTS_PER_PAGE = 15;
|
||||||
|
const SEARCH_ID_RESULTS_PER_PAGE = 50;
|
||||||
|
|
||||||
const MAX_CLEAN_COUNT = 150;
|
const MAX_CLEAN_COUNT = 150;
|
||||||
const MAX_CLEAN_TIME = 1 * DAYS;
|
const MAX_CLEAN_TIME = 1 * DAYS;
|
||||||
const CLEAN_COMMAND_DELETE_DELAY = 5000;
|
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 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();
|
const activeReloads: Map<string, TextChannel> = new Map();
|
||||||
|
|
||||||
|
@ -88,10 +96,14 @@ type MemberSearchParams = {
|
||||||
query?: string;
|
query?: string;
|
||||||
role?: string;
|
role?: string;
|
||||||
voice?: boolean;
|
voice?: boolean;
|
||||||
|
bot?: boolean;
|
||||||
sort?: string;
|
sort?: string;
|
||||||
"case-sensitive"?: boolean;
|
"case-sensitive"?: boolean;
|
||||||
|
regex?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
class SearchError extends Error {}
|
||||||
|
|
||||||
export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||||
public static pluginName = "utility";
|
public static pluginName = "utility";
|
||||||
public static configSchema = ConfigSchema;
|
public static configSchema = ConfigSchema;
|
||||||
|
@ -124,6 +136,7 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||||
can_vcmove: false,
|
can_vcmove: false,
|
||||||
can_help: false,
|
can_help: false,
|
||||||
can_about: false,
|
can_about: false,
|
||||||
|
can_context: false,
|
||||||
},
|
},
|
||||||
overrides: [
|
overrides: [
|
||||||
{
|
{
|
||||||
|
@ -138,6 +151,7 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||||
can_nickname: true,
|
can_nickname: true,
|
||||||
can_vcmove: true,
|
can_vcmove: true,
|
||||||
can_help: true,
|
can_help: true,
|
||||||
|
can_context: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -177,7 +191,7 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||||
options: [
|
options: [
|
||||||
{
|
{
|
||||||
name: "counts",
|
name: "counts",
|
||||||
type: "bool",
|
isSwitch: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "sort",
|
name: "sort",
|
||||||
|
@ -320,18 +334,27 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||||
matchingMembers = matchingMembers.filter(m => m.voiceState.channelID != null);
|
matchingMembers = matchingMembers.filter(m => m.voiceState.channelID != null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (args.bot) {
|
||||||
|
matchingMembers = matchingMembers.filter(m => m.bot);
|
||||||
|
}
|
||||||
|
|
||||||
if (args.query) {
|
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 => {
|
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"]
|
const fullUsername = `${member.user.username}#${member.user.discriminator}`;
|
||||||
? `${member.user.username}#${member.user.discriminator}`
|
if (fullUsername.match(queryRegex)) return true;
|
||||||
: `${member.user.username}#${member.user.discriminator}`.toLowerCase();
|
|
||||||
|
|
||||||
if (nick && nick.indexOf(query) !== -1) return true;
|
|
||||||
if (fullUsername.indexOf(query) !== -1) return true;
|
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
@ -344,15 +367,18 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||||
matchingMembers.sort(sorter(m => BigInt(m.id), realSortDir));
|
matchingMembers.sort(sorter(m => BigInt(m.id), realSortDir));
|
||||||
} else {
|
} else {
|
||||||
matchingMembers.sort(
|
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));
|
page = Math.min(lastPage, Math.max(1, page));
|
||||||
|
|
||||||
const from = (page - 1) * SEARCH_RESULTS_PER_PAGE;
|
const from = (page - 1) * perPage;
|
||||||
const to = Math.min(from + SEARCH_RESULTS_PER_PAGE, matchingMembers.length);
|
const to = Math.min(from + perPage, matchingMembers.length);
|
||||||
|
|
||||||
const pageMembers = matchingMembers.slice(from, to);
|
const pageMembers = matchingMembers.slice(from, to);
|
||||||
|
|
||||||
|
@ -371,15 +397,23 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||||
options: [
|
options: [
|
||||||
{
|
{
|
||||||
name: "page",
|
name: "page",
|
||||||
|
shortcut: "p",
|
||||||
type: "number",
|
type: "number",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "role",
|
name: "role",
|
||||||
|
shortcut: "r",
|
||||||
type: "string",
|
type: "string",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "voice",
|
name: "voice",
|
||||||
type: "bool",
|
shortcut: "v",
|
||||||
|
isSwitch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bot",
|
||||||
|
shortcut: "b",
|
||||||
|
isSwitch: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "sort",
|
name: "sort",
|
||||||
|
@ -395,6 +429,15 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||||
shortcut: "e",
|
shortcut: "e",
|
||||||
isSwitch: true,
|
isSwitch: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "ids",
|
||||||
|
isSwitch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "regex",
|
||||||
|
shortcut: "re",
|
||||||
|
isSwitch: true,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
extra: {
|
extra: {
|
||||||
info: <CommandInfo>{
|
info: <CommandInfo>{
|
||||||
|
@ -417,15 +460,18 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||||
msg: Message,
|
msg: Message,
|
||||||
args: {
|
args: {
|
||||||
query?: string;
|
query?: string;
|
||||||
role?: string;
|
|
||||||
page?: number;
|
page?: number;
|
||||||
|
role?: string;
|
||||||
voice?: boolean;
|
voice?: boolean;
|
||||||
|
bot?: boolean;
|
||||||
sort?: string;
|
sort?: string;
|
||||||
"case-sensitive"?: boolean;
|
"case-sensitive"?: boolean;
|
||||||
export?: 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 longestId = members.reduce((longest, member) => Math.max(longest, member.id.length), 0);
|
||||||
const lines = members.map(member => {
|
const lines = members.map(member => {
|
||||||
const paddedId = member.id.padEnd(longestId, " ");
|
const paddedId = member.id.padEnd(longestId, " ");
|
||||||
|
@ -433,23 +479,38 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||||
if (member.nick) line += ` (${member.nick})`;
|
if (member.nick) line += ` (${member.nick})`;
|
||||||
return line;
|
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.
|
// 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.
|
// Just get the results and dump them in an archive.
|
||||||
if (args.export) {
|
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) {
|
if (results.totalResults === 0) {
|
||||||
return this.sendErrorMessage(msg.channel, "No results found");
|
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(
|
const archiveId = await this.archives.create(
|
||||||
trimLines(`
|
trimLines(`
|
||||||
Search results (total ${results.totalResults}):
|
Search results (total ${results.totalResults}):
|
||||||
|
|
||||||
${resultLines.join("\n")}
|
${resultList}
|
||||||
`),
|
`),
|
||||||
moment().add(1, "hour"),
|
moment().add(1, "hour"),
|
||||||
);
|
);
|
||||||
|
@ -468,6 +529,8 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||||
let clearReactionsFn = null;
|
let clearReactionsFn = null;
|
||||||
let clearReactionsTimeout = null;
|
let clearReactionsTimeout = null;
|
||||||
|
|
||||||
|
const perPage = args.ids ? SEARCH_ID_RESULTS_PER_PAGE : SEARCH_RESULTS_PER_PAGE;
|
||||||
|
|
||||||
const loadSearchPage = async page => {
|
const loadSearchPage = async page => {
|
||||||
if (searching) return;
|
if (searching) return;
|
||||||
searching = true;
|
searching = true;
|
||||||
|
@ -482,23 +545,37 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||||
searchMsgPromise.then(m => (originalSearchMsg = m));
|
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) {
|
if (searchResult.totalResults === 0) {
|
||||||
return this.sendErrorMessage(msg.channel, "No results found");
|
return this.sendErrorMessage(msg.channel, "No results found");
|
||||||
}
|
}
|
||||||
|
|
||||||
const resultWord = searchResult.totalResults === 1 ? "matching member" : "matching members";
|
const resultWord = searchResult.totalResults === 1 ? "matching member" : "matching members";
|
||||||
const headerText =
|
const headerText =
|
||||||
searchResult.totalResults > SEARCH_RESULTS_PER_PAGE
|
searchResult.totalResults > perPage
|
||||||
? trimLines(`
|
? trimLines(`
|
||||||
**Page ${searchResult.page}** (${searchResult.from}-${searchResult.to}) (total ${searchResult.totalResults})
|
**Page ${searchResult.page}** (${searchResult.from}-${searchResult.to}) (total ${searchResult.totalResults})
|
||||||
`)
|
`)
|
||||||
: `Found ${searchResult.totalResults} ${resultWord}`;
|
: `Found ${searchResult.totalResults} ${resultWord}`;
|
||||||
const lines = formatSearchResultLines(searchResult.results);
|
|
||||||
|
const resultList = args.ids
|
||||||
|
? formatSearchResultIdList(searchResult.results)
|
||||||
|
: formatSearchResultList(searchResult.results);
|
||||||
|
|
||||||
const result = trimLines(`
|
const result = trimLines(`
|
||||||
${headerText}
|
${headerText}
|
||||||
\`\`\`js
|
\`\`\`js
|
||||||
${lines.join("\n")}
|
${resultList}
|
||||||
\`\`\`
|
\`\`\`
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
@ -506,7 +583,7 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||||
searchMsg.edit(result);
|
searchMsg.edit(result);
|
||||||
|
|
||||||
// Set up pagination reactions if needed. The reactions are cleared after a timeout.
|
// 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) {
|
if (!hasReactions) {
|
||||||
hasReactions = true;
|
hasReactions = true;
|
||||||
searchMsg.addReaction("⬅");
|
searchMsg.addReaction("⬅");
|
||||||
|
@ -514,6 +591,7 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||||
searchMsg.addReaction("🔄");
|
searchMsg.addReaction("🔄");
|
||||||
|
|
||||||
const removeListenerFn = this.on("messageReactionAdd", (rMsg: Message, emoji, userId) => {
|
const removeListenerFn = this.on("messageReactionAdd", (rMsg: Message, emoji, userId) => {
|
||||||
|
if (rMsg.id !== searchMsg.id) return;
|
||||||
if (userId !== msg.author.id) return;
|
if (userId !== msg.author.id) return;
|
||||||
if (!["⬅", "➡", "🔄"].includes(emoji.name)) return;
|
if (!["⬅", "➡", "🔄"].includes(emoji.name)) return;
|
||||||
|
|
||||||
|
@ -571,6 +649,8 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||||
count: savedMessages.length,
|
count: savedMessages.length,
|
||||||
archiveUrl,
|
archiveUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return { archiveUrl };
|
||||||
}
|
}
|
||||||
|
|
||||||
@d.command("clean", "<count:number>", {
|
@d.command("clean", "<count:number>", {
|
||||||
|
@ -683,17 +763,19 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||||
|
|
||||||
let responseMsg: Message;
|
let responseMsg: Message;
|
||||||
if (messagesToClean.length > 0) {
|
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"}`;
|
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));
|
responseMsg = await msg.channel.createMessage(successMessage(responseText));
|
||||||
} else {
|
} else {
|
||||||
responseMsg = await msg.channel.createMessage(errorMessage(`Found no messages to clean!`));
|
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
|
// 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)
|
// (so as not to spam the cleaned channel with the command itself)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
@ -710,9 +792,16 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||||
basicUsage: "!info 106391128718245888",
|
basicUsage: "!info 106391128718245888",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
name: "compact",
|
||||||
|
shortcut: "c",
|
||||||
|
isSwitch: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
})
|
})
|
||||||
@d.permission("can_info")
|
@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;
|
const user = args.user || msg.author;
|
||||||
|
|
||||||
let member;
|
let member;
|
||||||
|
@ -734,15 +823,40 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||||
embed.title = `${user.username}#${user.discriminator}`;
|
embed.title = `${user.username}#${user.discriminator}`;
|
||||||
embed.thumbnail = { url: user.avatarURL };
|
embed.thumbnail = { url: user.avatarURL };
|
||||||
|
|
||||||
embed.fields.push({
|
if (args.compact) {
|
||||||
name: "User information",
|
embed.fields.push({
|
||||||
value:
|
name: "User information",
|
||||||
trimLines(`
|
value: trimLines(`
|
||||||
ID: **${user.id}**
|
Profile: <@!${user.id}>
|
||||||
Profile: <@!${user.id}>
|
Created: **${accountAge} ago (${createdAt.format("YYYY-MM-DD[T]HH:mm:ss")})**
|
||||||
Created: **${accountAge} ago (${createdAt.format("YYYY-MM-DD[T]HH:mm:ss")})**
|
`),
|
||||||
`) + embedPadding,
|
});
|
||||||
});
|
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 {
|
} else {
|
||||||
embed.title = `Unknown user`;
|
embed.title = `Unknown user`;
|
||||||
}
|
}
|
||||||
|
@ -782,7 +896,6 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||||
value: embedPadding,
|
value: embedPadding,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const cases = (await this.cases.getByUserId(user.id)).filter(c => !c.is_hidden);
|
const cases = (await this.cases.getByUserId(user.id)).filter(c => !c.is_hidden);
|
||||||
|
|
||||||
if (cases.length > 0) {
|
if (cases.length > 0) {
|
||||||
|
@ -994,7 +1107,12 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Clean up test messages
|
// 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>", {
|
@d.command("source", "<messageId:string>", {
|
||||||
|
@ -1021,6 +1139,30 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||||
msg.channel.createMessage(`Message source: ${url}`);
|
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$>", {
|
@d.command("vcmove", "<member:resolvedMember> <channel:string$>", {
|
||||||
extra: {
|
extra: {
|
||||||
info: <CommandInfo>{
|
info: <CommandInfo>{
|
||||||
|
@ -1192,8 +1334,25 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||||
const uptime = getCurrentUptime();
|
const uptime = getCurrentUptime();
|
||||||
const prettyUptime = humanizeDuration(uptime, { largest: 2, round: true });
|
const prettyUptime = humanizeDuration(uptime, { largest: 2, round: true });
|
||||||
|
|
||||||
const lcl = new LCL();
|
let lastCommit;
|
||||||
const lastCommit = await lcl.getLastCommit();
|
|
||||||
|
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]);
|
const shard = this.bot.shards.get(this.bot.guildShardMap[this.guildId]);
|
||||||
|
|
||||||
|
@ -1205,8 +1364,8 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||||
const basicInfoRows = [
|
const basicInfoRows = [
|
||||||
["Uptime", prettyUptime],
|
["Uptime", prettyUptime],
|
||||||
["Last reload", `${lastReload} ago`],
|
["Last reload", `${lastReload} ago`],
|
||||||
["Last update", moment(lastCommit.committer.date, "X").format("LL [at] H:mm [(UTC)]")],
|
["Last update", lastUpdate],
|
||||||
["Version", lastCommit.shortHash],
|
["Version", version],
|
||||||
["API latency", `${shard.latency}ms`],
|
["API latency", `${shard.latency}ms`],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
@ -12,14 +12,16 @@ import {
|
||||||
resolveMember,
|
resolveMember,
|
||||||
resolveUser,
|
resolveUser,
|
||||||
resolveUserId,
|
resolveUserId,
|
||||||
|
tDeepPartial,
|
||||||
trimEmptyStartEndLines,
|
trimEmptyStartEndLines,
|
||||||
trimIndents,
|
trimIndents,
|
||||||
UnknownUser,
|
UnknownUser,
|
||||||
|
resolveRoleId,
|
||||||
} from "../utils";
|
} from "../utils";
|
||||||
import { Invite, Member, User } from "eris";
|
import { Invite, Member, User } from "eris";
|
||||||
import DiscordRESTError from "eris/lib/errors/DiscordRESTError"; // tslint:disable-line
|
import DiscordRESTError from "eris/lib/errors/DiscordRESTError"; // tslint:disable-line
|
||||||
import { performance } from "perf_hooks";
|
import { performance } from "perf_hooks";
|
||||||
import { decodeAndValidateStrict, StrictValidationError } from "../validatorUtils";
|
import { decodeAndValidateStrict, StrictValidationError, validate } from "../validatorUtils";
|
||||||
import { SimpleCache } from "../SimpleCache";
|
import { SimpleCache } from "../SimpleCache";
|
||||||
|
|
||||||
const SLOW_RESOLVE_THRESHOLD = 1500;
|
const SLOW_RESOLVE_THRESHOLD = 1500;
|
||||||
|
@ -52,8 +54,8 @@ export interface CommandInfo {
|
||||||
export function trimPluginDescription(str) {
|
export function trimPluginDescription(str) {
|
||||||
const emptyLinesTrimmed = trimEmptyStartEndLines(str);
|
const emptyLinesTrimmed = trimEmptyStartEndLines(str);
|
||||||
const lines = emptyLinesTrimmed.split("\n");
|
const lines = emptyLinesTrimmed.split("\n");
|
||||||
const lastLineIndentation = (lines[lines.length - 1].match(/^ +/g) || [""])[0].length;
|
const firstLineIndentation = (lines[0].match(/^ +/g) || [""])[0].length;
|
||||||
return trimIndents(emptyLinesTrimmed, lastLineIndentation);
|
return trimIndents(emptyLinesTrimmed, firstLineIndentation);
|
||||||
}
|
}
|
||||||
|
|
||||||
const inviteCache = new SimpleCache<Promise<Invite>>(10 * MINUTES, 200);
|
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);
|
throw new PluginRuntimeError(message, this.runtimePluginName, this.guildId);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected canActOn(member1, member2) {
|
protected canActOn(member1: Member, member2: Member, allowSameLevel = false) {
|
||||||
if (member1.id === member2.id || member2.id === this.bot.user.id) {
|
if (member2.id === this.bot.user.id) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ourLevel = this.getMemberLevel(member1);
|
const ourLevel = this.getMemberLevel(member1);
|
||||||
const memberLevel = this.getMemberLevel(member2);
|
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
|
? options.overrides
|
||||||
: (defaultOptions.overrides || []).concat(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);
|
mergedConfig = this.preprocessStaticConfig(mergedConfig);
|
||||||
|
|
||||||
const decodedConfig = this.configSchema ? decodeAndValidateStrict(this.configSchema, mergedConfig) : mergedConfig;
|
const decodedConfig = this.configSchema ? decodeAndValidateStrict(this.configSchema, mergedConfig) : mergedConfig;
|
||||||
|
@ -229,6 +238,16 @@ export class ZeppelinPlugin<TConfig extends {} = IBasePluginConfig> extends Plug
|
||||||
return user;
|
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.
|
* 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.
|
* 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 { GuildConfigReloader } from "./GuildConfigReloader";
|
||||||
import { ChannelArchiverPlugin } from "./ChannelArchiver";
|
import { ChannelArchiverPlugin } from "./ChannelArchiver";
|
||||||
import { AutomodPlugin } from "./Automod";
|
import { AutomodPlugin } from "./Automod";
|
||||||
|
import { RolesPlugin } from "./Roles";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Plugins available to be loaded for individual guilds
|
* Plugins available to be loaded for individual guilds
|
||||||
|
@ -58,6 +59,7 @@ export const availablePlugins = [
|
||||||
CompanionChannelPlugin,
|
CompanionChannelPlugin,
|
||||||
LocatePlugin,
|
LocatePlugin,
|
||||||
ChannelArchiverPlugin,
|
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,
|
GuildAuditLogEntry,
|
||||||
GuildChannel,
|
GuildChannel,
|
||||||
Member,
|
Member,
|
||||||
|
Message,
|
||||||
MessageContent,
|
MessageContent,
|
||||||
TextableChannel,
|
TextableChannel,
|
||||||
TextChannel,
|
TextChannel,
|
||||||
|
@ -27,6 +28,10 @@ import https from "https";
|
||||||
import tmp from "tmp";
|
import tmp from "tmp";
|
||||||
import { logger, waitForReaction } from "knub";
|
import { logger, waitForReaction } from "knub";
|
||||||
import { SavedMessage } from "./data/entities/SavedMessage";
|
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 = {
|
const delayStringMultipliers = {
|
||||||
w: 1000 * 60 * 60 * 24 * 7,
|
w: 1000 * 60 * 60 * 24 * 7,
|
||||||
|
@ -34,6 +39,7 @@ const delayStringMultipliers = {
|
||||||
h: 1000 * 60 * 60,
|
h: 1000 * 60 * 60,
|
||||||
m: 1000 * 60,
|
m: 1000 * 60,
|
||||||
s: 1000,
|
s: 1000,
|
||||||
|
x: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MS = 1;
|
export const MS = 1;
|
||||||
|
@ -41,11 +47,141 @@ export const SECONDS = 1000 * MS;
|
||||||
export const MINUTES = 60 * SECONDS;
|
export const MINUTES = 60 * SECONDS;
|
||||||
export const HOURS = 60 * MINUTES;
|
export const HOURS = 60 * MINUTES;
|
||||||
export const DAYS = 24 * HOURS;
|
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}>`);
|
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) {
|
export function dropPropertiesByName(obj, propName) {
|
||||||
if (obj.hasOwnProperty(propName)) delete obj[propName];
|
if (obj.hasOwnProperty(propName)) delete obj[propName];
|
||||||
for (const value of Object.values(obj)) {
|
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
|
* Turns a "delay string" such as "1h30m" to milliseconds
|
||||||
*/
|
*/
|
||||||
|
@ -79,8 +249,23 @@ export function convertDelayStringToMS(str, defaultUnit = "m"): number {
|
||||||
return ms;
|
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) {
|
export function successMessage(str) {
|
||||||
return `👌 ${str}`;
|
return `<:zep_check:650361014180904971> ${str}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function errorMessage(str) {
|
export function errorMessage(str) {
|
||||||
|
@ -189,7 +374,7 @@ const urlRegex = /(\S+\.\S+)/g;
|
||||||
const protocolRegex = /^[a-z]+:\/\//;
|
const protocolRegex = /^[a-z]+:\/\//;
|
||||||
|
|
||||||
export function getUrlsInString(str: string, unique = false): url.URL[] {
|
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));
|
if (unique) matches = Array.from(new Set(matches));
|
||||||
|
|
||||||
return matches.reduce((urls, match) => {
|
return matches.reduce((urls, match) => {
|
||||||
|
@ -216,15 +401,7 @@ export function getUrlsInString(str: string, unique = false): url.URL[] {
|
||||||
|
|
||||||
export function getInviteCodesInString(str: string): string[] {
|
export function getInviteCodesInString(str: string): string[] {
|
||||||
const inviteCodeRegex = /(?:discord.gg|discordapp.com\/invite)\/([a-z0-9]+)/gi;
|
const inviteCodeRegex = /(?:discord.gg|discordapp.com\/invite)\/([a-z0-9]+)/gi;
|
||||||
const inviteCodes = [];
|
return Array.from(str.matchAll(inviteCodeRegex)).map(m => m[1]);
|
||||||
let match;
|
|
||||||
|
|
||||||
// tslint:disable-next-line
|
|
||||||
while ((match = inviteCodeRegex.exec(str)) !== null) {
|
|
||||||
inviteCodes.push(match[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return inviteCodes;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const unicodeEmojiRegex = emojiRegex();
|
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 {
|
export function disableLinkPreviews(str: string): string {
|
||||||
return str.replace(/(?<!<)(https?:\/\/\S+)/gi, "<$1>");
|
return str.replace(/(?<!<)(https?:\/\/\S+)/gi, "<$1>");
|
||||||
|
@ -343,6 +520,17 @@ export function deactivateMentions(content: string): string {
|
||||||
return content.replace(/@/g, "@\u200b");
|
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 {
|
export function disableCodeBlocks(content: string): string {
|
||||||
return content.replace(/`/g, "`\u200b");
|
return content.replace(/`/g, "`\u200b");
|
||||||
}
|
}
|
||||||
|
@ -768,6 +956,32 @@ export async function resolveMember(bot: Client, guild: Guild, value: string): P
|
||||||
return null;
|
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 type StrictMessageContent = { content?: string; tts?: boolean; disableEveryone?: boolean; embed?: EmbedOptions };
|
||||||
|
|
||||||
export async function confirm(bot: Client, channel: TextableChannel, userId: string, content: MessageContent) {
|
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 {
|
export function verboseChannelMention(channel: GuildChannel): string {
|
||||||
return `<#${channel.id}> (**#${channel.name}**, \`${channel.id}\`)`;
|
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
|
// tslint:enable
|
||||||
|
|
||||||
export class StrictValidationError extends Error {
|
export class StrictValidationError extends Error {
|
||||||
private errors;
|
private readonly errors;
|
||||||
|
|
||||||
constructor(errors: string[]) {
|
constructor(errors: string[]) {
|
||||||
errors = Array.from(new Set(errors));
|
errors = Array.from(new Set(errors));
|
||||||
|
@ -83,6 +83,17 @@ const report = fold((errors: any): StrictValidationError | void => {
|
||||||
return new StrictValidationError(errorStrings);
|
return new StrictValidationError(errorStrings);
|
||||||
}, noop);
|
}, 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
|
* Decodes and validates the given value against the given schema while also disallowing extra properties
|
||||||
* See: https://github.com/gcanti/io-ts/issues/322
|
* 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,
|
"allowSyntheticDefaultImports": true,
|
||||||
"experimentalDecorators": true,
|
"experimentalDecorators": true,
|
||||||
"emitDecoratorMetadata": true,
|
"emitDecoratorMetadata": true,
|
||||||
"target": "esnext",
|
"target": "es2018",
|
||||||
"lib": [
|
"lib": [
|
||||||
"es2017",
|
|
||||||
"esnext"
|
"esnext"
|
||||||
],
|
],
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
|
@ -19,7 +18,8 @@
|
||||||
"@shared/*": [
|
"@shared/*": [
|
||||||
"../shared/src/*"
|
"../shared/src/*"
|
||||||
]
|
]
|
||||||
}
|
},
|
||||||
|
"sourceMap": true
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"src/**/*.ts"
|
"src/**/*.ts"
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "node",
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"target": "esnext",
|
"target": "es2018",
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"noImplicitAny": false,
|
"noImplicitAny": false,
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
|
|
6
package-lock.json
generated
6
package-lock.json
generated
|
@ -1218,9 +1218,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"prettier": {
|
"prettier": {
|
||||||
"version": "1.18.2",
|
"version": "1.19.1",
|
||||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-1.18.2.tgz",
|
"resolved": "https://registry.npmjs.org/prettier/-/prettier-1.19.1.tgz",
|
||||||
"integrity": "sha512-OeHeMc0JhFE9idD4ZdtNibzY0+TPHSpSSb9h8FqtP+YnoZZ1sl8Vc9b1sasjfymH3SonAF4QcA2+mzHPhMvIiw==",
|
"integrity": "sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"pump": {
|
"pump": {
|
||||||
|
|
|
@ -4,13 +4,13 @@
|
||||||
"description": "",
|
"description": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "cd backend && npm run test && cd ../shared && npm run test"
|
"format": "prettier --write \"./{backend,dashboard}/**/*.ts\""
|
||||||
},
|
},
|
||||||
"dependencies": {},
|
"dependencies": {},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"husky": "^3.0.9",
|
"husky": "^3.0.9",
|
||||||
"lint-staged": "^9.4.2",
|
"lint-staged": "^9.4.2",
|
||||||
"prettier": "^1.16.4",
|
"prettier": "^1.19.1",
|
||||||
"tslint": "^5.13.1",
|
"tslint": "^5.13.1",
|
||||||
"tslint-config-prettier": "^1.18.0",
|
"tslint-config-prettier": "^1.18.0",
|
||||||
"typescript": "^3.7.2"
|
"typescript": "^3.7.2"
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"experimentalDecorators": true,
|
"experimentalDecorators": true,
|
||||||
"emitDecoratorMetadata": true,
|
"emitDecoratorMetadata": true,
|
||||||
"target": "esnext",
|
"target": "es2018",
|
||||||
"lib": [
|
"lib": [
|
||||||
"esnext"
|
"esnext"
|
||||||
],
|
],
|
||||||
|
|
Loading…
Add table
Reference in a new issue