3
0
Fork 0
mirror of https://github.com/ZeppelinBot/Zeppelin.git synced 2025-03-15 05:41:51 +00:00

Merge branch 'master' into fixLocateUser

This commit is contained in:
Miikka 2020-01-12 11:51:59 +02:00 committed by GitHub
commit b1b4b85e94
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
65 changed files with 2573 additions and 2888 deletions

1
.gitignore vendored
View file

@ -72,3 +72,4 @@ desktop.ini
.cache
npm-ls.txt
npm-audit.txt
.vscode/launch.json

View file

@ -1,28 +1,50 @@
# Development
# Zeppelin
Zeppelin is a moderation bot for Discord, designed with large servers and reliability in mind.
**Main features include:**
- Extensive automoderator features (automod)
- Word filters, spam detection, etc.
- Detailed moderator action tracking and notes (cases)
- Customizable server logs
- Tags/custom commands
- Reaction roles
- Tons of utility commands, including a granular member search
- Full configuration via a web dashboard
- Override specific settings and permissions on e.g. a per-user, per-channel, or per-permission-level basis
- Bot-managed slowmodes
- Automatically switches between native slowmodes (for 6h or less) and bot-enforced (for longer slowmodes)
- Starboard
- And more!
See https://zeppelin.gg/ for more details.
## Development
These instructions are intended for bot development only.
👉 **No support is offered for self-hosting the bot!** 👈
## Running the bot
### Running the bot
1. `cd backend`
2. `npm ci`
3. Make a copy of `bot.env.example` called `bot.env`, fill in the values
4. Run the desired start script:
* `npm run start-bot-dev` to run the bot with `ts-node`
* `npm run build` followed by `npm run start-bot-prod` to run the bot compiled
* `npm run watch-bot` to run the bot with `ts-node` and restart on changes
* `npm run build` followed by `npm run start-bot-dev` to run the bot in a **development** environment
* `npm run build` followed by `npm run start-bot-prod` to run the bot in a **production** environment
* `npm run watch` to watch files and run the **bot and api both** in a **development** environment
with automatic restart on file changes
5. When testing, make sure you have your test server in the `allowed_guilds` table or the guild's config won't be loaded at all
## Running the API server
### Running the API server
1. `cd backend`
2. `npm ci`
3. Make a copy of `api.env.example` called `api.env`, fill in the values
4. Run the desired start script:
* `npm run start-api-dev` to run the API server with `ts-node`
* `npm run build` followed by `npm run start-api-prod` to run the API server compiled
* `npm run watch-api` to run the API server with `ts-node` and restart on changes
* `npm run build` followed by `npm run start-api-dev` to run the api in a **development** environment
* `npm run build` followed by `npm run start-api-prod` to run the api in a **production** environment
* `npm run watch` to watch files and run the **bot and api both** in a **development** environment
with automatic restart on file changes
## Running the dashboard
### Running the dashboard
1. `cd dashboard`
2. `npm ci`
3. Make a copy of `.env.example` called `.env`, fill in the values
@ -30,15 +52,15 @@ These instructions are intended for bot development only.
* `npm run build` compiles the dashboard's static files to `dist/` which can then be served with any web server
* `npm run watch` runs webpack's dev server that automatically reloads on changes
## Notes
### Notes
* Since we now use shared paths in `tsconfig.json`, the compiled files in `backend/dist/` have longer paths, e.g.
`backend/dist/backend/src/index.js` instead of `backend/dist/index.js`. This is because the compiled shared files
are placed in `backend/dist/shared`.
* The `backend/register-tsconfig-prod-paths.js` module takes care of registering shared paths from `tsconfig.json` for
`ts-node`, `ava`, and compiled `.js` files
* The `backend/register-tsconfig-paths.js` module takes care of registering shared paths from `tsconfig.json` for
`ava` and compiled `.js` files
* To run the tests for the files in the `shared/` directory, you also need to run `npm ci` there
## Config format example
### Config format example
Configuration is stored in the database in the `configs` table
```yml

View file

@ -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"
}

View file

@ -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"
}

View file

@ -16,13 +16,9 @@ try {
const moment = require('moment-timezone');
moment.tz.setDefault('UTC');
const entities = process.env.NODE_ENV === 'production'
? path.relative(process.cwd(), path.resolve(__dirname, 'dist/backend/src/data/entities/*.js'))
: path.relative(process.cwd(), path.resolve(__dirname, 'src/data/entities/*.ts'));
const migrations = process.env.NODE_ENV === 'production'
? path.relative(process.cwd(), path.resolve(__dirname, 'dist/backend/src/migrations/*.js'))
: path.relative(process.cwd(), path.resolve(__dirname, 'src/migrations/*.ts'));
const entities = path.relative(process.cwd(), path.resolve(__dirname, 'dist/backend/src/data/entities/*.js'));
const migrations = path.relative(process.cwd(), path.resolve(__dirname, 'dist/backend/src/migrations/*.js'));
const migrationsDir = path.relative(process.cwd(), path.resolve(__dirname, 'src/migrations'));
module.exports = {
type: "mysql",
@ -55,6 +51,6 @@ module.exports = {
// Migrations
migrations: [migrations],
cli: {
migrationsDir: path.dirname(migrations)
migrationsDir,
},
};

2254
backend/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -4,18 +4,20 @@
"description": "",
"private": true,
"scripts": {
"start-bot-dev": "node -r ts-node/register -r tsconfig-paths/register src/index.ts",
"start-bot-prod": "cross-env NODE_ENV=production node -r ./register-tsconfig-prod-paths.js dist/backend/src/index.js",
"watch-bot": "nodemon --config nodemon-bot.json",
"watch": "cross-env NODE_ENV=development tsc-watch --onSuccess \"node start-dev.js\"",
"build": "rimraf dist && tsc",
"start-api-dev": "node -r ts-node/register -r tsconfig-paths/register src/api/index.ts",
"start-api-prod": "cross-env NODE_ENV=production node -r ./register-tsconfig-prod-paths.js dist/backend/src/api/index.js",
"watch-api": "nodemon --config nodemon-api.json",
"format": "prettier --write \"./src/**/*.ts\"",
"typeorm": "node -r ts-node/register -r tsconfig-paths/register ./node_modules/typeorm/cli.js",
"migrate": "npm run typeorm -- migration:run",
"migrate-rollback": "npm run typeorm -- migration:revert",
"test": "ava"
"start-bot-dev": "cross-env NODE_ENV=development node -r ./register-tsconfig-paths.js --unhandled-rejections=strict --inspect=127.0.0.1:9229 dist/backend/src/index.js",
"start-bot-prod": "cross-env NODE_ENV=production node -r ./register-tsconfig-paths.js --unhandled-rejections=strict dist/backend/src/index.js",
"start-api-dev": "cross-env NODE_ENV=development node -r ./register-tsconfig-paths.js --unhandled-rejections=strict --inspect=127.0.0.1:9239 dist/backend/src/api/index.js",
"start-api-prod": "cross-env NODE_ENV=production node -r ./register-tsconfig-paths.js --unhandled-rejections=strict dist/backend/src/api/index.js",
"typeorm": "node -r ./register-tsconfig-paths.js ./node_modules/typeorm/cli.js",
"migrate-prod": "npm run typeorm -- migration:run",
"migrate-dev": "npm run build && npm run typeorm -- migration:run",
"migrate-rollback-prod": "npm run typeorm -- migration:revert",
"migrate-rollback-dev": "npm run build && npm run typeorm -- migration:revert",
"test": "npm run build && npm run run-tests",
"run-tests": "ava",
"test-watch": "tsc-watch --onSuccess \"npx ava\""
},
"dependencies": {
"cors": "^2.8.5",
@ -30,8 +32,8 @@
"humanize-duration": "^3.15.0",
"io-ts": "^2.0.0",
"js-yaml": "^3.13.1",
"knub": "^26.0.2",
"knub-command-manager": "^6.1.0",
"knub": "^28.0.0",
"knub-command-manager": "^7.0.0",
"last-commit-log": "^2.1.0",
"lodash.chunk": "^4.2.0",
"lodash.clonedeep": "^4.5.0",
@ -49,6 +51,7 @@
"seedrandom": "^3.0.1",
"tlds": "^1.203.1",
"tmp": "0.0.33",
"transliteration": "^2.1.7",
"tsconfig-paths": "^3.9.0",
"typeorm": "^0.2.14",
"uuid": "^3.3.2"
@ -64,24 +67,21 @@
"@types/passport": "^1.0.0",
"@types/passport-oauth2": "^1.4.8",
"@types/passport-strategy": "^0.2.35",
"@types/safe-regex": "^1.1.2",
"@types/tmp": "0.0.33",
"ava": "^2.4.0",
"nodemon": "^1.19.4",
"rimraf": "^2.6.2",
"ts-node": "^8.4.1",
"source-map-support": "^0.5.16",
"tsc-watch": "^4.0.0",
"typescript": "^3.7.2"
},
"ava": {
"compileEnhancements": false,
"files": [
"src/**/*.test.ts"
],
"extensions": [
"ts"
"dist/backend/src/**/*.test.js"
],
"require": [
"ts-node/register",
"tsconfig-paths/register"
"./register-tsconfig-paths.js"
]
}
}

View file

@ -53,6 +53,7 @@ export function initGuildsAPI(app: express.Express) {
return res.status(400).json({ errors: [e.message] });
}
// tslint:disable-next-line:no-console
console.error("Error when loading YAML: " + e.message);
return serverError(res, "Server error");
}

View file

@ -55,8 +55,10 @@
"MEMBER_MUTE_REJOIN": "⚠ Reapplied active mute for {userMention(member)} on rejoin",
"SCHEDULED_MESSAGE": "⏰ {userMention(author)} scheduled a message to be posted to {channelMention(channel)} on {date} at {time} (UTC)",
"SCHEDULED_REPEATED_MESSAGE": "⏰ {userMention(author)} scheduled a message to be posted to {channelMention(channel)} on {date} at {time} (UTC), repeated {repeatDetails}",
"REPEATED_MESSAGE": "⏰ {userMention(author)} scheduled a message to be posted to {channelMention(channel)} {repeatDetails}",
"POSTED_SCHEDULED_MESSAGE": "\uD83D\uDCE8 Posted scheduled message (`{messageId}`) to {channelMention(channel)} as scheduled by {userMention(author)}",
"BOT_ALERT": "⚠ {tmplEval(body)}",
"AUTOMOD_ACTION": "\uD83E\uDD16 Automod rule **{rule}** triggered by {userMention(user)}. Actions taken: **{actionsTaken}**\n{matchSummary}"
"AUTOMOD_ACTION": "\uD83E\uDD16 Automod rule **{rule}** triggered by {userMention(user)}\n{matchSummary}\nActions taken: **{actionsTaken}**"
}

View file

@ -50,6 +50,9 @@ export class GuildLogs extends EventEmitter {
}
clearIgnoredLog(type: LogType, ignoreId: any) {
this.ignoredLogs.splice(this.ignoredLogs.findIndex(info => type === info.type && ignoreId === info.ignoreId), 1);
this.ignoredLogs.splice(
this.ignoredLogs.findIndex(info => type === info.type && ignoreId === info.ignoreId),
1,
);
}
}

View file

@ -50,13 +50,14 @@ export class GuildReactionRoles extends BaseGuildRepository {
await this.reactionRoles.delete(criteria);
}
async add(channelId: string, messageId: string, emoji: string, roleId: string) {
async add(channelId: string, messageId: string, emoji: string, roleId: string, exclusive?: boolean) {
await this.reactionRoles.insert({
guild_id: this.guildId,
channel_id: channelId,
message_id: messageId,
emoji,
role_id: roleId,
is_exclusive: Boolean(exclusive),
});
}
}

View file

@ -34,13 +34,14 @@ export class GuildReminders extends BaseGuildRepository {
});
}
async add(userId: string, channelId: string, remindAt: string, body: string) {
async add(userId: string, channelId: string, remindAt: string, body: string, created_at: string) {
await this.reminders.insert({
guild_id: this.guildId,
user_id: userId,
channel_id: channelId,
remind_at: remindAt,
body,
created_at,
});
}
}

View file

@ -38,4 +38,8 @@ export class GuildScheduledPosts extends BaseGuildRepository {
guild_id: this.guildId,
});
}
async update(id: number, data: Partial<ScheduledPost>) {
await this.scheduledPosts.update(id, data);
}
}

View 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,
});
}
}

View 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,
});
}
}

View file

@ -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,
});
}
}

View 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();
}
}

View file

@ -59,4 +59,7 @@ export enum LogType {
BOT_ALERT,
AUTOMOD_ACTION,
SCHEDULED_REPEATED_MESSAGE,
REPEATED_MESSAGE,
}

View file

@ -19,7 +19,10 @@ export class ApiLogin {
@Column()
expires_at: string;
@ManyToOne(type => ApiUserInfo, userInfo => userInfo.logins)
@ManyToOne(
type => ApiUserInfo,
userInfo => userInfo.logins,
)
@JoinColumn({ name: "user_id" })
userInfo: ApiUserInfo;
}

View file

@ -18,7 +18,10 @@ export class ApiPermissionAssignment {
@Column("simple-array")
permissions: string[];
@ManyToOne(type => ApiUserInfo, userInfo => userInfo.permissionAssignments)
@ManyToOne(
type => ApiUserInfo,
userInfo => userInfo.permissionAssignments,
)
@JoinColumn({ name: "target_id" })
userInfo: ApiUserInfo;
}

View file

@ -20,9 +20,15 @@ export class ApiUserInfo {
@Column()
updated_at: string;
@OneToMany(type => ApiLogin, login => login.userInfo)
@OneToMany(
type => ApiLogin,
login => login.userInfo,
)
logins: ApiLogin[];
@OneToMany(type => ApiPermissionAssignment, p => p.userInfo)
@OneToMany(
type => ApiPermissionAssignment,
p => p.userInfo,
)
permissionAssignments: ApiPermissionAssignment[];
}

View file

@ -29,6 +29,9 @@ export class Case {
@Column() pp_name: string;
@OneToMany(type => CaseNote, note => note.case)
@OneToMany(
type => CaseNote,
note => note.case,
)
notes: CaseNote[];
}

View file

@ -15,7 +15,10 @@ export class CaseNote {
@Column() created_at: string;
@ManyToOne(type => Case, theCase => theCase.notes)
@ManyToOne(
type => Case,
theCase => theCase.notes,
)
@JoinColumn({ name: "case_id" })
case: Case;
}

View file

@ -19,4 +19,6 @@ export class ReactionRole {
emoji: string;
@Column() role_id: string;
@Column() is_exclusive: boolean;
}

View file

@ -15,4 +15,6 @@ export class Reminder {
@Column() remind_at: string;
@Column() body: string;
@Column() created_at: string;
}

View file

@ -22,5 +22,14 @@ export class ScheduledPost {
@Column() post_at: string;
/**
* How often to post the message, in milliseconds
*/
@Column() repeat_interval: number;
@Column() repeat_until: string;
@Column() repeat_times: number;
@Column() enable_mentions: boolean;
}

View file

@ -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[];
}

View file

@ -1,23 +1,20 @@
import { Entity, Column, PrimaryColumn, OneToMany, ManyToOne, JoinColumn, OneToOne } from "typeorm";
import { Starboard } from "./Starboard";
import { Case } from "./Case";
import { SavedMessage } from "./SavedMessage";
@Entity("starboard_messages")
export class StarboardMessage {
@Column()
@PrimaryColumn()
starboard_id: number;
message_id: string;
@Column()
@PrimaryColumn()
message_id: string;
starboard_message_id: string;
@Column() starboard_message_id: string;
@Column()
starboard_channel_id: string;
@ManyToOne(type => Starboard, sb => sb.starboardMessages)
@JoinColumn({ name: "starboard_id" })
starboard: Starboard;
@Column()
guild_id: string;
@OneToOne(type => SavedMessage)
@JoinColumn({ name: "message_id" })

View 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;
}

View 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;
}

View file

@ -24,33 +24,34 @@ const RECENT_DISCORD_ERROR_EXIT_THRESHOLD = 5;
setInterval(() => (recentPluginErrors = Math.max(0, recentPluginErrors - 1)), 2500);
setInterval(() => (recentDiscordErrors = Math.max(0, recentDiscordErrors - 1)), 2500);
function errorHandler(err) {
// tslint:disable:no-console
console.error(err);
if (process.env.NODE_ENV === "production") {
const errorHandler = err => {
// tslint:disable:no-console
console.error(err);
if (err instanceof PluginError) {
// Tolerate a few recent plugin errors before crashing
if (++recentPluginErrors >= RECENT_PLUGIN_ERROR_EXIT_THRESHOLD) {
console.error(`Exiting after ${RECENT_PLUGIN_ERROR_EXIT_THRESHOLD} plugin errors`);
if (err instanceof PluginError) {
// Tolerate a few recent plugin errors before crashing
if (++recentPluginErrors >= RECENT_PLUGIN_ERROR_EXIT_THRESHOLD) {
console.error(`Exiting after ${RECENT_PLUGIN_ERROR_EXIT_THRESHOLD} plugin errors`);
process.exit(1);
}
} else if (err instanceof DiscordRESTError || err instanceof DiscordHTTPError) {
// Discord API errors, usually safe to just log instead of crash
// We still bail if we get a ton of them in a short amount of time
if (++recentDiscordErrors >= RECENT_DISCORD_ERROR_EXIT_THRESHOLD) {
console.error(`Exiting after ${RECENT_DISCORD_ERROR_EXIT_THRESHOLD} API errors`);
process.exit(1);
}
} else {
// On other errors, crash immediately
process.exit(1);
}
} else if (err instanceof DiscordRESTError || err instanceof DiscordHTTPError) {
// Discord API errors, usually safe to just log instead of crash
// We still bail if we get a ton of them in a short amount of time
if (++recentDiscordErrors >= RECENT_DISCORD_ERROR_EXIT_THRESHOLD) {
console.error(`Exiting after ${RECENT_DISCORD_ERROR_EXIT_THRESHOLD} API errors`);
process.exit(1);
}
} else {
// On other errors, crash immediately
process.exit(1);
}
// tslint:enable:no-console
// tslint:enable:no-console
};
process.on("uncaughtException", errorHandler);
}
process.on("unhandledRejection", errorHandler);
process.on("uncaughtException", errorHandler);
// Verify required Node.js version
const REQUIRED_NODE_VERSION = "10.14.2";
const requiredParts = REQUIRED_NODE_VERSION.split(".").map(v => parseInt(v, 10));

View file

@ -2,8 +2,14 @@ import { MigrationInterface, QueryRunner, TableColumn, TableIndex } from "typeor
export class AddTypeAndPermissionsToApiPermissions1573158035867 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<any> {
await queryRunner.dropPrimaryKey("api_permissions");
await queryRunner.dropIndex("api_permissions", "IDX_5e371749d4cb4a5191f35e26f6");
try {
await queryRunner.dropPrimaryKey("api_permissions");
} catch (e) {} // tslint:disable-line
const table = await queryRunner.getTable("api_permissions");
if (table.indices.length) {
await queryRunner.dropIndex("api_permissions", table.indices[0]);
}
await queryRunner.addColumn(
"api_permissions",

View 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,
},
],
}),
);
}
}

View file

@ -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);
}
}

View file

@ -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");
}
}

View 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");
}
}

View file

@ -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");
}
}

View file

@ -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");
}
}

View file

@ -2,6 +2,8 @@ import { PluginInfo, trimPluginDescription, ZeppelinPlugin } from "./ZeppelinPlu
import * as t from "io-ts";
import {
convertDelayStringToMS,
disableInlineCode,
disableLinkPreviews,
getEmojiInString,
getInviteCodesInString,
getRoleMentions,
@ -12,12 +14,13 @@ import {
noop,
SECONDS,
stripObjectToScalars,
tDeepPartial,
tNullable,
UnknownUser,
verboseChannelMention,
} from "../utils";
import { configUtils, CooldownManager } from "knub";
import { Invite, Member, TextChannel } from "eris";
import { Member, TextChannel } from "eris";
import escapeStringRegexp from "escape-string-regexp";
import { SimpleCache } from "../SimpleCache";
import { Queue } from "../Queue";
@ -32,6 +35,7 @@ import { GuildLogs } from "../data/GuildLogs";
import { SavedMessage } from "../data/entities/SavedMessage";
import moment from "moment-timezone";
import { renderTemplate } from "../templateFormatter";
import { transliterate } from "transliteration";
import Timeout = NodeJS.Timeout;
type MessageInfo = { channelId: string; messageId: string };
@ -46,23 +50,26 @@ type TextTriggerWithMultipleMatchTypes = {
};
interface TriggerMatchResult {
trigger: string;
type: string;
}
interface MessageTextTriggerMatchResult extends TriggerMatchResult {
interface MessageTextTriggerMatchResult<T = any> extends TriggerMatchResult {
type: "message" | "embed";
str: string;
userId: string;
messageInfo: MessageInfo;
matchedValue: T;
}
interface OtherTextTriggerMatchResult extends TriggerMatchResult {
interface OtherTextTriggerMatchResult<T = any> extends TriggerMatchResult {
type: "username" | "nickname" | "visiblename" | "customstatus";
str: string;
userId: string;
matchedValue: T;
}
type TextTriggerMatchResult = MessageTextTriggerMatchResult | OtherTextTriggerMatchResult;
type TextTriggerMatchResult<T = any> = MessageTextTriggerMatchResult<T> | OtherTextTriggerMatchResult<T>;
interface TextSpamTriggerMatchResult extends TriggerMatchResult {
type: "textspam";
@ -93,13 +100,16 @@ type AnyTriggerMatchResult =
| OtherSpamTriggerMatchResult;
/**
* TRIGGERS
* CONFIG SCHEMA FOR TRIGGERS
*/
const MatchWordsTrigger = t.type({
words: t.array(t.string),
case_sensitive: t.boolean,
only_full_words: t.boolean,
normalize: t.boolean,
loose_matching: t.boolean,
loose_matching_threshold: t.number,
match_messages: t.boolean,
match_embeds: t.boolean,
match_visible_names: t.boolean,
@ -108,10 +118,12 @@ const MatchWordsTrigger = t.type({
match_custom_status: t.boolean,
});
type TMatchWordsTrigger = t.TypeOf<typeof MatchWordsTrigger>;
const defaultMatchWordsTrigger: TMatchWordsTrigger = {
words: [],
const defaultMatchWordsTrigger: Partial<TMatchWordsTrigger> = {
case_sensitive: false,
only_full_words: true,
normalize: false,
loose_matching: false,
loose_matching_threshold: 4,
match_messages: true,
match_embeds: true,
match_visible_names: false,
@ -123,6 +135,7 @@ const defaultMatchWordsTrigger: TMatchWordsTrigger = {
const MatchRegexTrigger = t.type({
patterns: t.array(TSafeRegex),
case_sensitive: t.boolean,
normalize: t.boolean,
match_messages: t.boolean,
match_embeds: t.boolean,
match_visible_names: t.boolean,
@ -133,6 +146,7 @@ const MatchRegexTrigger = t.type({
type TMatchRegexTrigger = t.TypeOf<typeof MatchRegexTrigger>;
const defaultMatchRegexTrigger: Partial<TMatchRegexTrigger> = {
case_sensitive: false,
normalize: false,
match_messages: true,
match_embeds: true,
match_visible_names: false,
@ -220,7 +234,7 @@ const VoiceMoveSpamTrigger = BaseSpamTrigger;
type TVoiceMoveSpamTrigger = t.TypeOf<typeof VoiceMoveSpamTrigger>;
/**
* ACTIONS
* CONFIG SCHEMA FOR ACTIONS
*/
const CleanAction = t.boolean;
@ -253,6 +267,9 @@ const ChangeNicknameAction = t.type({
const LogAction = t.boolean;
const AddRolesAction = t.array(t.string);
const RemoveRolesAction = t.array(t.string);
/**
* FULL CONFIG SCHEMA
*/
@ -287,6 +304,8 @@ const Rule = t.type({
alert: tNullable(AlertAction),
change_nickname: tNullable(ChangeNicknameAction),
log: tNullable(LogAction),
add_roles: tNullable(AddRolesAction),
remove_roles: tNullable(RemoveRolesAction),
}),
cooldown: tNullable(t.string),
});
@ -297,6 +316,8 @@ const ConfigSchema = t.type({
});
type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
const PartialConfigSchema = tDeepPartial(ConfigSchema);
/**
* DEFAULTS
*/
@ -361,6 +382,13 @@ const RECENT_NICKNAME_CHANGE_EXPIRY_TIME = 5 * MINUTES;
const inviteCache = new SimpleCache(10 * MINUTES);
/**
* General plugin flow:
* - When a message is posted:
* 1. Run logRecentActionsForMessage() -- used for detecting spam
* 2. Run matchRuleToMessage() for each automod rule. This checks if any triggers in the rule match the message.
* 3. If a rule matched, run applyActionsOnMatch() for that rule/match
*/
export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
public static pluginName = "automod";
public static configSchema = ConfigSchema;
@ -499,12 +527,10 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
protected archives: GuildArchives;
protected guildLogs: GuildLogs;
protected static preprocessStaticConfig(config) {
if (config.rules && typeof config.rules === "object") {
protected static preprocessStaticConfig(config: t.TypeOf<typeof PartialConfigSchema>) {
if (config.rules) {
// Loop through each rule
for (const [name, rule] of Object.entries(config.rules)) {
if (rule == null || typeof rule !== "object") continue;
rule["name"] = name;
// If the rule doesn't have an explicitly set "enabled" property, set it to true
@ -513,12 +539,11 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
}
// Loop through the rule's triggers
if (rule["triggers"] != null && Array.isArray(rule["triggers"])) {
if (rule["triggers"]) {
for (const trigger of rule["triggers"]) {
if (trigger == null || typeof trigger !== "object") continue;
// Apply default config to the triggers used in this rule
for (const [defaultTriggerName, defaultTrigger] of Object.entries(defaultTriggers)) {
if (trigger[defaultTriggerName] && typeof trigger[defaultTriggerName] === "object") {
if (trigger[defaultTriggerName]) {
trigger[defaultTriggerName] = configUtils.mergeConfig({}, defaultTrigger, trigger[defaultTriggerName]);
}
}
@ -526,7 +551,7 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
}
// Enable logging of automod actions by default
if (rule["actions"] && typeof rule["actions"] === "object") {
if (rule["actions"]) {
if (rule["actions"]["log"] == null) {
rule["actions"]["log"] = true;
}
@ -583,63 +608,92 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
clearInterval(this.recentNicknameChangesClearInterval);
}
protected evaluateMatchWordsTrigger(trigger: TMatchWordsTrigger, str: string): boolean {
/**
* @return Matched word
*/
protected evaluateMatchWordsTrigger(trigger: TMatchWordsTrigger, str: string): null | string {
if (trigger.normalize) {
str = transliterate(str);
}
const looseMatchingThreshold = Math.min(Math.max(trigger.loose_matching_threshold, 1), 64);
for (const word of trigger.words) {
const pattern = trigger.only_full_words ? `\\b${escapeStringRegexp(word)}\\b` : escapeStringRegexp(word);
// When performing loose matching, allow any amount of whitespace or up to looseMatchingThreshold number of other
// characters between the matched characters. E.g. if we're matching banana, a loose match could also match b a n a n a
let pattern = trigger.loose_matching
? [...word].map(c => escapeStringRegexp(c)).join(`(?:\\s*|.{0,${looseMatchingThreshold})`)
: escapeStringRegexp(word);
if (trigger.only_full_words) {
pattern = `\\b${pattern}\\b`;
}
const regex = new RegExp(pattern, trigger.case_sensitive ? "" : "i");
const test = regex.test(str);
if (test) return true;
if (test) return word;
}
return false;
return null;
}
protected evaluateMatchRegexTrigger(trigger: TMatchRegexTrigger, str: string): boolean {
/**
* @return Matched regex pattern
*/
protected evaluateMatchRegexTrigger(trigger: TMatchRegexTrigger, str: string): null | string {
if (trigger.normalize) {
str = transliterate(str);
}
// TODO: Time limit regexes
for (const pattern of trigger.patterns) {
const regex = new RegExp(pattern, trigger.case_sensitive ? "" : "i");
const test = regex.test(str);
if (test) return true;
if (test) return regex.source;
}
return false;
return null;
}
protected async evaluateMatchInvitesTrigger(trigger: TMatchInvitesTrigger, str: string): Promise<boolean> {
/**
* @return Matched invite code
*/
protected async evaluateMatchInvitesTrigger(trigger: TMatchInvitesTrigger, str: string): Promise<null | string> {
const inviteCodes = getInviteCodesInString(str);
if (inviteCodes.length === 0) return false;
if (inviteCodes.length === 0) return null;
const uniqueInviteCodes = Array.from(new Set(inviteCodes));
for (const code of uniqueInviteCodes) {
if (trigger.include_invite_codes && trigger.include_invite_codes.includes(code)) {
return true;
return code;
}
if (trigger.exclude_invite_codes && !trigger.exclude_invite_codes.includes(code)) {
return true;
return code;
}
}
const invites: Array<Invite | null> = await Promise.all(uniqueInviteCodes.map(code => this.resolveInvite(code)));
for (const invite of invites) {
// Always match on unknown invites
if (!invite) return true;
for (const inviteCode of uniqueInviteCodes) {
const invite = await this.resolveInvite(inviteCode);
if (!invite) return inviteCode;
if (trigger.include_guilds && trigger.include_guilds.includes(invite.guild.id)) {
return true;
return inviteCode;
}
if (trigger.exclude_guilds && !trigger.exclude_guilds.includes(invite.guild.id)) {
return true;
return inviteCode;
}
}
return false;
return null;
}
protected evaluateMatchLinksTrigger(trigger: TMatchLinksTrigger, str: string): boolean {
/**
* @return Matched link
*/
protected evaluateMatchLinksTrigger(trigger: TMatchLinksTrigger, str: string): null | string {
const links = getUrlsInString(str, true);
for (const link of links) {
const normalizedHostname = link.hostname.toLowerCase();
@ -647,10 +701,10 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
for (const domain of trigger.include_domains) {
const normalizedDomain = domain.toLowerCase();
if (normalizedDomain === normalizedHostname) {
return true;
return domain;
}
if (trigger.include_subdomains && normalizedHostname.endsWith(`.${domain}`)) {
return true;
return domain;
}
}
}
@ -659,25 +713,25 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
for (const domain of trigger.exclude_domains) {
const normalizedDomain = domain.toLowerCase();
if (normalizedDomain === normalizedHostname) {
return false;
return null;
}
if (trigger.include_subdomains && normalizedHostname.endsWith(`.${domain}`)) {
return false;
return null;
}
}
return true;
return link.toString();
}
}
return false;
return null;
}
protected matchTextSpamTrigger(
recentActionType: RecentActionType,
trigger: TBaseTextSpamTrigger,
msg: SavedMessage,
): TextSpamTriggerMatchResult {
): Partial<TextSpamTriggerMatchResult> {
const since = moment.utc(msg.posted_at).valueOf() - convertDelayStringToMS(trigger.within);
const recentActions = trigger.per_channel
? this.getMatchingRecentActions(recentActionType, `${msg.channel_id}-${msg.user_id}`, since)
@ -699,69 +753,85 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
return null;
}
protected async matchMultipleTextTypesOnMessage(
protected async matchMultipleTextTypesOnMessage<T>(
trigger: TextTriggerWithMultipleMatchTypes,
msg: SavedMessage,
cb,
): Promise<TextTriggerMatchResult> {
matchFn: (str: string) => T | Promise<T> | null,
): Promise<Partial<TextTriggerMatchResult<T>>> {
const messageInfo: MessageInfo = { channelId: msg.channel_id, messageId: msg.id };
const member = this.guild.members.get(msg.user_id);
if (trigger.match_messages) {
const str = msg.data.content;
const match = await cb(str);
if (match) return { type: "message", str, userId: msg.user_id, messageInfo };
const matchResult = await matchFn(str);
if (matchResult) {
return { type: "message", str, userId: msg.user_id, messageInfo, matchedValue: matchResult };
}
}
if (trigger.match_embeds && msg.data.embeds && msg.data.embeds.length) {
const str = JSON.stringify(msg.data.embeds[0]);
const match = await cb(str);
if (match) return { type: "embed", str, userId: msg.user_id, messageInfo };
const matchResult = await matchFn(str);
if (matchResult) {
return { type: "embed", str, userId: msg.user_id, messageInfo, matchedValue: matchResult };
}
}
if (trigger.match_visible_names) {
const str = member.nick || msg.data.author.username;
const match = await cb(str);
if (match) return { type: "visiblename", str, userId: msg.user_id };
const matchResult = await matchFn(str);
if (matchResult) {
return { type: "visiblename", str, userId: msg.user_id, matchedValue: matchResult };
}
}
if (trigger.match_usernames) {
const str = `${msg.data.author.username}#${msg.data.author.discriminator}`;
const match = await cb(str);
if (match) return { type: "username", str, userId: msg.user_id };
const matchResult = await matchFn(str);
if (matchResult) {
return { type: "username", str, userId: msg.user_id, matchedValue: matchResult };
}
}
if (trigger.match_nicknames && member.nick) {
const str = member.nick;
const match = await cb(str);
if (match) return { type: "nickname", str, userId: msg.user_id };
const matchResult = await matchFn(str);
if (matchResult) {
return { type: "nickname", str, userId: msg.user_id, matchedValue: matchResult };
}
}
// type 4 = custom status
if (trigger.match_custom_status && member.game && member.game.type === 4) {
const str = member.game.state;
const match = await cb(str);
if (match) return { type: "customstatus", str, userId: msg.user_id };
const matchResult = await matchFn(str);
if (matchResult) {
return { type: "customstatus", str, userId: msg.user_id, matchedValue: matchResult };
}
}
return null;
}
protected async matchMultipleTextTypesOnMember(
protected async matchMultipleTextTypesOnMember<T>(
trigger: TextTriggerWithMultipleMatchTypes,
member: Member,
cb,
): Promise<TextTriggerMatchResult> {
matchFn: (str: string) => T | Promise<T> | null,
): Promise<Partial<TextTriggerMatchResult<T>>> {
if (trigger.match_usernames) {
const str = `${member.user.username}#${member.user.discriminator}`;
const match = await cb(str);
if (match) return { type: "username", str, userId: member.id };
const matchResult = await matchFn(str);
if (matchResult) {
return { type: "username", str, userId: member.id, matchedValue: matchResult };
}
}
if (trigger.match_nicknames && member.nick) {
const str = member.nick;
const match = await cb(str);
if (match) return { type: "nickname", str, userId: member.id };
const matchResult = await matchFn(str);
if (matchResult) {
return { type: "nickname", str, userId: member.id, matchedValue: matchResult };
}
}
return null;
@ -781,63 +851,63 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
const match = await this.matchMultipleTextTypesOnMessage(trigger.match_words, msg, str => {
return this.evaluateMatchWordsTrigger(trigger.match_words, str);
});
if (match) return match;
if (match) return { ...match, trigger: "match_words" } as TextTriggerMatchResult;
}
if (trigger.match_regex) {
const match = await this.matchMultipleTextTypesOnMessage(trigger.match_regex, msg, str => {
return this.evaluateMatchRegexTrigger(trigger.match_regex, str);
});
if (match) return match;
if (match) return { ...match, trigger: "match_regex" } as TextTriggerMatchResult;
}
if (trigger.match_invites) {
const match = await this.matchMultipleTextTypesOnMessage(trigger.match_invites, msg, str => {
return this.evaluateMatchInvitesTrigger(trigger.match_invites, str);
});
if (match) return match;
if (match) return { ...match, trigger: "match_invites" } as TextTriggerMatchResult;
}
if (trigger.match_links) {
const match = await this.matchMultipleTextTypesOnMessage(trigger.match_links, msg, str => {
return this.evaluateMatchLinksTrigger(trigger.match_links, str);
});
if (match) return match;
if (match) return { ...match, trigger: "match_links" } as TextTriggerMatchResult;
}
if (trigger.message_spam) {
const match = this.matchTextSpamTrigger(RecentActionType.Message, trigger.message_spam, msg);
if (match) return match;
if (match) return { ...match, trigger: "message_spam" } as TextSpamTriggerMatchResult;
}
if (trigger.mention_spam) {
const match = this.matchTextSpamTrigger(RecentActionType.Mention, trigger.mention_spam, msg);
if (match) return match;
if (match) return { ...match, trigger: "mention_spam" } as TextSpamTriggerMatchResult;
}
if (trigger.link_spam) {
const match = this.matchTextSpamTrigger(RecentActionType.Link, trigger.link_spam, msg);
if (match) return match;
if (match) return { ...match, trigger: "link_spam" } as TextSpamTriggerMatchResult;
}
if (trigger.attachment_spam) {
const match = this.matchTextSpamTrigger(RecentActionType.Attachment, trigger.attachment_spam, msg);
if (match) return match;
if (match) return { ...match, trigger: "attachment_spam" } as TextSpamTriggerMatchResult;
}
if (trigger.emoji_spam) {
const match = this.matchTextSpamTrigger(RecentActionType.Emoji, trigger.emoji_spam, msg);
if (match) return match;
if (match) return { ...match, trigger: "emoji_spam" } as TextSpamTriggerMatchResult;
}
if (trigger.line_spam) {
const match = this.matchTextSpamTrigger(RecentActionType.Line, trigger.line_spam, msg);
if (match) return match;
if (match) return { ...match, trigger: "line_spam" } as TextSpamTriggerMatchResult;
}
if (trigger.character_spam) {
const match = this.matchTextSpamTrigger(RecentActionType.Character, trigger.character_spam, msg);
if (match) return match;
if (match) return { ...match, trigger: "character_spam" } as TextSpamTriggerMatchResult;
}
}
@ -1047,90 +1117,23 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
});
}
/**
* Apply the actions of the specified rule on the matched message/member
*/
protected async applyActionsOnMatch(rule: TRule, matchResult: AnyTriggerMatchResult) {
const actionsTaken = [];
let matchSummary = null;
let caseExtraNote = null;
if (rule.cooldown) {
let cooldownKey = rule.name + "-";
if (matchResult.type === "textspam") {
cooldownKey += matchResult.channelId ? `${matchResult.channelId}-${matchResult.userId}` : matchResult.userId;
} else if (matchResult.type === "message" || matchResult.type === "embed") {
cooldownKey += matchResult.userId;
} else if (
matchResult.type === "username" ||
matchResult.type === "nickname" ||
matchResult.type === "visiblename" ||
matchResult.type === "customstatus"
) {
cooldownKey += matchResult.userId;
} else if (matchResult.type === "otherspam") {
cooldownKey += matchResult.userId;
} else {
cooldownKey = null;
}
if (cooldownKey) {
if (this.cooldownManager.isOnCooldown(cooldownKey)) {
return;
}
const cooldownTime = convertDelayStringToMS(rule.cooldown, "s");
if (cooldownTime) {
this.cooldownManager.setCooldown(cooldownKey, cooldownTime);
}
}
if (rule.cooldown && this.checkAndUpdateCooldown(rule, matchResult)) {
return;
}
if (matchResult.type === "textspam") {
this.activateGracePeriod(matchResult);
this.clearSpecificRecentActions(
matchResult.actionType,
matchResult.channelId ? `${matchResult.channelId}-${matchResult.userId}` : matchResult.userId,
);
}
const matchSummary = this.getMatchSummary(matchResult);
// Match summary
let matchedMessageIds = [];
if (matchResult.type === "message" || matchResult.type === "embed") {
matchedMessageIds = [matchResult.messageInfo.messageId];
} else if (matchResult.type === "textspam" || matchResult.type === "raidspam") {
matchedMessageIds = matchResult.messageInfos.map(m => m.messageId);
}
if (matchedMessageIds.length > 1) {
const savedMessages = await this.savedMessages.getMultiple(matchedMessageIds);
const archiveId = await this.archives.createFromSavedMessages(savedMessages, this.guild);
const baseUrl = this.knub.getGlobalConfig().url;
const archiveUrl = this.archives.getUrl(baseUrl, archiveId);
matchSummary = `Matched messages: <${archiveUrl}>`;
} else if (matchedMessageIds.length === 1) {
const message = await this.savedMessages.find(matchedMessageIds[0]);
const channel = this.guild.channels.get(message.channel_id);
const channelMention = channel ? verboseChannelMention(channel) : `\`#${message.channel_id}\``;
matchSummary = `Matched message in ${channelMention} (originally posted at **${
message.posted_at
}**):\n${messageSummary(message)}`;
}
if (matchResult.type === "username") {
matchSummary = `Matched username: ${matchResult.str}`;
} else if (matchResult.type === "nickname") {
matchSummary = `Matched nickname: ${matchResult.str}`;
} else if (matchResult.type === "visiblename") {
matchSummary = `Matched visible name: ${matchResult.str}`;
} else if (matchResult.type === "customstatus") {
matchSummary = `Matched custom status: ${matchResult.str}`;
}
caseExtraNote = `Matched automod rule "${rule.name}"`;
let caseExtraNote = `Matched automod rule "${rule.name}"`;
if (matchSummary) {
caseExtraNote += `\n${matchSummary}`;
}
const actionsTaken = [];
// Actions
if (rule.actions.clean) {
const messagesToDelete: Array<{ channelId: string; messageId: string }> = [];
@ -1254,6 +1257,58 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
actionsTaken.push("nickname");
}
if (rule.actions.add_roles) {
const userIdsToChange = matchResult.type === "raidspam" ? matchResult.userIds : [matchResult.userId];
for (const userId of userIdsToChange) {
const member = await this.getMember(userId);
if (!member) continue;
const memberRoles = new Set(member.roles);
for (const roleId of rule.actions.add_roles) {
memberRoles.add(roleId);
}
if (memberRoles.size === member.roles.length) {
// No role changes
continue;
}
const rolesArr = Array.from(memberRoles.values());
await member.edit({
roles: rolesArr,
});
member.roles = rolesArr; // Make sure we know of the new roles internally as well
}
actionsTaken.push("add roles");
}
if (rule.actions.remove_roles) {
const userIdsToChange = matchResult.type === "raidspam" ? matchResult.userIds : [matchResult.userId];
for (const userId of userIdsToChange) {
const member = await this.getMember(userId);
if (!member) continue;
const memberRoles = new Set(member.roles);
for (const roleId of rule.actions.remove_roles) {
memberRoles.delete(roleId);
}
if (memberRoles.size === member.roles.length) {
// No role changes
continue;
}
const rolesArr = Array.from(memberRoles.values());
await member.edit({
roles: rolesArr,
});
member.roles = rolesArr; // Make sure we know of the new roles internally as well
}
actionsTaken.push("remove roles");
}
// Don't wait for the rest before continuing to other automod items in the queue
(async () => {
const user = matchResult.type !== "raidspam" ? this.getUser(matchResult.userId) : new UnknownUser();
@ -1261,6 +1316,15 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
const safeUser = stripObjectToScalars(user);
const safeUsers = users.map(u => stripObjectToScalars(u));
const logData = {
rule: rule.name,
user: safeUser,
users: safeUsers,
actionsTaken: actionsTaken.length ? actionsTaken.join(", ") : "<none>",
matchSummary,
};
const logMessage = this.getLogs().getLogMessage(LogType.AUTOMOD_ACTION, logData);
if (rule.actions.alert) {
const channel = this.guild.channels.get(rule.actions.alert.channel);
if (channel && channel instanceof TextChannel) {
@ -1271,6 +1335,7 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
users: safeUsers,
text,
matchSummary,
logMessage,
});
channel.createMessage(rendered);
actionsTaken.push("alert");
@ -1282,17 +1347,102 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
}
if (rule.actions.log) {
this.getLogs().log(LogType.AUTOMOD_ACTION, {
rule: rule.name,
user: safeUser,
users: safeUsers,
actionsTaken: actionsTaken.length ? actionsTaken.join(", ") : "<none>",
matchSummary,
});
this.getLogs().log(LogType.AUTOMOD_ACTION, logData);
}
})();
}
/**
* Check if the rule's on cooldown and bump its usage count towards the cooldown up
* @return Whether the rule's on cooldown
*/
protected checkAndUpdateCooldown(rule: TRule, matchResult: AnyTriggerMatchResult): boolean {
let cooldownKey = rule.name + "-";
if (matchResult.type === "textspam") {
cooldownKey += matchResult.channelId ? `${matchResult.channelId}-${matchResult.userId}` : matchResult.userId;
} else if (matchResult.type === "message" || matchResult.type === "embed") {
cooldownKey += matchResult.userId;
} else if (
matchResult.type === "username" ||
matchResult.type === "nickname" ||
matchResult.type === "visiblename" ||
matchResult.type === "customstatus"
) {
cooldownKey += matchResult.userId;
} else if (matchResult.type === "otherspam") {
cooldownKey += matchResult.userId;
} else {
cooldownKey = null;
}
if (cooldownKey) {
if (this.cooldownManager.isOnCooldown(cooldownKey)) {
return true;
}
const cooldownTime = convertDelayStringToMS(rule.cooldown, "s");
if (cooldownTime) {
this.cooldownManager.setCooldown(cooldownKey, cooldownTime);
}
}
return false;
}
/**
* Returns a text summary for the match result for use in logs/alerts
*/
protected async getMatchSummary(matchResult: AnyTriggerMatchResult): Promise<string> {
if (matchResult.type === "message" || matchResult.type === "embed") {
const message = await this.savedMessages.find(matchResult.messageInfo.messageId);
const channel = this.guild.channels.get(matchResult.messageInfo.channelId);
const channelMention = channel ? verboseChannelMention(channel) : `\`#${message.channel_id}\``;
return trimPluginDescription(`
Matched ${this.getMatchedValueText(matchResult)} in message in ${channelMention}:
${messageSummary(message)}
`);
} else if (matchResult.type === "textspam" || matchResult.type === "raidspam") {
const savedMessages = await this.savedMessages.getMultiple(matchResult.messageInfos.map(i => i.messageId));
const archiveId = await this.archives.createFromSavedMessages(savedMessages, this.guild);
const baseUrl = this.knub.getGlobalConfig().url;
const archiveUrl = this.archives.getUrl(baseUrl, archiveId);
return trimPluginDescription(`
Matched spam: ${disableLinkPreviews(archiveUrl)}
`);
} else if (matchResult.type === "username") {
return `Matched ${this.getMatchedValueText(matchResult)} in username: ${matchResult.str}`;
} else if (matchResult.type === "nickname") {
return `Matched ${this.getMatchedValueText(matchResult)} in nickname: ${matchResult.str}`;
} else if (matchResult.type === "visiblename") {
return `Matched ${this.getMatchedValueText(matchResult)} in visible name: ${matchResult.str}`;
} else if (matchResult.type === "customstatus") {
return `Matched ${this.getMatchedValueText(matchResult)} in custom status: ${matchResult.str}`;
}
}
/**
* Returns a formatted version of the matched value (word, regex pattern, link, etc.) for use in the match summary
*/
protected getMatchedValueText(matchResult: TextTriggerMatchResult): string | null {
if (matchResult.trigger === "match_words") {
return `word \`${disableInlineCode(matchResult.matchedValue)}\``;
} else if (matchResult.trigger === "match_regex") {
return `regex \`${disableInlineCode(matchResult.matchedValue)}\``;
} else if (matchResult.trigger === "match_invites") {
return `invite code \`${disableInlineCode(matchResult.matchedValue)}\``;
} else if (matchResult.trigger === "match_links") {
return `link \`${disableInlineCode(matchResult.matchedValue)}\``;
}
return typeof matchResult.matchedValue === "string" ? `\`${disableInlineCode(matchResult.matchedValue)}\`` : null;
}
/**
* Run automod actions on new messages
*/
protected onMessageCreate(msg: SavedMessage) {
if (msg.is_bot) return;
@ -1311,6 +1461,7 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
const matchResult = await this.matchRuleToMessage(rule, msg);
if (matchResult) {
await this.applyActionsOnMatch(rule, matchResult);
break; // Don't apply multiple rules to the same message
}
}
});

