Compare commits

...

61 Commits

Author SHA1 Message Date
Alyxia Sother 646198c7bc
Update to OL v2.2.1 2022-04-03 12:21:11 +02:00
Alyxia Sother 7c7cd6c7cf
Fix trimmed array not being joined in .info 2022-03-29 18:58:47 +02:00
Alyxia Sother 389aa35124
Fix the emote registry (again) 2022-02-17 11:07:58 +01:00
Alyxia Sother 5fc65e3c71
revert: Fix some leftover type errors 2021-12-29 20:26:31 +00:00
Alyxia Sother 3170d85376
Fix a CRITICAL BUG in the emote dump module 2021-12-29 20:17:39 +00:00
Alyxia Sother e7cc94408b
Fix some leftover type errors 2021-12-29 20:17:21 +00:00
Alyxia Sother e7217cecb5
Update to Discord.JS v13.5.0 (OL v2.2.0) 2021-12-29 15:12:28 +01:00
smartfridge 08e9ae5218
Fix random errors 2021-12-13 00:39:32 +01:00
Alyxia Sother 69a8452574
R.I.P. .translate, may you return another day 2021-11-03 13:52:39 +01:00
Alyxia Sother 78f3490003
Fixed .calc by moving to WolframAlpha API 2021-11-03 13:45:58 +01:00
Keanu Timmermans 8093224c46
Fixed .eco embeds. 2021-11-03 12:18:26 +01:00
Keanu Timmermans 3751d01756
Fix eco not functioning whatsoever. 2021-10-31 18:51:24 +01:00
Keanu Timmermans 64dde60dab
Fixed eco user embed not sending. 2021-10-31 17:59:29 +01:00
Keanu Timmermans ea58f3d52e
Remove the .neko command, as requested by top.gg. 2021-10-31 14:54:13 +01:00
Alyxia Sother ad82aef396
[eco] Undid accidental reverts
as reported by @Hades785
2021-10-30 12:50:57 +02:00
Alyxia Sother 0e66735565
[Scripts] Added repl script 2021-10-30 12:13:45 +02:00
Alyxia Sother 80deec025d
[Meta] Updated dependencies 2021-10-30 12:13:28 +02:00
Alyxia Sother 45cb482826
Ran `npm prune --production` 2021-10-29 15:44:05 +02:00
Alyxia Sother 4056c4ac0b
Fixed some miscellaneous errors 2021-10-29 15:44:03 +02:00
Alyxia Sother 203c541025
No more admin perms in the invite link!!! 2021-10-29 15:27:52 +02:00
Alyxia Sother fbb687d3d6
Upgrade to Discord.JS v13.2.0
Co-Authored-By: Dmytro Meleshko <dmytro.meleshko@gmail.com>
2021-10-29 15:22:59 +02:00
WatDuhHekBro 36bc488757
Updated to discord.js v13 preview 2021-10-29 15:16:41 +02:00
Keanu Timmermans 8ffbc367b1
Add BOT_VERSION to globals for .info bot. 2021-09-17 22:57:21 +02:00
Alyxia Sother 52c1420508
Implement disabling of message embeds
As requested by @Juby210
2021-08-21 13:31:28 +02:00
Alyxia Sother 985db250d9
Add autorole system
See https://lists.sr.ht/~keanucode/travbot-v3/%3C20210815204227.2899-1-lexisoth2005%40gmail.com%3E/raw
2021-08-15 22:47:44 +02:00
Dmytro Meleshko 73278b7e88
get rid of Gulp for simple tasks such as deletion of the build dir 2021-07-13 18:22:53 +02:00
Dmytro Meleshko 31c68a5d09 don't use networking in the owoify command 2021-07-07 10:51:26 +02:00
WatDuhHekBro e86abbef3e
Merge pull request #43 from EL20202/master
Make .eco daily's cooldown use the new Discord timestamps
2021-07-03 18:25:46 -05:00
EL2020 2c946c8558 fixed minor oversight with timestamp 2021-07-03 18:56:53 -04:00
EL2020 3844a4929d made .eco daily's cooldown message use the new timestamps 2021-07-03 18:53:30 -04:00
Keanu Timmermans 2cb94cc6ac
Fix EL's PR. 2021-06-23 23:24:55 +02:00
WatDuhHekBro 2969dfd814
Merge pull request #42 from EL20202/master
Minor modifications to the '.eco' command
2021-06-23 00:07:44 -05:00
EL2020 ce414cb266 made .eco daily display new balance 2021-06-22 22:13:56 -04:00
EL2020 62c5dd8602 made .eco's channel lock message more clear 2021-06-22 20:04:17 -04:00
Keanu Timmermans 1330b499c8
Fix message links for long Nitro messages. 2021-06-14 11:25:11 +02:00
WatDuhHekBro 2040dbdefd
Updated node version for Docker 2021-05-21 14:42:15 -05:00
Alyxia Sother ac81b6a103
WIP: Make Lavalink optional
At the moment, there's a broken instance of the Lavalink system running.
When executing `.play`, our home-hosted `musicbot` will start playing,
but the production TravBot instance will error.

I haven't implemented this as a choice in the setup yet, that's for
another time.
Right now, all I need is for it to be optional.
2021-05-21 11:54:20 +00:00
WatDuhHekBro 1e673a3969
Added DM channel purge 2021-05-18 14:13:41 -05:00
WatDuhHekBro 180acb318c
Updated package-lock.json 2021-05-17 14:23:09 -05:00
WatDuhHekBro 9d4610249d
Added gulp auto-clean instruction 2021-05-17 14:09:11 -05:00
WatDuhHekBro 077164ed23
Merge pull request #41 from lexisother/master
Added pat command
2021-05-17 12:56:17 -05:00
Alyxia Sother 6003367a6b
Apply suggestions from code review
Co-authored-by: WatDuhHekBro <watduhhekbro@protonmail.com>
2021-05-17 19:49:10 +02:00
Alyxia Sother 58858c5d09
Added pat command 2021-05-17 17:24:54 +00:00
WatDuhHekBro f643f61f29
Cleaned up logging invocations 2021-05-08 08:32:45 -05:00
WatDuhHekBro 736070d615
Began reworking the say command 2021-05-06 09:15:31 -05:00
Keanu Timmermans e249d4b86d
Added pat shop item and increased prices. 2021-04-26 15:25:48 +02:00
Keanu Timmermans e844c61ece
Merge pull request #40 from lexisother/master
Mirror the Docker image to GitHub
2021-04-26 14:42:58 +02:00
Lexi Sother 2c674cef95
Mirror the Docker image to GitHub 2021-04-26 12:29:10 +00:00
Keanu Timmermans a44798edb1
Merge pull request #39 from Hades785/master 2021-04-26 13:24:35 +02:00
フズキ fe9a4f9d7e
Added command to set default VC name
Co-authored-by: Lexi Sother <lexisoth2005@gmail.com>
2021-04-26 13:16:37 +02:00
Keanu Timmermans f0a342faec
Move emote registry dump to "public" directory. 2021-04-22 11:45:44 +02:00
WatDuhHekBro 8c6ffb963e
Merge branch 'master' of https://github.com/keanuplayz/TravBot-v3 2021-04-21 12:39:38 -05:00
WatDuhHekBro 548969daba
Added non-pinging mention to whois 2021-04-21 12:38:52 -05:00
WatDuhHekBro 80fa59a433
Merge pull request #38 from EL20202/master
Added a few names to whois
2021-04-20 23:25:16 -05:00
WatDuhHekBro 81f6779068
Merge pull request #37 from MrHappyHam/master
Update whois.ts
2021-04-20 23:25:08 -05:00
EL2020 d2a558dff4 Added a few names to whois 2021-04-20 23:11:07 -04:00
MrHappyHam a06ec300f7
Update whois.ts 2021-04-20 20:40:21 -06:00
WatDuhHekBro 576e55fbdf
Fixed info guild short-circuiting 2021-04-20 10:50:57 -05:00
Keanu Timmermans c4b077757f
Updated dependencies. 2021-04-19 18:50:31 +02:00
WatDuhHekBro c8dadad450
Removed typescript badge and updated workflows 2021-04-18 15:26:41 -05:00
Keanu Timmermans cc50aea4de
Merge pull request #36 from keanuplayz/typescript
Merge typescript into master and set master as the default branch
2021-04-18 22:15:06 +02:00
67 changed files with 5944 additions and 9487 deletions

View File

@ -2,7 +2,7 @@ name: CodeQL + Docker Image
on:
push:
branches:
- typescript
- master
jobs:
analyze:
@ -59,3 +59,13 @@ jobs:
docker buildx build \
--tag keanucode/travbot-v3:latest \
--platform linux/amd64,linux/arm/v7,linux/arm64 --push .
- name: Sync From Docker Hub to GitHub
uses: onichandame/docker-registry-sync-action@master
with:
source_repository: docker.io/keanucode/travbot-v3:latest
source_username: ${{ secrets.DOCKER_USERNAME }}
source_password: ${{ secrets.DOCKER_PASSWORD }}
target_repository: docker.pkg.github.com/keanuplayz/travbot-v3/travbot-v3:latest
target_username: ${{ secrets.GH_USERNAME }}
target_password: ${{ secrets.GH_TOKEN }}

4
.gitignore vendored
View File

@ -1,6 +1,8 @@
# Specific to this repository
dist/
data/
data/*
data/public/emote-registry.json
!data/public/
tmp/
test*
!test/

View File

@ -1,3 +1,14 @@
# 3.2.3
- Fixed `info guild` bug on servers without an icon
- Added non-pinging mention to `whois`
- Moved location of emote registry
- Added command to set default VC name
- Added pat shop item
- Reworked `say` command making use of webhooks to replicate ac2pic's Nitroless idea (Part 1)
- Fixed `poll` duration
- Fixed `eco pay` user searching
- Fixed `admin set welcome type none`
# 3.2.2
- Moved command handler code to [Onion Lasers](https://github.com/WatDuhHekBro/OnionLasers)
- Reworked `poll`

View File

@ -51,8 +51,8 @@
# https://github.com/Automattic/node-canvas/issues/866#issuecomment-330001221
# Took 7m 29s
FROM node:10.16.0-alpine
FROM mhart/alpine-node:8.5.0
FROM node:current-alpine
FROM mhart/alpine-node:latest
RUN apk add --no-cache \
build-base \
@ -71,4 +71,4 @@ RUN npm i
RUN npm run build
CMD ["npm", "start"]
CMD ["npm", "start"]

View File

@ -1,6 +1,7 @@
# TravBot-v3
<p align="center">
<!-- The image could potentially be a hyperlink to invite TravBot. -->
<img src="https://i.imgur.com/l2E2Tfi.png"/>
</p>
<p align="center">
@ -10,9 +11,6 @@
<a href="https://github.com/keanuplayz/TravBot-v3/blob/master/CHANGELOG.md">
<img src="https://img.shields.io/github/package-json/v/keanuplayz/travbot-v3" alt="Version">
</a>
<a href="https://github.com/keanuplayz/TravBot-v3/blob/typescript/CHANGELOG.md">
<img src="https://img.shields.io/github/package-json/v/keanuplayz/travbot-v3/typescript" alt="Version (Dev)">
</a>
<a href="https://discord.js.org/">
<img src="https://img.shields.io/github/package-json/dependency-version/keanuplayz/travbot-v3/discord.js" alt="Discord.js Version">
</a>

View File

@ -4,6 +4,7 @@
- [Setting up the development environment](#setting-up-the-development-environment)
- [Adding a new command](#adding-a-new-command)
- [Adding a new non-command feature](#adding-a-new-non-command-feature)
- [Notes](#notes)
# Introduction
@ -16,6 +17,8 @@ This is a brief overview that'll describe where and how to add new features to T
*Note: Make sure to avoid using `npm run build`! This will remove all your dev dependencies (in order to reduce space used). Instead, use `npm run once` to compile and build in non-dev mode.*
*Note: `npm run dev` will automatically delete any leftover files, preventing errors that might occur because of it. However, sometimes you'd like to test stuff without that build step. To do that, run `npm run dev-fast`. You'll then have to manually delete the `dist` folder to clear any old files.*
*Note: If you update one of the APIs or utility functions, make sure to update the [documentation](Documentation.md).*
# Adding a new command
@ -68,3 +71,15 @@ export const client = new Client();
import "./modules/myModule";
```
# Notes
## Logger
All calls to `console.error`, `console.warn`, `console.log`, and `console.debug` will also add to an in-memory log you can download, noted by verbosity levels `Error`, `Warn`, `Info`, and `Verbose` respectively.
- `Error`: This indicates stuff that could or is breaking at least some functionality of the bot.
- `Warn`: This indicates stuff that should probably be fixed but isn't going to break the bot.
- `Info`: Used for general events such as joining/leaving guilds for example, but try not to go overboard on logging everything.
- `Verbose`: This is used as a sort of separator for logging potentially error-prone events so that if an error occurs, you can find the context that error originated from.
- In order to make reading the logs easier, context should be provided with each call. For example, if a call is being made from the storage module, you'd do something like `console.log("[storage]", "the message")`.
- If a message is clear enough as to what the context was though, it's probably unnecessary to include this prefix. However, you should definitely attach context prefixes to error objects, who knows where those might originate.

13116
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,51 +1,53 @@
{
"name": "travebot",
"version": "3.2.2",
"version": "3.2.3",
"description": "TravBot Discord bot.",
"main": "dist/index.js",
"scripts": {
"build": "tsc --project tsconfig.prod.json && npm prune --production",
"build": "rimraf dist && tsc --project tsconfig.prod.json && npm prune --production",
"start": "node .",
"once": "tsc && npm start",
"dev": "tsc-watch --onSuccess \"node . dev\"",
"dev": "tsc-watch --onSuccess \"npm run dev-instance\"",
"dev-fast": "tsc-watch --onSuccess \"node . dev\"",
"dev-instance": "rimraf dist && tsc && node . dev",
"test": "jest",
"format": "prettier --write **/*",
"postinstall": "husky install"
},
"dependencies": {
"canvas": "^2.7.0",
"chalk": "^4.1.0",
"discord.js": "^12.5.1",
"discord.js-lavalink-lib": "^0.1.8",
"figlet": "^1.5.0",
"glob": "^7.1.6",
"inquirer": "^7.3.3",
"mathjs": "^9.3.0",
"canvas": "^2.8.0",
"chalk": "^4.1.2",
"discord.js": "^13.3.0",
"figlet": "^1.5.2",
"glob": "^7.2.0",
"inquirer": "^8.2.0",
"moment": "^2.29.1",
"ms": "^2.1.3",
"onion-lasers": "^1.1.0",
"node-wolfram-alpha": "^1.2.5",
"onion-lasers": "npm:onion-lasers-v13@^2.2.1",
"pet-pet-gif": "^1.0.9",
"relevant-urban": "^2.0.0",
"translate-google": "^1.4.3",
"weather-js": "^2.0.0"
},
"devDependencies": {
"@types/figlet": "^1.5.0",
"@types/glob": "^7.1.3",
"@types/inquirer": "^6.5.0",
"@types/jest": "^26.0.20",
"@types/mathjs": "^6.0.11",
"@types/figlet": "^1.5.4",
"@types/glob": "^7.2.0",
"@types/inquirer": "^8.1.3",
"@types/jest": "^27.0.2",
"@types/mathjs": "^9.4.1",
"@types/ms": "^0.7.31",
"@types/node": "^14.14.20",
"@types/ws": "^7.4.0",
"husky": "^5.0.6",
"jest": "^26.6.3",
"prettier": "2.1.2",
"ts-jest": "^26.4.4",
"tsc-watch": "^4.2.9",
"typescript": "^3.9.7"
"@types/node": "^16.11.6",
"@types/ws": "^8.2.0",
"husky": "^7.0.4",
"jest": "^27.3.1",
"prettier": "2.4.1",
"rimraf": "^3.0.2",
"ts-jest": "^27.0.7",
"tsc-watch": "^4.5.0",
"typescript": "^4.4.4"
},
"optionalDependencies": {
"fsevents": "^2.1.2"
"fsevents": "^2.3.2"
},
"author": "Keanu Timmermans",
"license": "MIT",

14
repl.js Normal file
View File

@ -0,0 +1,14 @@
const discord = require("discord.js");
let bot = new discord.Client({
intents: [
discord.Intents.FLAGS.GUILDS,
discord.Intents.FLAGS.GUILD_MEMBERS,
discord.Intents.FLAGS.GUILD_EMOJIS_AND_STICKERS,
discord.Intents.FLAGS.GUILD_VOICE_STATES,
discord.Intents.FLAGS.GUILD_PRESENCES,
discord.Intents.FLAGS.GUILD_MESSAGES,
discord.Intents.FLAGS.GUILD_MESSAGE_REACTIONS,
discord.Intents.FLAGS.DIRECT_MESSAGES
]
});
bot.login(require("./data/config.json").token);

View File

@ -1,5 +1,6 @@
import {NamedCommand, RestCommand} from "onion-lasers";
import figlet from "figlet";
import {Util} from "discord.js";
export default new NamedCommand({
description: "Generates a figlet of your input.",
@ -7,12 +8,11 @@ export default new NamedCommand({
any: new RestCommand({
async run({send, combined}) {
return send(
figlet.textSync(combined, {
horizontalLayout: "full"
}),
{
code: true
}
`\`\`\`\n${Util.cleanCodeBlockContent(
figlet.textSync(combined, {
horizontalLayout: "full"
})
)}\n\`\`\``
);
}
})

View File

@ -3,12 +3,12 @@ import {NamedCommand} from "onion-lasers";
export default new NamedCommand({
description: "Insult TravBot! >:D",
async run({send, channel, author}) {
channel.startTyping();
channel.sendTyping();
setTimeout(() => {
send(
`${author} What the fuck did you just fucking say about me, you little bitch? I'll have you know I graduated top of my class in the Navy Seals, and I've been involved in numerous secret raids on Al-Quaeda, and I have over 300 confirmed kills. I am trained in gorilla warfare and I'm the top sniper in the entire US armed forces. You are nothing to me but just another target. I will wipe you the fuck out with precision the likes of which has never been seen before on this Earth, mark my fucking words. You think you can get away with saying that shit to me over the Internet? Think again, fucker. As we speak I am contacting my secret network of spies across the USA and your IP is being traced right now so you better prepare for the storm, maggot. The storm that wipes out the pathetic little thing you call your life. You're fucking dead, kid. I can be anywhere, anytime, and I can kill you in over seven hundred ways, and that's just with my bare hands. Not only am I extensively trained in unarmed combat, but I have access to the entire arsenal of the United States Marine Corps and I will use it to its full extent to wipe your miserable ass off the face of the continent, you little shit. If only you could have known what unholy retribution your little "clever" comment was about to bring down upon you, maybe you would have held your fucking tongue. But you couldn't, you didn't, and now you're paying the price, you goddamn idiot. I will shit fury all over you and you will drown in it. You're fucking dead, kiddo.`
);
channel.stopTyping();
channel.sendTyping();
}, 60000);
}
});

View File

@ -5,6 +5,7 @@ export default new NamedCommand({
channelType: CHANNEL_TYPE.GUILD,
async run({send, guild}) {
const member = guild!.members.cache.random();
send(`I love ${member.nickname ?? member.user.username}!`);
if (!member) return send("For some reason, an error occurred fetching a member.");
return send(`I love ${member.nickname ?? member.user.username}!`);
}
});

View File

@ -39,9 +39,15 @@ export const BetCommand = new NamedCommand({
// handle invalid amount
if (amount <= 0) return send("You must bet at least one Mon!");
else if (sender.money < amount)
return send("You don't have enough Mons for that.", getMoneyEmbed(author));
return send({
content: "You don't have enough Mons for that.",
embeds: [getMoneyEmbed(author, true)]
});
else if (receiver.money < amount)
return send("They don't have enough Mons for that.", getMoneyEmbed(target));
return send({
content: "They don't have enough Mons for that.",
embeds: [getMoneyEmbed(target, true)]
});
return send("How long until the bet ends?");
} else return;
@ -67,9 +73,15 @@ export const BetCommand = new NamedCommand({
// handle invalid amount
if (amount <= 0) return send("You must bet at least one Mon!");
else if (sender.money < amount)
return send("You don't have enough Mons for that.", getMoneyEmbed(author));
return send({
content: "You don't have enough Mons for that.",
embeds: [getMoneyEmbed(author, true)]
});
else if (receiver.money < amount)
return send("They don't have enough Mons for that.", getMoneyEmbed(target));
return send({
content: "They don't have enough Mons for that.",
embeds: [getMoneyEmbed(target, true)]
});
// handle invalid duration
if (duration <= 0) return send("Invalid bet duration");
@ -107,7 +119,7 @@ export const BetCommand = new NamedCommand({
);
// Wait for the duration of the bet.
return client.setTimeout(async () => {
return setTimeout(async () => {
// In debug mode, saving the storage will break the references, so you have to redeclare sender and receiver for it to actually save.
const sender = Storage.getUser(author.id);
const receiver = Storage.getUser(target.id);

View File

@ -16,22 +16,31 @@ export const DailyCommand = new NamedCommand({
user.lastReceived = now;
Storage.save();
send({
embed: {
title: "Daily Reward",
description: "You received 1 Mon!",
color: ECO_EMBED_COLOR
}
embeds: [
{
title: "Daily Reward",
description: "You received 1 Mon!",
color: ECO_EMBED_COLOR,
fields: [
{
name: "New balance:",
value: pluralise(user.money, "Mon", "s")
}
]
}
]
});
} else
send({
embed: {
title: "Daily Reward",
description: `It's too soon to pick up your daily Mons. You have about ${(
(user.lastReceived + 79200000 - now) /
3600000
).toFixed(1)} hours to go.`,
color: ECO_EMBED_COLOR
}
embeds: [
{
title: "Daily Reward",
description: `It's too soon to pick up your daily Mons. Try again at <t:${Math.floor(
(user.lastReceived + 79200000) / 1000
)}:t>.`,
color: ECO_EMBED_COLOR
}
]
});
}
}
@ -50,25 +59,27 @@ export const GuildCommand = new NamedCommand({
}
send({
embed: {
title: `The Bank of ${guild!.name}`,
color: ECO_EMBED_COLOR,
fields: [
{
name: "Accounts",
value: Object.keys(users).length,
inline: true
},
{
name: "Total Mons",
value: totalAmount,
inline: true
embeds: [
{
title: `The Bank of ${guild!.name}`,
color: ECO_EMBED_COLOR,
fields: [
{
name: "Accounts",
value: Object.keys(users).length.toString(),
inline: true
},
{
name: "Total Mons",
value: totalAmount.toString(),
inline: true
}
],
thumbnail: {
url: guild?.iconURL() ?? ""
}
],
thumbnail: {
url: guild?.iconURL() ?? ""
}
}
]
});
}
}
@ -95,14 +106,16 @@ export const LeaderboardCommand = new NamedCommand({
}
send({
embed: {
title: "Top 10 Richest Players",
color: ECO_EMBED_COLOR,
fields: fields,
thumbnail: {
url: guild?.iconURL() ?? ""
embeds: [
{
title: "Top 10 Richest Players",
color: ECO_EMBED_COLOR,
fields: fields,
thumbnail: {
url: guild?.iconURL() ?? ""
}
}
}
]
});
}
}
@ -125,7 +138,10 @@ export const PayCommand = new NamedCommand({
if (amount <= 0) return send("You must send at least one Mon!");
else if (sender.money < amount)
return send("You don't have enough Mons for that.", getMoneyEmbed(author));
return send({
content: "You don't have enough Mons for that.",
embeds: [getMoneyEmbed(author, true)]
});
else if (target.id === author.id) return send("You can't send Mons to yourself!");
else if (target.bot && !IS_DEV_MODE) return send("You can't send Mons to a bot!");
@ -141,7 +157,7 @@ export const PayCommand = new NamedCommand({
run: "You must use the format `eco pay <user> <amount>`!"
}),
any: new RestCommand({
async run({send, args, author, channel, guild, combined}) {
async run({send, args, author, channel, guild}) {
if (isAuthorized(guild, channel)) {
const last = args.pop();
@ -152,27 +168,34 @@ export const PayCommand = new NamedCommand({
if (amount <= 0) return send("You must send at least one Mon!");
else if (sender.money < amount)
return send("You don't have enough Mons to do that!", getMoneyEmbed(author));
return send({
content: "You don't have enough Mons to do that!",
embeds: [getMoneyEmbed(author, true)]
});
else if (!guild)
return send("You have to use this in a server if you want to send Mons with a username!");
const user = await getUserByNickname(combined, guild);
// Do NOT use the combined parameter here, it won't account for args.pop() at the start.
const user = await getUserByNickname(args.join(" "), guild);
if (typeof user === "string") return send(user);
else if (user.id === author.id) return send("You can't send Mons to yourself!");
else if (user.bot && !IS_DEV_MODE) return send("You can't send Mons to a bot!");
const confirmed = await confirm(
await send(`Are you sure you want to send ${pluralise(amount, "Mon", "s")} to this person?`, {
embed: {
color: ECO_EMBED_COLOR,
author: {
name: user.tag,
icon_url: user.displayAvatarURL({
format: "png",
dynamic: true
})
await send({
content: `Are you sure you want to send ${pluralise(amount, "Mon", "s")} to this person?`,
embeds: [
{
color: ECO_EMBED_COLOR,
author: {
name: user.tag,
icon_url: user.displayAvatarURL({
format: "png",
dynamic: true
})
}
}
}
]
}),
author.id
);

View File

@ -21,7 +21,7 @@ export const MondayCommand = new NamedCommand({
user.money++;
user.lastMonday = now.getTime();
Storage.save();
send("It is **Mon**day, my dudes.", getMoneyEmbed(author));
send({content: "It is **Mon**day, my dudes.", embeds: [getMoneyEmbed(author, true)]});
} else send("You've already claimed your **Mon**day reward for this week.");
} else {
const weekdayName = WEEKDAY[weekday];
@ -47,7 +47,7 @@ export const AwardCommand = new NamedCommand({
const user = Storage.getUser(target.id);
user.money++;
Storage.save();
send(`1 Mon given to ${target.username}.`, getMoneyEmbed(target));
send({content: `1 Mon given to ${target.username}.`, embeds: [getMoneyEmbed(target, true)]});
} else {
send("This command is restricted to the bean.");
}
@ -62,7 +62,10 @@ export const AwardCommand = new NamedCommand({
const user = Storage.getUser(target.id);
user.money += amount;
Storage.save();
send(`${pluralise(amount, "Mon", "s")} given to ${target.username}.`, getMoneyEmbed(target));
send({
content: `${pluralise(amount, "Mon", "s")} given to ${target.username}.`,
embeds: [getMoneyEmbed(target, true)]
});
} else {
send("You need to enter a number greater than 0.");
}

View File

@ -11,18 +11,18 @@ export interface ShopItem {
export const ShopItems: ShopItem[] = [
{
cost: 1,
cost: 3,
title: "Hug",
description: "Hug Monika.",
description: "Hug Monica.",
usage: "hug",
run(message, cost) {
message.channel.send(`Transaction of ${cost} Mon completed successfully. <@394808963356688394>`);
message.channel.send(`Transaction of ${cost} Mons completed successfully. <@394808963356688394>`);
}
},
{
cost: 2,
cost: 5,
title: "Handholding",
description: "Hold Monika's hand.",
description: "Hold Monica's hand.",
usage: "handhold",
run(message, cost) {
message.channel.send(`Transaction of ${cost} Mons completed successfully. <@394808963356688394>`);
@ -31,19 +31,29 @@ export const ShopItems: ShopItem[] = [
{
cost: 1,
title: "Cute",
description: "Calls Monika cute.",
description: "Calls Monica cute.",
usage: "cute",
run(message) {
message.channel.send("<:MoniCheeseBlushRed:637513137083383826>");
}
},
{
cost: 3,
cost: 2,
title: "Pat",
description: "Pat Monica's head.",
usage: "pat",
run(message, cost) {
message.channel.send(`Transaction of ${cost} Mons completed successfully. <@394808963356688394>`);
}
},
{
cost: 15,
title: "Laser Bridge",
description: "Buys what is technically a laser bridge.",
usage: "laser bridge",
run(message) {
message.channel.send(random(lines), {
message.channel.send({
content: random(lines),
files: [
{
attachment:

View File

@ -3,13 +3,13 @@ import {pluralise, split} from "../../../lib";
import {Storage, getPrefix} from "../../../structures";
import {isAuthorized, ECO_EMBED_COLOR} from "./eco-utils";
import {ShopItems, ShopItem} from "./eco-shop-items";
import {EmbedField} from "discord.js";
import {EmbedField, MessageEmbedOptions} from "discord.js";
export const ShopCommand = new NamedCommand({
description: "Displays the list of items you can buy in the shop.",
async run({send, guild, channel, author}) {
if (isAuthorized(guild, channel)) {
function getShopEmbed(selection: ShopItem[], title: string) {
function getShopEmbed(selection: ShopItem[], title: string): MessageEmbedOptions {
const fields: EmbedField[] = [];
for (const item of selection)
@ -20,13 +20,11 @@ export const ShopCommand = new NamedCommand({
});
return {
embed: {
color: ECO_EMBED_COLOR,
title: title,
fields: fields,
footer: {
text: "Mon Shop | TravBot Services"
}
color: ECO_EMBED_COLOR,
title: title,
fields: fields,
footer: {
text: "Mon Shop | TravBot Services"
}
};
}
@ -35,10 +33,14 @@ export const ShopCommand = new NamedCommand({
const pageAmount = shopPages.length;
paginate(send, author.id, pageAmount, (page, hasMultiplePages) => {
return getShopEmbed(
shopPages[page],
hasMultiplePages ? `Shop (Page ${page + 1} of ${pageAmount})` : "Shop"
);
return {
embeds: [
getShopEmbed(
shopPages[page],
hasMultiplePages ? `Shop (Page ${page + 1} of ${pageAmount})` : "Shop"
)
]
};
});
}
}

View File

@ -1,14 +1,15 @@
import {pluralise} from "../../../lib";
import {Storage} from "../../../structures";
import {User, Guild, TextChannel, DMChannel, NewsChannel} from "discord.js";
import {User, Guild, TextChannel, DMChannel, NewsChannel, Channel, TextBasedChannel} from "discord.js";
export const ECO_EMBED_COLOR = 0xf1c40f;
export function getMoneyEmbed(user: User): object {
export function getMoneyEmbed(user: User, inline: boolean = false): object {
const profile = Storage.getUser(user.id);
console.log(profile);
return {
embed: {
if (inline) {
return {
color: ECO_EMBED_COLOR,
author: {
name: user.username,
@ -23,48 +24,83 @@ export function getMoneyEmbed(user: User): object {
value: pluralise(profile.money, "Mon", "s")
}
]
}
};
};
} else {
return {
embeds: [
{
color: ECO_EMBED_COLOR,
author: {
name: user.username,
icon_url: user.displayAvatarURL({
format: "png",
dynamic: true
})
},
fields: [
{
name: "Balance",
value: pluralise(profile.money, "Mon", "s")
}
]
}
]
};
}
}
export function getSendEmbed(sender: User, receiver: User, amount: number): object {
return {
embed: {
color: ECO_EMBED_COLOR,
author: {
name: sender.username,
icon_url: sender.displayAvatarURL({
format: "png",
dynamic: true
})
},
title: "Transaction",
description: `${sender.toString()} has sent ${pluralise(amount, "Mon", "s")} to ${receiver.toString()}!`,
fields: [
{
name: `Sender: ${sender.tag}`,
value: pluralise(Storage.getUser(sender.id).money, "Mon", "s")
embeds: [
{
color: ECO_EMBED_COLOR,
author: {
name: sender.username,
icon_url: sender.displayAvatarURL({
format: "png",
dynamic: true
})
},
{
name: `Receiver: ${receiver.tag}`,
value: pluralise(Storage.getUser(receiver.id).money, "Mon", "s")
title: "Transaction",
description: `${sender.toString()} has sent ${pluralise(
amount,
"Mon",
"s"
)} to ${receiver.toString()}!`,
fields: [
{
name: `Sender: ${sender.tag}`,
value: pluralise(Storage.getUser(sender.id).money, "Mon", "s")
},
{
name: `Receiver: ${receiver.tag}`,
value: pluralise(Storage.getUser(receiver.id).money, "Mon", "s")
}
],
footer: {
text: receiver.username,
icon_url: receiver.displayAvatarURL({
format: "png",
dynamic: true
})
}
],
footer: {
text: receiver.username,
icon_url: receiver.displayAvatarURL({
format: "png",
dynamic: true
})
}
}
]
};
}
export function isAuthorized(guild: Guild | null, channel: TextChannel | DMChannel | NewsChannel): boolean {
if ((guild?.id === "637512823676600330" && channel?.id === "669464416420364288") || IS_DEV_MODE) return true;
else {
channel.send("Sorry, this command can only be used in Monika's emote server. (#mon-stocks)");
export function isAuthorized(guild: Guild | null, channel: TextBasedChannel): boolean {
if (IS_DEV_MODE) {
return true;
}
if (guild?.id !== "637512823676600330") {
channel.send("Sorry, this command can only be used in Monika's emote server.");
return false;
} else if (channel?.id !== "669464416420364288") {
channel.send("Sorry, this command can only be used in <#669464416420364288>.");
return false;
} else {
return true;
}
}

View File

@ -1,52 +0,0 @@
import {URL} from "url";
import {Command, NamedCommand} from "onion-lasers";
import {getContent} from "../../lib";
const endpoints: {sfw: {[key: string]: string}} = {
sfw: {
tickle: "/img/tickle",
slap: "/img/slap",
poke: "/img/poke",
pat: "/img/pat",
neko: "/img/neko",
meow: "/img/meow",
lizard: "/img/lizard",
kiss: "/img/kiss",
hug: "/img/hug",
foxGirl: "/img/fox_girl",
feed: "/img/feed",
cuddle: "/img/cuddle",
why: "/why",
catText: "/cat",
fact: "/fact",
nekoGif: "/img/ngif",
kemonomimi: "/img/kemonomimi",
holo: "/img/holo",
smug: "/img/smug",
baka: "/img/baka",
woof: "/img/woof",
spoiler: "/spoiler",
wallpaper: "/img/wallpaper",
goose: "/img/goose",
gecg: "/img/gecg",
avatar: "/img/avatar",
waifu: "/img/waifu"
}
};
export default new NamedCommand({
description: "Provides you with a random image with the selected argument.",
async run({send}) {
send(`Please provide an image type. Available arguments:\n\`[${Object.keys(endpoints.sfw).join(", ")}]\`.`);
},
any: new Command({
description: "Image type to send.",
async run({send, args}) {
const arg = args[0];
if (!(arg in endpoints.sfw)) return send("Couldn't find that endpoint!");
let url = new URL(`https://nekos.life/api/v2${endpoints.sfw[arg]}`);
const content = await getContent(url.toString());
return send(content.url);
}
})
});

View File

@ -1,15 +1,21 @@
import {NamedCommand, RestCommand} from "onion-lasers";
import {getContent} from "../../lib";
import {URL} from "url";
import {random} from "../../lib";
export default new NamedCommand({
description: "OwO-ifies the input.",
run: "You need to specify some text to owoify.",
any: new RestCommand({
async run({send, combined}) {
let url = new URL(`https://nekos.life/api/v2/owoify?text=${combined}`);
const content = (await getContent(url.toString())) as any; // Apparently, the object in question is {owo: string}.
send(content.owo);
// Copied from <https://github.com/Nekos-life/neko-website/blob/78b2532de2d91375d6de45e4446fc766ba169472/app.py#L78-L87>.
const faces = ["owo", "UwU", ">w<", "^w^"];
const owoified = combined
.replace(/[rl]/g, "w")
.replace(/[RL]/g, "W")
.replace(/ove/g, "uv")
.replace(/n/g, "ny")
.replace(/N/g, "NY")
.replace(/\!/g, ` ${random(faces)} `);
send(owoified);
}
})
});

43
src/commands/fun/pat.ts Normal file
View File

@ -0,0 +1,43 @@
import {MessageAttachment, User} from "discord.js";
import {NamedCommand, Command, RestCommand, getUserByNickname} from "onion-lasers";
import petPetGif from "pet-pet-gif";
export default new NamedCommand({
description: "Generates a pat GIF of the provided attachment image OR the avatar of the mentioned user.",
usage: "(@user)",
async run({message, send, author}) {
if (message.attachments.size !== 0) {
const attachment = message.attachments.first()!;
const gif = await petPetGif(attachment.url);
const file = new MessageAttachment(gif, "pat.gif");
send({files: [file]});
} else {
const gif = await petPetGif(author.displayAvatarURL({format: "png"}));
const file = new MessageAttachment(gif, "pat.gif");
send({files: [file]});
}
},
id: "user",
user: new Command({
description: "User to generate a GIF of.",
async run({send, args}) {
const user: User = args[0];
const gif = await petPetGif(user.displayAvatarURL({format: "png"}));
const file = new MessageAttachment(gif, "pat.gif");
send({files: [file]});
}
}),
any: new RestCommand({
description: "User to generate a GIF of.",
async run({send, combined, guild}) {
const user = await getUserByNickname(combined, guild);
if (typeof user === "string") send(user);
else {
const gif = await petPetGif(user.displayAvatarURL({format: "png"}));
const file = new MessageAttachment(gif, "pat.gif");
send({files: [file]});
}
}
})
});

View File

@ -12,7 +12,7 @@ export default new NamedCommand({
any: new RestCommand({
description: "Question for the poll.",
async run({send, message, author, args, combined}) {
execPoll(send, message, author, combined, args[0]);
execPoll(send, message, author, combined, args[0] * 1000);
}
})
}),
@ -33,26 +33,35 @@ async function execPoll(send: SendFunction, message: Message, user: User, questi
dynamic: true,
size: 2048
}) || user.defaultAvatarURL;
const msg = await send(
new MessageEmbed()
.setAuthor(`Poll created by ${message.author.username}`, icon)
.setColor(0xffffff)
.setFooter("React to vote.")
.setDescription(question)
);
const msg = await send({
embeds: [
new MessageEmbed()
.setAuthor(`Poll created by ${message.author.username}`, icon)
.setColor(0xffffff)
.setFooter("React to vote.")
.setDescription(question)
]
});
const results = await poll(msg, [AGREE, DISAGREE], duration);
send(
new MessageEmbed()
.setAuthor(`The results of ${message.author.username}'s poll:`, icon)
.setTitle(question)
.setDescription(
`${AGREE} ${pluralise(
results[AGREE],
"",
"people who agree",
"person who agrees"
)}\n${DISAGREE} ${pluralise(results[DISAGREE], "", "people who disagree", "person who disagrees")}`
)
);
send({
embeds: [
new MessageEmbed()
.setAuthor(`The results of ${message.author.username}'s poll:`, icon)
.setTitle(question)
.setDescription(
`${AGREE} ${pluralise(
results[AGREE],
"",
"people who agree",
"person who agrees"
)}\n${DISAGREE} ${pluralise(
results[DISAGREE],
"",
"people who disagree",
"person who disagrees"
)}`
)
]
});
msg.delete();
}

View File

@ -6,15 +6,17 @@ export default new NamedCommand({
usage: "[number from 1 to 9]",
async run({send}) {
send({
embed: {
title: "Ravioli ravioli...",
image: {
url: `https://raw.githubusercontent.com/keanuplayz/TravBot/master/assets/ravi${Random.int(
1,
10
)}.png`
embeds: [
{
title: "Ravioli ravioli...",
image: {
url: `https://raw.githubusercontent.com/keanuplayz/TravBot/master/assets/ravi${Random.int(
1,
10
)}.png`
}
}
}
]
});
},
number: new Command({
@ -23,12 +25,14 @@ export default new NamedCommand({
if (arg >= 1 && arg <= 9) {
send({
embed: {
title: "Ravioli ravioli...",
image: {
url: `https://raw.githubusercontent.com/keanuplayz/TravBot/master/assets/ravi${arg}.png`
embeds: [
{
title: "Ravioli ravioli...",
image: {
url: `https://raw.githubusercontent.com/keanuplayz/TravBot/master/assets/ravi${arg}.png`
}
}
}
]
});
} else {
send("Please provide a number between 1 and 9.");

View File

@ -36,25 +36,25 @@ export default new NamedCommand({
usage: "([text])",
async run({send, author}) {
const msg = await send(transform(phrase));
msg.createReactionCollector(
(reaction, user) => {
msg.createReactionCollector({
filter: (reaction, user) => {
if (user.id === author.id && reaction.emoji.name === "❌") msg.delete();
return false;
},
{time: 60000}
);
time: 60000
});
},
any: new RestCommand({
async run({send, author, combined}) {
phrase = combined;
const msg = await send(transform(phrase));
msg.createReactionCollector(
(reaction, user) => {
msg.createReactionCollector({
filter: (reaction, user) => {
if (user.id === author.id && reaction.emoji.name === "❌") msg.delete();
return false;
},
{time: 60000}
);
time: 60000
});
}
})
});

View File

@ -21,7 +21,7 @@ export default new NamedCommand({
if (res.tags && res.tags.length > 0 && res.tags.join(" ").length < 1024)
embed.addField("Tags", res.tags.join(", "), true);
send(embed);
send({embeds: [embed]});
})
.catch(() => {
send("Sorry, that word was not found.");

View File

@ -29,7 +29,7 @@ export default new NamedCommand({
.addField("Winds", current.winddisplay, true)
.addField("Humidity", `${current.humidity}%`, true);
return send({
embed
embeds: [embed]
});
}
);

View File

@ -39,8 +39,15 @@ const registry: {[id: string]: string} = {
"389178357302034442": "In his dreams, he is the star. its him. <:itsMe:808174425253871657>",
"606395763404046349": "Me.",
"237359961842253835": "Good question.",
"320680803124248576":
"The resident meat lump and certified non-weeb. Inquire directly for details and keep that honey glaze to yourself.",
"689538764950994990":
"The slayer of memes, a vigilante of the voidborn, and the self-proclaimed prophet of Xereptheí.\n> And thus, I shall remain dormant once more. For when judgement day arrives, those whose names are sung shall pierce the heavens."
"The slayer of memes, a vigilante of the voidborn, and the self-proclaimed prophet of Xereptheí.\n> And thus, I shall remain dormant once more. For when judgement day arrives, those whose names are sung shall pierce the heavens.",
"273599683132260354":
"Does memes, art crimes, programming, programming accessories, and is accessory to meme, programming, and art crimes. Also, tiny potato.",
"156532969119547393": "Someone pretty cool for a bird made out of fire.",
"388522171393245184": "The bat. Likes pats. If mean, apply whacks. 🗞️",
"138840343855497216": "your face is a whois entry"
};
export default new NamedCommand({
@ -50,7 +57,7 @@ export default new NamedCommand({
const id = author.id;
if (id in registry) {
send(registry[id]);
send({content: `${author} ${registry[id]}`, allowedMentions: {parse: []}});
} else {
send("You haven't been added to the registry yet!");
}
@ -62,9 +69,9 @@ export default new NamedCommand({
const id = user.id;
if (id in registry) {
send(registry[id]);
send({content: `${user} ${registry[id]}`, allowedMentions: {parse: []}});
} else {
send(`\`${user.tag}\` hasn't been added to the registry yet!`);
send({content: `${user} hasn't been added to the registry yet!`, allowedMentions: {parse: []}});
}
}
}),
@ -74,9 +81,9 @@ export default new NamedCommand({
if (typeof user !== "string") {
if (user.id in registry) {
send(registry[user.id]);
send({content: `${user} ${registry[user.id]}`, allowedMentions: {parse: []}});
} else {
send(`\`${user.tag}\` hasn't been added to the registry yet!`);
send({content: `${user} hasn't been added to the registry yet!`, allowedMentions: {parse: []}});
}
} else {
send(user);

View File

@ -1,7 +1,6 @@
import {Command, NamedCommand, getPermissionLevel, getPermissionName, CHANNEL_TYPE, RestCommand} from "onion-lasers";
import {clean} from "../../lib";
import {Config, Storage} from "../../structures";
import {Permissions, TextChannel, User, Role, Channel} from "discord.js";
import {Permissions, TextChannel, User, Role, Channel, Util} from "discord.js";
import {logs} from "../../modules/globals";
function getLogBuffer(type: string) {
@ -60,6 +59,54 @@ export default new NamedCommand({
})
})
}),
messageembeds: new NamedCommand({
description: "Enable or disable sending message previews.",
usage: "enable/disable",
run: "Please specify `enable` or `disable`.",
subcommands: {
true: new NamedCommand({
description: "Enable sending of message previews.",
async run({send, guild}) {
Storage.getGuild(guild!.id).messageEmbeds = true;
Storage.save();
send("Sending of message previews has been enabled.");
}
}),
false: new NamedCommand({
description: "Disable sending of message previews.",
async run({send, guild}) {
Storage.getGuild(guild!.id).messageEmbeds = false;
Storage.save();
send("Sending of message previews has been disabled.");
}
})
}
}),
autoroles: new NamedCommand({
description: "Configure your server's autoroles.",
usage: "<roles...>",
async run({send, guild}) {
Storage.getGuild(guild!.id).autoRoles = [];
Storage.save();
send("Reset this server's autoroles.");
},
id: "role",
any: new RestCommand({
description: "The roles to set as autoroles.",
async run({send, guild, args}) {
const guildd = Storage.getGuild(guild!.id);
for (const role of args) {
if (!role.toString().match(/^<@&(\d{17,})>$/)) {
return send("Not all arguments are a role mention!");
}
const id = role.toString().match(/^<@&(\d{17,})>$/)![1];
guildd.autoRoles!.push(id);
}
Storage.save();
return send("Saved.");
}
})
}),
welcome: new NamedCommand({
description: "Configure your server's welcome settings for the bot.",
usage: "type/channel <...>",
@ -89,6 +136,13 @@ export default new NamedCommand({
Storage.save();
send("Set this server's welcome type to `graphical`.");
}
}),
none: new NamedCommand({
async run({send, guild}) {
Storage.getGuild(guild!.id).welcomeType = "none";
Storage.save();
send("Set this server's welcome type to `none`.");
}
})
}
}),
@ -204,6 +258,36 @@ export default new NamedCommand({
})
})
}
}),
name: new NamedCommand({
aliases: ["defaultname"],
description:
"Sets the name that the channel will be reset to once no more members are in the channel.",
usage: "(<name>)",
async run({send, guild, message}) {
const voiceChannel = message.member?.voice.channel;
if (!voiceChannel) return send("You are not in a voice channel.");
const guildStorage = Storage.getGuild(guild!.id);
delete guildStorage.channelNames[voiceChannel.id];
Storage.save();
return send(`Successfully removed the default channel name for ${voiceChannel}.`);
},
any: new RestCommand({
async run({send, guild, message, combined}) {
const voiceChannel = message.member?.voice.channel;
const guildID = guild!.id;
const guildStorage = Storage.getGuild(guildID);
const newName = combined;
if (!voiceChannel) return send("You are not in a voice channel.");
if (!guild!.me?.permissions.has(Permissions.FLAGS.MANAGE_CHANNELS))
return send("I can't change channel names without the `Manage Channels` permission.");
guildStorage.channelNames[voiceChannel.id] = newName;
Storage.save();
return await send(`Set default channel name to "${newName}".`);
}
})
})
}
}),
@ -252,18 +336,18 @@ export default new NamedCommand({
channelType: CHANNEL_TYPE.GUILD,
async run({send, message, channel, guild, client}) {
// It's probably better to go through the bot's own messages instead of calling bulkDelete which requires MANAGE_MESSAGES.
if (guild!.me?.hasPermission(Permissions.FLAGS.MANAGE_MESSAGES)) {
if (guild!.me?.permissions.has(Permissions.FLAGS.MANAGE_MESSAGES)) {
message.delete();
const msgs = await channel.messages.fetch({
limit: 100
});
const travMessages = msgs.filter((m) => m.author.id === client.user?.id);
await send(`Found ${travMessages.size} messages to delete.`).then((m) =>
m.delete({
timeout: 5000
})
);
await send(`Found ${travMessages.size} messages to delete.`).then((m) => {
setTimeout(() => {
m.delete();
}, 5000);
});
await (channel as TextChannel).bulkDelete(travMessages);
} else {
send("This command must be executed in a guild where I have the `MANAGE_MESSAGES` permission.");
@ -286,34 +370,43 @@ export default new NamedCommand({
}
})
}),
eval: new NamedCommand({
description: "Evaluate code.",
usage: "<code>",
permission: PERMISSIONS.BOT_OWNER,
run: "You have to enter some code to execute first.",
any: new RestCommand({
// You have to bring everything into scope to use them. AFAIK, there isn't a more maintainable way to do this, but at least TS will let you know if anything gets removed.
async run({send, message, channel, guild, author, member, client, args, combined}) {
try {
let evaled = eval(combined);
if (typeof evaled !== "string") evaled = require("util").inspect(evaled);
send(clean(evaled), {code: "js", split: true});
} catch (err) {
send(clean(err), {code: "js", split: true});
}
}
})
}),
// TODO: Reimplement this entire command, for `send` doesn't allow
// types like `unknown` to be sent anymore. Perhaps try to echo
// whatever `evaled` is into an empty buffer and send this.
// (see: `Buffer.alloc(...)`) This is unlikely to work though, since
// `Buffer.alloc(...)` requires a length, which we can't retrieve from
// an `unknown` variable.
// eval: new NamedCommand({
// description: "Evaluate code.",
// usage: "<code>",
// permission: PERMISSIONS.BOT_OWNER,
// run: "You have to enter some code to execute first.",
// any: new RestCommand({
// // You have to bring everything into scope to use them. AFAIK, there isn't a more maintainable way to do this, but at least TS will let you know if anything gets removed.
// async run({send, message, channel, guild, author, member, client, args, combined}) {
// try {
// let evaled: unknown = eval(combined);
// // If promises like message.channel.send() are invoked, await them so unnecessary error reports don't leak into the command handler.
// // Also, it's more useful to see the value rather than Promise { <pending> }.
// if (evaled instanceof Promise) evaled = await evaled;
// if (typeof evaled !== "string") evaled = inspect(evaled);
// // Also await this send call so that if the message is empty, it doesn't leak into the command handler.
// await send(clean(evaled), {code: "js", split: true});
// } catch (err) {
// send(clean(err), {code: "js", split: true});
// }
// }
// })
// }),
nick: new NamedCommand({
description: "Change the bot's nickname.",
permission: PERMISSIONS.BOT_SUPPORT,
channelType: CHANNEL_TYPE.GUILD,
run: "You have to specify a nickname to set for the bot",
any: new RestCommand({
async run({send, message, guild, combined}) {
async run({send, guild, combined}) {
await guild!.me?.setNickname(combined);
if (guild!.me?.hasPermission(Permissions.FLAGS.MANAGE_MESSAGES)) message.delete({timeout: 5000});
send(`Nickname set to \`${combined}\``).then((m) => m.delete({timeout: 5000}));
send(`Nickname set to \`${combined}\``);
}
})
}),
@ -321,8 +414,12 @@ export default new NamedCommand({
description: "Shows a list of all guilds the bot is a member of.",
permission: PERMISSIONS.BOT_SUPPORT,
async run({send, client}) {
const guildList = client.guilds.cache.array().map((e) => e.name);
send(guildList, {split: true});
const guildList = Util.splitMessage(
Array.from(client.guilds.cache.map((e) => e.name).values()).join("\n")
);
for (let guildListPart of guildList) {
send(guildListPart);
}
}
}),
activity: new NamedCommand({

View File

@ -22,7 +22,7 @@ export default new NamedCommand({
const helpMenuPages: [string, string][] = []; // An array of (category, description) tuples.
// Prevent the description of one category from overflowing by splitting it into multiple pages if needed.
for (const category of commands.keyArray()) {
for (const category of commands.keys()) {
const commandList = commands.get(category)!;
let output = LEGEND;
@ -45,10 +45,16 @@ export default new NamedCommand({
paginate(send, author.id, helpMenuPages.length, (page, hasMultiplePages) => {
const [category, output] = helpMenuPages[page];
return new MessageEmbed()
.setTitle(hasMultiplePages ? `${category} (Page ${page + 1} of ${helpMenuPages.length})` : category)
.setDescription(output)
.setColor(EMBED_COLOR);
return {
embeds: [
new MessageEmbed()
.setTitle(
hasMultiplePages ? `${category} (Page ${page + 1} of ${helpMenuPages.length})` : category
)
.setDescription(output)
.setColor(EMBED_COLOR)
]
};
});
},
any: new RestCommand({
@ -87,43 +93,45 @@ export default new NamedCommand({
aliases = formattedAliases.join(", ") || "None";
}
return send(
new MessageEmbed()
.setTitle(header)
.setDescription(command.description)
.setColor(EMBED_COLOR)
.addFields(
{
name: "Aliases",
value: aliases,
inline: true
},
{
name: "Category",
value: category,
inline: true
},
{
name: "Permission Required",
value: `\`${getPermissionName(result.permission)}\` (Level ${result.permission})`,
inline: true
},
{
name: "Channel Type",
value: getChannelTypeName(result.channelType),
inline: true
},
{
name: "NSFW Only?",
value: result.nsfw ? "Yes" : "No",
inline: true
},
{
name: "Usages",
value: append
}
)
);
return send({
embeds: [
new MessageEmbed()
.setTitle(header)
.setDescription(command.description)
.setColor(EMBED_COLOR)
.addFields(
{
name: "Aliases",
value: aliases,
inline: true
},
{
name: "Category",
value: category,
inline: true
},
{
name: "Permission Required",
value: `\`${getPermissionName(result.permission)}\` (Level ${result.permission})`,
inline: true
},
{
name: "Channel Type",
value: getChannelTypeName(result.channelType),
inline: true
},
{
name: "NSFW Only?",
value: result.nsfw ? "Yes" : "No",
inline: true
},
{
name: "Usages",
value: append
}
)
]
});
}
})
});

View File

@ -0,0 +1,34 @@
import {CHANNEL_TYPE, Command, NamedCommand} from "onion-lasers";
import {registerWebhook, deleteWebhook} from "../../modules/webhookStorageManager";
// Because adding webhooks involves sending tokens, you'll want to prevent this from being used in non-private contexts.
export default new NamedCommand({
channelType: CHANNEL_TYPE.DM,
description: "Manage webhooks stored by the bot.",
usage: "register/delete <webhook URL>",
run: "You need to use `register`/`delete`.",
subcommands: {
register: new NamedCommand({
description: "Adds a webhook to the bot's storage.",
any: new Command({
async run({send, args}) {
if (registerWebhook(args[0])) {
send("Registered webhook with bot.");
} else {
send("Invalid webhook URL.");
}
}
})
}),
delete: new NamedCommand({
description: "Removes a webhook from the bot's storage.",
any: new Command({
async run({send, args}) {
if (deleteWebhook(args[0])) {
send("Deleted webhook.");
} else send("Invalid webhook URL/ID.");
}
})
})
}
});

View File

@ -1,24 +1,34 @@
import {NamedCommand, RestCommand} from "onion-lasers";
import * as math from "mathjs";
import {WolframClient} from "node-wolfram-alpha";
import {MessageEmbed} from "discord.js";
import {Config} from "../../structures";
export default new NamedCommand({
description: "Calculates a specified math expression.",
run: "Please provide a calculation.",
any: new RestCommand({
async run({send, combined}) {
if (Config.wolfram === null) return send("There's no Wolfram token in the config.");
const wClient = new WolframClient(Config.wolfram);
let resp;
try {
resp = math.evaluate(combined);
} catch (e) {
return send("Please provide a *valid* calculation.");
resp = await wClient.query(combined);
} catch (e: any) {
return send("Something went wrong.");
}
if (!resp.data.queryresult.pods) return send("No pods were returned. Your query was likely invalid.");
else {
// TODO: Please don't hardcode the pod to fetch, try to figure out
// which is the right one based on some comparisons instead
const embed = new MessageEmbed()
.setColor(0xffffff)
.setTitle("Math Calculation")
.addField("Input", `\`\`\`\n${combined}\`\`\``)
.addField("Output", `\`\`\`\n${resp.data.queryresult.pods[1].subpods[0].plaintext}\`\`\``);
return send({embeds: [embed]});
}
const embed = new MessageEmbed()
.setColor(0xffffff)
.setTitle("Math Calculation")
.addField("Input", `\`\`\`js\n${combined}\`\`\``)
.addField("Output", `\`\`\`js\n${resp}\`\`\``);
return send(embed);
}
})
});

View File

@ -9,7 +9,7 @@ export default new NamedCommand({
const voiceChannel = message.member?.voice.channel;
if (!voiceChannel) return send("You are not in a voice channel.");
if (!voiceChannel.guild.me?.hasPermission("MANAGE_CHANNELS"))
if (!voiceChannel.guild.me?.permissions.has("MANAGE_CHANNELS"))
return send("I am lacking the required permissions to perform this action.");
const prevName = voiceChannel.name;

View File

@ -11,16 +11,16 @@ export default new NamedCommand({
var queryString = args[0];
let url = new URL(`https://djsdocs.sorta.moe/v2/embed?src=master&q=${queryString}`);
const content = await getContent(url.toString());
const msg = await send({embed: content});
const msg = await send({embeds: [content]});
const react = await msg.react("❌");
const collector = msg.createReactionCollector(
(reaction, user) => {
const collector = msg.createReactionCollector({
filter: (reaction, user) => {
if (user.id === author.id && reaction.emoji.name === "❌") msg.delete();
return false;
},
{time: 60000}
);
time: 60000
});
collector.on("end", () => {
if (!msg.deleted) react.users.remove(msg.author);

View File

@ -1,5 +1,5 @@
import {NamedCommand, RestCommand} from "onion-lasers";
import {processEmoteQueryFormatted} from "./modules/emote-utils";
import {processEmoteQuery} from "./modules/emote-utils";
export default new NamedCommand({
description:
@ -9,7 +9,7 @@ export default new NamedCommand({
description: "The emote(s) to send.",
usage: "<emotes...>",
async run({send, args}) {
const output = processEmoteQueryFormatted(args);
const output = processEmoteQuery(args, true).join("");
if (output.length > 0) send(output);
}
})

View File

@ -1,15 +1,15 @@
import {MessageEmbed, version as djsversion, Guild, User, GuildMember} from "discord.js";
import {MessageEmbed, version as djsversion, Guild, User, GuildMember, TextChannel, VoiceChannel} from "discord.js";
import ms from "ms";
import os from "os";
import {Command, NamedCommand, getUserByNickname, CHANNEL_TYPE, getGuildByName, RestCommand} from "onion-lasers";
import {formatBytes, trimArray} from "../../lib";
import {verificationLevels, filterLevels, regions} from "../../defs/info";
import {verificationLevels, filterLevels} from "../../defs/info";
import moment, {utc} from "moment";
export default new NamedCommand({
description: "Command to provide all sorts of info about the current server, a user, etc.",
async run({send, author, member}) {
send(await getUserInfo(author, member));
send({embeds: [await getUserInfo(author, member)]});
},
subcommands: {
avatar: new NamedCommand({
@ -55,39 +55,47 @@ export default new NamedCommand({
const core = os.cpus()[0];
const embed = new MessageEmbed()
.setColor(guild?.me?.displayHexColor || "BLUE")
.addField("General", [
`** Client:** ${client.user?.tag} (${client.user?.id})`,
`** Servers:** ${client.guilds.cache.size.toLocaleString()}`,
`** Users:** ${client.guilds.cache
.reduce((a: any, b: {memberCount: any}) => a + b.memberCount, 0)
.toLocaleString()}`,
`** Channels:** ${client.channels.cache.size.toLocaleString()}`,
`** Creation Date:** ${utc(client.user?.createdTimestamp).format("Do MMMM YYYY HH:mm:ss")}`,
`** Node.JS:** ${process.version}`,
`** Version:** v${process.env.npm_package_version}`,
`** Discord.JS:** ${djsversion}`,
"\u200b"
])
.addField("System", [
`** Platform:** ${process.platform}`,
`** Uptime:** ${ms(os.uptime() * 1000, {
long: true
})}`,
`** CPU:**`,
`\u3000 • Cores: ${os.cpus().length}`,
`\u3000 • Model: ${core.model}`,
`\u3000 • Speed: ${core.speed}MHz`,
`** Memory:**`,
`\u3000 • Total: ${formatBytes(process.memoryUsage().heapTotal)}`,
`\u3000 • Used: ${formatBytes(process.memoryUsage().heapUsed)}`
])
.addField(
"General",
[
`** Client:** ${client.user?.tag} (${client.user?.id})`,
`** Servers:** ${client.guilds.cache.size.toLocaleString()}`,
`** Users:** ${client.guilds.cache
.reduce((a: any, b: {memberCount: any}) => a + b.memberCount, 0)
.toLocaleString()}`,
`** Channels:** ${client.channels.cache.size.toLocaleString()}`,
`** Creation Date:** ${utc(client.user?.createdTimestamp).format(
"Do MMMM YYYY HH:mm:ss"
)}`,
`** Node.JS:** ${process.version}`,
`** Version:** v${process.env.npm_package_version}`,
`** Discord.JS:** v${djsversion}`,
"\u200b"
].join("\n")
)
.addField(
"System",
[
`** Platform:** ${process.platform}`,
`** Uptime:** ${ms(os.uptime() * 1000, {
long: true
})}`,
`** CPU:**`,
`\u3000 • Cores: ${os.cpus().length}`,
`\u3000 • Model: ${core.model}`,
`\u3000 • Speed: ${core.speed}MHz`,
`** Memory:**`,
`\u3000 • Total: ${formatBytes(process.memoryUsage().heapTotal)}`,
`\u3000 • Used: ${formatBytes(process.memoryUsage().heapUsed)}`
].join("\n")
)
.setTimestamp();
const avatarURL = client.user?.displayAvatarURL({
dynamic: true,
size: 2048
});
if (avatarURL) embed.setThumbnail(avatarURL);
send(embed);
send({embeds: [embed]});
}
}),
guild: new NamedCommand({
@ -95,14 +103,14 @@ export default new NamedCommand({
usage: "(<guild name>/<guild ID>)",
channelType: CHANNEL_TYPE.GUILD,
async run({send, guild}) {
send(await getGuildInfo(guild!, guild));
send({embeds: [await getGuildInfo(guild!, guild)]});
},
id: "guild",
guild: new Command({
description: "Display info about a guild by its ID.",
async run({send, guild, args}) {
const targetGuild = args[0] as Guild;
send(await getGuildInfo(targetGuild, guild));
send({embeds: [await getGuildInfo(targetGuild, guild)]});
}
}),
any: new RestCommand({
@ -111,7 +119,7 @@ export default new NamedCommand({
const targetGuild = getGuildByName(combined);
if (typeof targetGuild !== "string") {
send(await getGuildInfo(targetGuild, guild));
send({embeds: [await getGuildInfo(targetGuild, guild)]});
} else {
send(targetGuild);
}
@ -126,7 +134,7 @@ export default new NamedCommand({
const user = args[0] as User;
// Transforms the User object into a GuildMember object of the current guild.
const member = guild?.members.resolve(user);
send(await getUserInfo(user, member));
send({embeds: [await getUserInfo(user, member)]});
}
}),
any: new RestCommand({
@ -135,7 +143,7 @@ export default new NamedCommand({
const user = await getUserByNickname(combined, guild);
// Transforms the User object into a GuildMember object of the current guild.
const member = guild?.members.resolve(user);
if (typeof user !== "string") send(await getUserInfo(user, member));
if (typeof user !== "string") send({embeds: [await getUserInfo(user, member)]});
else send(user);
}
})
@ -147,20 +155,21 @@ async function getUserInfo(user: User, member: GuildMember | null | undefined):
const embed = new MessageEmbed()
.setThumbnail(user.displayAvatarURL({dynamic: true, size: 512}))
.setColor("BLUE")
.addField("User", [
`** Username:** ${user.username}`,
`** Discriminator:** ${user.discriminator}`,
`** ID:** ${user.id}`,
`** Flags:** ${userFlags.length ? userFlags.join(", ") : "None"}`,
`** Avatar:** [Link to avatar](${user.displayAvatarURL({
dynamic: true
})})`,
`** Time Created:** ${moment(user.createdTimestamp).format("LT")} ${moment(user.createdTimestamp).format(
"LL"
)} ${moment(user.createdTimestamp).fromNow()}`,
`** Status:** ${user.presence.status}`,
`** Game:** ${user.presence.activities || "Not playing a game."}`
]);
.addField(
"User",
[
`** Username:** ${user.username}`,
`** Discriminator:** ${user.discriminator}`,
`** ID:** ${user.id}`,
`** Flags:** ${userFlags.length ? userFlags.join(", ") : "None"}`,
`** Avatar:** [Link to avatar](${user.displayAvatarURL({
dynamic: true
})})`,
`** Time Created:** ${moment(user.createdTimestamp).format("LT")} ${moment(
user.createdTimestamp
).format("LL")} ${moment(user.createdTimestamp).fromNow()}`
].join("\n")
);
if (member) {
const roles = member.roles.cache
@ -170,16 +179,21 @@ async function getUserInfo(user: User, member: GuildMember | null | undefined):
embed
.setColor(member.displayHexColor)
.addField("Member", [
`** Highest Role:** ${
member.roles.highest.id === member.guild.id ? "None" : member.roles.highest.name
}`,
`** Server Join Date:** ${moment(member.joinedAt).format("LL LTS")}`,
`** Hoist Role:** ${member.roles.hoist ? member.roles.hoist.name : "None"}`,
`** Roles:** [${roles.length}]: ${
roles.length == 0 ? "None" : roles.length <= 10 ? roles.join(", ") : trimArray(roles).join(", ")
}`
]);
.addField(
"Member",
[
`** Status:** ${member.presence?.status}`,
`** Game:** ${member.presence?.activities ?? "Not playing a game."}`,
`** Highest Role:** ${
member.roles.highest.id === member.guild.id ? "None" : member.roles.highest.name
}`,
`** Server Join Date:** ${moment(member.joinedAt).format("LL LTS")}`,
`** Hoist Role:** ${member.roles.hoist ? member.roles.hoist.name : "None"}`,
`** Roles:** [${roles.length}]: ${
roles.length == 0 ? "None" : roles.length <= 10 ? roles.join(", ") : trimArray(roles).join(", ")
}`
].join("\n")
);
}
return embed;
@ -196,14 +210,15 @@ async function getGuildInfo(guild: Guild, currentGuild: Guild | null) {
const iconURL = guild.iconURL({dynamic: true});
const embed = new MessageEmbed().setDescription(`**Guild information for __${guild.name}__**`).setColor("BLUE");
const displayRoles = !!(currentGuild && guild.id === currentGuild.id);
if (iconURL) {
embed
.setThumbnail(iconURL)
.addField("General", [
const owner = await guild.fetchOwner();
embed
.addField(
"General",
[
`** Name:** ${guild.name}`,
`** ID:** ${guild.id}`,
`** Owner:** ${guild.owner?.user.tag} (${guild.ownerID})`,
`** Region:** ${regions[guild.region]}`,
`** Owner:** ${owner.user.tag} (${guild.ownerId})`,
`** Boost Tier:** ${guild.premiumTier ? `Tier ${guild.premiumTier}` : "None"}`,
`** Explicit Filter:** ${filterLevels[guild.explicitContentFilter]}`,
`** Verification Level:** ${verificationLevels[guild.verificationLevel]}`,
@ -211,36 +226,44 @@ async function getGuildInfo(guild: Guild, currentGuild: Guild | null) {
guild.createdTimestamp
).format("LL")} ${moment(guild.createdTimestamp).fromNow()}`,
"\u200b"
])
.addField("Statistics", [
].join("\n")
)
.addField(
"Statistics",
[
`** Role Count:** ${roles.length}`,
`** Emoji Count:** ${emojis.size}`,
`** Regular Emoji Count:** ${emojis.filter((emoji) => !emoji.animated).size}`,
`** Animated Emoji Count:** ${emojis.filter((emoji) => emoji.animated).size}`,
`** Animated Emoji Count:** ${emojis.filter((emoji) => !!emoji.animated).size}`,
`** Member Count:** ${guild.memberCount}`,
`** Humans:** ${members.filter((member) => !member.user.bot).size}`,
`** Bots:** ${members.filter((member) => member.user.bot).size}`,
`** Text Channels:** ${channels.filter((channel) => channel.type === "text").size}`,
`** Voice Channels:** ${channels.filter((channel) => channel.type === "voice").size}`,
`** Text Channels:** ${channels.filter((channel) => channel instanceof TextChannel).size}`,
`** Voice Channels:** ${channels.filter((channel) => channel instanceof VoiceChannel).size}`,
`** Boost Count:** ${guild.premiumSubscriptionCount || "0"}`,
`\u200b`
])
.addField("Presence", [
`** Online:** ${members.filter((member) => member.presence.status === "online").size}`,
`** Idle:** ${members.filter((member) => member.presence.status === "idle").size}`,
`** Do Not Disturb:** ${members.filter((member) => member.presence.status === "dnd").size}`,
`** Offline:** ${members.filter((member) => member.presence.status === "offline").size}`,
].join("\n")
)
.addField(
"Presence",
[
`** Online:** ${members.filter((member) => member.presence?.status === "online").size}`,
`** Idle:** ${members.filter((member) => member.presence?.status === "idle").size}`,
`** Do Not Disturb:** ${members.filter((member) => member.presence?.status === "dnd").size}`,
`** Offline:** ${members.filter((member) => member.presence?.status === "offline").size}`,
displayRoles ? "\u200b" : ""
])
.setTimestamp();
].join("\n")
)
.setTimestamp();
// Only add the roles if the guild the bot is sending the message to is the same one that's being requested.
if (displayRoles) {
embed.addField(
`Roles [${roles.length - 1}]`,
roles.length < 10 ? roles.join(", ") : roles.length > 10 ? trimArray(roles) : "None"
);
}
if (iconURL) embed.setThumbnail(iconURL);
// Only add the roles if the guild the bot is sending the message to is the same one that's being requested.
if (displayRoles) {
embed.addField(
`Roles [${roles.length - 1}]`,
roles.length < 10 ? roles.join(", ") : roles.length > 10 ? trimArray(roles).join(", ") : "None"
);
}
return embed;

View File

@ -3,7 +3,11 @@ import {Command, NamedCommand} from "onion-lasers";
export default new NamedCommand({
description: "Gives you the invite link.",
async run({send, client}) {
send(`https://discordapp.com/api/oauth2/authorize?client_id=${client.user!.id}&permissions=8&scope=bot`);
send(
`https://discordapp.com/api/oauth2/authorize?client_id=${
client.user!.id
}&permissions=138046467152&scope=bot`
);
},
number: new Command({
async run({send, client, args}) {

View File

@ -9,7 +9,7 @@ export default new NamedCommand({
description: "Lists all emotes the bot has in it's registry,",
usage: "<regex pattern> (-flags)",
async run({send, author, client}) {
displayEmoteList(client.emojis.cache.array(), send, author);
displayEmoteList(Array.from(client.emojis.cache.values()), send, author);
},
any: new RestCommand({
description:
@ -20,7 +20,7 @@ export default new NamedCommand({
const guildID: string = args[0];
displayEmoteList(
client.emojis.cache.filter((emote) => emote.guild.id === guildID).array(),
Array.from(client.emojis.cache.filter((emote) => emote.guild.id === guildID).values()),
send,
author
);
@ -32,13 +32,15 @@ export default new NamedCommand({
flags = args.pop().substring(1);
}
let emoteCollection = client.emojis.cache.array();
let emoteCollection = Array.from(client.emojis.cache.values());
// Creates a sandbox to stop a regular expression if it takes too much time to search.
// To avoid passing in a giant data structure, I'll just pass in the structure {[id: string]: [name: string]}.
let emotes = new Map<string, string>();
for (const emote of emoteCollection) {
emotes.set(emote.id, emote.name);
if (emote.name) {
emotes.set(emote.id, emote.name);
}
}
// The result will be sandbox.emotes because it'll be modified in-place.
@ -59,12 +61,18 @@ export default new NamedCommand({
emoteCollection = emoteCollection.filter((emote) => emotes.has(emote.id)); // Only allow emotes that haven't been deleted.
displayEmoteList(emoteCollection, send, author);
} catch (error) {
if (error.code === "ERR_SCRIPT_EXECUTION_TIMEOUT") {
// FIXME: `error` is of type `unknown` here.
// Also: <https://stackoverflow.com/questions/40141005/property-code-does-not-exist-on-type-error>
let errorName = "???";
if (error instanceof Error) {
errorName = error.name;
}
if (errorName === "ERR_SCRIPT_EXECUTION_TIMEOUT") {
send(
`The regular expression you entered exceeded the time limit of ${REGEX_TIMEOUT_MS} milliseconds.`
);
} else {
throw new Error(error);
throw new Error(errorName);
}
}
} else {
@ -77,6 +85,7 @@ export default new NamedCommand({
async function displayEmoteList(emotes: GuildEmoji[], send: SendFunction, author: User) {
emotes.sort((a, b) => {
if (!a.name || !b.name) return 0;
const first = a.name.toLowerCase();
const second = b.name.toLowerCase();
@ -99,7 +108,7 @@ async function displayEmoteList(emotes: GuildEmoji[], send: SendFunction, author
}
embed.setDescription(desc);
return embed;
return {embeds: [embed]};
});
} else {
send("No valid emotes found by that query.");

View File

@ -51,9 +51,11 @@ function searchSimilarEmotes(query: string): GuildEmoji[] {
const emoteCandidates: {emote: GuildEmoji; dist: number}[] = [];
for (const emote of client.emojis.cache.values()) {
const dist = levenshtein(emote.name, query);
if (dist <= maxAcceptedDistance) {
emoteCandidates.push({emote, dist});
if (emote.name) {
const dist = levenshtein(emote.name, query);
if (dist <= maxAcceptedDistance) {
emoteCandidates.push({emote, dist});
}
}
}
@ -61,11 +63,40 @@ function searchSimilarEmotes(query: string): GuildEmoji[] {
return emoteCandidates.map((em) => em.emote);
}
const unicodeEmojiRegex = /^(?:[\u2700-\u27bf]|(?:\ud83c[\udde6-\uddff]){2}|[\ud800-\udbff][\udc00-\udfff]|[\u0023-\u0039]\ufe0f?\u20e3|\u3299|\u3297|\u303d|\u3030|\u24c2|\ud83c[\udd70-\udd71]|\ud83c[\udd7e-\udd7f]|\ud83c\udd8e|\ud83c[\udd91-\udd9a]|\ud83c[\udde6-\uddff]|\ud83c[\ude01-\ude02]|\ud83c\ude1a|\ud83c\ude2f|\ud83c[\ude32-\ude3a]|\ud83c[\ude50-\ude51]|\u203c|\u2049|[\u25aa-\u25ab]|\u25b6|\u25c0|[\u25fb-\u25fe]|\u00a9|\u00ae|\u2122|\u2139|\ud83c\udc04|[\u2600-\u26FF]|\u2b05|\u2b06|\u2b07|\u2b1b|\u2b1c|\u2b50|\u2b55|\u231a|\u231b|\u2328|\u23cf|[\u23e9-\u23f3]|[\u23f8-\u23fa]|\ud83c\udccf|\u2934|\u2935|[\u2190-\u21ff])[\ufe00-\ufe0f]?$/;
const unicodeEmojiRegex =
/^(?:[\u2700-\u27bf]|(?:\ud83c[\udde6-\uddff]){2}|[\ud800-\udbff][\udc00-\udfff]|[\u0023-\u0039]\ufe0f?\u20e3|\u3299|\u3297|\u303d|\u3030|\u24c2|\ud83c[\udd70-\udd71]|\ud83c[\udd7e-\udd7f]|\ud83c\udd8e|\ud83c[\udd91-\udd9a]|\ud83c[\udde6-\uddff]|\ud83c[\ude01-\ude02]|\ud83c\ude1a|\ud83c\ude2f|\ud83c[\ude32-\ude3a]|\ud83c[\ude50-\ude51]|\u203c|\u2049|[\u25aa-\u25ab]|\u25b6|\u25c0|[\u25fb-\u25fe]|\u00a9|\u00ae|\u2122|\u2139|\ud83c\udc04|[\u2600-\u26FF]|\u2b05|\u2b06|\u2b07|\u2b1b|\u2b1c|\u2b50|\u2b55|\u231a|\u231b|\u2328|\u23cf|[\u23e9-\u23f3]|[\u23f8-\u23fa]|\ud83c\udccf|\u2934|\u2935|[\u2190-\u21ff])[\ufe00-\ufe0f]?$/;
const discordEmoteMentionRegex = /^<a?:\w+:\d+>$/;
const emoteNameWithSelectorRegex = /^(.+)~(\d+)$/;
function processEmoteQuery(query: string[], isFormatted: boolean): string[] {
export function searchNearestEmote(query: string, additionalEmotes?: GuildEmoji[]): string {
// Selector number used for disambiguating multiple emotes with same name.
let selector = 0;
// If the query has emoteName~123 format, extract the actual name and the selector number.
const queryWithSelector = query.match(emoteNameWithSelectorRegex);
if (queryWithSelector) {
query = queryWithSelector[1];
selector = +queryWithSelector[2];
}
// Try to match an emote name directly if the selector is for the closest match.
if (selector == 0) {
const directMatchEmote = client.emojis.cache.find((em) => em.name === query);
if (directMatchEmote) return directMatchEmote.toString();
}
// Find all similar emote candidates within certain threshold and select Nth top one according to the selector.
const similarEmotes = searchSimilarEmotes(query);
if (similarEmotes.length > 0) {
selector = Math.min(selector, similarEmotes.length - 1);
return similarEmotes[selector].toString();
}
// Return some "missing/invalid emote" indicator.
return "❓";
}
export function processEmoteQuery(query: string[], isFormatted: boolean): string[] {
return query.map((emote) => {
emote = emote.trim();
@ -79,33 +110,6 @@ function processEmoteQuery(query: string[], isFormatted: boolean): string[] {
if (emote == "_") return "\u200b";
}
// Selector number used for disambiguating multiple emotes with same name.
let selector = 0;
// If the query has emoteName~123 format, extract the actual name and the selector number.
const queryWithSelector = emote.match(emoteNameWithSelectorRegex);
if (queryWithSelector) {
emote = queryWithSelector[1];
selector = +queryWithSelector[2];
}
// Try to match an emote name directly if the selector is for the closest match.
if (selector == 0) {
const directMatchEmote = client.emojis.cache.find((em) => em.name === emote);
if (directMatchEmote) return directMatchEmote.toString();
}
// Find all similar emote candidates within certian threshold and select Nth top one according to the selector.
const similarEmotes = searchSimilarEmotes(emote);
if (similarEmotes.length > 0) {
selector = Math.min(selector, similarEmotes.length - 1);
return similarEmotes[selector].toString();
}
// Return some "missing/invalid emote" indicator.
return "❓";
return searchNearestEmote(emote);
});
}
export const processEmoteQueryArray = (query: string[]): string[] => processEmoteQuery(query, false);
export const processEmoteQueryFormatted = (query: string[]): string => processEmoteQuery(query, true).join("");

View File

@ -0,0 +1,45 @@
import {NamedCommand, getPermissionLevel, getPermissionName, hasPermission} from "onion-lasers";
import {DMChannel, Permissions} from "discord.js";
export default new NamedCommand({
description:
"Purges the bot's messages in either a guild channel (requiring the BOT_SUPPORT permission level) or a DM channel (no permission required). Limited to the last 100 messages.",
async run({send, message, channel, guild, client, author, member}) {
if (channel instanceof DMChannel) {
const messages = await channel.messages.fetch({
limit: 100
});
for (const message of messages.values()) {
if (message.author.id === client.user!.id) {
message.delete();
}
}
} else if (hasPermission(author, member, PERMISSIONS.BOT_SUPPORT)) {
if (guild!.me?.permissions.has(Permissions.FLAGS.MANAGE_MESSAGES)) message.delete();
const messages = await channel.messages.fetch({
limit: 100
});
const travMessages = messages.filter((msg) => msg.author.id === client.user!.id);
send(`Found ${travMessages.size} messages to delete.`).then((msg) => setTimeout(() => msg.delete(), 5000));
// It's better to go through the bot's own messages instead of calling bulkDelete which requires MANAGE_MESSAGES.
for (const message of messages.values()) {
if (message.author.id === client.user!.id) {
message.delete();
}
}
} else {
const userPermLevel = getPermissionLevel(author, member);
send(
`You don't have access to this command! Your permission level is \`${getPermissionName(
userPermLevel
)}\` (${userPermLevel}), but this command requires a permission level of \`${getPermissionName(
PERMISSIONS.BOT_SUPPORT
)}\` (${PERMISSIONS.BOT_SUPPORT}).`
);
}
}
});

View File

@ -1,8 +1,9 @@
import {NamedCommand, RestCommand} from "onion-lasers";
import {Message, Channel, TextChannel} from "discord.js";
import {processEmoteQueryArray} from "./modules/emote-utils";
import {Message, Channel, TextChannel, TextBasedChannel} from "discord.js";
import {processEmoteQuery} from "./modules/emote-utils";
export default new NamedCommand({
aliases: ["r"],
description:
"Reacts to the a previous message in your place. You have to react with the same emote before the bot removes that reaction.",
usage: 'react <emotes...> (<distance / message ID / "Copy ID" / "Copy Message Link">)',
@ -14,7 +15,7 @@ export default new NamedCommand({
if (message.reference) {
// If the command message is a reply to another message, use that as the react target.
target = await channel.messages.fetch(message.reference.messageID!);
target = await channel.messages.fetch(message.reference.messageId!);
}
// handles reacts by message id/distance
else if (args.length >= 2) {
@ -28,7 +29,7 @@ export default new NamedCommand({
const guildID = match[1];
const channelID = match[2];
const messageID = match[3];
let tmpChannel: Channel | undefined = channel;
let tmpChannel: TextBasedChannel | undefined = channel;
if (guild?.id !== guildID) {
try {
@ -38,12 +39,13 @@ export default new NamedCommand({
}
}
if (tmpChannel.id !== channelID) tmpChannel = guild.channels.cache.get(channelID);
if (tmpChannel?.id !== channelID)
tmpChannel = guild.channels.cache.get(channelID) as TextBasedChannel;
if (!tmpChannel) return send(`\`${channelID}\` is an invalid channel ID!`);
if (message.id !== messageID) {
try {
target = await (tmpChannel as TextChannel).messages.fetch(messageID);
target = await tmpChannel.messages.fetch(messageID);
} catch {
return send(`\`${messageID}\` is an invalid message ID!`);
}
@ -56,14 +58,15 @@ export default new NamedCommand({
const match = copyIDPattern.exec(last)!;
const channelID = match[1];
const messageID = match[2];
let tmpChannel: Channel | undefined = channel;
let tmpChannel: TextBasedChannel | undefined = channel;
if (tmpChannel.id !== channelID) tmpChannel = guild?.channels.cache.get(channelID);
if (tmpChannel?.id !== channelID)
tmpChannel = guild?.channels.cache.get(channelID) as TextBasedChannel;
if (!tmpChannel) return send(`\`${channelID}\` is an invalid channel ID!`);
if (message.id !== messageID) {
try {
target = await (tmpChannel as TextChannel).messages.fetch(messageID);
target = await tmpChannel.messages.fetch(messageID);
} catch {
return send(`\`${messageID}\` is an invalid message ID!`);
}
@ -100,7 +103,7 @@ export default new NamedCommand({
).last();
}
for (const emote of processEmoteQueryArray(args)) {
for (const emote of processEmoteQuery(args, false)) {
// Even though the bot will always grab *some* emote, the user can choose not to keep that emote there if it isn't what they want
const reaction = await target!.react(emote);

View File

@ -1,13 +1,75 @@
import {NamedCommand, RestCommand} from "onion-lasers";
import {NamedCommand, RestCommand, CHANNEL_TYPE} from "onion-lasers";
import {TextChannel, NewsChannel, Permissions} from "discord.js";
import {searchNearestEmote} from "../utility/modules/emote-utils";
import {resolveWebhook} from "../../modules/webhookStorageManager";
import {parseVarsCallback} from "../../lib";
// Description //
// This is the message-based counterpart to the react command, which replicates Nitro's ability to send emotes in messages.
// This takes advantage of webhooks' ability to change the username and avatar per request.
// Uses "@user says:" as a fallback in case no webhook is set for the channel.
// Limitations / Points of Interest //
// - Webhooks can fetch any emote in existence and use it as long as it hasn't been deleted.
// - The emote name from <:name:id> DOES matter if the user isn't part of that guild. That's the fallback essentially, otherwise, it doesn't matter.
// - The animated flag must be correct. <:name:id> on an animated emote will make it not animated, <a:name:id> will display an invalid image.
// - Rate limits for webhooks shouldn't be that big of an issue (5 requests every 2 seconds).
export default new NamedCommand({
description: "Repeats your message.",
aliases: ["s"],
channelType: CHANNEL_TYPE.GUILD,
description: "Repeats your message with emotes in /slashes/.",
usage: "<message>",
run: "Please provide a message for me to say!",
any: new RestCommand({
description: "Message to repeat.",
async run({send, author, combined}) {
send(`*${author} says:*\n${combined}`);
async run({send, channel, author, member, message, combined, guild}) {
const webhook = await resolveWebhook(channel as TextChannel | NewsChannel);
if (webhook) {
const resolvedMessage = resolveMessageWithEmotes(combined);
if (resolvedMessage)
webhook.send({
content: resolvedMessage,
username: member!.nickname ?? author.username,
// Webhooks cannot have animated avatars, so requesting the animated version is a moot point.
avatarURL:
author.avatarURL({
format: "png"
}) || author.defaultAvatarURL,
allowedMentions: {parse: []}, // avoids double pings
// "embeds" will not be included because it messes with the default ones that generate
files: Array.from(message.attachments.values())
});
else send("Cannot send an empty message.");
} else {
const resolvedMessage = resolveMessageWithEmotes(combined);
if (resolvedMessage)
send({content: `*${author} says:*\n${resolvedMessage}`, allowedMentions: {parse: []}});
else send("Cannot send an empty message.");
}
if (guild!.me?.permissions.has(Permissions.FLAGS.MANAGE_MESSAGES)) message.delete();
}
})
});
const FETCH_EMOTE_PATTERN = /^(\d{17,})(?: ([^ ]+?))?(?: (a))?$/;
// Send extra emotes only for webhook messages (because the bot user can't fetch any emote in existence while webhooks can).
function resolveMessageWithEmotes(text: string, extraEmotes?: null): string {
return parseVarsCallback(
text,
(variable) => {
if (FETCH_EMOTE_PATTERN.test(variable)) {
// Although I *could* make this ping the CDN to see if gif exists to see whether it's animated or not, it'd take too much time to wait on it.
// Plus, with the way this function is setup, I wouldn't be able to incorporate a search without changing the function to async.
const [_, id, name, animated] = FETCH_EMOTE_PATTERN.exec(variable)!;
return `<${animated ?? ""}:${name ?? "_"}:${id}>`;
}
return searchNearestEmote(variable);
},
"/"
);
}

View File

@ -1,7 +1,7 @@
import {NamedCommand, CHANNEL_TYPE} from "onion-lasers";
import {pluralise} from "../../lib";
import moment from "moment";
import {Collection, TextChannel} from "discord.js";
import {Collection, TextChannel, Util} from "discord.js";
const lastUsedTimestamps = new Collection<string, number>();
@ -24,7 +24,7 @@ export default new NamedCommand({
const stats: {
[id: string]: {
name: string;
name: string | null;
formatted: string;
users: number;
bots: number;
@ -33,7 +33,7 @@ export default new NamedCommand({
let totalUserEmoteUsage = 0;
// IMPORTANT: You MUST check if the bot actually has access to the channel in the first place. It will get the list of all channels, but that doesn't mean it has access to every channel. Without this, it'll require admin access and throw an annoying unhelpful DiscordAPIError: Missing Access otherwise.
const allTextChannelsInCurrentGuild = guild!.channels.cache.filter(
(channel) => channel.type === "text" && channel.viewable
(channel) => channel instanceof TextChannel && channel.viewable
) as Collection<string, TextChannel>;
let messagesSearched = 0;
let channelsSearched = 0;
@ -41,7 +41,7 @@ export default new NamedCommand({
const totalChannels = allTextChannelsInCurrentGuild.size;
const statusMessage = await send("Gathering emotes...");
let warnings = 0;
channel.startTyping();
channel.sendTyping();
// Initialize the emote stats object with every emote in the current guild.
// The goal here is to cut the need to access guild.emojis.get() which'll make it faster and easier to work with.
@ -63,7 +63,7 @@ export default new NamedCommand({
for (const channel of allTextChannelsInCurrentGuild.values()) {
currentChannelName = channel.name;
let selected = channel.lastMessageID ?? message.id;
let selected = channel.lastMessageId ?? message.id;
let continueLoop = true;
while (continueLoop) {
@ -129,6 +129,7 @@ export default new NamedCommand({
if (reaction.count !== userReactions + botReactions) {
console.warn(
"[scanemotes]",
`[Channel: ${channel.id}, Message: ${msg.id}] A reaction count of ${reaction.count} was expected but was given ${userReactions} user reactions and ${botReactions} bot reactions.`
);
warnings++;
@ -159,8 +160,7 @@ export default new NamedCommand({
"y"
)}.`
);
console.log(`Finished operation in ${finishTime - startTime} ms.`);
channel.stopTyping();
console.log("[scanemotes]", `Finished operation in ${finishTime - startTime} ms.`);
// Display stats on emote usage.
// This can work outside the loop now that it's synchronous, and now it's clearer what code is meant to execute at the end.
@ -179,7 +179,10 @@ export default new NamedCommand({
);
}
return await send(lines, {split: true});
let emoteList = Util.splitMessage(lines.join("\n"));
for (let emoteListPart of emoteList) {
return await send(emoteListPart);
}
},
subcommands: {
forcereset: new NamedCommand({

View File

@ -34,11 +34,14 @@ export default new NamedCommand({
const stream = streamList.get(userID)!;
stream.description = combined;
stream.update();
send("Successfully set the stream description to:", {
embed: {
description: stream.description,
color: member!.displayColor
}
send({
content: "Successfully set the stream description to:",
embeds: [
{
description: stream.description,
color: member!.displayColor
}
]
});
} else {
send("You can only use this command when streaming.");
@ -70,12 +73,15 @@ export default new NamedCommand({
const stream = streamList.get(userID)!;
stream.thumbnail = combined;
stream.update();
send(`Successfully set the stream thumbnail to: ${combined}`, {
embed: {
description: stream.description,
thumbnail: {url: combined},
color: member!.displayColor
}
send({
content: `Successfully set the stream thumbnail to: ${combined}`,
embeds: [
{
description: stream.description,
thumbnail: {url: combined},
color: member!.displayColor
}
]
});
} else {
send("You can only use this command when streaming.");

View File

@ -124,42 +124,40 @@ function getTimeEmbed(user: User) {
}
const embed = {
embed: {
color: TIME_EMBED_COLOR,
author: {
name: user.username,
icon_url: user.displayAvatarURL({
format: "png",
dynamic: true
})
color: TIME_EMBED_COLOR,
author: {
name: user.username,
icon_url: user.displayAvatarURL({
format: "png",
dynamic: true
})
},
fields: [
{
name: "Local Date",
value: localDate
},
fields: [
{
name: "Local Date",
value: localDate
},
{
name: "Day of the Week",
value: dayOfWeek
},
{
name: "Local Time",
value: localTime
},
{
name: daylightSavingsRegion !== null ? "Current Timezone Offset" : "Timezone Offset",
value: timezoneOffset
},
{
name: "Observes Daylight Savings?",
value: daylightSavingsRegion ? "Yes" : "No"
}
]
}
{
name: "Day of the Week",
value: dayOfWeek
},
{
name: "Local Time",
value: localTime
},
{
name: daylightSavingsRegion !== null ? "Current Timezone Offset" : "Timezone Offset",
value: timezoneOffset
},
{
name: "Observes Daylight Savings?",
value: daylightSavingsRegion ? "Yes" : "No"
}
]
};
if (daylightSavingsRegion) {
embed.embed.fields.push(
embed.fields.push(
{
name: "Daylight Savings Active?",
value: hasDaylightSavings(daylightSavingsRegion) ? "Yes" : "No"
@ -178,7 +176,7 @@ export default new NamedCommand({
description: "Show others what time it is for you.",
aliases: ["tz"],
async run({send, author}) {
send(getTimeEmbed(author));
send({embeds: [getTimeEmbed(author)]});
},
subcommands: {
// Welcome to callback hell. We hope you enjoy your stay here!
@ -298,10 +296,11 @@ export default new NamedCommand({
const finalize = () => {
Storage.save();
send(
"You've finished setting up your timezone! Just check to see if this looks right, and if it doesn't, run this setup again.",
getTimeEmbed(author)
);
send({
content:
"You've finished setting up your timezone! Just check to see if this looks right, and if it doesn't, run this setup again.",
embeds: [getTimeEmbed(author)]
});
};
if (hasDST) {
@ -358,23 +357,25 @@ export default new NamedCommand({
const time = moment().utc();
send({
embed: {
color: TIME_EMBED_COLOR,
fields: [
{
name: "Local Date",
value: time.format(DATE_FORMAT)
},
{
name: "Day of the Week",
value: time.format(DOW_FORMAT)
},
{
name: "Local Time",
value: time.format(TIME_FORMAT)
}
]
}
embeds: [
{
color: TIME_EMBED_COLOR,
fields: [
{
name: "Local Date",
value: time.format(DATE_FORMAT)
},
{
name: "Day of the Week",
value: time.format(DOW_FORMAT)
},
{
name: "Local Time",
value: time.format(TIME_FORMAT)
}
]
}
]
});
}
}),
@ -387,14 +388,14 @@ export default new NamedCommand({
user: new Command({
description: "See what time it is for someone else.",
async run({send, args}) {
send(getTimeEmbed(args[0]));
send({embeds: [getTimeEmbed(args[0])]});
}
}),
any: new RestCommand({
description: "See what time it is for someone else (by their username).",
async run({send, guild, combined}) {
const user = await getUserByNickname(combined, guild);
if (typeof user !== "string") send(getTimeEmbed(user));
if (typeof user !== "string") send({embeds: [getTimeEmbed(user)]});
else send(user);
}
})

View File

@ -17,7 +17,7 @@ export default new NamedCommand({
);
}
send(embed);
send({embeds: [embed]});
},
subcommands: {
add: new NamedCommand({

View File

@ -1,43 +0,0 @@
import {Command, NamedCommand, RestCommand} from "onion-lasers";
import translate from "translate-google";
export default new NamedCommand({
description: "Translates your input.",
usage: "<lang ID> <input>",
run: "You need to specify a language to translate to.",
any: new Command({
run: "You need to enter some text to translate.",
any: new RestCommand({
async run({send, args}) {
const lang = args[0];
const input = args.slice(1).join(" ");
translate(input, {
to: lang
})
.then((res) => {
send({
embed: {
title: "Translation",
fields: [
{
name: "Input",
value: `\`\`\`${input}\`\`\``
},
{
name: "Output",
value: `\`\`\`${res}\`\`\``
}
]
}
});
})
.catch((error) => {
console.error(error);
send(
`${error}\nPlease use the following list: https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes`
);
});
}
})
})
});

4
src/defs/petpet.d.ts vendored Normal file
View File

@ -0,0 +1,4 @@
declare module "pet-pet-gif" {
function petPetGif(image: string): Promise<Buffer>;
export = petPetGif;
}

View File

@ -1,9 +0,0 @@
interface TranslateOptions {
from?: string;
to?: string;
}
declare module "translate-google" {
function translate(input: string, options: TranslateOptions): Promise<string>;
export = translate;
}

View File

@ -1,10 +1,21 @@
import "./modules/globals";
import {Client, Permissions} from "discord.js";
import {Client, Permissions, Intents} from "discord.js";
import path from "path";
// This is here in order to make it much less of a headache to access the client from other files.
// This of course won't actually do anything until the setup process is complete and it logs in.
export const client = new Client();
export const client = new Client({
intents: [
Intents.FLAGS.GUILDS,
Intents.FLAGS.GUILD_MEMBERS,
Intents.FLAGS.GUILD_EMOJIS_AND_STICKERS,
Intents.FLAGS.GUILD_VOICE_STATES,
Intents.FLAGS.GUILD_PRESENCES,
Intents.FLAGS.GUILD_MESSAGES,
Intents.FLAGS.GUILD_MESSAGE_REACTIONS,
Intents.FLAGS.DIRECT_MESSAGES
]
});
import {launch} from "onion-lasers";
import setup from "./modules/setup";
@ -31,20 +42,20 @@ launch(client, path.join(__dirname, "commands"), {
name: "Moderator",
check: (_user, member) =>
!!member &&
(member.hasPermission(Permissions.FLAGS.MANAGE_ROLES) ||
member.hasPermission(Permissions.FLAGS.MANAGE_MESSAGES) ||
member.hasPermission(Permissions.FLAGS.KICK_MEMBERS) ||
member.hasPermission(Permissions.FLAGS.BAN_MEMBERS))
(member.permissions.has(Permissions.FLAGS.MANAGE_ROLES) ||
member.permissions.has(Permissions.FLAGS.MANAGE_MESSAGES) ||
member.permissions.has(Permissions.FLAGS.KICK_MEMBERS) ||
member.permissions.has(Permissions.FLAGS.BAN_MEMBERS))
},
{
// ADMIN //
name: "Administrator",
check: (_user, member) => !!member && member.hasPermission(Permissions.FLAGS.ADMINISTRATOR)
check: (_user, member) => !!member && member.permissions.has(Permissions.FLAGS.ADMINISTRATOR)
},
{
// OWNER //
name: "Server Owner",
check: (_user, member) => !!member && member.guild.ownerID === member.id
check: (_user, member) => !!member && member.guild.ownerId === member.id
},
{
// BOT_SUPPORT //
@ -67,10 +78,14 @@ launch(client, path.join(__dirname, "commands"), {
// Initialize Modules //
import "./modules/ready";
import "./modules/presence";
import "./modules/lavalink";
// TODO: Reimplement entire music system, contact Sink
// import "./modules/lavalink";
import "./modules/emoteRegistry";
import "./modules/systemInfo";
import "./modules/intercept";
import "./modules/messageEmbed";
// import "./modules/messageEmbed";
import "./modules/guildMemberAdd";
import "./modules/streamNotifications";
import "./modules/channelDefaults";
// This module must be loaded last for the dynamic event reading to work properly.
import "./modules/eventLogging";

View File

@ -1,5 +1,5 @@
import {strict as assert} from "assert";
import {pluralise, pluraliseSigned, replaceAll, toTitleCase, split, parseVars} from "./lib";
import {pluralise, pluraliseSigned, replaceAll, toTitleCase, split, parseVars, parseVarsCallback} from "./lib";
// I can't figure out a way to run the test suite while running the bot.
describe("Wrappers", () => {
@ -58,6 +58,15 @@ describe("Wrappers", () => {
});
});
describe("#parseVarsCallback()", () => {
it('should replace %test% with "yeet"', () => {
assert.strictEqual(
parseVarsCallback("ya %test% the %pear%", (variable) => (variable === "test" ? "yeet" : "null")),
"ya yeet the null"
);
});
});
describe("#toTitleCase()", () => {
it("should capitalize the first letter of each word", () => {
assert.strictEqual(

View File

@ -38,15 +38,20 @@ export function parseArgs(line: string): string[] {
* - `%%` = `%`
* - If the invalid token is null/undefined, nothing is changed.
*/
export function parseVars(line: string, definitions: {[key: string]: string}, invalid: string | null = ""): string {
export function parseVars(
line: string,
definitions: {[key: string]: string},
delimiter = "%",
invalid: string | null = ""
): string {
let result = "";
let inVariable = false;
let token = "";
for (const c of line) {
if (c === "%") {
if (c === delimiter) {
if (inVariable) {
if (token === "") result += "%";
if (token === "") result += delimiter;
else {
if (token in definitions) result += definitions[token];
else if (invalid === null) result += `%${token}%`;
@ -64,6 +69,29 @@ export function parseVars(line: string, definitions: {[key: string]: string}, in
return result;
}
export function parseVarsCallback(line: string, callback: (variable: string) => string, delimiter = "%"): string {
let result = "";
let inVariable = false;
let token = "";
for (const c of line) {
if (c === delimiter) {
if (inVariable) {
if (token === "") result += delimiter;
else {
result += callback(token);
token = "";
}
}
inVariable = !inVariable;
} else if (inVariable) token += c;
else result += c;
}
return result;
}
export function isType(value: any, type: any): boolean {
if (value === undefined && type === undefined) return true;
else if (value === null && type === null) return true;
@ -86,7 +114,7 @@ export function select<T>(value: any, fallback: T, type: Function, isArray = fal
}
}
export function clean(text: any) {
export function clean(text: unknown) {
if (typeof text === "string")
return text.replace(/`/g, "`" + String.fromCharCode(8203)).replace(/@/g, "@" + String.fromCharCode(8203));
else return text;
@ -126,7 +154,11 @@ export function getContent(url: string): Promise<{url: string}> {
const parsedData = JSON.parse(rawData);
resolve(parsedData);
} catch (e) {
reject(`Error: ${e.message}`);
let errorMessage = "Something went wrong! We don't know what, though...";
if (e instanceof Error) {
errorMessage = e.message;
}
reject(`Error: ${errorMessage}`);
}
});
}).on("error", (err: {message: any}) => {

View File

@ -0,0 +1,17 @@
import {client} from "../index";
import {Storage} from "../structures";
import {Permissions} from "discord.js";
client.on("voiceStateUpdate", async (before, after) => {
const channel = before.channel;
const {channelNames} = Storage.getGuild(after.guild.id);
if (
channel &&
channel.members.size === 0 &&
channel.id in channelNames &&
before.guild.me?.permissions.has(Permissions.FLAGS.MANAGE_CHANNELS)
) {
channel.setName(channelNames[channel.id]);
}
});

View File

@ -2,17 +2,18 @@ import {client} from "../index";
import FileManager from "./storage";
import {EmoteRegistryDump} from "../structures";
function updateGlobalEmoteRegistry(): void {
async function updateGlobalEmoteRegistry(): Promise<void> {
const data: EmoteRegistryDump = {version: 1, list: []};
for (const guild of client.guilds.cache.values()) {
for (const emote of guild.emojis.cache.values()) {
let g = await guild.fetch();
for (const emote of g.emojis.cache.values()) {
data.list.push({
ref: emote.name,
id: emote.id,
name: emote.name,
requires_colons: emote.requiresColons || false,
animated: emote.animated,
requires_colons: emote.requiresColons ?? false,
animated: emote.animated ?? false,
url: emote.url,
guild_id: emote.guild.name,
guild_name: emote.guild.name
@ -20,34 +21,13 @@ function updateGlobalEmoteRegistry(): void {
}
}
FileManager.write("emote-registry", data, true);
FileManager.open("data/public"); // generate folder if it doesn't exist
FileManager.write("public/emote-registry", data, false);
}
client.on("emojiCreate", (emote) => {
console.log(`Updated emote registry. ${emote.name}`);
updateGlobalEmoteRegistry();
});
client.on("emojiDelete", () => {
console.log("Updated emote registry.");
updateGlobalEmoteRegistry();
});
client.on("emojiUpdate", () => {
console.log("Updated emote registry.");
updateGlobalEmoteRegistry();
});
client.on("guildCreate", () => {
console.log("Updated emote registry.");
updateGlobalEmoteRegistry();
});
client.on("guildDelete", () => {
console.log("Updated emote registry.");
updateGlobalEmoteRegistry();
});
client.on("ready", () => {
updateGlobalEmoteRegistry();
});
client.on("emojiCreate", updateGlobalEmoteRegistry);
client.on("emojiDelete", updateGlobalEmoteRegistry);
client.on("emojiUpdate", updateGlobalEmoteRegistry);
client.on("guildCreate", updateGlobalEmoteRegistry);
client.on("guildDelete", updateGlobalEmoteRegistry);
client.on("ready", updateGlobalEmoteRegistry);

View File

@ -0,0 +1,56 @@
// This will keep track of the last event that occurred to provide context to error messages.
// Like with logging each command invocation, it's not a good idea to pollute the logs with this kind of stuff when it works most of the time.
// However, it's also a pain to debug when no context is provided for an error message.
import {client} from "..";
import {setExecuteCommandListener} from "onion-lasers";
import {TextChannel, DMChannel, NewsChannel} from "discord.js";
let lastEvent = "N/A";
let lastCommandInfo: {
header: string;
args: string[];
channel: TextChannel | DMChannel | NewsChannel | null;
} = {
header: "N/A",
args: [],
channel: null
};
process.on("unhandledRejection", (reason: any) => {
const isLavalinkError = reason?.code === "ECONNREFUSED";
const isDiscordError = reason?.name === "DiscordAPIError";
if (!isLavalinkError) {
// If it's a DiscordAPIError on a message event, I'll make the assumption that it comes from the command handler.
// That's not always the case though, especially if you add your own message events. Just be wary of that.
if (isDiscordError && lastEvent === "message") {
console.error(
`Command Error: ${lastCommandInfo.header} (${lastCommandInfo.args.join(", ")})\n${reason.stack}`
);
lastCommandInfo.channel?.send(
`There was an error while trying to execute that command!\`\`\`${reason.stack}\`\`\``
);
} else {
console.error(
`@${lastEvent} : /${lastCommandInfo.header} (${lastCommandInfo.args.join(", ")})\n${reason.stack}`
);
}
}
});
// Store info on which command was executed last.
setExecuteCommandListener(({header, args, channel}) => {
lastCommandInfo = {
header,
args,
channel
};
});
// This will dynamically attach all known events instead of doing it manually.
// As such, it needs to be placed after all other events are attached or the tracking won't be done properly.
for (const event of client.eventNames()) {
client.on(event, () => {
lastEvent = event.toString();
});
}

View File

@ -3,6 +3,7 @@ import chalk from "chalk";
declare global {
var IS_DEV_MODE: boolean;
var PERMISSIONS: typeof PermissionsEnum;
var BOT_VERSION: string;
interface Console {
ready: (...data: any[]) => void;
@ -21,6 +22,7 @@ enum PermissionsEnum {
global.IS_DEV_MODE = process.argv[2] === "dev";
global.PERMISSIONS = PermissionsEnum;
global.BOT_VERSION = "3.2.3";
const oldConsole = console;

View File

@ -16,12 +16,16 @@ function applyText(canvas: Canvas, text: string) {
}
client.on("guildMemberAdd", async (member) => {
const {welcomeType, welcomeChannel, welcomeMessage} = Storage.getGuild(member.guild.id);
const {welcomeType, welcomeChannel, welcomeMessage, autoRoles} = Storage.getGuild(member.guild.id);
if (autoRoles) {
member.roles.add(autoRoles);
}
if (welcomeChannel) {
const channel = member.guild.channels.cache.get(welcomeChannel);
if (channel && channel.type === "text") {
if (channel && channel instanceof TextChannel) {
if (welcomeType === "graphical") {
const canvas = createCanvas(700, 250);
const ctx = canvas.getContext("2d");
@ -56,9 +60,9 @@ client.on("guildMemberAdd", async (member) => {
ctx.drawImage(avatar, 25, 25, 200, 200);
const attachment = new MessageAttachment(canvas.toBuffer("image/png"), "welcome-image.png");
(channel as TextChannel).send(`Welcome \`${member.user.tag}\`!`, attachment);
channel.send({content: `Welcome \`${member.user.tag}\`!`, attachments: [attachment]});
} else if (welcomeType === "text") {
(channel as TextChannel).send(
channel.send(
parseVars(
welcomeMessage || "Say hello to `%user%`, everyone! We all need a warm welcome sometimes :D",
{
@ -68,7 +72,7 @@ client.on("guildMemberAdd", async (member) => {
);
}
} else {
console.error(`"${welcomeChannel}" is not a valid text channel ID!`);
console.error("[modules/guildMemberAdd]", `"${welcomeChannel}" is not a valid text channel ID!`);
}
}
});

View File

@ -1,5 +1,10 @@
import {client} from "../index";
// Potentially port CE's intercept module to here?
// - ` ${text} `.test(/[ \.,\?!]hon[ \.,\?!]/)
// - "oil" will remain the same though, it's better that way (anything even remotely "oil"-related calls the image)
// - Also uwu and owo penalties
client.on("message", (message) => {
if (message.content.toLowerCase().includes("remember to drink water")) {
message.react("🚱");

View File

@ -1,48 +1,49 @@
import attachClientToLavalink from "discord.js-lavalink-lib";
import {Config} from "../structures";
import {client} from "../index";
// Although the example showed to do "client.music = LavaLink(...)" and "(client as any).music = Lavalink(...)" was done to match that, nowhere in the library is client.music ever actually used nor does the function return anything. In other words, client.music is undefined and is never used.
attachClientToLavalink(client, {
lavalink: {
restnode: {
host: "localhost",
port: 2333,
password: "youshallnotpass"
},
nodes: [
{
host: "localhost",
port: 2333,
password: "youshallnotpass"
}
]
},
prefix: Config.prefix,
helpCmd: "mhelp",
admins: ["717352467280691331"]
});
// Disable the unhandledRejection listener by Lavalink because it captures every single unhandled
// rejection and adds its message with it. Then replace it with a better, more selective error handler.
for (const listener of process.listeners("unhandledRejection")) {
if (listener.toString().includes("discord.js-lavalink-musicbot")) {
process.off("unhandledRejection", listener);
}
}
process.on("unhandledRejection", (reason: any) => {
if (reason?.code === "ECONNREFUSED") {
console.error(
`[discord.js-lavalink-musicbot] Caught unhandled rejection: ${reason.stack}\nIf this is causing issues, head to the support server at https://discord.gg/dNN4azK`
);
}
});
// It's unsafe to process uncaughtException because after an uncaught exception, the system
// becomes corrupted. So disable Lavalink from adding a hook to it.
for (const listener of process.listeners("uncaughtException")) {
if (listener.toString().includes("discord.js-lavalink-musicbot")) {
process.off("uncaughtException", listener);
}
}
// import attachClientToLavalink from "discord.js-lavalink-lib";
// import {Config} from "../structures";
// import {client} from "../index";
//
// // Although the example showed to do "client.music = LavaLink(...)" and "(client as any).music = Lavalink(...)" was done to match that, nowhere in the library is client.music ever actually used nor does the function return anything. In other words, client.music is undefined and is never used.
// attachClientToLavalink(client, {
// lavalink: {
// restnode: {
// host: "localhost",
// port: 2333,
// password: "youshallnotpass"
// },
// nodes: [
// {
// host: "localhost",
// port: 2333,
// password: "youshallnotpass"
// }
// ]
// },
// prefix: Config.prefix,
// helpCmd: "mhelp",
// admins: ["717352467280691331"]
// });
//
// // Disable the unhandledRejection listener by Lavalink because it captures every single unhandled
// // rejection and adds its message with it. Then replace it with a better, more selective error handler.
// for (const listener of process.listeners("unhandledRejection")) {
// if (listener.toString().includes("discord.js-lavalink-musicbot")) {
// process.off("unhandledRejection", listener);
// }
// }
//
// process.on("unhandledRejection", (reason: any) => {
// if (reason?.code === "ECONNREFUSED") {
// // This is console.warn instead of console.error because on development environments, unless Lavalink is being tested, it won't interfere with the bot's functionality.
// console.warn(
// `[discord.js-lavalink-musicbot] Caught unhandled rejection: ${reason.stack}\nIf this is causing issues, head to the support server at https://discord.gg/dNN4azK`
// );
// }
// });
//
// // It's unsafe to process uncaughtException because after an uncaught exception, the system
// // becomes corrupted. So disable Lavalink from adding a hook to it.
// for (const listener of process.listeners("uncaughtException")) {
// if (listener.toString().includes("discord.js-lavalink-musicbot")) {
// process.off("uncaughtException", listener);
// }
// }

View File

@ -3,45 +3,47 @@ import {MessageEmbed} from "discord.js";
import {getPrefix} from "../structures";
import {getMessageByID} from "onion-lasers";
client.on("message", async (message) => {
// Only execute if the message is from a user and isn't a command.
if (message.content.startsWith(getPrefix(message.guild)) || message.author.bot) return;
const messageLink = extractFirstMessageLink(message.content);
if (!messageLink) return;
const [guildID, channelID, messageID] = messageLink;
client.on("message", (message) => {
(async () => {
// Only execute if the message is from a user and isn't a command.
if (message.content.startsWith(getPrefix(message.guild)) || message.author.bot) return;
const messageLink = extractFirstMessageLink(message.content);
if (!messageLink) return;
const [guildID, channelID, messageID] = messageLink;
const linkMessage = await getMessageByID(channelID, messageID);
const linkMessage = await getMessageByID(channelID, messageID);
// If it's an invalid link (or the bot doesn't have access to it).
if (typeof linkMessage === "string") {
return message.channel.send("I don't have access to that channel!");
}
// If it's an invalid link (or the bot doesn't have access to it).
if (typeof linkMessage === "string") {
return message.channel.send("I don't have access to that channel!");
}
const embeds = [
...linkMessage.embeds.filter((embed) => embed.type === "rich"),
...linkMessage.attachments.values()
];
const embeds = [
...linkMessage.embeds.filter((embed) => embed.type === "rich"),
...linkMessage.attachments.values()
];
if (!linkMessage.cleanContent && embeds.length === 0) {
return message.channel.send(new MessageEmbed().setDescription("🚫 The message is empty."));
}
if (!linkMessage.cleanContent && embeds.length === 0) {
return message.channel.send({embeds: [new MessageEmbed().setDescription("🚫 The message is empty.")]});
}
const infoEmbed = new MessageEmbed()
.setAuthor(
linkMessage.author.username,
linkMessage.author.displayAvatarURL({format: "png", dynamic: true, size: 4096})
)
.setTimestamp(linkMessage.createdTimestamp)
.setDescription(
`${linkMessage.cleanContent}\n\nSent in **${linkMessage.guild?.name}** | <#${linkMessage.channel.id}> ([link](https://discord.com/channels/${guildID}/${channelID}/${messageID}))`
);
const infoEmbed = new MessageEmbed()
.setAuthor(
linkMessage.author.username,
linkMessage.author.displayAvatarURL({format: "png", dynamic: true, size: 4096})
)
.setTimestamp(linkMessage.createdTimestamp)
.setDescription(
`${linkMessage.cleanContent}\n\nSent in **${linkMessage.guild?.name}** | <#${linkMessage.channel.id}> ([link](https://discord.com/channels/${guildID}/${channelID}/${messageID}))`
);
if (linkMessage.attachments.size !== 0) {
const image = linkMessage.attachments.first();
infoEmbed.setImage(image!.url);
}
if (linkMessage.attachments.size !== 0) {
const image = linkMessage.attachments.first();
infoEmbed.setImage(image!.url);
}
return await message.channel.send(infoEmbed);
return await message.channel.send({embeds: [infoEmbed]});
})();
});
export function extractFirstMessageLink(message: string): [string, string, string] | null {

View File

@ -4,7 +4,7 @@ import {Config, Storage} from "../structures";
client.once("ready", () => {
if (client.user) {
console.ready(
`Logged in as ${client.user.tag}, ready to serve ${client.users.cache.size} users in ${client.guilds.cache.size} servers..`
`Logged in as ${client.user.tag}, ready to serve ${client.users.cache.size} users in ${client.guilds.cache.size} servers.`
);
client.user.setActivity({
type: "LISTENING",

View File

@ -3,16 +3,6 @@ import inquirer from "inquirer";
import Storage from "./storage";
import {Config} from "../structures";
// A generic process handler is set to catch unhandled rejections other than the ones from Lavalink and Discord.
process.on("unhandledRejection", (reason: any) => {
const isLavalinkError = reason?.code === "ECONNREFUSED";
const isDiscordError = reason?.name === "DiscordAPIError";
if (!isLavalinkError && !isDiscordError) {
console.error(reason.stack);
}
});
// This file is called (or at least should be called) automatically as long as a config file doesn't exist yet.
// And that file won't be written until the data is successfully initialized.
const prompts = [

View File

@ -14,18 +14,18 @@ const Storage = {
data = JSON.parse(file);
} catch (error) {
if (process.argv[2] !== "dev") {
console.warn(`Malformed JSON data (header: ${header}), backing it up.`, file);
fs.writeFile(
`${path}.backup`,
file,
generateHandler(`Backup file of "${header}" successfully written as ${file}.`)
);
console.warn("[storage.read]", `Malformed JSON data (header: ${header}), backing it up.`, file);
fs.writeFile(`${path}.backup`, file, (error) => {
if (error) console.error("[storage.read]", error);
console.log("[storage.read]", `Backup file of "${header}" successfully written as ${file}.`);
});
}
}
}
return data;
},
// There is no need to log successfully written operations as it pollutes the log with useless info for debugging.
write(header: string, data: object, asynchronous = true) {
this.open("data");
const path = `data/${header}.json`;
@ -34,12 +34,17 @@ const Storage = {
const result = JSON.stringify(data, null, "\t");
if (asynchronous)
fs.writeFile(path, result, generateHandler(`"${header}" sucessfully spaced and written.`));
fs.writeFile(path, result, (error) => {
if (error) console.error("[storage.write]", error);
});
else fs.writeFileSync(path, result);
} else {
const result = JSON.stringify(data);
if (asynchronous) fs.writeFile(path, result, generateHandler(`"${header}" sucessfully written.`));
if (asynchronous)
fs.writeFile(path, result, (error) => {
if (error) console.error("[storage.write]", error);
});
else fs.writeFileSync(path, result);
}
},
@ -54,15 +59,10 @@ const Storage = {
},
close(path: string) {
if (fs.existsSync(path) && fs.readdirSync(path).length === 0)
fs.rmdir(path, generateHandler(`"${path}" successfully closed.`));
fs.rmdir(path, (error) => {
if (error) console.error("[storage.close]", error);
});
}
};
export function generateHandler(message: string) {
return (error: Error | null) => {
if (error) console.error(error);
else console.debug(message);
};
}
export default Storage;

View File

@ -1,10 +1,10 @@
import {GuildMember, VoiceChannel, MessageEmbed, TextChannel, Message, Collection} from "discord.js";
import {GuildMember, VoiceChannel, MessageEmbed, TextChannel, Message, Collection, StageChannel} from "discord.js";
import {client} from "../index";
import {Storage} from "../structures";
type Stream = {
streamer: GuildMember;
channel: VoiceChannel;
channel: VoiceChannel | StageChannel;
category: string;
description?: string;
thumbnail?: string;
@ -19,7 +19,7 @@ export const streamList = new Collection<string, Stream>();
// Probably find a better, DRY way of doing this.
function getStreamEmbed(
streamer: GuildMember,
channel: VoiceChannel,
channel: VoiceChannel | StageChannel,
streamStart: number,
category: string,
description?: string,
@ -38,7 +38,7 @@ function getStreamEmbed(
// I decided to not include certain fields:
// .addField("Activity", "CrossCode", true) - Probably too much presence data involved, increasing memory usage.
// .addField("Viewers", 5, true) - There doesn't seem to currently be a way to track how many viewers there are. Presence data for "WATCHING" doesn't seem to affect it, and listening to raw client events doesn't seem to make it appear either.
.addField("Voice Channel", channel, true)
.addField("Voice Channel", channel.toString(), true)
.addField("Category", category, true)
.setColor(streamer.displayColor)
.setFooter(
@ -90,21 +90,23 @@ client.on("voiceStateUpdate", async (before, after) => {
streamer: member,
channel: voiceChannel,
category,
message: await textChannel.send(
streamNotificationPing,
getStreamEmbed(member, voiceChannel, streamStart, category)
),
message: await textChannel.send({
content: streamNotificationPing,
embeds: [getStreamEmbed(member, voiceChannel, streamStart, category)]
}),
update(this: Stream) {
this.message.edit(
getStreamEmbed(
this.streamer,
this.channel,
streamStart,
this.category,
this.description,
this.thumbnail
)
);
this.message.edit({
embeds: [
getStreamEmbed(
this.streamer,
this.channel,
streamStart,
this.category,
this.description,
this.thumbnail
)
]
});
},
streamStart
});
@ -125,7 +127,7 @@ client.on("voiceStateUpdate", async (before, after) => {
});
client.on("channelUpdate", (before, after) => {
if (before.type === "voice" && after.type === "voice") {
if (before instanceof VoiceChannel && after instanceof VoiceChannel) {
for (const stream of streamList.values()) {
if (after.id === stream.channel.id) {
stream.update();

View File

@ -1,40 +1,21 @@
import {client} from "../index";
import {GuildChannel, TextChannel} from "discord.js";
import {TextChannel} from "discord.js";
import {Config} from "../structures";
client.on("channelCreate", async (channel) => {
const botGuilds = client.guilds;
// Logging which guilds the bot is added to and removed from makes sense.
// However, logging the specific channels that are added/removed is a tad bit privacy-invading.
if (channel instanceof GuildChannel) {
const createdGuild = await botGuilds.fetch(channel.guild.id);
console.log(`Channel created in '${createdGuild.name}' called '#${channel.name}'`);
}
});
client.on("guildCreate", async (guild) => {
const owner = await guild.fetchOwner();
client.on("channelDelete", async (channel) => {
const botGuilds = client.guilds;
if (channel instanceof GuildChannel) {
const createdGuild = await botGuilds.fetch(channel.guild.id);
console.log(`Channel deleted in '${createdGuild.name}' called '#${channel.name}'`);
}
});
client.on("guildCreate", (guild) => {
console.log(
`[GUILD JOIN] ${guild.name} (${guild.id}) added the bot. Owner: ${guild.owner!.user.tag} (${
guild.owner!.user.id
}).`
);
console.log(`[GUILD JOIN] ${guild.name} (${guild.id}) added the bot. Owner: ${owner.user.tag} (${owner.user.id}).`);
if (Config.systemLogsChannel) {
const channel = client.channels.cache.get(Config.systemLogsChannel);
if (channel && channel.type === "text") {
(channel as TextChannel).send(
`TravBot joined: \`${guild.name}\`. The owner of this guild is: \`${guild.owner!.user.tag}\` (\`${
guild.owner!.user.id
}\`)`
if (channel instanceof TextChannel) {
channel.send(
`TravBot joined: \`${guild.name}\`. The owner of this guild is: \`${owner.user.tag}\` (\`${owner.user.id}\`)`
);
} else {
console.warn(`${Config.systemLogsChannel} is not a valid text channel for system logs!`);
@ -48,10 +29,14 @@ client.on("guildDelete", (guild) => {
if (Config.systemLogsChannel) {
const channel = client.channels.cache.get(Config.systemLogsChannel);
if (channel && channel.type === "text") {
(channel as TextChannel).send(`\`${guild.name}\` (\`${guild.id}\`) removed the bot.`);
if (channel instanceof TextChannel) {
channel.send(`\`${guild.name}\` (\`${guild.id}\`) removed the bot.`);
} else {
console.warn(`${Config.systemLogsChannel} is not a valid text channel for system logs!`);
console.warn(
`${Config.systemLogsChannel} is not a valid text channel for system logs! Removing it from storage.`
);
Config.systemLogsChannel = null;
Config.save();
}
}
});

View File

@ -0,0 +1,70 @@
import {Webhook, TextChannel, NewsChannel, Permissions, Collection} from "discord.js";
import {client} from "..";
import {Config} from "../structures";
export const webhookStorage = new Collection<string, Webhook>(); // Channel ID: Webhook
const WEBHOOK_PATTERN = /https:\/\/discord\.com\/api\/webhooks\/(\d{17,})\/(.+)/;
const ID_PATTERN = /(\d{17,})/;
// Resolve any available webhooks available for a selected channel.
export async function resolveWebhook(channel: TextChannel | NewsChannel): Promise<Webhook | null> {
if (channel.guild.me?.permissions.has(Permissions.FLAGS.MANAGE_WEBHOOKS)) {
const webhooksInChannel = await channel.fetchWebhooks();
if (webhooksInChannel.size > 0) return webhooksInChannel.first()!;
else return null;
}
for (const [channelID, webhook] of webhookStorage.entries()) if (channel.id === channelID) return webhook;
return null;
}
export function registerWebhook(url: string): boolean {
if (WEBHOOK_PATTERN.test(url)) {
const [_, id, token] = WEBHOOK_PATTERN.exec(url)!;
Config.webhooks[id] = token;
Config.save();
refreshWebhookCache();
return true;
} else {
return false;
}
}
export function deleteWebhook(urlOrID: string): boolean {
let id: string | null = null;
if (WEBHOOK_PATTERN.test(urlOrID)) id = WEBHOOK_PATTERN.exec(urlOrID)![1];
else if (ID_PATTERN.test(urlOrID)) id = ID_PATTERN.exec(urlOrID)![1];
if (id) {
delete Config.webhooks[id];
Config.save();
refreshWebhookCache();
}
return !!id;
}
// This will return the target channel of a webhook create/edit/delete event.
// No permission is needed to receive this event, but since you only get the target channel, all stored webhooks must be fetched again.
// You can't rely on guilds giving the bot the manage webhooks permission.
client.on("webhookUpdate", refreshWebhookCache);
client.on("ready", refreshWebhookCache);
// Reload webhook objects from the storage.
export async function refreshWebhookCache(): Promise<void> {
webhookStorage.clear();
for (const [id, token] of Object.entries(Config.webhooks)) {
// If there are stored webhook IDs/tokens that don't work, delete those webhooks from storage.
try {
const webhook = await client.fetchWebhook(id, token);
webhookStorage.set(webhook.channelId, webhook);
} catch {
delete Config.webhooks[id];
Config.save();
}
}
}

View File

@ -13,7 +13,10 @@ class ConfigStructure extends GenericStructure {
public owner: string;
public admins: string[];
public support: string[];
public lavalink: boolean | null;
public wolfram: string | null;
public systemLogsChannel: string | null;
public webhooks: {[id: string]: string}; // id-token pairs
constructor(data: GenericJSON) {
super("config");
@ -22,7 +25,18 @@ class ConfigStructure extends GenericStructure {
this.owner = select(data.owner, "", String);
this.admins = select(data.admins, [], String, true);
this.support = select(data.support, [], String, true);
this.lavalink = select(data.lavalink, null, Boolean);
this.wolfram = select(data.wolfram, null, String);
this.systemLogsChannel = select(data.systemLogsChannel, null, String);
this.webhooks = {};
for (const id in data.webhooks) {
const token = data.webhooks[id];
if (/\d{17,}/g.test(id) && typeof token === "string") {
this.webhooks[id] = token;
}
}
}
}
@ -67,19 +81,25 @@ class Member {
class Guild {
public prefix: string | null;
public messageEmbeds: boolean | null;
public welcomeType: "none" | "text" | "graphical";
public welcomeChannel: string | null;
public welcomeMessage: string | null;
public autoRoles: string[] | null; // StringArray of role IDs
public streamingChannel: string | null;
public streamingRoles: {[role: string]: string}; // Role ID: Category Name
public channelNames: {[channel: string]: string};
public members: {[id: string]: Member};
constructor(data?: GenericJSON) {
this.prefix = select(data?.prefix, null, String);
this.messageEmbeds = select(data?.messageLinks, true, Boolean);
this.welcomeChannel = select(data?.welcomeChannel, null, String);
this.welcomeMessage = select(data?.welcomeMessage, null, String);
this.autoRoles = select(data?.autoRoles, null, String, true);
this.streamingChannel = select(data?.streamingChannel, null, String);
this.streamingRoles = {};
this.channelNames = {};
this.members = {};
switch (data?.welcomeType) {
@ -104,6 +124,16 @@ class Guild {
}
}
if (data?.channelNames) {
for (const id in data.channelNames) {
const name = data.channelNames[id];
if (/\d{17,}/g.test(id) && typeof name === "string") {
this.channelNames[id] = name;
}
}
}
if (data?.members) {
for (let id in data.members) {
if (/\d{17,}/g.test(id)) {
@ -116,7 +146,10 @@ class Guild {
/** Gets a member's profile if they exist and generate one if not. */
public getMember(id: string): Member {
if (!/\d{17,}/g.test(id))
console.warn(`"${id}" is not a valid user ID! It will be erased when the data loads again.`);
console.warn(
"[structures]",
`"${id}" is not a valid user ID! It will be erased when the data loads again.`
);
if (id in this.members) return this.members[id];
else {
@ -142,7 +175,10 @@ class StorageStructure extends GenericStructure {
/** Gets a user's profile if they exist and generate one if not. */
public getUser(id: string): User {
if (!/\d{17,}/g.test(id))
console.warn(`"${id}" is not a valid user ID! It will be erased when the data loads again.`);
console.warn(
"[structures]",
`"${id}" is not a valid user ID! It will be erased when the data loads again.`
);
if (id in this.users) return this.users[id];
else {
@ -155,7 +191,10 @@ class StorageStructure extends GenericStructure {
/** Gets a guild's settings if they exist and generate one if not. */
public getGuild(id: string): Guild {
if (!/\d{17,}/g.test(id))
console.warn(`"${id}" is not a valid guild ID! It will be erased when the data loads again.`);
console.warn(
"[structures]",
`"${id}" is not a valid guild ID! It will be erased when the data loads again.`
);
if (id in this.guilds) return this.guilds[id];
else {
@ -173,8 +212,7 @@ export let Storage = new StorageStructure(FileManager.read("storage"));
// This part will allow the user to manually edit any JSON files they want while the program is running which'll update the program's cache.
// However, fs.watch is a buggy mess that should be avoided in production. While it helps test out stuff for development, it's not a good idea to have it running outside of development as it causes all sorts of issues.
if (IS_DEV_MODE) {
watch("data", (event, filename) => {
console.debug("File Watcher:", event, filename);
watch("data", (_event, filename) => {
const header = filename.substring(0, filename.indexOf(".json"));
switch (header) {
@ -207,9 +245,9 @@ export function getPrefix(guild: DiscordGuild | null): string {
}
export interface EmoteRegistryDumpEntry {
ref: string;
ref: string | null;
id: Snowflake;
name: string;
name: string | null;
requires_colons: boolean;
animated: boolean;
url: string;

View File

@ -10,7 +10,7 @@
// Type Settings //
"strict": true, // Enables all strict checks possible.
"noImplicitReturns": true, // Makes sure you don't accidentally return something + undefined.
"noImplicitReturns": false, // Makes sure you don't accidentally return something + undefined.
"noFallthroughCasesInSwitch": true, // Prevents accidentally forgetting to break every switch case. Of course, if you know what you're doing, feel free to add a @ts-ignore, which also signals that it's not a mistake.
"forceConsistentCasingInFileNames": true, // Make import paths case-sensitive. "./tEst" is no longer the same as "./test".
"esModuleInterop": true, // Enables compatibility with Node.js' module system since the entire export can be whatever you want. allowSyntheticDefaultImports doesn't address runtime issues and is made redundant by this setting.