View file

@ -91,7 +91,8 @@ export class LocatePlugin extends ZeppelinPlugin<TConfigSchema> {
sendWhere(this.guild, member, msg.channel, `${msg.member.mention} |`);
}
@d.command("vcalert", "<member:resolvedMember> <duration:delay> [reminder:string$]", {
@d.command("vcalert", "<member:resolvedMember> <duration:delay> <reminder:string$>", {
overloads: ["<member:resolvedMember> <duration:delay>", "<member:resolvedMember>"],
aliases: ["vca"],
extra: {
info: {

View file

@ -356,9 +356,11 @@ export class LogsPlugin extends ZeppelinPlugin<TConfigSchema> {
async onMemberUpdate(_, member: Member, oldMember: Member) {
if (!oldMember) return;
const logMember = stripObjectToScalars(member, ["user", "roles"]);
if (member.nick !== oldMember.nick) {
this.guildLogs.log(LogType.MEMBER_NICK_CHANGE, {
member,
member: logMember,
oldNick: oldMember.nick != null ? oldMember.nick : "<none>",
newNick: member.nick != null ? member.nick : "<none>",
});
@ -379,7 +381,7 @@ export class LogsPlugin extends ZeppelinPlugin<TConfigSchema> {
this.guildLogs.log(
LogType.MEMBER_ROLE_CHANGES,
{
member,
member: logMember,
addedRoles: addedRoles
.map(roleId => this.guild.roles.get(roleId) || { id: roleId, name: `Unknown (${roleId})` })
.map(r => r.name)
@ -397,7 +399,7 @@ export class LogsPlugin extends ZeppelinPlugin<TConfigSchema> {
this.guildLogs.log(
LogType.MEMBER_ROLE_ADD,
{
member,
member: logMember,
roles: addedRoles
.map(roleId => this.guild.roles.get(roleId) || { id: roleId, name: `Unknown (${roleId})` })
.map(r => r.name)
@ -411,7 +413,7 @@ export class LogsPlugin extends ZeppelinPlugin<TConfigSchema> {
this.guildLogs.log(
LogType.MEMBER_ROLE_REMOVE,
{
member,
member: logMember,
roles: removedRoles
.map(roleId => this.guild.roles.get(roleId) || { id: roleId, name: `Unknown (${roleId})` })
.map(r => r.name)

View file

@ -125,7 +125,10 @@ export class MessageSaverPlugin extends ZeppelinPlugin<TConfigSchema> {
await msg.channel.createMessage(`Saving pins from <#${args.channel.id}>...`);
const pins = await args.channel.getPins();
const { savedCount, failed } = await this.saveMessagesToDB(args.channel, pins.map(m => m.id));
const { savedCount, failed } = await this.saveMessagesToDB(
args.channel,
pins.map(m => m.id),
);
if (failed.length) {
msg.channel.createMessage(

View file

@ -197,7 +197,10 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
}
clearIgnoredEvent(type: IgnoredEventType, userId: any) {
this.ignoredEvents.splice(this.ignoredEvents.findIndex(info => type === info.type && userId === info.userId), 1);
this.ignoredEvents.splice(
this.ignoredEvents.findIndex(info => type === info.type && userId === info.userId),
1,
);
}
formatReasonWithAttachments(reason: string, attachments: Attachment[]) {

View file

@ -13,6 +13,10 @@ import {
deactivateMentions,
createChunkedMessage,
stripObjectToScalars,
isValidEmbed,
MINUTES,
StrictMessageContent,
DAYS,
} from "../utils";
import { GuildSavedMessages } from "../data/GuildSavedMessages";
import { ZeppelinPlugin } from "./ZeppelinPlugin";
@ -23,6 +27,7 @@ import moment, { Moment } from "moment-timezone";
import { GuildLogs } from "../data/GuildLogs";
import { LogType } from "../data/LogType";
import * as t from "io-ts";
import humanizeDuration from "humanize-duration";
const ConfigSchema = t.type({
can_post: t.boolean,
@ -33,9 +38,13 @@ const fsp = fs.promises;
const COLOR_MATCH_REGEX = /^#?([0-9a-f]{6})$/;
const SCHEDULED_POST_CHECK_INTERVAL = 15 * SECONDS;
const SCHEDULED_POST_CHECK_INTERVAL = 5 * SECONDS;
const SCHEDULED_POST_PREVIEW_TEXT_LENGTH = 50;
const MIN_REPEAT_TIME = 5 * MINUTES;
const MAX_REPEAT_TIME = Math.pow(2, 32);
const MAX_REPEAT_UNTIL = moment().add(100, "years");
export class PostPlugin extends ZeppelinPlugin<TConfigSchema> {
public static pluginName = "post";
public static configSchema = ConfigSchema;
@ -142,17 +151,25 @@ export class PostPlugin extends ZeppelinPlugin<TConfigSchema> {
}
protected parseScheduleTime(str): Moment {
const dtMatch = str.match(/^\d{4}-\d{2}-\d{2} \d{1,2}:\d{1,2}(:\d{1,2})?$/);
if (dtMatch) {
const dt = moment(str, dtMatch[1] ? "YYYY-MM-DD H:m:s" : "YYYY-MM-DD H:m");
return dt;
const dt1 = moment(str, "YYYY-MM-DD HH:mm:ss");
if (dt1 && dt1.isValid()) return dt1;
const dt2 = moment(str, "YYYY-MM-DD HH:mm");
if (dt2 && dt2.isValid()) return dt2;
const date = moment(str, "YYYY-MM-DD");
if (date && date.isValid()) return date;
const t1 = moment(str, "HH:mm:ss");
if (t1 && t1.isValid()) {
if (t1.isBefore(moment())) t1.add(1, "day");
return t1;
}
const tMatch = str.match(/^\d{1,2}:\d{1,2}(:\d{1,2})?$/);
if (tMatch) {
const dt = moment(str, tMatch[1] ? "H:m:s" : "H:m");
if (dt.isBefore(moment())) dt.add(1, "day");
return dt;
const t2 = moment(str, "HH:mm");
if (t2 && t2.isValid()) {
if (t2.isBefore(moment())) t2.add(1, "day");
return t2;
}
const delayStringMS = convertDelayStringToMS(str, "m");
@ -194,12 +211,205 @@ export class PostPlugin extends ZeppelinPlugin<TConfigSchema> {
}
}
await this.scheduledPosts.delete(post.id);
let shouldClear = true;
if (post.repeat_interval) {
const nextPostAt = moment().add(post.repeat_interval, "ms");
if (post.repeat_until) {
const repeatUntil = moment(post.repeat_until, DBDateFormat);
if (nextPostAt.isSameOrBefore(repeatUntil)) {
await this.scheduledPosts.update(post.id, {
post_at: nextPostAt.format(DBDateFormat),
});
shouldClear = false;
}
} else if (post.repeat_times) {
if (post.repeat_times > 1) {
await this.scheduledPosts.update(post.id, {
post_at: nextPostAt.format(DBDateFormat),
repeat_times: post.repeat_times - 1,
});
shouldClear = false;
}
}
}
if (shouldClear) {
await this.scheduledPosts.delete(post.id);
}
}
this.scheduledPostLoopTimeout = setTimeout(() => this.scheduledPostLoop(), SCHEDULED_POST_CHECK_INTERVAL);
}
/**
* Since !post and !post_embed have a lot of overlap for post scheduling, repeating, etc., that functionality is abstracted out to here
*/
async actualPostCmd(
msg: Message,
targetChannel: Channel,
content: StrictMessageContent,
opts?: {
"enable-mentions"?: boolean;
schedule?: string;
repeat?: number;
"repeat-until"?: string;
"repeat-times"?: number;
},
) {
if (!(targetChannel instanceof TextChannel)) {
msg.channel.createMessage(errorMessage("Channel is not a text channel"));
return;
}
if (content == null && msg.attachments.length === 0) {
msg.channel.createMessage(errorMessage("Message content or attachment required"));
return;
}
if (opts.repeat) {
if (opts.repeat < MIN_REPEAT_TIME) {
return this.sendErrorMessage(msg.channel, `Minimum time for -repeat is ${humanizeDuration(MIN_REPEAT_TIME)}`);
}
if (opts.repeat > MAX_REPEAT_TIME) {
return this.sendErrorMessage(msg.channel, `Max time for -repeat is ${humanizeDuration(MAX_REPEAT_TIME)}`);
}
}
// If this is a scheduled or repeated post, figure out the next post date
let postAt;
if (opts.schedule) {
// Schedule the post to be posted later
postAt = this.parseScheduleTime(opts.schedule);
if (!postAt) {
return this.sendErrorMessage(msg.channel, "Invalid schedule time");
}
} else if (opts.repeat) {
postAt = moment().add(opts.repeat, "ms");
}
// For repeated posts, make sure repeat-until or repeat-times is specified
let repeatUntil: moment.Moment = null;
let repeatTimes: number = null;
let repeatDetailsStr: string = null;
if (opts["repeat-until"]) {
repeatUntil = this.parseScheduleTime(opts["repeat-until"]);
// Invalid time
if (!repeatUntil) {
return this.sendErrorMessage(msg.channel, "Invalid time specified for -repeat-until");
}
if (repeatUntil.isBefore(moment())) {
return this.sendErrorMessage(msg.channel, "You can't set -repeat-until in the past");
}
if (repeatUntil.isAfter(MAX_REPEAT_UNTIL)) {
return this.sendErrorMessage(
msg.channel,
"Unfortunately, -repeat-until can only be at most 100 years into the future. Maybe 99 years would be enough?",
);
}
} else if (opts["repeat-times"]) {
repeatTimes = opts["repeat-times"];
if (repeatTimes <= 0) {
return this.sendErrorMessage(msg.channel, "-repeat-times must be 1 or more");
}
}
if (repeatUntil && repeatTimes) {
return this.sendErrorMessage(msg.channel, "You can only use one of -repeat-until or -repeat-times at once");
}
if (opts.repeat && !repeatUntil && !repeatTimes) {
return this.sendErrorMessage(
msg.channel,
"You must specify -repeat-until or -repeat-times for repeated messages",
);
}
if (opts.repeat) {
repeatDetailsStr = repeatUntil
? `every ${humanizeDuration(opts.repeat)} until ${repeatUntil.format(DBDateFormat)}`
: `every ${humanizeDuration(opts.repeat)}, ${repeatTimes} times in total`;
}
// Save schedule/repeat information in DB
if (postAt) {
if (postAt < moment()) {
return this.sendErrorMessage(msg.channel, "Post can't be scheduled to be posted in the past");
}
await this.scheduledPosts.create({
author_id: msg.author.id,
author_name: `${msg.author.username}#${msg.author.discriminator}`,
channel_id: targetChannel.id,
content,
attachments: msg.attachments,
post_at: postAt.format(DBDateFormat),
enable_mentions: opts["enable-mentions"],
repeat_interval: opts.repeat,
repeat_until: repeatUntil ? repeatUntil.format(DBDateFormat) : null,
repeat_times: repeatTimes ?? null,
});
if (opts.repeat) {
this.logs.log(LogType.SCHEDULED_REPEATED_MESSAGE, {
author: stripObjectToScalars(msg.author),
channel: stripObjectToScalars(targetChannel),
date: postAt.format("YYYY-MM-DD"),
time: postAt.format("HH:mm:ss"),
repeatInterval: humanizeDuration(opts.repeat),
repeatDetails: repeatDetailsStr,
});
} else {
this.logs.log(LogType.SCHEDULED_MESSAGE, {
author: stripObjectToScalars(msg.author),
channel: stripObjectToScalars(targetChannel),
date: postAt.format("YYYY-MM-DD"),
time: postAt.format("HH:mm:ss"),
});
}
}
// When the message isn't scheduled for later, post it immediately
if (!opts.schedule) {
await this.postMessage(targetChannel, content, msg.attachments, opts["enable-mentions"]);
}
if (opts.repeat) {
this.logs.log(LogType.REPEATED_MESSAGE, {
author: stripObjectToScalars(msg.author),
channel: stripObjectToScalars(targetChannel),
date: postAt.format("YYYY-MM-DD"),
time: postAt.format("HH:mm:ss"),
repeatInterval: humanizeDuration(opts.repeat),
repeatDetails: repeatDetailsStr,
});
}
// Bot reply schenanigans
let successMessage = opts.schedule
? `Message scheduled to be posted in <#${targetChannel.id}> on ${postAt.format("YYYY-MM-DD [at] HH:mm:ss")} (UTC)`
: `Message posted in <#${targetChannel.id}>`;
if (opts.repeat) {
successMessage += `. Message will be automatically reposted every ${humanizeDuration(opts.repeat)}`;
if (repeatUntil) {
successMessage += ` until ${repeatUntil.format("YYYY-MM-DD [at] HH:mm:ss")} (UTC)`;
} else if (repeatTimes) {
successMessage += `, ${repeatTimes} times in total`;
}
successMessage += ".";
}
if (targetChannel.id !== msg.channel.id || opts.schedule || opts.repeat) {
this.sendSuccessMessage(msg.channel, successMessage);
}
}
/**
* COMMAND: Post a regular text message as the bot to the specified channel
*/
@ -207,66 +417,40 @@ export class PostPlugin extends ZeppelinPlugin<TConfigSchema> {
options: [
{
name: "enable-mentions",
type: "bool",
isSwitch: true,
},
{
name: "schedule",
type: "string",
},
{
name: "repeat",
type: "delay",
},
{
name: "repeat-until",
type: "string",
},
{
name: "repeat-times",
type: "number",
},
],
})
@d.permission("can_post")
async postCmd(
msg: Message,
args: { channel: Channel; content?: string; "enable-mentions": boolean; schedule?: string },
args: {
channel: Channel;
content?: string;
"enable-mentions": boolean;
schedule?: string;
repeat?: number;
"repeat-until"?: string;
"repeat-times"?: number;
},
) {
if (!(args.channel instanceof TextChannel)) {
msg.channel.createMessage(errorMessage("Channel is not a text channel"));
return;
}
if (args.content == null && msg.attachments.length === 0) {
msg.channel.createMessage(errorMessage("Text content or attachment required"));
return;
}
if (args.schedule) {
// Schedule the post to be posted later
const postAt = this.parseScheduleTime(args.schedule);
if (!postAt) {
return this.sendErrorMessage(msg.channel, "Invalid schedule time");
}
if (postAt < moment()) {
return this.sendErrorMessage(msg.channel, "Post can't be scheduled to be posted in the past");
}
await this.scheduledPosts.create({
author_id: msg.author.id,
author_name: `${msg.author.username}#${msg.author.discriminator}`,
channel_id: args.channel.id,
content: { content: args.content },
attachments: msg.attachments,
post_at: postAt.format(DBDateFormat),
enable_mentions: args["enable-mentions"],
});
this.sendSuccessMessage(
msg.channel,
`Message scheduled to be posted in <#${args.channel.id}> on ${postAt.format("YYYY-MM-DD [at] HH:mm:ss")} (UTC)`,
);
this.logs.log(LogType.SCHEDULED_MESSAGE, {
author: stripObjectToScalars(msg.author),
channel: stripObjectToScalars(args.channel),
date: postAt.format("YYYY-MM-DD"),
time: postAt.format("HH:mm:ss"),
});
} else {
// Post the message immediately
await this.postMessage(args.channel, args.content, msg.attachments, args["enable-mentions"]);
if (args.channel.id !== msg.channel.id) {
this.sendSuccessMessage(msg.channel, `Message posted in <#${args.channel.id}>`);
}
}
this.actualPostCmd(msg, args.channel, { content: args.content }, args);
}
/**
@ -278,6 +462,19 @@ export class PostPlugin extends ZeppelinPlugin<TConfigSchema> {
{ name: "content", type: "string" },
{ name: "color", type: "string" },
{ name: "schedule", type: "string" },
{ name: "raw", isSwitch: true, shortcut: "r" },
{
name: "repeat",
type: "delay",
},
{
name: "repeat-until",
type: "string",
},
{
name: "repeat-times",
type: "number",
},
],
})
@d.permission("can_post")
@ -290,13 +487,12 @@ export class PostPlugin extends ZeppelinPlugin<TConfigSchema> {
content?: string;
color?: string;
schedule?: string;
raw?: boolean;
repeat?: number;
"repeat-until"?: string;
"repeat-times"?: number;
},
) {
if (!(args.channel instanceof TextChannel)) {
msg.channel.createMessage(errorMessage("Channel is not a text channel"));
return;
}
const content = args.content || args.maincontent;
if (!args.title && !content) {
@ -315,59 +511,32 @@ export class PostPlugin extends ZeppelinPlugin<TConfigSchema> {
color = parseInt(colorMatch[1], 16);
}
const embed: EmbedBase = {};
let embed: EmbedBase = {};
if (args.title) embed.title = args.title;
if (content) embed.description = this.formatContent(content);
if (color) embed.color = color;
if (args.schedule) {
// Schedule the post to be posted later
const postAt = this.parseScheduleTime(args.schedule);
if (!postAt) {
return this.sendErrorMessage(msg.channel, "Invalid schedule time");
}
if (content) {
if (args.raw) {
let parsed;
try {
parsed = JSON.parse(content);
} catch (e) {
this.sendErrorMessage(msg.channel, "Syntax error in embed JSON");
return;
}
if (postAt < moment()) {
return this.sendErrorMessage(msg.channel, "Post can't be scheduled to be posted in the past");
}
if (!isValidEmbed(parsed)) {
this.sendErrorMessage(msg.channel, "Embed is not valid");
return;
}
await this.scheduledPosts.create({
author_id: msg.author.id,
author_name: `${msg.author.username}#${msg.author.discriminator}`,
channel_id: args.channel.id,
content: { embed },
attachments: msg.attachments,
post_at: postAt.format(DBDateFormat),
});
await this.sendSuccessMessage(
msg.channel,
`Embed scheduled to be posted in <#${args.channel.id}> on ${postAt.format("YYYY-MM-DD [at] HH:mm:ss")} (UTC)`,
);
this.logs.log(LogType.SCHEDULED_MESSAGE, {
author: stripObjectToScalars(msg.author),
channel: stripObjectToScalars(args.channel),
date: postAt.format("YYYY-MM-DD"),
time: postAt.format("HH:mm:ss"),
});
} else {
const createdMsg = await args.channel.createMessage({ embed });
this.savedMessages.setPermanent(createdMsg.id);
if (msg.channel.id !== args.channel.id) {
await this.sendSuccessMessage(msg.channel, `Embed posted in <#${args.channel.id}>`);
embed = Object.assign({}, embed, parsed);
} else {
embed.description = this.formatContent(content);
}
}
if (args.content) {
const prefix = this.guildConfig.prefix || "!";
msg.channel.createMessage(
trimLines(`
<@!${msg.author.id}> You can now specify an embed's content directly at the end of the command:
\`${prefix}post_embed -title "Some title" content goes here\`
The \`-content\` option will soon be removed in favor of this.
`),
);
}
this.actualPostCmd(msg, args.channel, { embed }, args);
}
/**
@ -472,6 +641,16 @@ export class PostPlugin extends ZeppelinPlugin<TConfigSchema> {
const parts = [`\`#${i++}\` \`[${p.post_at}]\` ${previewText}${isTruncated ? "..." : ""}`];
if (p.attachments.length) parts.push("*(with attachment)*");
if (p.content.embed) parts.push("*(embed)*");
if (p.repeat_until) {
parts.push(`*(repeated every ${humanizeDuration(p.repeat_interval)} until ${p.repeat_until})*`);
}
if (p.repeat_times) {
parts.push(
`*(repeated every ${humanizeDuration(p.repeat_interval)}, ${p.repeat_times} more ${
p.repeat_times === 1 ? "time" : "times"
})*`,
);
}
parts.push(`*(${p.author_name})*`);
return parts.join(" ");
@ -480,7 +659,7 @@ export class PostPlugin extends ZeppelinPlugin<TConfigSchema> {
const finalMessage = trimLines(`
${postLines.join("\n")}
Use \`scheduled_posts show <num>\` to view a scheduled post in full
Use \`scheduled_posts <num>\` to view a scheduled post in full
Use \`scheduled_posts delete <num>\` to delete a scheduled post
`);
createChunkedMessage(msg.channel, finalMessage);

View file

@ -268,9 +268,17 @@ export class ReactionRolesPlugin extends ZeppelinPlugin<TConfigSchema> {
* :zep_twitch: = 473086848831455234
* :zep_ps4: = 543184300250759188
*/
@d.command("reaction_roles", "<messageId:string> <reactionRolePairs:string$>")
@d.command("reaction_roles", "<messageId:string> <reactionRolePairs:string$>", {
options: [
{
name: "exclusive",
shortcut: "e",
isSwitch: true,
},
],
})
@d.permission("can_manage")
async reactionRolesCmd(msg: Message, args: { messageId: string; reactionRolePairs: string }) {
async reactionRolesCmd(msg: Message, args: { messageId: string; reactionRolePairs: string; exclusive?: boolean }) {
const savedMessage = await this.savedMessages.find(args.messageId);
if (!savedMessage) {
msg.channel.createMessage(errorMessage("Unknown message"));
@ -331,7 +339,7 @@ export class ReactionRolesPlugin extends ZeppelinPlugin<TConfigSchema> {
// Save the new reaction roles to the database
for (const pair of emojiRolePairs) {
await this.reactionRoles.add(channel.id, targetMessage.id, pair[0], pair[1]);
await this.reactionRoles.add(channel.id, targetMessage.id, pair[0], pair[1], args.exclusive);
}
// Apply the reactions themselves
@ -370,6 +378,14 @@ export class ReactionRolesPlugin extends ZeppelinPlugin<TConfigSchema> {
const matchingReactionRole = await this.reactionRoles.getByMessageAndEmoji(msg.id, emoji.id || emoji.name);
if (!matchingReactionRole) return;
// If the reaction role is exclusive, remove any other roles in the message first
if (matchingReactionRole.is_exclusive) {
const messageReactionRoles = await this.reactionRoles.getForMessage(msg.id);
for (const reactionRole of messageReactionRoles) {
this.addMemberPendingRoleChange(userId, "-", reactionRole.role_id);
}
}
this.addMemberPendingRoleChange(userId, "+", matchingReactionRole.role_id);
}

View file

@ -70,9 +70,19 @@ export class RemindersPlugin extends ZeppelinPlugin<TConfigSchema> {
const channel = this.guild.channels.get(reminder.channel_id);
if (channel && channel instanceof TextChannel) {
try {
await channel.createMessage(
disableLinkPreviews(`<@!${reminder.user_id}> You asked me to remind you: ${reminder.body}`),
);
// Only show created at date if one exists
if (moment(reminder.created_at).isValid()) {
const target = moment();
const diff = target.diff(moment(reminder.created_at, "YYYY-MM-DD HH:mm:ss"));
const result = humanizeDuration(diff, { largest: 2, round: true });
await channel.createMessage(
disableLinkPreviews(
`Reminder for <@!${reminder.user_id}>: ${reminder.body} \n\`Set at ${reminder.created_at} (${result} ago)\``,
),
);
} else {
await channel.createMessage(disableLinkPreviews(`Reminder for <@!${reminder.user_id}>: ${reminder.body}`));
}
} catch (e) {
// Probably random Discord internal server error or missing permissions or somesuch
// Try again next round unless we've already tried to post this a bunch of times
@ -127,7 +137,13 @@ export class RemindersPlugin extends ZeppelinPlugin<TConfigSchema> {
}
const reminderBody = args.reminder || `https://discordapp.com/channels/${this.guildId}/${msg.channel.id}/${msg.id}`;
await this.reminders.add(msg.author.id, msg.channel.id, reminderTime.format("YYYY-MM-DD HH:mm:ss"), reminderBody);
await this.reminders.add(
msg.author.id,
msg.channel.id,
reminderTime.format("YYYY-MM-DD HH:mm:ss"),
reminderBody,
moment().format("YYYY-MM-DD HH:mm:ss"),
);
const msUntilReminder = reminderTime.diff(now);
const timeUntilReminder = humanizeDuration(msUntilReminder, { largest: 2, round: true });
@ -152,7 +168,10 @@ export class RemindersPlugin extends ZeppelinPlugin<TConfigSchema> {
const lines = Array.from(reminders.entries()).map(([i, reminder]) => {
const num = i + 1;
const paddedNum = num.toString().padStart(longestNum, " ");
return `\`${paddedNum}.\` \`${reminder.remind_at}\` ${reminder.body}`;
const target = moment(reminder.remind_at, "YYYY-MM-DD HH:mm:ss");
const diff = target.diff(moment());
const result = humanizeDuration(diff, { largest: 2, round: true });
return `\`${paddedNum}.\` \`${reminder.remind_at} (${result})\` ${reminder.body}`;
});
createChunkedMessage(msg.channel, lines.join("\n"));

View 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!");
}
}

View file

@ -212,7 +212,7 @@ export class SlowmodePlugin extends ZeppelinPlugin<TConfigSchema> {
options: [
{
name: "force",
type: "bool",
isSwitch: true,
},
],
})

View file

@ -1,58 +1,162 @@
import { decorators as d, waitForReply, utils as knubUtils, IBasePluginConfig, IPluginOptions } from "knub";
import { ZeppelinPlugin } from "./ZeppelinPlugin";
import { GuildStarboards } from "../data/GuildStarboards";
import { GuildChannel, Message, TextChannel } from "eris";
import { decorators as d, IPluginOptions } from "knub";
import { ZeppelinPlugin, trimPluginDescription } from "./ZeppelinPlugin";
import { Embed, EmbedBase, GuildChannel, Message, TextChannel } from "eris";
import {
customEmojiRegex,
errorMessage,
getEmojiInString,
getUrlsInString,
messageLink,
noop,
snowflakeRegex,
successMessage,
TDeepPartialProps,
tNullable,
tDeepPartial,
UnknownUser,
EMPTY_CHAR,
} from "../utils";
import { Starboard } from "../data/entities/Starboard";
import path from "path";
import moment from "moment-timezone";
import { GuildSavedMessages } from "../data/GuildSavedMessages";
import { SavedMessage } from "../data/entities/SavedMessage";
import * as t from "io-ts";
import { GuildStarboardMessages } from "../data/GuildStarboardMessages";
import { StarboardMessage } from "../data/entities/StarboardMessage";
import { GuildStarboardReactions } from "../data/GuildStarboardReactions";
const StarboardOpts = t.type({
channel_id: t.string,
stars_required: t.number,
star_emoji: tNullable(t.array(t.string)),
enabled: tNullable(t.boolean),
});
type TStarboardOpts = t.TypeOf<typeof StarboardOpts>;
const ConfigSchema = t.type({
can_manage: t.boolean,
boards: t.record(t.string, StarboardOpts),
can_migrate: t.boolean,
});
type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
const PartialConfigSchema = tDeepPartial(ConfigSchema);
const defaultStarboardOpts: Partial<TStarboardOpts> = {
star_emoji: ["⭐"],
enabled: true,
};
export class StarboardPlugin extends ZeppelinPlugin<TConfigSchema> {
public static pluginName = "starboard";
public static showInDocs = false;
public static configSchema = ConfigSchema;
protected starboards: GuildStarboards;
public static pluginInfo = {
prettyName: "Starboard",
description: trimPluginDescription(`
This plugin allows you to set up starboards on your server. Starboards are like user voted pins where messages with enough reactions get immortalized on a "starboard" channel.
`),
configurationGuide: trimPluginDescription(`
### Note on emojis
To specify emoji in the config, you need to use the emoji's "raw form".
To obtain this, post the emoji with a backslash in front of it.
- Example with a default emoji: "\:star:" => "⭐"
- Example with a custom emoji: "\:mrvnSmile:" => "<:mrvnSmile:543000534102310933>"
### Basic starboard
Any message on the server that gets 5 star reactions will be posted into the starboard channel (604342689038729226).
~~~yml
starboard:
config:
boards:
basic:
channel_id: "604342689038729226"
stars_required: 5
~~~
### Custom star emoji
This is identical to the basic starboard above, but accepts two emoji: the regular star and a custom :mrvnSmile: emoji
~~~yml
starboard:
config:
boards:
basic:
channel_id: "604342689038729226"
star_emoji: ["⭐", "<:mrvnSmile:543000534102310933>"]
stars_required: 5
~~~
### Limit starboard to a specific channel
This is identical to the basic starboard above, but only works from a specific channel (473087035574321152).
~~~yml
starboard:
config:
boards:
basic:
enabled: false # The starboard starts disabled and is then enabled in a channel override below
channel_id: "604342689038729226"
stars_required: 5
overrides:
- channel: "473087035574321152"
config:
boards:
basic:
enabled: true
~~~
`),
};
protected savedMessages: GuildSavedMessages;
protected starboardMessages: GuildStarboardMessages;
protected starboardReactions: GuildStarboardReactions;
private onMessageDeleteFn;
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
return {
config: {
can_manage: false,
can_migrate: false,
boards: {},
},
overrides: [
{
level: ">=100",
config: {
can_manage: true,
can_migrate: true,
},
},
],
};
}
protected static preprocessStaticConfig(config: t.TypeOf<typeof PartialConfigSchema>) {
if (config.boards) {
for (const [name, opts] of Object.entries(config.boards)) {
config.boards[name] = Object.assign({}, defaultStarboardOpts, config.boards[name]);
}
}
return config;
}
protected getStarboardOptsForStarboardChannel(starboardChannel): TStarboardOpts[] {
const config = this.getConfigForChannel(starboardChannel);
const configs = Object.values(config.boards).filter(opts => opts.channel_id === starboardChannel.id);
configs.forEach(cfg => {
if (cfg.enabled == null) cfg.enabled = defaultStarboardOpts.enabled;
if (cfg.star_emoji == null) cfg.star_emoji = defaultStarboardOpts.star_emoji;
if (cfg.stars_required == null) cfg.stars_required = defaultStarboardOpts.stars_required;
});
return configs;
}
onLoad() {
this.starboards = GuildStarboards.getGuildInstance(this.guildId);
this.savedMessages = GuildSavedMessages.getGuildInstance(this.guildId);
this.starboardMessages = GuildStarboardMessages.getGuildInstance(this.guildId);
this.starboardReactions = GuildStarboardReactions.getGuildInstance(this.guildId);
this.onMessageDeleteFn = this.onMessageDelete.bind(this);
this.savedMessages.events.on("delete", this.onMessageDeleteFn);
@ -62,143 +166,13 @@ export class StarboardPlugin extends ZeppelinPlugin<TConfigSchema> {
this.savedMessages.events.off("delete", this.onMessageDeleteFn);
}
/**
* An interactive setup for creating a starboard
*/
@d.command("starboard create")
@d.permission("can_manage")
async setupCmd(msg: Message) {
const cancelMsg = () => msg.channel.createMessage("Cancelled");
msg.channel.createMessage(
`⭐ Let's make a starboard! What channel should we use as the board? ("cancel" to cancel)`,
);
let starboardChannel;
do {
const reply = await waitForReply(this.bot, msg.channel as TextChannel, msg.author.id, 60000);
if (reply.content == null || reply.content === "cancel") return cancelMsg();
starboardChannel = knubUtils.resolveChannel(this.guild, reply.content || "");
if (!starboardChannel) {
msg.channel.createMessage("Invalid channel. Try again?");
continue;
}
const existingStarboard = await this.starboards.getStarboardByChannelId(starboardChannel.id);
if (existingStarboard) {
msg.channel.createMessage("That channel already has a starboard. Try again?");
starboardChannel = null;
continue;
}
} while (starboardChannel == null);
msg.channel.createMessage(`Ok. Which emoji should we use as the trigger? ("cancel" to cancel)`);
let emoji;
do {
const reply = await waitForReply(this.bot, msg.channel as TextChannel, msg.author.id);
if (reply.content == null || reply.content === "cancel") return cancelMsg();
const allEmojis = getEmojiInString(reply.content || "");
if (!allEmojis.length) {
msg.channel.createMessage("Invalid emoji. Try again?");
continue;
}
emoji = allEmojis[0];
const customEmojiMatch = emoji.match(customEmojiRegex);
if (customEmojiMatch) {
// <:name:id> to name:id, as Eris puts them in the message reactions object
emoji = `${customEmojiMatch[1]}:${customEmojiMatch[2]}`;
}
} while (emoji == null);
msg.channel.createMessage(
`And how many reactions are required to immortalize a message in the starboard? ("cancel" to cancel)`,
);
let requiredReactions;
do {
const reply = await waitForReply(this.bot, msg.channel as TextChannel, msg.author.id);
if (reply.content == null || reply.content === "cancel") return cancelMsg();
requiredReactions = parseInt(reply.content || "", 10);
if (Number.isNaN(requiredReactions)) {
msg.channel.createMessage("Invalid number. Try again?");
continue;
}
if (typeof requiredReactions === "number") {
if (requiredReactions <= 0) {
msg.channel.createMessage("The number must be higher than 0. Try again?");
continue;
} else if (requiredReactions > 65536) {
msg.channel.createMessage("The number must be smaller than 65536. Try again?");
continue;
}
}
} while (requiredReactions == null);
msg.channel.createMessage(
`And finally, which channels can messages be starred in? "All" for any channel. ("cancel" to cancel)`,
);
let channelWhitelist;
do {
const reply = await waitForReply(this.bot, msg.channel as TextChannel, msg.author.id);
if (reply.content == null || reply.content === "cancel") return cancelMsg();
if (reply.content.toLowerCase() === "all") {
channelWhitelist = null;
break;
}
channelWhitelist = reply.content.match(new RegExp(snowflakeRegex, "g"));
let hasInvalidChannels = false;
for (const id of channelWhitelist) {
const channel = this.guild.channels.get(id);
if (!channel || !(channel instanceof TextChannel)) {
msg.channel.createMessage(`Couldn't recognize channel <#${id}> (\`${id}\`). Try again?`);
hasInvalidChannels = true;
break;
}
}
if (hasInvalidChannels) continue;
} while (channelWhitelist == null);
await this.starboards.create(starboardChannel.id, channelWhitelist, emoji, requiredReactions);
msg.channel.createMessage(successMessage("Starboard created!"));
}
/**
* Deletes the starboard from the specified channel. The already-posted starboard messages are retained.
*/
@d.command("starboard delete", "<channelId:channelId>")
@d.permission("can_manage")
async deleteCmd(msg: Message, args: { channelId: string }) {
const starboard = await this.starboards.getStarboardByChannelId(args.channelId);
if (!starboard) {
msg.channel.createMessage(errorMessage(`Channel <#${args.channelId}> doesn't have a starboard!`));
return;
}
await this.starboards.delete(starboard.channel_id);
msg.channel.createMessage(successMessage(`Starboard deleted from <#${args.channelId}>!`));
}
/**
* When a reaction is added to a message, check if there are any applicable starboards and if the reactions reach
* the required threshold. If they do, post the message in the starboard channel.
*/
@d.event("messageReactionAdd")
@d.lock("starboardReaction")
async onMessageReactionAdd(msg: Message, emoji: { id: string; name: string }) {
async onMessageReactionAdd(msg: Message, emoji: { id: string; name: string }, userId: string) {
if (!msg.author) {
// Message is not cached, fetch it
try {
@ -209,63 +183,80 @@ export class StarboardPlugin extends ZeppelinPlugin<TConfigSchema> {
}
}
const emojiStr = emoji.id ? `${emoji.name}:${emoji.id}` : emoji.name;
const applicableStarboards = await this.starboards.getStarboardsByEmoji(emojiStr);
// No self-votes!
if (msg.author.id === userId) return;
const user = await this.resolveUser(userId);
if (user instanceof UnknownUser) return;
if (user.bot) return;
const config = this.getConfigForMemberIdAndChannelId(userId, msg.channel.id);
const applicableStarboards = Object.values(config.boards)
.filter(board => board.enabled)
// Can't star messages in the starboard channel itself
.filter(board => board.channel_id !== msg.channel.id)
// Matching emoji
.filter(board => {
return board.star_emoji.some((boardEmoji: string) => {
if (emoji.id) {
// Custom emoji
const customEmojiMatch = boardEmoji.match(/^<?:.+?:(\d+)>?$/);
if (customEmojiMatch) {
return customEmojiMatch[1] === emoji.id;
}
return boardEmoji === emoji.id;
} else {
// Unicode emoji
return emoji.name === boardEmoji;
}
});
});
for (const starboard of applicableStarboards) {
// Can't star messages in the starboard channel itself
if (msg.channel.id === starboard.channel_id) continue;
// Save reaction into the database
await this.starboardReactions.createStarboardReaction(msg.id, userId).catch(noop);
if (starboard.channel_whitelist) {
const allowedChannelIds = starboard.channel_whitelist.split(",");
if (!allowedChannelIds.includes(msg.channel.id)) continue;
}
// If the message has already been posted to this starboard, we don't need to do anything else
const starboardMessages = await this.starboardMessages.getMatchingStarboardMessages(starboard.channel_id, msg.id);
if (starboardMessages.length > 0) continue;
// If the message has already been posted to this starboard, we don't need to do anything else here
const existingSavedMessage = await this.starboards.getStarboardMessageByStarboardIdAndMessageId(
starboard.id,
msg.id,
);
if (existingSavedMessage) return;
const reactionsCount = await this.countReactions(msg, emojiStr);
if (reactionsCount >= starboard.reactions_required) {
await this.saveMessageToStarboard(msg, starboard);
const reactions = await this.starboardReactions.getAllReactionsForMessageId(msg.id);
const reactionsCount = reactions.length;
if (reactionsCount >= starboard.stars_required) {
await this.saveMessageToStarboard(msg, starboard.channel_id);
}
}
}
/**
* Counts the specific reactions in the message, ignoring the message author
*/
async countReactions(msg: Message, reaction) {
let reactionsCount = (msg.reactions[reaction] && msg.reactions[reaction].count) || 0;
@d.event("messageReactionRemove")
async onStarboardReactionRemove(msg: Message, emoji: { id: string; name: string }, userId: string) {
await this.starboardReactions.deleteStarboardReaction(msg.id, userId);
}
// Ignore self-stars
const reactors = await msg.getReaction(reaction);
if (reactors.some(u => u.id === msg.author.id)) reactionsCount--;
return reactionsCount;
@d.event("messageReactionRemoveAll")
async onMessageReactionRemoveAll(msg: Message) {
await this.starboardReactions.deleteAllStarboardReactionsForMessageId(msg.id);
}
/**
* Saves/posts a message to the specified starboard. The message is posted as an embed and image attachments are
* included as the embed image.
* Saves/posts a message to the specified starboard.
* The message is posted as an embed and image attachments are included as the embed image.
*/
async saveMessageToStarboard(msg: Message, starboard: Starboard) {
const channel = this.guild.channels.get(starboard.channel_id);
async saveMessageToStarboard(msg: Message, starboardChannelId: string) {
const channel = this.guild.channels.get(starboardChannelId);
if (!channel) return;
const time = moment(msg.timestamp, "x").format("YYYY-MM-DD [at] HH:mm:ss [UTC]");
const embed: any = {
const embed: EmbedBase = {
footer: {
text: `#${(msg.channel as GuildChannel).name} - ${time}`,
text: `#${(msg.channel as GuildChannel).name}`,
},
author: {
name: `${msg.author.username}#${msg.author.discriminator}`,
},
timestamp: new Date(msg.timestamp).toISOString(),
};
if (msg.author.avatarURL) {
@ -276,6 +267,7 @@ export class StarboardPlugin extends ZeppelinPlugin<TConfigSchema> {
embed.description = msg.content;
}
// Include attachments
if (msg.attachments.length) {
const attachment = msg.attachments[0];
const ext = path
@ -285,87 +277,96 @@ export class StarboardPlugin extends ZeppelinPlugin<TConfigSchema> {
if (["jpeg", "jpg", "png", "gif", "webp"].includes(ext)) {
embed.image = { url: attachment.url };
}
} else if (msg.content) {
const links = getUrlsInString(msg.content);
for (const link of links) {
const parts = link
.toString()
.replace(/\/$/, "")
.split(".");
const ext = parts[parts.length - 1].toLowerCase();
if (
(link.hostname === "i.imgur.com" || link.hostname === "cdn.discordapp.com") &&
["jpeg", "jpg", "png", "gif", "webp"].includes(ext)
) {
embed.image = { url: link.toString() };
break;
}
}
}
const starboardMessage = await (channel as TextChannel).createMessage({
content: `https://discordapp.com/channels/${this.guildId}/${msg.channel.id}/${msg.id}`,
embed,
});
await this.starboards.createStarboardMessage(starboard.id, msg.id, starboardMessage.id);
// Include any embed images in the original message
if (msg.embeds.length && msg.embeds[0].image) {
embed.image = msg.embeds[0].image;
}
embed.fields = [{ name: EMPTY_CHAR, value: `[Jump to message](${messageLink(msg)})` }];
const starboardMessage = await (channel as TextChannel).createMessage({ embed });
await this.starboardMessages.createStarboardMessage(channel.id, msg.id, starboardMessage.id);
}
/**
* Remove a message from the specified starboard
*/
async removeMessageFromStarboard(msgId: string, starboard: Starboard) {
const starboardMessage = await this.starboards.getStarboardMessageByStarboardIdAndMessageId(starboard.id, msgId);
if (!starboardMessage) return;
async removeMessageFromStarboard(msg: StarboardMessage) {
await this.bot.deleteMessage(msg.starboard_channel_id, msg.starboard_message_id).catch(noop);
}
await this.bot.deleteMessage(starboard.channel_id, starboardMessage.starboard_message_id).catch(noop);
await this.starboards.deleteStarboardMessage(starboard.id, msgId);
async removeMessageFromStarboardMessages(starboard_message_id: string, channel_id: string) {
await this.starboardMessages.deleteStarboardMessage(starboard_message_id, channel_id);
}
/**
* When a message is deleted, also delete it from any starboards it's been posted in.
* Likewise, if a starboard message (i.e. the bot message in the starboard) is deleted, remove it from the database.
* This function is called in response to GuildSavedMessages events.
* TODO: When a message is removed from the starboard itself, i.e. the bot's embed is removed, also remove that message from the starboard_messages database table
*/
async onMessageDelete(msg: SavedMessage) {
const starboardMessages = await this.starboards.with("starboard").getStarboardMessagesByMessageId(msg.id);
if (!starboardMessages.length) return;
// Deleted source message
const starboardMessages = await this.starboardMessages.getStarboardMessagesForMessageId(msg.id);
for (const starboardMessage of starboardMessages) {
if (!starboardMessage.starboard) continue;
this.removeMessageFromStarboard(starboardMessage.message_id, starboardMessage.starboard);
this.removeMessageFromStarboard(starboardMessage);
}
// Deleted message from the starboard
const deletedStarboardMessages = await this.starboardMessages.getStarboardMessagesForStarboardMessageId(msg.id);
if (deletedStarboardMessages.length === 0) return;
for (const starboardMessage of deletedStarboardMessages) {
this.removeMessageFromStarboardMessages(
starboardMessage.starboard_message_id,
starboardMessage.starboard_channel_id,
);
}
}
@d.command("starboard migrate_pins", "<pinChannelId:channelId> <starboardChannelId:channelId>")
async migratePinsCmd(msg: Message, args: { pinChannelId: string; starboardChannelId }) {
const starboard = await this.starboards.getStarboardByChannelId(args.starboardChannelId);
@d.command("starboard migrate_pins", "<pinChannel:channel> <starboardName:string>", {
extra: {
info: {
description:
"Posts all pins from a channel to the specified starboard. The pins are NOT unpinned automatically.",
},
},
})
@d.permission("can_migrate")
async migratePinsCmd(msg: Message, args: { pinChannel: GuildChannel; starboardName: string }) {
const config = await this.getConfig();
const starboard = config.boards[args.starboardName];
if (!starboard) {
msg.channel.createMessage(errorMessage("The specified channel doesn't have a starboard!"));
this.sendErrorMessage(msg.channel, "Unknown starboard specified");
return;
}
const channel = (await this.guild.channels.get(args.pinChannelId)) as GuildChannel & TextChannel;
if (!channel) {
msg.channel.createMessage(errorMessage("Could not find the specified channel to migrate pins from!"));
if (!(args.pinChannel instanceof TextChannel)) {
this.sendErrorMessage(msg.channel, "Unknown/invalid pin channel id");
return;
}
msg.channel.createMessage(`Migrating pins from <#${channel.id}> to <#${args.starboardChannelId}>...`);
const starboardChannel = this.guild.channels.get(starboard.channel_id);
if (!starboardChannel || !(starboardChannel instanceof TextChannel)) {
this.sendErrorMessage(msg.channel, "Starboard has an unknown/invalid channel id");
return;
}
const pins = await channel.getPins();
msg.channel.createMessage(`Migrating pins from <#${args.pinChannel.id}> to <#${starboardChannel.id}>...`);
const pins = await args.pinChannel.getPins();
pins.reverse(); // Migrate pins starting from the oldest message
for (const pin of pins) {
const existingStarboardMessage = await this.starboards.getStarboardMessageByStarboardIdAndMessageId(
starboard.id,
const existingStarboardMessage = await this.starboardMessages.getMatchingStarboardMessages(
starboardChannel.id,
pin.id,
);
if (existingStarboardMessage) continue;
await this.saveMessageToStarboard(pin, starboard);
if (existingStarboardMessage.length > 0) continue;
await this.saveMessageToStarboard(pin, starboardChannel.id);
}
msg.channel.createMessage(successMessage("Pins migrated!"));
this.sendSuccessMessage(msg.channel, `Pins migrated from <#${args.pinChannel.id}> to <#${starboardChannel.id}>!`);
}
}

View 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);
}
}
}
}

View file

@ -183,8 +183,20 @@ export class TagsPlugin extends ZeppelinPlugin<TConfigSchema> {
msg.channel.createMessage(successMessage(`Tag set! Use it with: \`${prefix}${args.tag}\``));
}
@d.command("tag", "<tag:string>")
async tagSourceCmd(msg: Message, args: { tag: string }) {
@d.command("tag", "<tag:string>", {
options: [
{
name: "delete",
shortcut: "d",
isSwitch: true,
},
],
})
async tagSourceCmd(msg: Message, args: { tag: string; delete?: boolean }) {
if (args.delete) {
return this.deleteTagCmd(msg, { tag: args.tag });
}
const tag = await this.tags.find(args.tag);
if (!tag) {
msg.channel.createMessage(errorMessage("No tag with that name"));

View file

@ -29,6 +29,7 @@ import {
get,
getInviteCodesInString,
isSnowflake,
messageLink,
MINUTES,
multiSorter,
noop,
@ -56,6 +57,9 @@ import { getCurrentUptime } from "../uptime";
import LCL from "last-commit-log";
import * as t from "io-ts";
import { ICommandDefinition } from "knub-command-manager";
import path from "path";
import escapeStringRegexp from "escape-string-regexp";
import safeRegex from "safe-regex";
const ConfigSchema = t.type({
can_roles: t.boolean,
@ -71,16 +75,20 @@ const ConfigSchema = t.type({
can_vcmove: t.boolean,
can_help: t.boolean,
can_about: t.boolean,
can_context: t.boolean,
});
type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
const { performance } = require("perf_hooks");
const SEARCH_RESULTS_PER_PAGE = 15;
const SEARCH_ID_RESULTS_PER_PAGE = 50;
const MAX_CLEAN_COUNT = 150;
const MAX_CLEAN_TIME = 1 * DAYS;
const CLEAN_COMMAND_DELETE_DELAY = 5000;
const MEMBER_REFRESH_FREQUENCY = 10 * 60 * 1000; // How often to do a full member refresh when using !search or !roles --counts
const SEARCH_EXPORT_LIMIT = 1_000_000;
const activeReloads: Map<string, TextChannel> = new Map();
@ -88,10 +96,14 @@ type MemberSearchParams = {
query?: string;
role?: string;
voice?: boolean;
bot?: boolean;
sort?: string;
"case-sensitive"?: boolean;
regex?: boolean;
};
class SearchError extends Error {}
export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
public static pluginName = "utility";
public static configSchema = ConfigSchema;
@ -124,6 +136,7 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
can_vcmove: false,
can_help: false,
can_about: false,
can_context: false,
},
overrides: [
{
@ -138,6 +151,7 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
can_nickname: true,
can_vcmove: true,
can_help: true,
can_context: true,
},
},
{
@ -177,7 +191,7 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
options: [
{
name: "counts",
type: "bool",
isSwitch: true,
},
{
name: "sort",
@ -320,18 +334,27 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
matchingMembers = matchingMembers.filter(m => m.voiceState.channelID != null);
}
if (args.bot) {
matchingMembers = matchingMembers.filter(m => m.bot);
}
if (args.query) {
const query = args["case-sensitive"] ? args.query.trimStart() : args.query.toLowerCase().trimStart();
let queryRegex: RegExp;
if (args.regex) {
queryRegex = new RegExp(args.query.trimStart(), args["case-sensitive"] ? "" : "i");
} else {
queryRegex = new RegExp(escapeStringRegexp(args.query.trimStart()), args["case-sensitive"] ? "" : "i");
}
if (!safeRegex(queryRegex)) {
throw new SearchError("Unsafe/too complex regex (star depth is limited to 1)");
}
matchingMembers = matchingMembers.filter(member => {
const nick = args["case-sensitive"] ? member.nick : member.nick && member.nick.toLowerCase();
if (member.nick && member.nick.match(queryRegex)) return true;
const fullUsername = args["case-sensitive"]
? `${member.user.username}#${member.user.discriminator}`
: `${member.user.username}#${member.user.discriminator}`.toLowerCase();
if (nick && nick.indexOf(query) !== -1) return true;
if (fullUsername.indexOf(query) !== -1) return true;
const fullUsername = `${member.user.username}#${member.user.discriminator}`;
if (fullUsername.match(queryRegex)) return true;
return false;
});
@ -344,15 +367,18 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
matchingMembers.sort(sorter(m => BigInt(m.id), realSortDir));
} else {
matchingMembers.sort(
multiSorter([[m => m.username.toLowerCase(), realSortDir], [m => m.discriminator, realSortDir]]),
multiSorter([
[m => m.username.toLowerCase(), realSortDir],
[m => m.discriminator, realSortDir],
]),
);
}
const lastPage = Math.ceil(matchingMembers.length / SEARCH_RESULTS_PER_PAGE);
const lastPage = Math.max(1, Math.ceil(matchingMembers.length / perPage));
page = Math.min(lastPage, Math.max(1, page));
const from = (page - 1) * SEARCH_RESULTS_PER_PAGE;
const to = Math.min(from + SEARCH_RESULTS_PER_PAGE, matchingMembers.length);
const from = (page - 1) * perPage;
const to = Math.min(from + perPage, matchingMembers.length);
const pageMembers = matchingMembers.slice(from, to);
@ -371,15 +397,23 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
options: [
{
name: "page",
shortcut: "p",
type: "number",
},
{
name: "role",
shortcut: "r",
type: "string",
},
{
name: "voice",
type: "bool",
shortcut: "v",
isSwitch: true,
},
{
name: "bot",
shortcut: "b",
isSwitch: true,
},
{
name: "sort",
@ -395,6 +429,15 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
shortcut: "e",
isSwitch: true,
},
{
name: "ids",
isSwitch: true,
},
{
name: "regex",
shortcut: "re",
isSwitch: true,
},
],
extra: {
info: <CommandInfo>{
@ -417,15 +460,18 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
msg: Message,
args: {
query?: string;
role?: string;
page?: number;
role?: string;
voice?: boolean;
bot?: boolean;
sort?: string;
"case-sensitive"?: boolean;
export?: boolean;
ids?: boolean;
regex?: boolean;
},
) {
const formatSearchResultLines = (members: Member[]) => {
const formatSearchResultList = (members: Member[]): string => {
const longestId = members.reduce((longest, member) => Math.max(longest, member.id.length), 0);
const lines = members.map(member => {
const paddedId = member.id.padEnd(longestId, " ");
@ -433,23 +479,38 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
if (member.nick) line += ` (${member.nick})`;
return line;
});
return lines;
return lines.join("\n");
};
const formatSearchResultIdList = (members: Member[]): string => {
return members.map(m => m.id).join(" ");
};
// If we're exporting the results, we don't need all the fancy schmancy pagination stuff.
// Just get the results and dump them in an archive.
if (args.export) {
const results = await this.performMemberSearch(args, 1, Infinity);
let results;
try {
results = await this.performMemberSearch(args, 1, SEARCH_EXPORT_LIMIT);
} catch (e) {
if (e instanceof SearchError) {
return this.sendErrorMessage(msg.channel, e.message);
}
throw e;
}
if (results.totalResults === 0) {
return this.sendErrorMessage(msg.channel, "No results found");
}
const resultLines = formatSearchResultLines(results.results);
const resultList = args.ids ? formatSearchResultIdList(results.results) : formatSearchResultList(results.results);
const archiveId = await this.archives.create(
trimLines(`
Search results (total ${results.totalResults}):
${resultLines.join("\n")}
${resultList}
`),
moment().add(1, "hour"),
);
@ -468,6 +529,8 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
let clearReactionsFn = null;
let clearReactionsTimeout = null;
const perPage = args.ids ? SEARCH_ID_RESULTS_PER_PAGE : SEARCH_RESULTS_PER_PAGE;
const loadSearchPage = async page => {
if (searching) return;
searching = true;
@ -482,23 +545,37 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
searchMsgPromise.then(m => (originalSearchMsg = m));
}
const searchResult = await this.performMemberSearch(args, page, SEARCH_RESULTS_PER_PAGE);
let searchResult;
try {
searchResult = await this.performMemberSearch(args, page, perPage);
} catch (e) {
if (e instanceof SearchError) {
return this.sendErrorMessage(msg.channel, e.message);
}
throw e;
}
if (searchResult.totalResults === 0) {
return this.sendErrorMessage(msg.channel, "No results found");
}
const resultWord = searchResult.totalResults === 1 ? "matching member" : "matching members";
const headerText =
searchResult.totalResults > SEARCH_RESULTS_PER_PAGE
searchResult.totalResults > perPage
? trimLines(`
**Page ${searchResult.page}** (${searchResult.from}-${searchResult.to}) (total ${searchResult.totalResults})
`)
: `Found ${searchResult.totalResults} ${resultWord}`;
const lines = formatSearchResultLines(searchResult.results);
const resultList = args.ids
? formatSearchResultIdList(searchResult.results)
: formatSearchResultList(searchResult.results);
const result = trimLines(`
${headerText}
\`\`\`js
${lines.join("\n")}
${resultList}
\`\`\`
`);
@ -506,7 +583,7 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
searchMsg.edit(result);
// Set up pagination reactions if needed. The reactions are cleared after a timeout.
if (searchResult.totalResults > SEARCH_RESULTS_PER_PAGE) {
if (searchResult.totalResults > perPage) {
if (!hasReactions) {
hasReactions = true;
searchMsg.addReaction("⬅");
@ -514,6 +591,7 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
searchMsg.addReaction("🔄");
const removeListenerFn = this.on("messageReactionAdd", (rMsg: Message, emoji, userId) => {
if (rMsg.id !== searchMsg.id) return;
if (userId !== msg.author.id) return;
if (!["⬅", "➡", "🔄"].includes(emoji.name)) return;
@ -571,6 +649,8 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
count: savedMessages.length,
archiveUrl,
});
return { archiveUrl };
}
@d.command("clean", "<count:number>", {
@ -683,17 +763,19 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
let responseMsg: Message;
if (messagesToClean.length > 0) {
await this.cleanMessages(targetChannel, messagesToClean, msg.author);
const cleanResult = await this.cleanMessages(targetChannel, messagesToClean, msg.author);
let responseText = `Cleaned ${messagesToClean.length} ${messagesToClean.length === 1 ? "message" : "messages"}`;
if (targetChannel.id !== msg.channel.id) responseText += ` in <#${targetChannel.id}>`;
if (targetChannel.id !== msg.channel.id) {
responseText += ` in <#${targetChannel.id}>\n${cleanResult.archiveUrl}`;
}
responseMsg = await msg.channel.createMessage(successMessage(responseText));
} else {
responseMsg = await msg.channel.createMessage(errorMessage(`Found no messages to clean!`));
}
if (targetChannel.id !== msg.channel.id) {
if (targetChannel.id === msg.channel.id) {
// Delete the !clean command and the bot response if a different channel wasn't specified
// (so as not to spam the cleaned channel with the command itself)
setTimeout(() => {
@ -710,9 +792,16 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
basicUsage: "!info 106391128718245888",
},
},
options: [
{
name: "compact",
shortcut: "c",
isSwitch: true,
},
],
})
@d.permission("can_info")
async infoCmd(msg: Message, args: { user?: User | UnknownUser }) {
async infoCmd(msg: Message, args: { user?: User | UnknownUser; compact?: boolean }) {
const user = args.user || msg.author;
let member;
@ -734,15 +823,40 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
embed.title = `${user.username}#${user.discriminator}`;
embed.thumbnail = { url: user.avatarURL };
embed.fields.push({
name: "User information",
value:
trimLines(`
ID: **${user.id}**
Profile: <@!${user.id}>
Created: **${accountAge} ago (${createdAt.format("YYYY-MM-DD[T]HH:mm:ss")})**
`) + embedPadding,
});
if (args.compact) {
embed.fields.push({
name: "User information",
value: trimLines(`
Profile: <@!${user.id}>
Created: **${accountAge} ago (${createdAt.format("YYYY-MM-DD[T]HH:mm:ss")})**
`),
});
if (member) {
const joinedAt = moment(member.joinedAt);
const joinAge = humanizeDuration(moment().valueOf() - member.joinedAt, {
largest: 2,
round: true,
});
embed.fields[0].value += `\nJoined: **${joinAge} ago (${joinedAt.format("YYYY-MM-DD[T]HH:mm:ss")})**`;
} else {
embed.fields.push({
name: "!! USER IS NOT ON THE SERVER !!",
value: embedPadding,
});
}
msg.channel.createMessage({ embed });
return;
} else {
embed.fields.push({
name: "User information",
value:
trimLines(`
ID: **${user.id}**
Profile: <@!${user.id}>
Created: **${accountAge} ago (${createdAt.format("YYYY-MM-DD[T]HH:mm:ss")})**
`) + embedPadding,
});
}
} else {
embed.title = `Unknown user`;
}
@ -782,7 +896,6 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
value: embedPadding,
});
}
const cases = (await this.cases.getByUserId(user.id)).filter(c => !c.is_hidden);
if (cases.length > 0) {
@ -994,7 +1107,12 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
);
// Clean up test messages
this.bot.deleteMessages(messages[0].channel.id, messages.map(m => m.id)).catch(noop);
this.bot
.deleteMessages(
messages[0].channel.id,
messages.map(m => m.id),
)
.catch(noop);
}
@d.command("source", "<messageId:string>", {
@ -1021,6 +1139,30 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
msg.channel.createMessage(`Message source: ${url}`);
}
@d.command("context", "<channel:channel> <messageId:string>", {
extra: {
info: <CommandInfo>{
description: "Get a link to the context of the specified message",
basicUsage: "!context 94882524378968064 650391267720822785",
},
},
})
@d.permission("can_context")
async contextCmd(msg: Message, args: { channel: Channel; messageId: string }) {
if (!(args.channel instanceof TextChannel)) {
this.sendErrorMessage(msg.channel, "Channel must be a text channel");
return;
}
const previousMessage = (await this.bot.getMessages(args.channel.id, 1, args.messageId))[0];
if (!previousMessage) {
this.sendErrorMessage(msg.channel, "Message context not found");
return;
}
msg.channel.createMessage(messageLink(this.guildId, previousMessage.channel.id, previousMessage.id));
}
@d.command("vcmove", "<member:resolvedMember> <channel:string$>", {
extra: {
info: <CommandInfo>{
@ -1192,8 +1334,25 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
const uptime = getCurrentUptime();
const prettyUptime = humanizeDuration(uptime, { largest: 2, round: true });
const lcl = new LCL();
const lastCommit = await lcl.getLastCommit();
let lastCommit;
try {
// From project root
// FIXME: Store these paths properly somewhere
const lcl = new LCL(path.resolve(__dirname, "..", "..", ".."));
lastCommit = await lcl.getLastCommit();
} catch (e) {} // tslint:disable-line:no-empty
let lastUpdate;
let version;
if (lastCommit) {
lastUpdate = moment(lastCommit.committer.date, "X").format("LL [at] H:mm [(UTC)]");
version = lastCommit.shortHash;
} else {
lastUpdate = "?";
version = "?";
}
const shard = this.bot.shards.get(this.bot.guildShardMap[this.guildId]);
@ -1205,8 +1364,8 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
const basicInfoRows = [
["Uptime", prettyUptime],
["Last reload", `${lastReload} ago`],
["Last update", moment(lastCommit.committer.date, "X").format("LL [at] H:mm [(UTC)]")],
["Version", lastCommit.shortHash],
["Last update", lastUpdate],
["Version", version],
["API latency", `${shard.latency}ms`],
];

View file

@ -12,14 +12,16 @@ import {
resolveMember,
resolveUser,
resolveUserId,
tDeepPartial,
trimEmptyStartEndLines,
trimIndents,
UnknownUser,
resolveRoleId,
} from "../utils";
import { Invite, Member, User } from "eris";
import DiscordRESTError from "eris/lib/errors/DiscordRESTError"; // tslint:disable-line
import { performance } from "perf_hooks";
import { decodeAndValidateStrict, StrictValidationError } from "../validatorUtils";
import { decodeAndValidateStrict, StrictValidationError, validate } from "../validatorUtils";
import { SimpleCache } from "../SimpleCache";
const SLOW_RESOLVE_THRESHOLD = 1500;
@ -52,8 +54,8 @@ export interface CommandInfo {
export function trimPluginDescription(str) {
const emptyLinesTrimmed = trimEmptyStartEndLines(str);
const lines = emptyLinesTrimmed.split("\n");
const lastLineIndentation = (lines[lines.length - 1].match(/^ +/g) || [""])[0].length;
return trimIndents(emptyLinesTrimmed, lastLineIndentation);
const firstLineIndentation = (lines[0].match(/^ +/g) || [""])[0].length;
return trimIndents(emptyLinesTrimmed, firstLineIndentation);
}
const inviteCache = new SimpleCache<Promise<Invite>>(10 * MINUTES, 200);
@ -69,14 +71,14 @@ export class ZeppelinPlugin<TConfig extends {} = IBasePluginConfig> extends Plug
throw new PluginRuntimeError(message, this.runtimePluginName, this.guildId);
}
protected canActOn(member1, member2) {
if (member1.id === member2.id || member2.id === this.bot.user.id) {
protected canActOn(member1: Member, member2: Member, allowSameLevel = false) {
if (member2.id === this.bot.user.id) {
return false;
}
const ourLevel = this.getMemberLevel(member1);
const memberLevel = this.getMemberLevel(member2);
return ourLevel > memberLevel;
return allowSameLevel ? ourLevel >= memberLevel : ourLevel > memberLevel;
}
/**
@ -121,6 +123,13 @@ export class ZeppelinPlugin<TConfig extends {} = IBasePluginConfig> extends Plug
? options.overrides
: (defaultOptions.overrides || []).concat(options.overrides || []);
// Before preprocessing the static config, do a loose check by checking the schema as deeply partial.
// This way the preprocessing function can trust that if a property exists, its value will be the correct (partial) type.
const initialLooseCheck = this.configSchema ? validate(tDeepPartial(this.configSchema), mergedConfig) : null;
if (initialLooseCheck) {
throw initialLooseCheck;
}
mergedConfig = this.preprocessStaticConfig(mergedConfig);
const decodedConfig = this.configSchema ? decodeAndValidateStrict(this.configSchema, mergedConfig) : mergedConfig;
@ -229,6 +238,16 @@ export class ZeppelinPlugin<TConfig extends {} = IBasePluginConfig> extends Plug
return user;
}
/**
* Resolves a role from the passed string. The passed string can be a role ID, a role mention or a role name.
* In the event of duplicate role names, this function will return the first one it comes across.
* @param roleResolvable
*/
async resolveRoleId(roleResolvable: string): Promise<string | null> {
const roleId = await resolveRoleId(this.bot, this.guildId, roleResolvable);
return roleId;
}
/**
* Resolves a member from the passed string. The passed string can be a user id, a user mention, a full username (with discrim), etc.
* If the member is not found in the cache, it's fetched from the API.

View file

@ -27,6 +27,7 @@ import { LocatePlugin } from "./LocateUser";
import { GuildConfigReloader } from "./GuildConfigReloader";
import { ChannelArchiverPlugin } from "./ChannelArchiver";
import { AutomodPlugin } from "./Automod";
import { RolesPlugin } from "./Roles";
/**
* Plugins available to be loaded for individual guilds
@ -58,6 +59,7 @@ export const availablePlugins = [
CompanionChannelPlugin,
LocatePlugin,
ChannelArchiverPlugin,
RolesPlugin,
];
/**

47
backend/src/utils.test.ts Normal file
View 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);
});

View file

@ -9,6 +9,7 @@ import {
GuildAuditLogEntry,
GuildChannel,
Member,
Message,
MessageContent,
TextableChannel,
TextChannel,
@ -27,6 +28,10 @@ import https from "https";
import tmp from "tmp";
import { logger, waitForReaction } from "knub";
import { SavedMessage } from "./data/entities/SavedMessage";
import { decodeAndValidateStrict, StrictValidationError } from "./validatorUtils";
import { either } from "fp-ts/lib/Either";
import safeRegex from "safe-regex";
import moment from "moment-timezone";
const delayStringMultipliers = {
w: 1000 * 60 * 60 * 24 * 7,
@ -34,6 +39,7 @@ const delayStringMultipliers = {
h: 1000 * 60 * 60,
m: 1000 * 60,
s: 1000,
x: 1,
};
export const MS = 1;
@ -41,11 +47,141 @@ export const SECONDS = 1000 * MS;
export const MINUTES = 60 * SECONDS;
export const HOURS = 60 * MINUTES;
export const DAYS = 24 * HOURS;
export const WEEKS = 7 * 24 * HOURS;
export function tNullable<T extends t.Type<any, any, unknown>>(type: T) {
export const EMPTY_CHAR = "\u200b";
export function tNullable<T extends t.Type<any, any>>(type: T) {
return t.union([type, t.undefined, t.null], `Nullable<${type.name}>`);
}
function typeHasProps(type: any): type is t.TypeC<any> {
return type.props != null;
}
function typeIsArray(type: any): type is t.ArrayC<any> {
return type._tag === "ArrayType";
}
export type TDeepPartial<T> = T extends t.InterfaceType<any>
? TDeepPartialProps<T["props"]>
: T extends t.DictionaryType<any, any>
? t.DictionaryType<T["domain"], TDeepPartial<T["codomain"]>>
: T extends t.UnionType<any[]>
? t.UnionType<Array<TDeepPartial<T["types"][number]>>>
: T extends t.IntersectionType<any>
? t.IntersectionType<Array<TDeepPartial<T["types"][number]>>>
: T extends t.ArrayType<any>
? t.ArrayType<TDeepPartial<T["type"]>>
: T;
// Based on t.PartialC
export interface TDeepPartialProps<P extends t.Props>
extends t.PartialType<
P,
{
[K in keyof P]?: TDeepPartial<t.TypeOf<P[K]>>;
},
{
[K in keyof P]?: TDeepPartial<t.OutputOf<P[K]>>;
}
> {}
export function tDeepPartial<T>(type: T): TDeepPartial<T> {
if (type instanceof t.InterfaceType) {
const newProps = {};
for (const [key, prop] of Object.entries(type.props)) {
newProps[key] = tDeepPartial(prop);
}
return t.partial(newProps) as TDeepPartial<T>;
} else if (type instanceof t.DictionaryType) {
return t.record(type.domain, tDeepPartial(type.codomain)) as TDeepPartial<T>;
} else if (type instanceof t.UnionType) {
return t.union(type.types.map(unionType => tDeepPartial(unionType))) as TDeepPartial<T>;
} else if (type instanceof t.IntersectionType) {
const types = type.types.map(intersectionType => tDeepPartial(intersectionType));
return (t.intersection(types as [t.Mixed, t.Mixed]) as unknown) as TDeepPartial<T>;
} else if (type instanceof t.ArrayType) {
return t.array(tDeepPartial(type.type)) as TDeepPartial<T>;
} else {
return type as TDeepPartial<T>;
}
}
function tDeepPartialProp(prop: any) {
if (typeHasProps(prop)) {
return tDeepPartial(prop);
} else if (typeIsArray(prop)) {
return t.array(tDeepPartialProp(prop.type));
} else {
return prop;
}
}
/**
* Mirrors EmbedOptions from Eris
*/
export const tEmbed = t.type({
title: tNullable(t.string),
description: tNullable(t.string),
url: tNullable(t.string),
timestamp: tNullable(t.string),
color: tNullable(t.number),
footer: tNullable(
t.type({
text: t.string,
icon_url: tNullable(t.string),
proxy_icon_url: tNullable(t.string),
}),
),
image: tNullable(
t.type({
url: tNullable(t.string),
proxy_url: tNullable(t.string),
width: tNullable(t.number),
height: tNullable(t.number),
}),
),
thumbnail: tNullable(
t.type({
url: tNullable(t.string),
proxy_url: tNullable(t.string),
width: tNullable(t.number),
height: tNullable(t.number),
}),
),
video: tNullable(
t.type({
url: tNullable(t.string),
width: tNullable(t.number),
height: tNullable(t.number),
}),
),
provider: tNullable(
t.type({
name: t.string,
url: tNullable(t.string),
}),
),
fields: tNullable(
t.array(
t.type({
name: tNullable(t.string),
value: tNullable(t.string),
inline: tNullable(t.boolean),
}),
),
),
author: tNullable(
t.type({
name: t.string,
url: tNullable(t.string),
width: tNullable(t.number),
height: tNullable(t.number),
}),
),
});
export function dropPropertiesByName(obj, propName) {
if (obj.hasOwnProperty(propName)) delete obj[propName];
for (const value of Object.values(obj)) {
@ -55,6 +191,40 @@ export function dropPropertiesByName(obj, propName) {
}
}
export const tAlphanumeric = new t.Type<string, string>(
"tAlphanumeric",
(s): s is string => typeof s === "string",
(from, to) =>
either.chain(t.string.validate(from, to), s => {
return s.match(/\W/) ? t.failure(from, to, "String must be alphanumeric") : t.success(s);
}),
s => s,
);
export const tDateTime = new t.Type<string, string>(
"tDateTime",
(s): s is string => typeof s === "string",
(from, to) =>
either.chain(t.string.validate(from, to), s => {
const parsed =
s.length === 10 ? moment(s, "YYYY-MM-DD") : s.length === 19 ? moment(s, "YYYY-MM-DD HH:mm:ss") : null;
return parsed && parsed.isValid() ? t.success(s) : t.failure(from, to, "Invalid datetime");
}),
s => s,
);
export const tDelayString = new t.Type<string, string>(
"tDelayString",
(s): s is string => typeof s === "string",
(from, to) =>
either.chain(t.string.validate(from, to), s => {
const ms = convertDelayStringToMS(s);
return ms === null ? t.failure(from, to, "Invalid delay string") : t.success(s);
}),
s => s,
);
/**
* Turns a "delay string" such as "1h30m" to milliseconds
*/
@ -79,8 +249,23 @@ export function convertDelayStringToMS(str, defaultUnit = "m"): number {
return ms;
}
export function convertMSToDelayString(ms: number): string {
let result = "";
let remaining = ms;
for (const [abbr, multiplier] of Object.entries(delayStringMultipliers)) {
if (multiplier <= remaining) {
const amount = Math.floor(remaining / multiplier);
result += `${amount}${abbr}`;
remaining -= amount * multiplier;
}
if (remaining === 0) break;
}
return result;
}
export function successMessage(str) {
return `👌 ${str}`;
return `<:zep_check:650361014180904971> ${str}`;
}
export function errorMessage(str) {
@ -189,7 +374,7 @@ const urlRegex = /(\S+\.\S+)/g;
const protocolRegex = /^[a-z]+:\/\//;
export function getUrlsInString(str: string, unique = false): url.URL[] {
let matches = (str.match(urlRegex) || []).map(m => m[0]);
let matches = str.match(urlRegex) || [];
if (unique) matches = Array.from(new Set(matches));
return matches.reduce((urls, match) => {
@ -216,15 +401,7 @@ export function getUrlsInString(str: string, unique = false): url.URL[] {
export function getInviteCodesInString(str: string): string[] {
const inviteCodeRegex = /(?:discord.gg|discordapp.com\/invite)\/([a-z0-9]+)/gi;
const inviteCodes = [];
let match;
// tslint:disable-next-line
while ((match = inviteCodeRegex.exec(str)) !== null) {
inviteCodes.push(match[1]);
}
return inviteCodes;
return Array.from(str.matchAll(inviteCodeRegex)).map(m => m[1]);
}
export const unicodeEmojiRegex = emojiRegex();
@ -333,7 +510,7 @@ export function getRoleMentions(str: string) {
}
/**
* Disables link previews in the given string by wrapping links in < >
* Disable link previews in the given string by wrapping links in < >
*/
export function disableLinkPreviews(str: string): string {
return str.replace(/(?<!<)(https?:\/\/\S+)/gi, "<$1>");
@ -343,6 +520,17 @@ export function deactivateMentions(content: string): string {
return content.replace(/@/g, "@\u200b");
}
/**
* Disable inline code in the given string by replacing backticks/grave accents with acute accents
* FIXME: Find a better way that keeps the grave accents? Can't use the code block approach here since it's just 1 character.
*/
export function disableInlineCode(content: string): string {
return content.replace(/`/g, "\u00b4");
}
/**
* Disable code blocks in the given string by adding invisible unicode characters between backticks
*/
export function disableCodeBlocks(content: string): string {
return content.replace(/`/g, "`\u200b");
}
@ -768,6 +956,32 @@ export async function resolveMember(bot: Client, guild: Guild, value: string): P
return null;
}
export async function resolveRoleId(bot: Client, guildId: string, value: string) {
if (value == null) {
return null;
}
// Role mention
const mentionMatch = value.match(/^<@&?(\d+)>$/);
if (mentionMatch) {
return mentionMatch[1];
}
// Role name
const roleList = await bot.getRESTGuildRoles(guildId);
const role = roleList.filter(x => x.name.toLocaleLowerCase() === value.toLocaleLowerCase());
if (role[0]) {
return role[0].id;
}
// Role ID
const idMatch = value.match(/^\d+$/);
if (idMatch) {
return value;
}
return null;
}
export type StrictMessageContent = { content?: string; tts?: boolean; disableEveryone?: boolean; embed?: EmbedOptions };
export async function confirm(bot: Client, channel: TextableChannel, userId: string, content: MessageContent) {
@ -805,3 +1019,28 @@ export function verboseUserName(user: User | UnknownUser): string {
export function verboseChannelMention(channel: GuildChannel): string {
return `<#${channel.id}> (**#${channel.name}**, \`${channel.id}\`)`;
}
export function messageLink(message: Message): string;
export function messageLink(guildId: string, channelId: string, messageId: string): string;
export function messageLink(guildIdOrMessage: string | Message | null, channelId?: string, messageId?: string): string {
let guildId;
if (guildIdOrMessage == null) {
// Full arguments without a guild id -> DM/Group chat
guildId = "@me";
} else if (guildIdOrMessage instanceof Message) {
// Message object as the only argument
guildId = (guildIdOrMessage.channel as GuildChannel).guild?.id ?? "@me";
channelId = guildIdOrMessage.channel.id;
messageId = guildIdOrMessage.id;
} else {
// Full arguments with all IDs
guildId = guildIdOrMessage;
}
return `https://discordapp.com/channels/${guildId}/${channelId}/${messageId}`;
}
export function isValidEmbed(embed: any): boolean {
const result = decodeAndValidateStrict(tEmbed, embed);
return !(result instanceof StrictValidationError);
}

View 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);
});

View file

@ -57,7 +57,7 @@ function getContextPath(context) {
// tslint:enable
export class StrictValidationError extends Error {
private errors;
private readonly errors;
constructor(errors: string[]) {
errors = Array.from(new Set(errors));
@ -83,6 +83,17 @@ const report = fold((errors: any): StrictValidationError | void => {
return new StrictValidationError(errorStrings);
}, noop);
export function validate(schema: t.Type<any>, value: any): StrictValidationError | null {
const validationResult = schema.decode(value);
return pipe(
validationResult,
fold(
err => report(validationResult),
result => null,
),
);
}
/**
* Decodes and validates the given value against the given schema while also disallowing extra properties
* See: https://github.com/gcanti/io-ts/issues/322

18
backend/start-dev.js Normal file
View 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],
});

View file

@ -6,9 +6,8 @@
"allowSyntheticDefaultImports": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"target": "esnext",
"target": "es2018",
"lib": [
"es2017",
"esnext"
],
"baseUrl": ".",
@ -19,7 +18,8 @@
"@shared/*": [
"../shared/src/*"
]
}
},
"sourceMap": true
},
"include": [
"src/**/*.ts"

View file

@ -2,7 +2,7 @@
"compilerOptions": {
"moduleResolution": "node",
"module": "esnext",
"target": "esnext",
"target": "es2018",
"sourceMap": true,
"noImplicitAny": false,
"allowSyntheticDefaultImports": true,

6
package-lock.json generated
View file

@ -1218,9 +1218,9 @@
}
},
"prettier": {
"version": "1.18.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-1.18.2.tgz",
"integrity": "sha512-OeHeMc0JhFE9idD4ZdtNibzY0+TPHSpSSb9h8FqtP+YnoZZ1sl8Vc9b1sasjfymH3SonAF4QcA2+mzHPhMvIiw==",
"version": "1.19.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-1.19.1.tgz",
"integrity": "sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew==",
"dev": true
},
"pump": {

View file

@ -4,13 +4,13 @@
"description": "",
"private": true,
"scripts": {
"test": "cd backend && npm run test && cd ../shared && npm run test"
"format": "prettier --write \"./{backend,dashboard}/**/*.ts\""
},
"dependencies": {},
"devDependencies": {
"husky": "^3.0.9",
"lint-staged": "^9.4.2",
"prettier": "^1.16.4",
"prettier": "^1.19.1",
"tslint": "^5.13.1",
"tslint-config-prettier": "^1.18.0",
"typescript": "^3.7.2"

View file

@ -6,7 +6,7 @@
"allowSyntheticDefaultImports": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"target": "esnext",
"target": "es2018",
"lib": [
"esnext"
],