Merge branch 'rewrite'

This commit is contained in:
Ean Milligan (Bastion) 2023-05-01 13:53:53 -04:00
commit 5660e08574
77 changed files with 5632 additions and 2460 deletions

View File

@ -11,5 +11,9 @@
"spellright.documentTypes": [],
"deno.suggest.imports.hosts": {
"https://deno.land": true
}
},
"cSpell.words": [
"sproc",
"USTZ"
]
}

41
PRIVACY.md Normal file
View File

@ -0,0 +1,41 @@
# Group Up's Privacy Policy
## Information relating to Discord Interactions
### Public Bot Information
Publicly available versions of `Group Up#1305` (Discord ID: `847256159123013722`) (herein referred to as _The Bot_ or _Bot_) do not automatically track or collect user information via Discord.
Upon inviting _The Bot_ to a user's guild, _The Bot_ sends the guild name, Discord Guild ID, and current count of guild members to Burn_E99#1062 (herein referred to as _The Developer_) via a private Discord Guild. The guild name, Discord Guild ID, and current count of guild members are only used to roughly gage how popular _The Bot_ is and to determine if _The Bot_'s hosting solution needs to be improved. These pieces of information will never be sold or shared with anyone.
_The Bot_ reads every message that it is allowed to, meaning if _The Bot_ is allowed to see a channel in a guild, it reads every new message sent in said channel. This is for the automated cleanup of designated Event Channels.
_The Bot_ does not read any user messages sent in the past, but does read its own past messages. This is to limit the amount of data that _The Bot_ needs to store outside of Discord, giving users better control of their own data.
* Messages that do not begin with _The Bot_'s command prefix are not saved or stored anywhere. Messages that do not begin with _The Bot_'s command prefix that are sent outside of a designated Event Channel are ignored and not processed.
* Slash Commands sent to _The Bot_ do not automatically log user data, and most commands to not log any data. The commands that log data are the report command (in Discord, this command is known as `/report [message]`), the Event Channel setup command (known as `/setup`), and the Create New Event command (known as `/create-event` or the `Create New Event` button).
* The report command only stores the text placed within the message that is directly after the command (herein referred to as _The Report Text_). This command is entirely optional, meaning users never need to run this command under normal usage of _The Bot_. This command is only intended to be used to report roll commands that did not output what was expected. This command will accept any value for _The Report Text_, thus it is up to the user to remove any sensitive information before sending the command. _The Report Text_ is stored in a private Discord Guild in a channel that only _The Developer_ can see. _The Report Text_ is solely used to improve _The Bot_, either by providing a feature suggestions or alerting _The Developer_ to bugs that need patched.
* The Event Channel setup command only stores Discord IDs. The setup command will always store the Discord Channel ID and Discord Guild ID from where it was run.
* If the Event Channel setup command was run with the `with-manager-role` option, the submitted Discord Role ID and Discord Channel ID for the desired Manager Role and Log Channel will also be stored.
* The Create New Event command stores the following data for every event that is created:
* The Discord Message ID, Discord Channel ID, and Discord Guild ID of the event that the user created. These IDs are stored so that _The Bot_ can locate the event for future use, including but not limited to: editing or deleting the event, and notifying the members of the event.
* The Discord User ID of the creator. This ID is stored so that _The Bot_ knows who created the event to control who can edit or delete the event. This ID is also used when _The Bot_ fails to find an event so that the event creator is aware that _The Bot_ was unable to notify the event members.
* The full Date and Time of the event. The Date and Time of the event are stored so that _The Bot_ can send notifications to members of events when the event starts.
* If a Custom Activity is created during the Create New Event command, the following additional data is stored:
* The Activity Title, Subtitle, and Max Members entered by the event creator. These are stored so that _The Developer_ can determine if a new activity preset is necessary to improve user experience while using _The Bot_.
* The Discord Guild ID that the Custom Activity was created in. This ID is stored so that _The Bot_ can delete the Custom Activity from its database if _The Bot_ is removed from the Guild.
All commands contribute to a global counter to track the number of times a command is used. These counters do not keep track of where commands were run, only counting the number of times the command has been called. These counters have no way of being tracked back to the individual commands run by the users.
If the Discord interaction is not explicitly mentioned above, it does not collect any data at all.
### Private Bot Information
Privately hosted versions of Group Up (in other words, bots running Group Up's source code, but not running under the publicly available _Bot_, `Group Up#1305`) (herein referred to as _Rehosts_ or _Rehost_) may exist. _The Developer_ is not responsible for _Rehosts_, thus _Rehosts_ of _The Bot_ are not recommended to be used.
All policies described in **Public Bot Information** apply to _Rehosts_.
Due to the nature of open source code, _Rehosts_ may not use the same codebase that is available in this repository. _The Developer_ does not moderate what other developers do to this codebase. This means that if you are not using the publicly available _Bot_ and instead using a _Rehost_, this _Rehost_ could collect any information it desires.
# Deleting Your Data
## Event Deletion
If you wish to remove all data that _The Bot_ has on your Guild, simply remove _The Bot_ from your Guild. Upon removal, _The Bot_ deletes all data on Event Channel, all data on Events created in the Guild, and all Custom Activities created in the Guild.
## User Data Deletion
If you would like to ensure that all of your submitted reports are removed from _The Bot_'s private development server, please contact _The Developer_ via Discord (by sending a direct message to `Burn_E99#1062`) or via email (<ean@milligan.dev>) with a message along the lines of `"Please remove all of my submitted reports from your development server."`. Submitted reports are deleted from the server as they are processed, which happens roughly once a week, but this can be accelerated if requested.

View File

@ -1,8 +1,59 @@
# Group Up - An Event Scheduling Discord Bot | V0.5.8 - 2022/12/09
# Group Up - An Event Scheduling Discord Bot | V1.0.0 - 2022/05/03
[![SonarCloud](https://sonarcloud.io/images/project_badges/sonarcloud-orange.svg)](https://sonarcloud.io/summary/new_code?id=GroupUp)
[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=GroupUp&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=GroupUp) [![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=GroupUp&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=GroupUp) [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=GroupUp&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=GroupUp) [![Bugs](https://sonarcloud.io/api/project_badges/measure?project=GroupUp&metric=bugs)](https://sonarcloud.io/summary/new_code?id=GroupUp) [![Duplicated Lines (%)](https://sonarcloud.io/api/project_badges/measure?project=GroupUp&metric=duplicated_lines_density)](https://sonarcloud.io/summary/new_code?id=GroupUp) [![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=GroupUp&metric=ncloc)](https://sonarcloud.io/summary/new_code?id=GroupUp)
Group Up is a Discord bot built for scheduling events in your Guild.
Group Up is a Discord bot built for scheduling events in your Guild. The bot utilizes user-friendly Buttons, Slash Commands, and Forms to create events. Currently, Group Up has built in support for Destiny 2 and Among Us, but any event can be created for any game using the Create Custom Activity button.
# Invite Link
https://discord.com/api/oauth2/authorize?client_id=847256159123013722&permissions=92160&scope=bot
This bot was originally designed to replace the less reliable event scheduling provided by the Destiny 2 bot, Charlemagne, utilizing new Discord features many months before Charlemagne did.
## Using Group Up
I am hosting this bot for public use and you may find its invite link below. If you would like to host this bot yourself, details of how to do so are found at the end of this README, but I do not recommend this unless you are experienced with running Discord bots.
After inviting the bot, if you want to create a dedicated event channel, simply run `/setup` in the desired channel and follow the on-screen prompts. If you don't want a dedicated channel, just run `/create-event` anywhere.
Note: The `MANAGE_GUILD`, `MANAGE_CHANNELS`, and `MANAGE_ROLES` permissions are only necessary for the `/setup` command. Once you create all of the event channel that you need, you may remove these permissions from the bot without causing any issues.
[Bot Invite Link](https://discord.com/api/oauth2/authorize?client_id=847256159123013722&permissions=268527664&scope=bot%20applications.commands)
[Support Server Invite Link](https://discord.gg/peHASXMZYv)
---
## Available Commands
* `/help`
* Provides a message to help users get Group Up set up in their guild.
* `/info`
* Outputs some information and links relating to the bot including the Privacy Policy and Terms of Service.
* `/report [issue, feature request]`
* People aren't perfect, but this bot is trying to be.
* If you encounter a command that errors out or returns something unexpected, please use this command to alert the developers of the problem.
* Additionally, if you have a feature request, this is one of the ways to request one
* `/create-event`
* Starts the event creation process.
* `/setup [options]` **ONLY Available to Guild Members with the `ADMINISTRATOR` permission**
* Designates the current channel as a Event Channel. After the command successfully runs, Group Up will be in control of the channel for running events.
* `/delete-lfg-channel` **ONLY Available to Guild Members with the `ADMINISTRATOR` permission**
* Removes the Event Channel designation from the current channel
* `/event [options]` **ONLY Available to Guild Members with a Group Up Manager role in a managed Event Channel**
* Allows Group Up Managers to Join/Leave/Alternate members to events
## Problems? Feature requests?
If you run into any errors or problems with the bot, or think you have a good idea to add to the bot, please submit a new GitHub issue detailing it. If you don't have a GitHub account, a report command (detailed above) is provided for use in Discord.
---
## Self Hosting Group Up
Group Up is built on [Deno](https://deno.land/) `v1.33.1` using [Discordeno](https://discordeno.mod.land/) `v17.0.1`. If you choose to run this yourself, you will need to rename `config.example.ts` to `config.ts` and edit some values. You will need to create a new [Discord Application](https://discord.com/developers/applications) and copy the newly generated token into the `"token"` field. If you want to utilize some of the bots dev features, you will need to fill in the keys `"logChannel"` and `"reportChannel"` with text channel IDs and `"devServer"` with a guild ID.
You will also need to install and setup a MySQL database with a user for the bot to use to add/modify the database. This user must have the `"DB Manager"` admin rights and `"REFERENCES"` Global Privileges. Once the DB is installed and a user is setup, run the provided `db\initialize.ts` to create the schema and tables. After this, run `db\populateDefaults.ts` to insert some needed values into the tables.
Once everything is set up, starting the bot can simply be done with `deno run --allow-write=./logs --allow-net .\mod.ts`.
---
## Privacy Policy and Terms of Service
Group Up has a Privacy Policy and Terms of Service to detail expectations of what user data is stored and how users should use Group Up. The following Privacy Policy and Terms of Service only apply to the officially hosted version of Group Up (`Group Up#1305`, Discord ID: `847256159123013722`).
Privacy Policy TL;DR: Group Up stores data relating to events, event channels, and text from the `/report` command. For more detailed information, please check out the full [PRIVACY POLICY](https://github.com/Burn-E99/TheArtificer/blob/master/PRIVACY.md).
Terms of Service TL;DR: Don't abuse or attempt to hack/damage Group Up. If you do, you may be banned from use. For more detailed information, please check out the full [TERMS OF SERVICE](https://github.com/Burn-E99/TheArtificer/blob/master/TERMS.md).

14
TERMS.md Normal file
View File

@ -0,0 +1,14 @@
# Group Up's Terms of Service/User Agreement
By using Group Up, you agree to the following terms. Breaking these terms may result in your account being banned from using Group Up (Discord Bot).
1. **User Conduct**. You agree to obey all applicable laws in using the Service, and agree that you are responsible for the content and/or communications you send to or initiate via Group Up. You agree that you are responsible for everything that you transmit to or in relation to Group Up and you specifically agree (in relation to Group Up) not to participate in any form of activity which is unlawful, harassing, libellous, defamatory, abusive, threatening, harmful, vulgar, obscene, profane, sexually-oriented, racially-offensive or otherwise includes objectionable material;
* not to collect personal data about other Users (for any purpose);
* not to register more than one account for yourself or anyone else;
* not to use Group Up to engage in any commercial activities not approved in writing by the Developer, Ean Milligan;
* not to impose an unreasonable or disproportionately large load on our infrastructure; and
* not to attempt to gain unauthorized access to Group Up's computer systems or engage in any activity that disrupts, diminishes the quality of, interferes with the performance of, or impairs the functionality of Group Up.
2. **Hacking**. You agree and undertake not to attempt to damage, deny service to, hack, crack, or otherwise interfere (collectively, "Interfere") with Group Up in any manner. If you in any way Interfere with these, you agree to pay all damages we incur as a result. We reserve the right to deny any or all access or service to any User for any reason, at any time, at our sole discretion. You agree that we may block your access, and at our sole discretion to disallow your continued use of Group Up. We reserve the right to take any action we may deem appropriate in our sole discretion with respect to violations or enforcement of the terms of this Agreement, and we expressly reserve all rights and remedies available to us at law or in equity.
3. **Termination of this Agreement**. Group Up may at any time terminate this legal Agreement, in our sole discretion without prior notice to you, if we believe that you may have breached (or acted in a manner indicating that you do not intend to or are unable to comply with) any term herein, or if we are legally required to do so by law, or if continuation is likely to be no longer commercially viable.
4. **Contacting Us**. If you have any questions, please contact us via email at <ean@milligan.dev>.

View File

@ -1,21 +1,27 @@
export const config = {
'name': 'Group Up', // Name of the bot
'version': '0.5.9', // Version of the bot
'version': '1.0.0', // Version of the bot
'token': 'the_bot_token', // Discord API Token for this bot
'localtoken': 'local_testing_token', // Discord API Token for a secondary OPTIONAL testing bot, THIS MUST BE DIFFERENT FROM "token"
'prefix': 'gu!', // Prefix for all commands
'localToken': 'local_testing_token', // Discord API Token for a secondary OPTIONAL testing bot, THIS MUST BE DIFFERENT FROM "token"
'prefix': '/', // Prefix for all commands
'db': { // Settings for the MySQL database, this is required for use with the API, if you do not want to set this up, you will need to rip all code relating to the DB out of the bot
'host': '', // IP address for the db, usually localhost
'localhost': '', // IP address for a secondary OPTIONAL local testing DB, usually also is localhost, but depends on your dev environment
'port': 3306, // Port for the db
'username': '', // Username for the account that will access your DB, this account will need "DB Manager" admin rights and "REFERENCES" Global Privalages
'username': '', // Username for the account that will access your DB, this account will need "DB Manager" admin rights and "REFERENCES" Global Privileges
'password': '', // Password for the account, user account may need to be authenticated with the "Standard" Authentication Type if this does not work out of the box
'name': '', // Name of the database Schema to use for the bot
},
'logChannel': 'the_log_channel', // Discord channel ID where the bot should put startup messages and other error messages needed
'reportChannel': 'the_report_channel', // Discord channel ID where reports will be sent when using the built-in report command
'devServer': 'the_dev_server', // Discord guild ID where testing of indev features/commands will be handled, used in conjuction with the DEVMODE bool in mod.ts
'owner': 'the_bot_owner', // Discord user ID of the bot admin
'link': { // Links to various sites
'sourceCode': 'https://github.com/Burn-E99/GroupUp', // Link to the repository
'supportServer': '', // Invite link to the Discord support server
'addToCalendar': '', // Link to where the icsGenerator is hosted
'creatorIcon': '', // Link to where the GroupUpSinglePerson.png (or similar image) is hosted
},
'logChannel': 0n, // Discord channel ID where the bot should put startup messages and other error messages needed
'reportChannel': 0n, // Discord channel ID where reports will be sent when using the built-in report command
'devServer': 0n, // Discord guild ID where testing of indev features/commands will be handled, used in conjunction with the DEVMODE bool in mod.ts
'owner': 0n, // Discord user ID of the bot admin
'botLists': [ // Array of objects containing all bot lists that stats should be posted to
{ // Bot List object, duplicate for each bot list
'name': 'Bot List Name', // Name of bot list, not used

View File

@ -1,21 +1,8 @@
// This file will create all tables for the artificer schema
// This file will create all tables for the groupup schema
// DATA WILL BE LOST IF DB ALREADY EXISTS, RUN AT OWN RISK
import {
// MySQL deps
Client,
} from '../deps.ts';
import { LOCALMODE } from '../flags.ts';
import config from '../config.ts';
// Log into the MySQL DB
const dbClient = await new Client().connect({
hostname: LOCALMODE ? config.db.localhost : config.db.host,
port: config.db.port,
username: config.db.username,
password: config.db.password,
});
import { dbClient } from '../src/db.ts';
console.log('Attempting to create DB');
await dbClient.execute(`CREATE SCHEMA IF NOT EXISTS ${config.db.name};`);
@ -23,11 +10,12 @@ await dbClient.execute(`USE ${config.db.name}`);
console.log('DB created');
console.log('Attempt to drop all tables');
await dbClient.execute(`DROP VIEW IF EXISTS db_size;`);
await dbClient.execute(`DROP PROCEDURE IF EXISTS INC_CNT;`);
await dbClient.execute(`DROP TABLE IF EXISTS command_cnt;`);
await dbClient.execute(`DROP TABLE IF EXISTS guild_prefix;`);
await dbClient.execute(`DROP TABLE IF EXISTS guild_mod_role;`);
await dbClient.execute(`DROP TABLE IF EXISTS guild_clean_channel;`);
await dbClient.execute(`DROP TABLE IF EXISTS guild_settings;`);
await dbClient.execute(`DROP TABLE IF EXISTS active_events;`);
await dbClient.execute(`DROP TABLE IF EXISTS custom_activities;`);
console.log('Tables dropped');
console.log('Attempting to create table command_cnt');
@ -47,44 +35,80 @@ await dbClient.execute(`
IN cmd CHAR(20)
)
BEGIN
declare oldcnt bigint unsigned;
set oldcnt = (SELECT count FROM command_cnt WHERE command = cmd);
UPDATE command_cnt SET count = oldcnt + 1 WHERE command = cmd;
declare oldCnt bigint unsigned;
set oldCnt = (SELECT count FROM command_cnt WHERE command = cmd);
UPDATE command_cnt SET count = oldCnt + 1 WHERE command = cmd;
END
`);
console.log('Stored Procedure created');
console.log('Attempting to create table guild_prefix');
console.log('Attempting to create table guild_settings');
await dbClient.execute(`
CREATE TABLE guild_prefix (
CREATE TABLE guild_settings (
guildId bigint unsigned NOT NULL,
prefix char(10) NOT NULL,
PRIMARY KEY (guildid),
UNIQUE KEY guild_prefix_guildid_UNIQUE (guildid)
lfgChannelId bigint unsigned NOT NULL,
managerRoleId bigint unsigned NOT NULL,
logChannelId bigint unsigned NOT NULL,
PRIMARY KEY (guildId, lfgChannelId)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
`);
console.log('Table created');
console.log('Attempting to create table guild_mod_role');
console.log('Attempting to create table active_events');
await dbClient.execute(`
CREATE TABLE guild_mod_role (
guildId bigint unsigned NOT NULL,
roleId bigint unsigned NOT NULL,
PRIMARY KEY (guildid),
UNIQUE KEY guild_mod_role_guildid_UNIQUE (guildid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
`);
console.log('Table created');
console.log('Attempting to create table guild_clean_channel');
await dbClient.execute(`
CREATE TABLE guild_clean_channel (
guildId bigint unsigned NOT NULL,
CREATE TABLE active_events (
messageId bigint unsigned NOT NULL,
channelId bigint unsigned NOT NULL,
PRIMARY KEY (guildid, channelId)
guildId bigint unsigned NOT NULL,
ownerId bigint unsigned NOT NULL,
eventTime datetime NOT NULL,
notifiedFlag tinyint(1) NOT NULL DEFAULT 0,
lockedFlag tinyint(1) NOT NULL DEFAULT 0,
PRIMARY KEY (messageId, channelId)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
`);
console.log('Table created');
/**
* notifiedFlag
* 0 = Not notified
* 1 = Notified Successfully
* -1 = Failed to notify
* lockedFlag
* 0 = Not locked
* 1 = Locked Successfully
* -1 = Failed to lock
*
* If both are -1, the event failed to delete
*/
console.log('Attempting to create table custom_activities');
await dbClient.execute(`
CREATE TABLE custom_activities (
id int unsigned NOT NULL AUTO_INCREMENT,
guildId bigint unsigned NOT NULL,
activityTitle char(35) NOT NULL,
activitySubtitle char(50) NOT NULL,
maxMembers tinyint NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY custom_activities_id_UNIQUE (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
`);
console.log('Table created');
// Database sizes view
console.log('Attempting to create view db_size');
await dbClient.execute(`
CREATE VIEW db_size AS
SELECT
table_name AS "table",
ROUND(((data_length + index_length) / 1024 / 1024), 3) AS "size",
table_rows AS "rows"
FROM information_schema.TABLES
WHERE
table_schema = "${config.db.name}"
AND table_name <> "db_size";
`);
console.log('View Created');
await dbClient.close();
console.log('Done!');

View File

@ -1,26 +1,44 @@
// This file will populate the tables with default values
import { dbClient } from '../src/db.ts';
import {
// MySQL deps
Client,
} from '../deps.ts';
import { LOCALMODE } from '../flags.ts';
import config from '../config.ts';
// Log into the MySQL DB
const dbClient = await new Client().connect({
hostname: LOCALMODE ? config.db.localhost : config.db.host,
port: config.db.port,
db: config.db.name,
username: config.db.username,
password: config.db.password,
});
console.log('Attempting to insert default commands into command_cnt');
const commands = ['ping', 'help', 'info', 'version', 'report', 'privacy', 'lfg', 'prefix'];
for (const command of commands) {
await dbClient.execute('INSERT INTO command_cnt(command) values(?)', [command]).catch((e) => {
console.log('Attempting to insert default actions into command_cnt');
const actions = [
'msg-mention',
'cmd-audit',
'cmd-delete',
'cmd-help',
'cmd-info',
'cmd-report',
'cmd-setup',
'cmd-gameSel',
'cmd-join',
'cmd-leave',
'cmd-alternate',
'btn-gameSel',
'btn-customAct',
'btn-createEvt',
'btn-createWLEvt',
'btn-joinEvent',
'btn-joinWLEvent',
'btn-leaveEvent',
'btn-leaveEventViaDM',
'btn-altEvent',
'btn-joinReqApprove',
'btn-joinReqDeny',
'btn-joinReqAlt',
'btn-delEvent',
'btn-confirmDelEvent',
'btn-editEvent',
'btn-eeChangeAct',
'btn-eeCustomAct',
'btn-eeChangeTime',
'btn-eeChangeDesc',
'btn-eeMakePublic',
'btn-eeMakeWL',
'lfg-filled',
];
for (const action of actions) {
await dbClient.execute('INSERT INTO command_cnt(command) values(?)', [action]).catch((e) => {
console.log(`Failed to insert into database`, e);
});
}

View File

@ -1,31 +1,47 @@
{
"compilerOptions": {
"allowJs": true,
"lib": ["deno.window"],
"lib": [
"deno.window"
],
"strict": true
},
"lint": {
"files": {
"include": ["src/", "db/", "mod.ts", "deps.ts", "config.ts", "config.example.ts"],
"include": [
"src/",
"db/",
"mod.ts",
"deps.ts",
"config.ts",
"config.example.ts"
],
"exclude": []
},
"rules": {
"tags": ["recommended"],
"include": ["ban-untagged-todo"],
"tags": [
"recommended"
],
"include": [
"ban-untagged-todo"
],
"exclude": []
}
},
"fmt": {
"files": {
"include": ["src/", "db/", "mod.ts", "deps.ts", "config.ts", "config.example.ts"],
"exclude": []
},
"options": {
"include": [
"src/",
"db/",
"mod.ts",
"deps.ts",
"config.ts",
"config.example.ts"
],
"exclude": [],
"useTabs": true,
"lineWidth": 200,
"indentWidth": 2,
"singleQuote": true,
"proseWrap": "preserve"
}
}
}

69
deps.ts
View File

@ -1,40 +1,49 @@
// All external dependancies are to be loaded here to make updating dependancy versions much easier
export {
botId,
cache,
cacheHandlers,
deleteMessage,
DiscordActivityTypes,
DiscordButtonStyles,
DiscordInteractionResponseTypes,
DiscordInteractionTypes,
editBotNickname,
editBotStatus,
getGuild,
getMessage,
getUser,
hasGuildPermissions,
Intents,
sendDirectMessage,
sendInteractionResponse,
sendMessage,
startBot,
structures,
} from 'https://deno.land/x/discordeno@12.0.1/mod.ts';
// All external dependencies are to be loaded here to make updating dependency versions much easier
import { getBotIdFromToken } from 'https://deno.land/x/discordeno@17.0.1/mod.ts';
import config from './config.ts';
import { LOCALMODE } from './flags.ts';
export const botId = getBotIdFromToken(LOCALMODE ? config.localToken : config.token);
export { enableCachePlugin, enableCacheSweepers } from 'https://deno.land/x/discordeno@17.0.1/plugins/cache/mod.ts';
export type { BotWithCache } from 'https://deno.land/x/discordeno@17.0.1/plugins/cache/mod.ts';
export {
ActivityTypes,
ApplicationCommandFlags,
ApplicationCommandOptionTypes,
ApplicationCommandTypes,
BitwisePermissionFlags,
ButtonStyles,
ChannelTypes,
createBot,
getBotIdFromToken,
Intents,
InteractionResponseTypes,
MessageComponentTypes,
OverwriteTypes,
startBot,
TextStyles,
} from 'https://deno.land/x/discordeno@17.0.1/mod.ts';
export type {
ActionRow,
ApplicationCommand,
ApplicationCommandOption,
Bot,
ButtonComponent,
ButtonData,
CreateApplicationCommand,
CreateMessage,
DebugArg,
DiscordenoGuild,
DiscordenoMember,
DiscordenoMessage,
DiscordEmbedField,
Embed,
EmbedField,
EventHandlers,
Guild,
Interaction,
} from 'https://deno.land/x/discordeno@12.0.1/mod.ts';
InteractionResponse,
MakeRequired,
Message,
PermissionStrings,
SelectMenuComponent,
SelectOption,
} from 'https://deno.land/x/discordeno@17.0.1/mod.ts';
export { Client } from 'https://deno.land/x/mysql@v2.11.0/mod.ts';

View File

@ -2,5 +2,5 @@
export const DEVMODE = false;
// DEBUG is used to toggle the cmdPrompt
export const DEBUG = false;
// LOCALMODE is used to run a differnt bot token for local testing
export const LOCALMODE = false;
// LOCALMODE is used to run a different bot token for local testing
export const LOCALMODE = true;

20
groupup.rc Normal file
View File

@ -0,0 +1,20 @@
#!/bin/sh
# PROVIDE: groupup
. /etc/rc.subr
name="groupup"
rcvar="groupup_enable"
pidfile="/var/dbots/GroupUp/groupup.pid"
groupup_root="/var/dbots/GroupUp"
groupup_write="./logs/"
groupup_log="/var/log/groupup.log"
groupup_chdir="${groupup_root}"
command="/usr/sbin/daemon"
command_args="-f -R 5 -P ${pidfile} -o ${groupup_log} /usr/local/bin/deno run --allow-write=${groupup_write} --allow-net ${groupup_root}/mod.ts"
load_rc_config groupup
run_rc_command "$1"

1283
mod.ts

File diff suppressed because it is too large Load Diff

26
src/botListPoster.ts Normal file
View File

@ -0,0 +1,26 @@
import { botId, log, LT } from '../deps.ts';
import config from '../config.ts';
// updateListStatistics(bot ID, current guild count) returns nothing, posts to botlists
// Sends the current server count to all bot list sites we are listed on
export const updateBotListStatistics = (serverCount: number): void => {
config.botLists.forEach(async (botList) => {
try {
log(LT.LOG, `Updating statistics for ${JSON.stringify(botList)}`);
if (botList.enabled) {
const tempHeaders = new Headers();
tempHeaders.append(botList.headers[0].header, botList.headers[0].value);
tempHeaders.append('Content-Type', 'application/json');
// ?{} is a template used in config, just need to replace it with the real value
const response = await fetch(botList.apiUrl.replace('?{bot_id}', botId.toString()), {
'method': 'POST',
'headers': tempHeaders,
'body': JSON.stringify(botList.body).replace('"?{server_count}"', serverCount.toString()), // ?{server_count} needs the "" removed from around it aswell to make sure its sent as a number
});
log(LT.INFO, `Posted server count to ${botList.name}. Results: ${JSON.stringify(response)}`);
}
} catch (err) {
log(LT.ERROR, `Failed to update statistics for ${botList.name} | Error: ${err.name} - ${err.message}`)
}
});
};

48
src/buttons/_index.ts Normal file
View File

@ -0,0 +1,48 @@
import { Button } from '../types/commandTypes.ts';
import { gameSelectionButton } from './event-creation/step1-gameSelection.ts';
import { createCustomEventButton } from './event-creation/step1a-openCustomModal.ts';
import { verifyCustomEventButton } from './event-creation/step1b-verifyCustomActivity.ts';
import { finalizeEventButton } from './event-creation/step2-finalize.ts';
import { createEventButton } from './event-creation/step3-createEvent.ts';
import { joinEventButton } from './live-event/joinEvent.ts';
import { leaveEventButton } from './live-event/leaveEvent.ts';
import { alternateEventButton } from './live-event/alternateEvent.ts';
import { joinRequestButton } from './live-event/joinRequest.ts';
import { alternateRequestButton } from './live-event/alternateRequest.ts';
import { deleteEventButton } from './live-event/deleteEvent.ts';
import { deleteConfirmedButton } from './live-event/deleteConfirmed.ts';
import { editEventButton } from './live-event/editEvent.ts';
import { editDescriptionButton } from './live-event/editDescription.ts';
import { editDateTimeButton } from './live-event/editDateTime.ts';
import { applyDescriptionButton } from './live-event/applyDescription.ts';
import { applyDateTimeButton } from './live-event/applyDateTime.ts';
import { updateEventButton } from './live-event/updateEvent.ts';
import { toggleWLStatusButton } from './live-event/toggleWLStatus.ts';
import { editActivityButton } from './live-event/editActivity.ts';
import { editActivityCustomButton } from './live-event/editActivity-custom.ts';
import { leaveEventViaDMButton } from './live-event/leaveViaDM.ts';
export const buttons: Array<Button> = [
gameSelectionButton,
createCustomEventButton,
verifyCustomEventButton,
finalizeEventButton,
createEventButton,
joinEventButton,
leaveEventButton,
alternateEventButton,
joinRequestButton,
alternateRequestButton,
deleteEventButton,
deleteConfirmedButton,
editEventButton,
editDescriptionButton,
editDateTimeButton,
applyDescriptionButton,
applyDateTimeButton,
updateEventButton,
toggleWLStatusButton,
editActivityButton,
editActivityCustomButton,
leaveEventViaDMButton,
];

View File

@ -0,0 +1,211 @@
// Activity should either have maxMembers or options specified, NOT both
export type Activity = {
name: string;
maxMembers?: number;
options?: Array<Activity>;
};
// Max depth is limited to 4, 5th component row must be reserved for the custom button
export const Activities: Array<Activity> = [
{
name: 'Destiny 2',
options: [
{
name: 'Raids',
options: [
{
name: 'Root of Nightmares',
maxMembers: 6,
},
{
name: 'King\'s Fall',
maxMembers: 6,
},
{
name: 'Vow of the Disciple',
maxMembers: 6,
},
{
name: 'Vault of Glass',
maxMembers: 6,
},
{
name: 'Deep Stone Crypt',
maxMembers: 6,
},
{
name: 'Garden of Salvation',
maxMembers: 6,
},
{
name: 'Last Wish',
maxMembers: 6,
},
],
},
{
name: 'Dungeons',
options: [
{
name: 'Spire of the Watcher',
maxMembers: 3,
},
{
name: 'Duality',
maxMembers: 3,
},
{
name: 'Grasp of Avarice',
maxMembers: 3,
},
{
name: 'Prophecy',
maxMembers: 3,
},
{
name: 'Pit of Heresy',
maxMembers: 3,
},
{
name: 'Shattered Throne',
maxMembers: 3,
},
],
},
{
name: 'Crucible',
options: [
{
name: 'Crucible Labs',
maxMembers: 6,
},
{
name: 'Competitive',
maxMembers: 3,
},
{
name: 'Clash',
maxMembers: 6,
},
{
name: 'Weekly Mode',
maxMembers: 3,
},
{
name: 'Iron Banner',
maxMembers: 6,
},
{
name: 'Trials of Osiris',
maxMembers: 3,
},
{
name: 'Private Match',
maxMembers: 12,
},
],
},
{
name: 'Gambit',
options: [
{
name: 'Classic',
maxMembers: 4,
},
{
name: 'Private Match',
maxMembers: 8,
},
],
},
{
name: 'Vanguard',
options: [
{
name: 'Vanguard Ops',
maxMembers: 3,
},
{
name: 'Nightfall',
maxMembers: 3,
},
{
name: 'Grandmaster Nightfall',
maxMembers: 3,
},
],
},
{
name: 'Exotic Missions',
options: [
{
name: '//node.ovrd.AVALON//',
maxMembers: 3,
},
],
},
{
name: 'Miscellaneous',
options: [
{
name: 'Defiant Battlegrounds',
maxMembers: 3,
},
{
name: 'Terminal Overload',
maxMembers: 12,
},
{
name: 'Vex Incursion Zone',
maxMembers: 12,
},
{
name: 'Partition: Ordnance',
maxMembers: 3,
},
{
name: 'Lightfall Campaign Mission',
maxMembers: 3,
},
{
name: 'Weekly Witch Queen Campaign Mission',
maxMembers: 3,
},
{
name: 'Wellspring',
maxMembers: 6,
},
{
name: 'Dares of Eternity',
maxMembers: 6,
},
{
name: 'Wrathborn Hunt',
maxMembers: 3,
},
{
name: 'Empire Hunt',
maxMembers: 3,
},
],
},
],
},
{
name: 'Among Us',
options: [
{
name: 'Vanilla',
maxMembers: 15,
},
{
name: 'Hide n Seek',
maxMembers: 15,
},
{
name: 'Modded',
maxMembers: 15,
},
],
},
];

View File

@ -0,0 +1,176 @@
const monthsLong: Array<string> = ['JANUARY', 'FEBRUARY', 'MARCH', 'APRIL', 'MAY', 'JUNE', 'JULY', 'AUGUST', 'SEPTEMBER', 'OCTOBER', 'NOVEMBER', 'DECEMBER'];
export const monthsShort: Array<string> = monthsLong.map((month) => month.slice(0, 3));
const tzMap: Map<string, string> = new Map([
['CDT', '-05:00'],
['CST', '-06:00'],
['PST', '-08:00'],
['IST', '+05:30'],
['GMT', '+00:00'],
['EAT', '+03:00'],
['CET', '+01:00'],
['WAT', '+01:00'],
['CAT', '+02:00'],
['EET', '+02:00'],
['CEST', '+02:00'],
['SAST', '+02:00'],
['HST', '-10:00'],
['HDT', '-09:00'],
['AKST', '-09:00'],
['AKDT', '-08:00'],
['AST', '-04:00'],
['EST', '-05:00'],
['MST', '-07:00'],
['MDT', '-06:00'],
['EDT', '-04:00'],
['PDT', '-07:00'],
['ADT', '-03:00'],
['NST', '-03:30'],
['NDT', '-02:30'],
['AEST', '+10:00'],
['AEDT', '+11:00'],
['NZST', '+12:00'],
['NZDT', '+13:00'],
['EEST', '+03:00'],
['HKT', '+08:00'],
['WIB', '+07:00'],
['WIT', '+09:00'],
['IDT', '+03:00'],
['PKT', '+05:00'],
['WITA', '+08:00'],
['KST', '+09:00'],
['JST', '+09:00'],
['WET', '+00:00'],
['WEST', '+01:00'],
['ACST', '+09:30'],
['ACDT', '+10:30'],
['AWST', '+08:00'],
['UTC', '+00:00'],
['BST', '+01:00'],
['MSK', '+03:00'],
['MET', '+01:00'],
['MEST', '+02:00'],
['CHST', '+10:00'],
['SST', '-11:00'],
]);
const shorthandUSTZ: Array<string> = ['ET', 'CT', 'MT', 'PT'];
// Takes user input Time and makes it actually usable
const parseEventTime = (preParsedEventTime: string): [string, string, string] => {
let parsedEventTimePeriod = '';
// Get AM or PM out of the rawTime
if (preParsedEventTime.endsWith('AM') || preParsedEventTime.endsWith('PM')) {
parsedEventTimePeriod = preParsedEventTime.slice(-2);
preParsedEventTime = preParsedEventTime.slice(0, -2).trim();
}
let parsedEventTimeHours: string;
let parsedEventTimeMinutes: string;
// Get Hours and Minutes out of rawTime
if (preParsedEventTime.length > 2) {
parsedEventTimeMinutes = preParsedEventTime.slice(-2);
parsedEventTimeHours = preParsedEventTime.slice(0, -2).trim();
} else {
parsedEventTimeHours = preParsedEventTime.trim();
parsedEventTimeMinutes = '00';
}
// Determine if we need to remove the time period
if (parseInt(parsedEventTimeHours) > 12) {
parsedEventTimePeriod = '';
}
if (!parsedEventTimePeriod && parsedEventTimeHours.length < 2) {
parsedEventTimeHours = `0${parsedEventTimeHours}`;
}
return [parsedEventTimeHours, parsedEventTimeMinutes, parsedEventTimePeriod];
};
// Check if DST is currently active
export const isDSTActive = (): boolean => {
const today = new Date();
const jan = new Date(today.getFullYear(), 0, 1);
const jul = new Date(today.getFullYear(), 6, 1);
return today.getTimezoneOffset() < Math.max(jan.getTimezoneOffset(), jul.getTimezoneOffset());
};
// Takes user input Time Zone and makes it actually usable
const parseEventTimeZone = (preParsedEventTimeZone: string): [string, string] => {
if (shorthandUSTZ.includes(preParsedEventTimeZone)) {
// Handle shorthand US timezones, adding S for standard time and D for Daylight Savings
if (isDSTActive()) {
preParsedEventTimeZone = `${preParsedEventTimeZone.slice(0, 1)}DT`;
} else {
preParsedEventTimeZone = `${preParsedEventTimeZone.slice(0, 1)}ST`;
}
}
if (tzMap.has(preParsedEventTimeZone)) {
// TZ is proper abbreviation, use our map to convert
return [`UTC${tzMap.get(preParsedEventTimeZone)}`, preParsedEventTimeZone];
} else {
// Determine if user put in UTC4, which needs to be UTC+4
let addPlusSign = false;
if (!preParsedEventTimeZone.includes('+') && !preParsedEventTimeZone.includes('-')) {
addPlusSign = true;
}
// Determine if we need to prepend UTC/GMT, handle adding the + into the string
if (!preParsedEventTimeZone.startsWith('UTC') && preParsedEventTimeZone.startsWith('GMT')) {
preParsedEventTimeZone = `UTC${addPlusSign && '+'}${preParsedEventTimeZone}`;
} else if (addPlusSign) {
preParsedEventTimeZone = `${preParsedEventTimeZone.slice(0, 3)}+${preParsedEventTimeZone.slice(3)}`;
}
return [preParsedEventTimeZone, preParsedEventTimeZone];
}
};
// Takes user input Date and makes it actually usable
const parseEventDate = (preParsedEventDate: string): [string, string, string] => {
const today = new Date();
let [parsedEventMonth, parsedEventDay, parsedEventYear] = preParsedEventDate.split(/[\s,\\/-]+/g);
if (isNaN(parseInt(parsedEventDay))) {
// User only provided one word, we're assuming it was TOMORROW, and all others will be treated as today
if (parsedEventMonth.includes('TOMORROW')) {
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
parsedEventYear = tomorrow.getFullYear().toString();
parsedEventMonth = monthsLong[tomorrow.getMonth()];
parsedEventDay = tomorrow.getDate().toString();
} else {
parsedEventYear = today.getFullYear().toString();
parsedEventMonth = monthsLong[today.getMonth()];
parsedEventDay = today.getDate().toString();
}
} else {
// Month and Day exist, so determine year and parse month/day
parsedEventYear = (isNaN(parseInt(parsedEventYear)) ? today.getFullYear() : parseInt(parsedEventYear)).toString();
parsedEventDay = parseInt(parsedEventDay).toString();
if (!monthsLong.includes(parsedEventMonth) && !monthsShort.includes(parsedEventMonth)) {
parsedEventMonth = monthsShort[parseInt(parsedEventMonth) - 1];
}
}
return [parsedEventYear, parsedEventMonth, parsedEventDay];
};
// Take full raw Date/Time input and convert it to a proper Date
export const getDateFromRawInput = (rawEventTime: string, rawEventTimeZone: string, rawEventDate: string): [Date, string, boolean, boolean] => {
// Verify/Set Time
const [parsedEventTimeHours, parsedEventTimeMinutes, parsedEventTimePeriod] = parseEventTime(rawEventTime.replaceAll(':', '').toUpperCase());
// Verify/Set Time Zone
const [parsedEventTimeZone, userInputTimeZone] = parseEventTimeZone(rawEventTimeZone.replaceAll(' ', '').trim().toUpperCase());
// Verify/Set Date
const [parsedEventYear, parsedEventMonth, parsedEventDay] = parseEventDate(rawEventDate.trim().toUpperCase());
const parsedDateTime = new Date(`${parsedEventMonth} ${parsedEventDay}, ${parsedEventYear} ${parsedEventTimeHours}:${parsedEventTimeMinutes} ${parsedEventTimePeriod} ${parsedEventTimeZone}`);
return [
parsedDateTime,
`${parsedEventTimeHours}${parsedEventTimePeriod ? ':' : ''}${parsedEventTimeMinutes} ${parsedEventTimePeriod} ${userInputTimeZone} ${parsedEventMonth.slice(0, 1)}${
parsedEventMonth.slice(1, 3).toLowerCase()
} ${parsedEventDay}, ${parsedEventYear}`,
parsedDateTime.getTime() > new Date().getTime(),
!isNaN(parsedDateTime.getTime()),
];
};

View File

@ -0,0 +1,150 @@
import { ActionRow, ApplicationCommandFlags, ApplicationCommandTypes, Bot, ButtonStyles, Interaction, InteractionResponseTypes, MessageComponentTypes, SelectMenuComponent } from '../../../deps.ts';
import { infoColor1, somethingWentWrong } from '../../commandUtils.ts';
import { CommandDetails } from '../../types/commandTypes.ts';
import { Activities } from './activities.ts';
import { generateActionRow, getNestedActivity, invalidDateTimeStr } from './utils.ts';
import { dateTimeFields, descriptionTextField, fillerChar, idSeparator, LfgEmbedIndexes, lfgStartTimeName, pathIdxEnder, pathIdxSeparator } from '../eventUtils.ts';
import { addTokenToMap, deleteTokenEarly, generateMapId, selfDestructMessage, tokenMap } from '../tokenCleanup.ts';
import utils from '../../utils.ts';
import { customId as createCustomActivityBtnId } from './step1a-openCustomModal.ts';
import { customId as finalizeEventBtnId } from './step2-finalize.ts';
import { monthsShort } from './dateTimeUtils.ts';
import { dbClient, queries } from '../../db.ts';
import { createEventSlashName } from '../../commands/slashCommandNames.ts';
export const customId = 'gameSel';
const details: CommandDetails = {
name: createEventSlashName,
description: 'Creates a new event in this channel.',
type: ApplicationCommandTypes.ChatInput,
};
const generateCustomEventRow = (title: string, subtitle: string): ActionRow => ({
type: MessageComponentTypes.ActionRow,
components: [{
type: MessageComponentTypes.Button,
style: ButtonStyles.Primary,
label: 'Create Custom Event',
customId: `${createCustomActivityBtnId}${idSeparator}${title}${pathIdxSeparator}${subtitle}${pathIdxSeparator}`,
}],
});
const execute = async (bot: Bot, interaction: Interaction) => {
if (interaction.data && (interaction.data.name === createEventSlashName || interaction.data.customId) && interaction.member && interaction.guildId && interaction.channelId) {
// Light Telemetry
if (interaction.data.name === createEventSlashName) {
dbClient.execute(queries.callIncCnt('cmd-gameSel')).catch((e) => utils.commonLoggers.dbError('step1-gameSelection.ts@cmd', 'call sproc INC_CNT on', e));
}
if (interaction.data.customId === customId) {
dbClient.execute(queries.callIncCnt('btn-gameSel')).catch((e) => utils.commonLoggers.dbError('step1-gameSelection.ts@btn', 'call sproc INC_CNT on', e));
}
// Check if we are done
const customIdIdxPath = (interaction.data.customId || '').substring((interaction.data.customId || '').indexOf(idSeparator) + 1) || '';
const valuesIdxPath = interaction.data?.values?.[0] || '';
const strippedIdxPath = interaction.data.customId?.includes(idSeparator) ? customIdIdxPath : valuesIdxPath;
const finalizedIdxPath = strippedIdxPath.substring(0, strippedIdxPath.lastIndexOf(pathIdxEnder));
if ((interaction.data.customId?.includes(idSeparator) && interaction.data.customId.endsWith(pathIdxEnder)) || interaction.data?.values?.[0].endsWith(pathIdxEnder)) {
let prefillTime = '';
let prefillTimeZone = '';
let prefillDate = '';
let prefillDescription = '';
if (interaction.message && interaction.message.embeds[0].fields && interaction.message.embeds[0].fields[LfgEmbedIndexes.StartTime].name === lfgStartTimeName) {
if (interaction.message.embeds[0].fields[LfgEmbedIndexes.StartTime].value !== invalidDateTimeStr) {
let rawEventDateTime = interaction.message.embeds[0].fields[LfgEmbedIndexes.StartTime].value.split('\n')[0].split(' ');
const monthIdx = rawEventDateTime.findIndex((item) => monthsShort.includes(item.toUpperCase()));
prefillTime = rawEventDateTime.slice(0, monthIdx - 1).join(' ').trim();
prefillTimeZone = (rawEventDateTime[monthIdx - 1] || '').trim();
prefillDate = rawEventDateTime.slice(monthIdx).join(' ').trim();
}
prefillDescription = interaction.message.embeds[0].fields[LfgEmbedIndexes.Description].value.trim();
}
bot.helpers.sendInteractionResponse(interaction.id, interaction.token, {
type: InteractionResponseTypes.Modal,
data: {
title: 'Enter Event Details',
customId: `${finalizeEventBtnId}${idSeparator}${finalizedIdxPath}`,
components: [...dateTimeFields(prefillTime, prefillTimeZone, prefillDate), descriptionTextField(prefillDescription)],
},
}).catch((e: Error) => utils.commonLoggers.interactionSendError('step1-gameSelection.ts:modal', interaction, e));
return;
}
// Parse indexPath from the select value
const rawIdxPath: Array<string> = interaction.data.values ? interaction.data.values[0].split(pathIdxSeparator) : [''];
const idxPath: Array<number> = rawIdxPath.map((rawIdx) => rawIdx ? parseInt(rawIdx) : -1);
const selectMenus: Array<ActionRow> = [];
// Use fillerChar to create unique customIds for dropdowns
// We also leverage this to determine if its the first time the user has entered gameSel
let selectMenuCustomId = `${customId}${fillerChar}`;
let currentBaseValue = '';
for (let i = 0; i < idxPath.length; i++) {
const idx = idxPath[i];
const idxPathCopy = [...idxPath].slice(0, i);
selectMenus.push(generateActionRow(currentBaseValue, getNestedActivity(idxPathCopy, Activities), selectMenuCustomId, idx));
selectMenuCustomId = `${selectMenuCustomId}${fillerChar}`;
currentBaseValue = `${currentBaseValue}${idx}${pathIdxSeparator}`;
}
// Prefill the custom event modal
const prefillArray: Array<string> = [];
selectMenus.forEach((menu) => {
try {
const menuOption = (menu.components[0] as SelectMenuComponent).options.find((option) => option.default);
if (menuOption) {
prefillArray.push(menuOption.label);
}
} catch (_e) {
// do nothing, don't care
}
});
selectMenus.push(generateCustomEventRow(prefillArray.length ? prefillArray[0] : '', prefillArray.length > 1 ? prefillArray[prefillArray.length - 1] : ''));
if (interaction.data.customId && interaction.data.customId.includes(fillerChar)) {
// Let discord know we didn't ignore the user
await bot.helpers.sendInteractionResponse(interaction.id, interaction.token, {
type: InteractionResponseTypes.DeferredUpdateMessage,
}).catch((e: Error) => utils.commonLoggers.interactionSendError('step1-gameSelection.ts:ping', interaction, e));
// Update the original game selector
await bot.helpers.editOriginalInteractionResponse(tokenMap.get(generateMapId(interaction.guildId, interaction.channelId, interaction.member.id))?.token || '', {
components: selectMenus,
}).catch((e: Error) => utils.commonLoggers.interactionSendError('step1-gameSelection.ts:edit', interaction, e));
} else {
// Delete old token entry if it exists
await deleteTokenEarly(bot, interaction, interaction.guildId, interaction.channelId, interaction.member.id);
// Store token for later use
addTokenToMap(bot, interaction, interaction.guildId, interaction.channelId, interaction.member.id);
// Send initial interaction
bot.helpers.sendInteractionResponse(interaction.id, interaction.token, {
type: InteractionResponseTypes.ChannelMessageWithSource,
data: {
embeds: [{
title: 'Please select a Game and Activity, or create a Custom Event.',
description: selfDestructMessage(new Date().getTime()),
color: infoColor1,
}],
flags: ApplicationCommandFlags.Ephemeral,
components: selectMenus,
},
}).catch((e: Error) => utils.commonLoggers.interactionSendError('step1-gameSelection.ts:init', interaction, e));
}
} else {
somethingWentWrong(bot, interaction, 'missingCoreValuesOnGameSel');
}
};
export const gameSelectionCommand = {
details,
execute,
};
export const gameSelectionButton = {
customId,
execute,
};

View File

@ -0,0 +1,30 @@
import { Bot, Interaction, InteractionResponseTypes } from '../../../deps.ts';
import { generateCustomActivityFields, idSeparator, pathIdxSeparator } from '../eventUtils.ts';
import { customId as verifyCustomActivityId } from './step1b-verifyCustomActivity.ts';
import utils from '../../utils.ts';
import { dbClient, queries } from '../../db.ts';
export const customId = 'customAct';
const execute = async (bot: Bot, interaction: Interaction) => {
if (interaction.data?.customId && interaction.member && interaction.guildId && interaction.channelId) {
// Light Telemetry
dbClient.execute(queries.callIncCnt('btn-customAct')).catch((e) => utils.commonLoggers.dbError('step1a-openCustomModal.ts', 'call sproc INC_CNT on', e));
const [actTitle, actSubtitle, activityMaxPlayers] = (interaction.data.customId.split(idSeparator)[1] || '').split(pathIdxSeparator);
bot.helpers.sendInteractionResponse(interaction.id, interaction.token, {
type: InteractionResponseTypes.Modal,
data: {
title: 'Create Custom Activity',
customId: verifyCustomActivityId,
components: generateCustomActivityFields(actTitle, actSubtitle, activityMaxPlayers),
},
}).catch((e: Error) => utils.commonLoggers.interactionSendError('step1a-openCustomModal.ts:modal', interaction, e));
}
};
export const createCustomEventButton = {
customId,
execute,
};

View File

@ -0,0 +1,95 @@
import config from '../../../config.ts';
import { ApplicationCommandFlags, Bot, ButtonStyles, Interaction, InteractionResponseTypes, MessageComponentTypes } from '../../../deps.ts';
import { failColor, infoColor1, safelyDismissMsg, somethingWentWrong } from '../../commandUtils.ts';
import { activityMaxPlayersId, activitySubtitleId, activityTitleId, idSeparator, pathIdxEnder, pathIdxSeparator } from '../eventUtils.ts';
import { addTokenToMap, deleteTokenEarly, selfDestructMessage } from '../tokenCleanup.ts';
import { customId as gameSelectionId } from './step1-gameSelection.ts';
import { customId as openCustomModalId } from './step1a-openCustomModal.ts';
import utils from '../../utils.ts';
export const customId = 'verifyCustomActivity';
const execute = async (bot: Bot, interaction: Interaction) => {
if (interaction.data?.components?.length && interaction.guildId && interaction.channelId && interaction.member) {
await deleteTokenEarly(bot, interaction, interaction.guildId, interaction.channelId, interaction.member.id);
// Parse out our data
const tempDataMap: Map<string, string> = new Map();
for (const row of interaction.data.components) {
if (row.components?.[0]) {
const textField = row.components[0];
tempDataMap.set(textField.customId || 'missingCustomId', textField.value || 'missingValue');
}
}
// Remove any pipe characters to avoid issues down the process
const activityTitle = (tempDataMap.get(activityTitleId) || '').replace(/\|/g, '');
const activitySubtitle = (tempDataMap.get(activitySubtitleId) || '').replace(/\|/g, '');
const activityMaxPlayers = parseInt(tempDataMap.get(activityMaxPlayersId) || '0');
if (isNaN(activityMaxPlayers) || activityMaxPlayers < 1 || activityMaxPlayers > 99) {
bot.helpers.sendInteractionResponse(interaction.id, interaction.token, {
type: InteractionResponseTypes.ChannelMessageWithSource,
data: {
flags: ApplicationCommandFlags.Ephemeral,
embeds: [{
color: failColor,
title: 'Invalid Max Member count!',
description: `${config.name} parsed the max members as \`${
isNaN(activityMaxPlayers) ? 'Not a Number' : activityMaxPlayers
}\`, which is outside of the allowed range. Please recreate this activity, but make sure the maximum player count is between 1 and 99.\n\n${safelyDismissMsg}`,
}],
},
}).catch((e: Error) => utils.commonLoggers.interactionSendError('step1b-verifyCustomActivity.ts:invalidPlayer', interaction, e));
return;
}
if (!activityMaxPlayers || !activitySubtitle || !activityTitle) {
// Verify fields exist
somethingWentWrong(bot, interaction, `missingFieldFromCustomActivity@${activityTitle}|${activitySubtitle}|${activityMaxPlayers}$`);
return;
}
addTokenToMap(bot, interaction, interaction.guildId, interaction.channelId, interaction.member.id);
const idxPath = `${idSeparator}${activityTitle}${pathIdxSeparator}${activitySubtitle}${pathIdxSeparator}${activityMaxPlayers}`;
bot.helpers.sendInteractionResponse(interaction.id, interaction.token, {
type: InteractionResponseTypes.ChannelMessageWithSource,
data: {
flags: ApplicationCommandFlags.Ephemeral,
embeds: [{
color: infoColor1,
title: 'Please verify the following Custom Event details:',
description: `Please note, pipe characters (\`|\`) are not allowed and will be automatically removed.\n\n${selfDestructMessage(new Date().getTime())}`,
fields: [{
name: 'Activity Title:',
value: activityTitle,
}, {
name: 'Activity Subtitle:',
value: activitySubtitle,
}, {
name: 'Maximum Players:',
value: `${activityMaxPlayers}`,
}],
}],
components: [{
type: MessageComponentTypes.ActionRow,
components: [{
type: MessageComponentTypes.Button,
style: ButtonStyles.Success,
label: 'Yup, looks great!',
customId: `${gameSelectionId}${idxPath}${pathIdxEnder}`,
}, {
type: MessageComponentTypes.Button,
style: ButtonStyles.Danger,
label: 'Nope, let me change something.',
customId: `${openCustomModalId}${idxPath}`,
}],
}],
},
}).catch((e: Error) => utils.commonLoggers.interactionSendError('step1b-verifyCustomActivity.ts:message', interaction, e));
} else {
somethingWentWrong(bot, interaction, 'noDataFromCustomActivityModal');
}
};
export const verifyCustomEventButton = {
customId,
execute,
};

View File

@ -0,0 +1,99 @@
import { Bot, Interaction } from '../../../deps.ts';
import { somethingWentWrong } from '../../commandUtils.ts';
import { createLFGPost, getFinalActivity } from './utils.ts';
import { eventDateId, eventDescriptionId, eventTimeId, eventTimeZoneId, idSeparator, noDescProvided, pathIdxSeparator } from '../eventUtils.ts';
import { addTokenToMap, deleteTokenEarly } from '../tokenCleanup.ts';
import { Activities, Activity } from './activities.ts';
import { getDateFromRawInput } from './dateTimeUtils.ts';
import utils from '../../utils.ts';
import { dbClient, queries } from '../../db.ts';
export const customId = 'finalize';
const execute = async (bot: Bot, interaction: Interaction) => {
if (interaction.data?.components?.length && interaction.guildId && interaction.channelId && interaction.member && interaction.member.user) {
// User selected activity and has filled out fields, delete the selectMenus
await deleteTokenEarly(bot, interaction, interaction.guildId, interaction.channelId, interaction.member.id);
const tempDataMap: Map<string, string> = new Map();
for (const row of interaction.data.components) {
if (row.components?.[0]) {
const textField = row.components[0];
tempDataMap.set(textField.customId || 'missingCustomId', textField.value || '');
}
}
const customIdIdxPath = (interaction.data.customId || '').substring((interaction.data.customId || '').indexOf(idSeparator) + 1) || '';
const rawIdxPath: Array<string> = customIdIdxPath.split(pathIdxSeparator);
const idxPath: Array<number> = rawIdxPath.map((rawIdx) => rawIdx ? parseInt(rawIdx) : -1);
let category: string;
let activity: Activity;
let customAct = false;
if (idxPath.some((idx) => isNaN(idx) || idx < 0)) {
customAct = true;
// Handle custom activity
category = rawIdxPath[0];
activity = {
name: rawIdxPath[1],
maxMembers: parseInt(rawIdxPath[2]) || NaN,
};
} else {
// Handle preset activity
category = Activities[idxPath[0]].name;
activity = getFinalActivity(idxPath, Activities);
}
if (!category || !activity.name || !activity.maxMembers || isNaN(activity.maxMembers)) {
// Error out if our activity or category is missing
somethingWentWrong(bot, interaction, `missingActivityFromFinalize@${category}_${activity.name}_${activity.maxMembers}`);
}
// Log custom event to see if we should add it as a preset
if (customAct) {
dbClient.execute(queries.insertCustomActivity, [interaction.guildId, category, activity.name, activity.maxMembers]).catch((e) => utils.commonLoggers.dbError('step2-finalize.ts@custom', 'insert into', e));
}
const rawEventTime = tempDataMap.get(eventTimeId) || '';
const rawEventTimeZone = tempDataMap.get(eventTimeZoneId) || '';
const rawEventDate = tempDataMap.get(eventDateId) || '';
const eventDescription = tempDataMap.get(eventDescriptionId) || noDescProvided;
if (!rawEventTime || !rawEventTimeZone || !rawEventDate) {
// Error out if user somehow failed to provide one of the fields (eventDescription is allowed to be null/empty)
somethingWentWrong(bot, interaction, `missingFieldFromEventDescription@${rawEventTime}_${rawEventTimeZone}_${rawEventDate}`);
return;
}
// Get Date Object from user input
const [eventDateTime, eventDateTimeStr, eventInFuture, dateTimeValid] = getDateFromRawInput(rawEventTime, rawEventTimeZone, rawEventDate);
addTokenToMap(bot, interaction, interaction.guildId, interaction.channelId, interaction.member.id);
bot.helpers.sendInteractionResponse(
interaction.id,
interaction.token,
createLFGPost(
category,
activity,
eventDateTime,
eventDateTimeStr,
eventDescription,
interaction.member.id,
interaction.member.user.username,
[{
id: interaction.member.id,
name: interaction.member.user.username,
}],
[],
customIdIdxPath,
eventInFuture,
dateTimeValid,
),
).catch((e: Error) => utils.commonLoggers.interactionSendError('step2-finalize.ts', interaction, e));
} else {
somethingWentWrong(bot, interaction, 'noDataFromEventDescriptionModal');
}
};
export const finalizeEventButton = {
customId,
execute,
};

View File

@ -0,0 +1,84 @@
import { ApplicationCommandFlags, Bot, Interaction, InteractionResponseTypes, MessageComponentTypes } from '../../../deps.ts';
import { generateLFGButtons } from './utils.ts';
import { idSeparator, LfgEmbedIndexes } from '../eventUtils.ts';
import { deleteTokenEarly } from '../tokenCleanup.ts';
import { dmTestMessage, safelyDismissMsg, sendDirectMessage, somethingWentWrong, warnColor } from '../../commandUtils.ts';
import { dbClient, queries } from '../../db.ts';
import utils from '../../utils.ts';
export const customId = 'createEvent';
const execute = async (bot: Bot, interaction: Interaction) => {
if (
interaction.data?.customId && interaction.member && interaction.guildId && interaction.channelId && interaction.message && interaction.message.embeds[0] && interaction.message.embeds[0].fields
) {
// Light Telemetry
dbClient.execute(queries.callIncCnt(interaction.data.customId.includes(idSeparator) ? 'btn-createWLEvt' : 'btn-createEvt')).catch((e) =>
utils.commonLoggers.dbError('step3-createEvent.ts', 'call sproc INC_CNT on', e)
);
deleteTokenEarly(bot, interaction, interaction.guildId, interaction.channelId, interaction.member.id);
// Get OwnerId and EventTime from embed for DB
const ownerId: bigint = BigInt(interaction.message.embeds[0].footer?.iconUrl?.split('#')[1] || '0');
const eventTime: Date = new Date(parseInt(interaction.message.embeds[0].fields[LfgEmbedIndexes.ICSLink].value.split('?t=')[1].split('&n=')[0] || '0'));
// Check if we need to ensure DMs are open
if (interaction.data.customId.includes(idSeparator)) {
const dmSuccess = Boolean(await sendDirectMessage(bot, interaction.member.id, dmTestMessage).catch((e: Error) => utils.commonLoggers.messageSendError('toggleWLStatus.ts', 'send DM fail', e)));
if (!dmSuccess) {
bot.helpers.sendInteractionResponse(interaction.id, interaction.token, {
type: InteractionResponseTypes.ChannelMessageWithSource,
data: {
flags: ApplicationCommandFlags.Ephemeral,
embeds: [{
color: warnColor,
title: 'Event not created.',
description: `In order to create a whitelisted event, your DMs must be open to receive Join Requests. Please open your DMs and try again.\n\n${safelyDismissMsg}`,
}],
},
}).catch((e: Error) => utils.commonLoggers.interactionSendError('toggleWLStatus.ts@dmFail', interaction, e));
return;
}
}
// Send Event Message
const eventMessage = await bot.helpers.sendMessage(interaction.channelId, {
embeds: [interaction.message.embeds[0]],
components: [{
type: MessageComponentTypes.ActionRow,
components: generateLFGButtons(interaction.data.customId.includes(idSeparator)),
}],
}).catch((e: Error) => utils.commonLoggers.messageSendError('step3-createEvent.ts', 'createEvent', e));
if (!eventMessage) {
somethingWentWrong(bot, interaction, 'creatingEventSendMessageFinalizeEventStep');
return;
}
// Store in DB
let dbErrorOut = false;
await dbClient.execute(queries.insertEvent, [eventMessage.id, eventMessage.channelId, interaction.guildId, ownerId, eventTime]).catch((e) => {
utils.commonLoggers.dbError('step3-createEvent.ts', 'INSERT event to DB', e);
dbErrorOut = true;
});
if (dbErrorOut) {
bot.helpers.deleteMessage(eventMessage.channelId, eventMessage.id, 'Failed to log event to DB').catch((e: Error) =>
utils.commonLoggers.messageDeleteError('step3-createEvent.ts', 'deleteEventFailedDB', e)
);
somethingWentWrong(bot, interaction, 'creatingEventDBStoreFinalizeEventStep');
return;
}
// Let discord know we didn't ignore the user
bot.helpers.sendInteractionResponse(interaction.id, interaction.token, {
type: InteractionResponseTypes.DeferredUpdateMessage,
}).catch((e: Error) => utils.commonLoggers.interactionSendError('step3-createEvent.ts', interaction, e));
} else {
somethingWentWrong(bot, interaction, 'noDataFromFinalizeEventStep');
}
};
export const createEventButton = {
customId,
execute,
};

View File

@ -0,0 +1,183 @@
import { ActionRow, ApplicationCommandFlags, ButtonComponent, ButtonStyles, InteractionResponse, InteractionResponseTypes, MessageComponentTypes, SelectOption } from '../../../deps.ts';
import config from '../../../config.ts';
import { Activity } from './activities.ts';
import {
alternateEventBtnStr,
generateAlternateList,
generateMemberList,
generateMemberTitle,
idSeparator,
joinEventBtnStr,
leaveEventBtnStr,
lfgStartTimeName,
pathIdxEnder,
pathIdxSeparator,
requestToJoinEventBtnStr,
} from '../eventUtils.ts';
import { selfDestructMessage } from '../tokenCleanup.ts';
import { successColor, warnColor } from '../../commandUtils.ts';
import { LFGMember } from '../../types/commandTypes.ts';
import { customId as gameSelCustomId } from './step1-gameSelection.ts';
import { customId as createEventCustomId } from './step3-createEvent.ts';
import { customId as joinEventCustomId } from '../live-event/joinEvent.ts';
import { customId as leaveEventCustomId } from '../live-event/leaveEvent.ts';
import { customId as alternateEventCustomId } from '../live-event/alternateEvent.ts';
import { customId as deleteEventCustomId } from '../live-event/deleteEvent.ts';
import { customId as editEventCustomId } from '../live-event/editEvent.ts';
export const getNestedActivity = (idxPath: Array<number>, activities: Array<Activity>): Array<Activity> => {
const nextIdx = idxPath[0];
if (idxPath.length && activities[nextIdx] && activities[nextIdx].options) {
idxPath.shift();
return getNestedActivity(idxPath, activities[nextIdx].options || []);
} else {
return activities;
}
};
export const getFinalActivity = (idxPath: Array<number>, activities: Array<Activity>): Activity => getNestedActivity(idxPath, activities)[idxPath[idxPath.length - 1]];
const getSelectOptions = (baseValue: string, activities: Array<Activity>, defaultIdx?: number): Array<SelectOption> =>
activities.map((act, idx) => ({
label: act.name,
value: `${baseValue}${idx}${act.maxMembers ? pathIdxEnder : pathIdxSeparator}`,
default: idx === defaultIdx,
}));
export const generateActionRow = (baseValue: string, activities: Array<Activity>, customId: string, defaultIdx?: number): ActionRow => ({
type: MessageComponentTypes.ActionRow,
components: [{
type: MessageComponentTypes.SelectMenu,
customId,
options: getSelectOptions(baseValue, activities, defaultIdx),
}],
});
const createEventBtnName = 'Create Event';
const createWhitelistedBtnName = 'Create Whitelisted Event';
const editEventDetailsBtnName = 'Edit Event Details';
export const invalidDateTimeStr = '`Invalid Date/Time`';
const finalizeButtons = (idxPath: string, eventInFuture: boolean): [ButtonComponent, ButtonComponent, ButtonComponent] | [ButtonComponent] => {
const editButton: ButtonComponent = {
type: MessageComponentTypes.Button,
label: editEventDetailsBtnName,
style: ButtonStyles.Secondary,
customId: `${gameSelCustomId}${idSeparator}${idxPath}${pathIdxEnder}`,
};
if (eventInFuture) {
return [{
type: MessageComponentTypes.Button,
label: createEventBtnName,
style: ButtonStyles.Success,
customId: createEventCustomId,
}, {
type: MessageComponentTypes.Button,
label: createWhitelistedBtnName,
style: ButtonStyles.Primary,
customId: `${createEventCustomId}${idSeparator}`,
}, editButton];
} else {
return [editButton];
}
};
export const generateLFGButtons = (whitelist: boolean): [ButtonComponent, ButtonComponent, ButtonComponent, ButtonComponent, ButtonComponent] => [{
type: MessageComponentTypes.Button,
label: whitelist ? requestToJoinEventBtnStr : joinEventBtnStr,
style: ButtonStyles.Success,
customId: `${joinEventCustomId}${whitelist ? idSeparator : ''}`,
}, {
type: MessageComponentTypes.Button,
label: leaveEventBtnStr,
style: ButtonStyles.Danger,
customId: leaveEventCustomId,
}, {
type: MessageComponentTypes.Button,
label: alternateEventBtnStr,
style: ButtonStyles.Primary,
customId: alternateEventCustomId,
}, {
type: MessageComponentTypes.Button,
label: '',
style: ButtonStyles.Secondary,
customId: editEventCustomId,
emoji: {
name: '✏️',
},
}, {
type: MessageComponentTypes.Button,
label: '',
style: ButtonStyles.Secondary,
customId: deleteEventCustomId,
emoji: {
name: '🗑️',
},
}];
export const generateTimeFieldStr = (eventDateTimeStr: string, eventDateTime: Date) => `${eventDateTimeStr}\n<t:${Math.floor(eventDateTime.getTime() / 1000)}:R>`;
export const createLFGPost = (
category: string,
activity: Activity,
eventDateTime: Date,
eventDateTimeStr: string,
eventDescription: string,
authorId: bigint,
author: string,
memberList: Array<LFGMember>,
alternateList: Array<LFGMember>,
idxPath: string,
eventInFuture: boolean,
dateTimeValid: boolean,
): InteractionResponse => {
const icsDetails = `${category}: ${activity.name}`;
return {
type: InteractionResponseTypes.ChannelMessageWithSource,
data: {
flags: ApplicationCommandFlags.Ephemeral,
content: eventInFuture
? `Please verify the information below, then click on the \`${createEventBtnName}\` or \`${createWhitelistedBtnName}\` button, or change the event \`Date/Time\` or \`Description\` with the \`${editEventDetailsBtnName}\` button below. \n\n${
selfDestructMessage(new Date().getTime())
}`
: `You cannot create an event ${dateTimeValid ? 'in the past' : 'with an invalid date/time'}. Please change the event's \`Date/Time\` to be ${
dateTimeValid ? 'in the future' : 'valid'
} with the \`${editEventDetailsBtnName}\` button below.`,
embeds: [{
color: eventInFuture ? successColor : warnColor,
fields: [{
name: `${category}:`,
value: activity.name,
inline: true,
}, {
name: lfgStartTimeName,
value: dateTimeValid ? generateTimeFieldStr(eventDateTimeStr, eventDateTime) : invalidDateTimeStr,
inline: true,
}, {
name: 'Add to Calendar:',
value: `[Download ICS File](${config.links.addToCalendar}?t=${eventDateTime.getTime()}&n=${icsDetails.replaceAll(' ', '+')})`,
inline: true,
}, {
name: 'Description:',
value: eventDescription,
}, {
name: generateMemberTitle(memberList, activity.maxMembers || 0),
value: generateMemberList(memberList),
inline: true,
}, {
name: 'Alternates:',
value: generateAlternateList(alternateList),
inline: true,
}],
footer: {
text: `Created by: ${author}`,
iconUrl: `${config.links.creatorIcon}#${authorId}`,
},
timestamp: eventDateTime.getTime(),
}],
components: [{
type: MessageComponentTypes.ActionRow,
components: finalizeButtons(idxPath, eventInFuture),
}],
},
};
};

134
src/buttons/eventUtils.ts Normal file
View File

@ -0,0 +1,134 @@
import { ActionRow, MessageComponentTypes, TextStyles } from '../../deps.ts';
import { LFGMember } from '../types/commandTypes.ts';
import { isDSTActive } from './event-creation/dateTimeUtils.ts';
// Index enum to standardize access to the field
export enum LfgEmbedIndexes {
Activity,
StartTime,
ICSLink,
Description,
JoinedMembers,
AlternateMembers,
}
// Common strings
export const idSeparator = '@';
export const pathIdxSeparator = '|';
export const pathIdxEnder = '&';
export const fillerChar = '$';
export const lfgStartTimeName = 'Start Time:';
export const noMembersStr = 'None';
export const joinEventBtnStr = 'Join';
export const requestToJoinEventBtnStr = 'Request to Join';
export const leaveEventBtnStr = 'Leave';
export const alternateEventBtnStr = 'Join as Alternate';
export const noDescProvided = 'No description provided.';
// Member List generators
export const generateMemberTitle = (memberList: Array<LFGMember>, maxMembers: number): string => `Members Joined: ${memberList.length}/${maxMembers}`;
export const generateMemberList = (memberList: Array<LFGMember>): string => memberList.length ? memberList.map((member) => `${member.name} - <@${member.id}>`).join('\n') : noMembersStr;
export const generateAlternateList = (alternateList: Array<LFGMember>): string =>
alternateList.length ? alternateList.map((member) => `${member.name} - <@${member.id}>${member.joined ? ' *' : ''}`).join('\n') : noMembersStr;
// Fields for event creation and editing modals
export const eventTimeId = 'eventTime';
export const eventTimeZoneId = 'eventTimeZone';
export const eventDateId = 'eventDate';
export const eventDescriptionId = 'eventDescription';
export const descriptionTextField = (prefillDescription = ''): ActionRow => ({
type: MessageComponentTypes.ActionRow,
components: [{
type: MessageComponentTypes.InputText,
customId: eventDescriptionId,
label: 'Description:',
placeholder: 'Briefly describe the event',
style: TextStyles.Paragraph,
required: false,
minLength: 0,
maxLength: 1000,
value: prefillDescription || undefined,
}],
});
// DST notice to try to get people to use the right TZ
const dstNotice = isDSTActive() ? '(Note: DST is in effect in NA)' : '';
export const dateTimeFields = (prefillTime = '', prefillTimeZone = '', prefillDate = ''): ActionRow[] => [{
type: MessageComponentTypes.ActionRow,
components: [{
type: MessageComponentTypes.InputText,
customId: eventTimeId,
label: 'Start Time:',
placeholder: 'Enter the start time as "HH:MM AM/PM"',
style: TextStyles.Short,
minLength: 1,
maxLength: 8,
value: prefillTime || undefined,
}],
}, {
type: MessageComponentTypes.ActionRow,
components: [{
type: MessageComponentTypes.InputText,
customId: eventTimeZoneId,
label: `Time Zone: ${dstNotice}`,
placeholder: 'Enter your time zone abbreviation (UTC±## also works)',
style: TextStyles.Short,
minLength: 2,
maxLength: 8,
value: prefillTimeZone || undefined,
}],
}, {
type: MessageComponentTypes.ActionRow,
components: [{
type: MessageComponentTypes.InputText,
customId: eventDateId,
label: 'Start Date:',
placeholder: 'Enter date as "MONTH/DAY/YEAR" or "Month Day, Year"',
style: TextStyles.Short,
minLength: 1,
maxLength: 20,
value: prefillDate || undefined,
}],
}];
export const activityTitleId = 'activityTitle';
export const activitySubtitleId = 'activitySubtitle';
export const activityMaxPlayersId = 'activityMaxPlayers';
export const generateCustomActivityFields = (actTitle = '', actSubtitle = '', activityMaxPlayers = ''): ActionRow[] => [{
type: MessageComponentTypes.ActionRow,
components: [{
type: MessageComponentTypes.InputText,
customId: activityTitleId,
label: 'Activity Title:',
placeholder: 'The name of the game or event.',
style: TextStyles.Short,
minLength: 1,
maxLength: 35,
value: actTitle || undefined,
}],
}, {
type: MessageComponentTypes.ActionRow,
components: [{
type: MessageComponentTypes.InputText,
customId: activitySubtitleId,
label: 'Activity Subtitle:',
placeholder: 'The specific activity within the game or event.',
style: TextStyles.Short,
minLength: 1,
maxLength: 50,
value: actSubtitle || undefined,
}],
}, {
type: MessageComponentTypes.ActionRow,
components: [{
type: MessageComponentTypes.InputText,
customId: activityMaxPlayersId,
label: 'Maximum Players:',
placeholder: 'Please enter a number between 1 and 99.',
style: TextStyles.Short,
minLength: 1,
maxLength: 2,
value: activityMaxPlayers || undefined,
}],
}];

View File

@ -0,0 +1,27 @@
import { Bot, Interaction } from '../../../deps.ts';
import { dbClient, queries } from '../../db.ts';
import { somethingWentWrong } from '../../commandUtils.ts';
import utils from '../../utils.ts';
import { alternateMemberToEvent } from './utils.ts';
export const customId = 'alternateEvent';
const execute = async (bot: Bot, interaction: Interaction) => {
if (interaction.data?.customId && interaction.member && interaction.member.user && interaction.channelId && interaction.message && interaction.message.embeds[0]) {
// Light Telemetry
dbClient.execute(queries.callIncCnt('btn-altEvent')).catch((e) => utils.commonLoggers.dbError('alternateEvent.ts', 'call sproc INC_CNT on', e));
// Alternate user to event
alternateMemberToEvent(bot, interaction, interaction.message.embeds[0], interaction.message.id, interaction.channelId, {
id: interaction.member.id,
name: interaction.member.user.username,
});
} else {
somethingWentWrong(bot, interaction, 'noDataFromAlternateEventButton');
}
};
export const alternateEventButton = {
customId,
execute,
};

View File

@ -0,0 +1,44 @@
import { Bot, Interaction } from '../../../deps.ts';
import { dbClient, queries } from '../../db.ts';
import { somethingWentWrong } from '../../commandUtils.ts';
import utils from '../../utils.ts';
import { alternateMemberToEvent } from './utils.ts';
export const customId = 'alternateRequest';
const execute = async (bot: Bot, interaction: Interaction) => {
if (interaction.data?.customId && interaction.user && interaction.message && interaction.message.embeds[0] && interaction.message.embeds[0].description) {
// Light Telemetry
dbClient.execute(queries.callIncCnt('btn-joinReqAlt')).catch((e) => utils.commonLoggers.dbError('alternateRequest.ts', 'call sproc INC_CNT on', e));
// Get details from message
const eventIds = utils.messageUrlToIds(interaction.message.embeds[0].description.split(')')[0] || '');
const eventMessage = await bot.helpers.getMessage(eventIds.channelId, eventIds.messageId).catch((e: Error) => utils.commonLoggers.messageGetError('alternateRequest.ts', 'get eventMessage', e));
// Try to alternate the member to the event
if (eventMessage) {
alternateMemberToEvent(
bot,
interaction,
eventMessage.embeds[0],
eventIds.messageId,
eventIds.channelId,
{
id: interaction.user.id,
name: interaction.user.username,
},
false,
true,
);
} else {
somethingWentWrong(bot, interaction, 'eventMissingFromAlternateRequestButton');
}
} else {
somethingWentWrong(bot, interaction, 'noDataFromAlternateRequestButton');
}
};
export const alternateRequestButton = {
customId,
execute,
};

View File

@ -0,0 +1,90 @@
import { ApplicationCommandFlags, Bot, Interaction, InteractionResponseTypes } from '../../../deps.ts';
import { somethingWentWrong, warnColor } from '../../commandUtils.ts';
import { eventDateId, eventTimeId, eventTimeZoneId, idSeparator, LfgEmbedIndexes, pathIdxEnder, pathIdxSeparator } from '../eventUtils.ts';
import { addTokenToMap, deleteTokenEarly } from '../tokenCleanup.ts';
import utils from '../../utils.ts';
import { applyEditButtons, applyEditMessage } from './utils.ts';
import { getDateFromRawInput } from '../event-creation/dateTimeUtils.ts';
import { generateTimeFieldStr } from '../event-creation/utils.ts';
export const customId = 'applyDateTime';
const execute = async (bot: Bot, interaction: Interaction) => {
if (interaction.data?.customId && interaction.data?.components?.length && interaction.member && interaction.channelId && interaction.guildId) {
await deleteTokenEarly(bot, interaction, interaction.guildId, interaction.channelId, interaction.member.id);
const [evtChannelId, evtMessageId] = (interaction.data.customId.replaceAll(pathIdxEnder, '').split(idSeparator)[1] || '').split(pathIdxSeparator).map((id) => BigInt(id || '0'));
const eventMessage = await bot.helpers.getMessage(evtChannelId, evtMessageId).catch((e: Error) => utils.commonLoggers.messageGetError('applyDateTime.ts', 'get eventMessage', e));
const tempDataMap: Map<string, string> = new Map();
for (const row of interaction.data.components) {
if (row.components?.[0]) {
const textField = row.components[0];
tempDataMap.set(textField.customId || 'missingCustomId', textField.value || '');
}
}
const newTime = tempDataMap.get(eventTimeId);
const newTimeZone = tempDataMap.get(eventTimeZoneId);
const newDate = tempDataMap.get(eventDateId);
if (!newTime || !newTimeZone || !newDate) {
// Error out if user somehow failed to provide one of the fields (eventDescription is allowed to be null/empty)
somethingWentWrong(bot, interaction, `missingFieldFromEventDescription@${newTime}_${newTimeZone}_${newDate}`);
return;
}
// Get Date Object from user input
const [eventDateTime, eventDateTimeStr, eventInFuture, dateTimeValid] = getDateFromRawInput(newTime, newTimeZone, newDate);
if (!eventInFuture || !dateTimeValid) {
bot.helpers.sendInteractionResponse(interaction.id, interaction.token, {
type: InteractionResponseTypes.ChannelMessageWithSource,
data: {
flags: ApplicationCommandFlags.Ephemeral,
embeds: [{
color: warnColor,
title: dateTimeValid ? 'You cannot create an event in the past.' : 'Could not parse date/time.',
description: `Please dismiss this message and try again with a ${dateTimeValid ? 'date in the future' : 'valid date/time'}.`,
fields: dateTimeValid
? [{
name: 'Date/Time Entered:',
value: generateTimeFieldStr(eventDateTimeStr, eventDateTime),
}]
: undefined,
}],
},
}).catch((e: Error) => utils.commonLoggers.interactionSendError('applyDateTime.ts', interaction, e));
return;
}
if (eventMessage && eventMessage.embeds[0].fields) {
// eventMessage.embeds[0].fields[LfgEmbedIndexes.Description].value = newDescription || noDescProvided;
eventMessage.embeds[0].fields[LfgEmbedIndexes.StartTime].value = generateTimeFieldStr(eventDateTimeStr, eventDateTime);
const tIdx = eventMessage.embeds[0].fields[LfgEmbedIndexes.ICSLink].value.indexOf('?t=') + 3;
const nIdx = eventMessage.embeds[0].fields[LfgEmbedIndexes.ICSLink].value.indexOf('&n=');
eventMessage.embeds[0].fields[LfgEmbedIndexes.ICSLink].value = `${eventMessage.embeds[0].fields[LfgEmbedIndexes.ICSLink].value.slice(0, tIdx)}${eventDateTime.getTime()}${
eventMessage.embeds[0].fields[LfgEmbedIndexes.ICSLink].value.slice(nIdx)
}`;
eventMessage.embeds[0].timestamp = eventDateTime.getTime();
// Send edit confirmation
addTokenToMap(bot, interaction, interaction.guildId, interaction.channelId, interaction.member.id);
bot.helpers.sendInteractionResponse(interaction.id, interaction.token, {
type: InteractionResponseTypes.ChannelMessageWithSource,
data: {
flags: ApplicationCommandFlags.Ephemeral,
content: applyEditMessage(new Date().getTime()),
embeds: [eventMessage.embeds[0]],
components: applyEditButtons(interaction.data.customId.split(idSeparator)[1] || ''),
},
}).catch((e: Error) => utils.commonLoggers.interactionSendError('applyDateTime.ts', interaction, e));
} else {
somethingWentWrong(bot, interaction, 'failedToGetEventMsgInApplyDateTime');
}
} else {
somethingWentWrong(bot, interaction, 'noDataFromApplyDateTime');
}
};
export const applyDateTimeButton = {
customId,
execute,
};

View File

@ -0,0 +1,50 @@
import { ApplicationCommandFlags, Bot, Interaction, InteractionResponseTypes } from '../../../deps.ts';
import { somethingWentWrong } from '../../commandUtils.ts';
import { eventDescriptionId, idSeparator, LfgEmbedIndexes, noDescProvided, pathIdxEnder, pathIdxSeparator } from '../eventUtils.ts';
import { addTokenToMap, deleteTokenEarly } from '../tokenCleanup.ts';
import utils from '../../utils.ts';
import { applyEditButtons, applyEditMessage } from './utils.ts';
export const customId = 'applyDescription';
const execute = async (bot: Bot, interaction: Interaction) => {
if (interaction.data?.customId && interaction.data?.components?.length && interaction.member && interaction.channelId && interaction.guildId) {
await deleteTokenEarly(bot, interaction, interaction.guildId, interaction.channelId, interaction.member.id);
const [evtChannelId, evtMessageId] = (interaction.data.customId.replaceAll(pathIdxEnder, '').split(idSeparator)[1] || '').split(pathIdxSeparator).map((id) => BigInt(id || '0'));
const eventMessage = await bot.helpers.getMessage(evtChannelId, evtMessageId).catch((e: Error) => utils.commonLoggers.messageGetError('applyDescription.ts', 'get eventMessage', e));
const tempDataMap: Map<string, string> = new Map();
for (const row of interaction.data.components) {
if (row.components?.[0]) {
const textField = row.components[0];
tempDataMap.set(textField.customId || 'missingCustomId', textField.value || '');
}
}
const newDescription = tempDataMap.get(eventDescriptionId);
if (eventMessage && eventMessage.embeds[0].fields) {
eventMessage.embeds[0].fields[LfgEmbedIndexes.Description].value = newDescription || noDescProvided;
// Send edit confirmation
addTokenToMap(bot, interaction, interaction.guildId, interaction.channelId, interaction.member.id);
bot.helpers.sendInteractionResponse(interaction.id, interaction.token, {
type: InteractionResponseTypes.ChannelMessageWithSource,
data: {
flags: ApplicationCommandFlags.Ephemeral,
content: applyEditMessage(new Date().getTime()),
embeds: [eventMessage.embeds[0]],
components: applyEditButtons(interaction.data.customId.split(idSeparator)[1] || ''),
},
}).catch((e: Error) => utils.commonLoggers.interactionSendError('applyDescription.ts', interaction, e));
} else {
somethingWentWrong(bot, interaction, 'failedToGetEventMsgInApplyDescription');
}
} else {
somethingWentWrong(bot, interaction, 'noDataFromApplyDescription');
}
};
export const applyDescriptionButton = {
customId,
execute,
};

View File

@ -0,0 +1,124 @@
import { ApplicationCommandFlags, Bot, Interaction, InteractionResponseTypes } from '../../../deps.ts';
import { dbClient, generateGuildSettingKey, lfgChannelSettings, queries } from '../../db.ts';
import { failColor, infoColor1, infoColor2, safelyDismissMsg, sendDirectMessage, somethingWentWrong, successColor } from '../../commandUtils.ts';
import { generateMemberList, idSeparator, pathIdxEnder, pathIdxSeparator } from '../eventUtils.ts';
import utils from '../../utils.ts';
import config from '../../../config.ts';
import { getGuildName } from './utils.ts';
export const customId = 'deleteConfirmed';
export const confirmedCustomId = 'confirmedCustomId';
export const confirmStr = 'yes';
const execute = async (bot: Bot, interaction: Interaction) => {
if (interaction.data?.customId && interaction.data?.components?.length && interaction.channelId && interaction.guildId && interaction.member && interaction.member.user) {
// Light Telemetry
dbClient.execute(queries.callIncCnt('btn-confirmDelEvent')).catch((e) => utils.commonLoggers.dbError('deleteConfirmed.ts@incCnt', 'call sproc INC_CNT on', e));
// Parse out our data
const tempDataMap: Map<string, string> = new Map();
for (const row of interaction.data.components) {
if (row.components?.[0]) {
const textField = row.components[0];
tempDataMap.set(textField.customId || 'missingCustomId', textField.value || 'missingValue');
}
}
const actionByManager = interaction.data.customId.endsWith(pathIdxEnder);
const [evtChannelId, evtMessageId] = (interaction.data.customId.replaceAll(pathIdxEnder, '').split(idSeparator)[1] || '').split(pathIdxSeparator).map((id) => BigInt(id || '0'));
const lfgChannelSetting = lfgChannelSettings.get(generateGuildSettingKey(interaction.guildId, interaction.channelId)) || {
managed: false,
managerRoleId: 0n,
logChannelId: 0n,
};
if (tempDataMap.get(confirmedCustomId)?.toLowerCase() === confirmStr) {
const guildName = await getGuildName(bot, interaction.guildId);
const eventMessage = await bot.helpers.getMessage(evtChannelId, evtMessageId).catch((e: Error) => utils.commonLoggers.messageGetError('deleteConfirmed.ts', 'get eventMessage', e));
const userId = interaction.member.id;
const userName = interaction.member.user.username;
// Delete event
bot.helpers.deleteMessage(evtChannelId, evtMessageId, 'User deleted event').then(() => {
dbClient.execute(queries.deleteEvent, [evtChannelId, evtMessageId]).catch((e) => utils.commonLoggers.dbError('deleteConfirmed.ts@deleteEvent', 'delete event from', e));
// Acknowledge user so discord doesn't get annoyed
bot.helpers.sendInteractionResponse(interaction.id, interaction.token, {
type: InteractionResponseTypes.ChannelMessageWithSource,
data: {
flags: ApplicationCommandFlags.Ephemeral,
embeds: [{
color: successColor,
title: 'Event successfully deleted.',
description: safelyDismissMsg,
}],
},
}).catch((e: Error) => utils.commonLoggers.interactionSendError('deleteConfirmed.ts', interaction, e));
if (actionByManager) {
const ownerId = BigInt(eventMessage?.embeds[0].footer?.iconUrl?.split('#')[1] || '0');
const eventEmbed = eventMessage?.embeds[0] || { title: 'Event not found', color: failColor };
bot.helpers.sendMessage(lfgChannelSetting.logChannelId, {
embeds: [{
color: infoColor2,
title: `Event deleted by a ${config.name} Manager`,
description: `The following event was deleted by ${userName} - <@${userId}>.`,
timestamp: new Date().getTime(),
}, eventEmbed],
}).catch((e: Error) => utils.commonLoggers.messageSendError('deleteConfirmed.ts', 'send log message', e));
sendDirectMessage(bot, ownerId, {
embeds: [{
color: infoColor2,
title: `Notice: A ${config.name} Manager has deleted one of your events in ${guildName}`,
description: 'The deleted event is listed below.',
fields: [
{
name: `${config.name} Manager:`,
value: generateMemberList([{
id: userId,
name: userName,
}]),
inline: true,
},
{
name: 'Are you unhappy with this action?',
value: `Please reach out to the ${config.name} Manager that performed this action, or the moderators/administrators of ${guildName}.`,
},
],
}, eventEmbed],
}).catch((e: Error) => utils.commonLoggers.messageSendError('deleteConfirmed.ts', 'send DM fail', e));
}
}).catch((e) => {
utils.commonLoggers.messageDeleteError('deleteConfirmed.ts', 'deleteEventFailedDB', e);
somethingWentWrong(bot, interaction, 'deleteEventMessageInDeleteConfirmedButton');
});
} else {
// User either did not type yes confirm field was missing, lets see which it was
if (tempDataMap.get(confirmedCustomId)) {
// User did not type yes.
bot.helpers.sendInteractionResponse(interaction.id, interaction.token, {
type: InteractionResponseTypes.ChannelMessageWithSource,
data: {
flags: ApplicationCommandFlags.Ephemeral,
embeds: [{
color: infoColor1,
title: 'Event not deleted.',
description: `If you are trying to delete the event, please make sure you type \`${confirmStr}\` into the field provided.
${safelyDismissMsg}`,
}],
},
}).catch((e: Error) => utils.commonLoggers.interactionSendError('deleteConfirmed.ts', interaction, e));
} else {
// Field was missing
somethingWentWrong(bot, interaction, 'noIdsFromDeleteConfirmedButton');
}
}
} else {
somethingWentWrong(bot, interaction, 'noDataFromDeleteConfirmedButton');
}
};
export const deleteConfirmedButton = {
customId,
execute,
};

View File

@ -0,0 +1,57 @@
import { Bot, Interaction, InteractionResponseTypes, MessageComponentTypes, TextStyles } from '../../../deps.ts';
import { dbClient, generateGuildSettingKey, lfgChannelSettings, queries } from '../../db.ts';
import { somethingWentWrong, stopThat } from '../../commandUtils.ts';
import { idSeparator, pathIdxEnder, pathIdxSeparator } from '../eventUtils.ts';
import { confirmedCustomId, confirmStr, customId as deleteConfirmedCustomId } from './deleteConfirmed.ts';
import utils from '../../utils.ts';
export const customId = 'deleteEvent';
const execute = async (bot: Bot, interaction: Interaction) => {
if (interaction.data?.customId && interaction.member && interaction.member.user && interaction.channelId && interaction.guildId && interaction.message && interaction.message.embeds[0]) {
// Light Telemetry
dbClient.execute(queries.callIncCnt('btn-delEvent')).catch((e) => utils.commonLoggers.dbError('deleteEvent.ts', 'call sproc INC_CNT on', e));
const ownerId = BigInt(interaction.message.embeds[0].footer?.iconUrl?.split('#')[1] || '0');
const lfgChannelSetting = lfgChannelSettings.get(generateGuildSettingKey(interaction.guildId, interaction.channelId)) || {
managed: false,
managerRoleId: 0n,
logChannelId: 0n,
};
// Make sure this is being done by the owner or a Group Up Manager
if (interaction.member.user.id === ownerId || (lfgChannelSetting.managed && interaction.member.roles.includes(lfgChannelSetting.managerRoleId))) {
const actionByManager = interaction.member.user.id !== ownerId;
// Open Delete Confirmation
bot.helpers.sendInteractionResponse(interaction.id, interaction.token, {
type: InteractionResponseTypes.Modal,
data: {
title: 'Are you sure you want to delete this event?',
customId: `${deleteConfirmedCustomId}${idSeparator}${interaction.channelId}${pathIdxSeparator}${interaction.message.id}${actionByManager ? pathIdxEnder : ''}`,
components: [{
type: MessageComponentTypes.ActionRow,
components: [{
type: MessageComponentTypes.InputText,
customId: confirmedCustomId,
label: `To confirm, type '${confirmStr}' in the field below:`,
placeholder: 'To cancel, just click cancel on this modal.',
style: TextStyles.Short,
minLength: 3,
maxLength: 3,
}],
}],
},
}).catch((e: Error) => utils.commonLoggers.interactionSendError('step1a-openCustomModal.ts:modal', interaction, e));
} else {
// Not owner or manager, tell user they can't
stopThat(bot, interaction, 'delete');
}
} else {
somethingWentWrong(bot, interaction, 'noDataFromDeleteEventButton');
}
};
export const deleteEventButton = {
customId,
execute,
};

View File

@ -0,0 +1,28 @@
import { Bot, Interaction, InteractionResponseTypes } from '../../../deps.ts';
import { generateCustomActivityFields, idSeparator } from '../eventUtils.ts';
import { customId as editActivityCustomId } from './editActivity.ts';
import utils from '../../utils.ts';
import { dbClient, queries } from '../../db.ts';
export const customId = 'editActivityCustom';
const execute = async (bot: Bot, interaction: Interaction) => {
if (interaction.data?.customId && interaction.member && interaction.guildId && interaction.channelId) {
// Light Telemetry
dbClient.execute(queries.callIncCnt('btn-eeCustomAct')).catch((e) => utils.commonLoggers.dbError('step1a-openCustomModal.ts', 'call sproc INC_CNT on', e));
bot.helpers.sendInteractionResponse(interaction.id, interaction.token, {
type: InteractionResponseTypes.Modal,
data: {
title: 'Create Custom Activity',
customId: `${editActivityCustomId}${idSeparator}${interaction.data.customId.split(idSeparator)[1] || ''}`,
components: generateCustomActivityFields(),
},
}).catch((e: Error) => utils.commonLoggers.interactionSendError('editActivity-custom.ts', interaction, e));
}
};
export const editActivityCustomButton = {
customId,
execute,
};

View File

@ -0,0 +1,224 @@
import { ActionRow, ApplicationCommandFlags, Bot, ButtonStyles, Interaction, InteractionResponseTypes, MessageComponentTypes } from '../../../deps.ts';
import { failColor, infoColor1, safelyDismissMsg, somethingWentWrong } from '../../commandUtils.ts';
import { Activities, Activity } from '../event-creation/activities.ts';
import { generateActionRow, getFinalActivity, getNestedActivity } from '../event-creation/utils.ts';
import {
activityMaxPlayersId,
activitySubtitleId,
activityTitleId,
fillerChar,
generateAlternateList,
generateMemberList,
generateMemberTitle,
idSeparator,
LfgEmbedIndexes,
pathIdxEnder,
pathIdxSeparator,
} from '../eventUtils.ts';
import { addTokenToMap, deleteTokenEarly, generateMapId, selfDestructMessage, tokenMap } from '../tokenCleanup.ts';
import utils from '../../utils.ts';
import config from '../../../config.ts';
import { dbClient, queries } from '../../db.ts';
import { customId as editActivityCustomCustomId } from './editActivity-custom.ts';
import { applyEditButtons, applyEditMessage, getEventMemberCount, getLfgMembers } from './utils.ts';
import { LFGMember } from '../../types/commandTypes.ts';
export const customId = 'editActivity';
const makeCustomEventRow = (customIdIdxPath: string): ActionRow => ({
type: MessageComponentTypes.ActionRow,
components: [{
type: MessageComponentTypes.Button,
style: ButtonStyles.Primary,
label: 'Create Custom Event',
customId: `${editActivityCustomCustomId}${idSeparator}${customIdIdxPath}`,
}],
});
const execute = async (bot: Bot, interaction: Interaction) => {
if (interaction.data?.customId && interaction.member && interaction.guildId && interaction.channelId) {
const [evtChannelId, evtMessageId] = (interaction.data.customId.replaceAll(pathIdxEnder, '').replaceAll(fillerChar, '').split(idSeparator)[1] || '').split(pathIdxSeparator).map((id) =>
BigInt(id || '0')
);
// Check if we are done
const valuesIdxPath = interaction.data?.values?.[0] || '';
const finalizedIdxPath = valuesIdxPath.substring(0, valuesIdxPath.lastIndexOf(pathIdxEnder));
if (interaction.data?.values?.[0].endsWith(pathIdxEnder) || (interaction.data?.components && interaction.data.components.length > 0)) {
// User selected activity, give them the confirmation message and delete the selectMenus
await deleteTokenEarly(bot, interaction, interaction.guildId, interaction.channelId, interaction.member.id);
// Fill in the activity details
let selectedCategory = '';
let selectedActivity: Activity = {
name: '',
maxMembers: 0,
};
if (interaction.data.components) {
// Parse out our data
const tempDataMap: Map<string, string> = new Map();
for (const row of interaction.data.components) {
if (row.components?.[0]) {
const textField = row.components[0];
tempDataMap.set(textField.customId || 'missingCustomId', textField.value || '');
}
}
// Remove any pipe characters to avoid issues down the process
const activityTitle = (tempDataMap.get(activityTitleId) || '').replace(/\|/g, '');
const activitySubtitle = (tempDataMap.get(activitySubtitleId) || '').replace(/\|/g, '');
const activityMaxPlayers = parseInt(tempDataMap.get(activityMaxPlayersId) || '0');
if (isNaN(activityMaxPlayers) || activityMaxPlayers < 1 || activityMaxPlayers > 99) {
bot.helpers.sendInteractionResponse(interaction.id, interaction.token, {
type: InteractionResponseTypes.ChannelMessageWithSource,
data: {
flags: ApplicationCommandFlags.Ephemeral,
embeds: [{
color: failColor,
title: 'Invalid Max Member count!',
description: `${config.name} parsed the max members as \`${
isNaN(activityMaxPlayers) ? 'Not a Number' : activityMaxPlayers
}\`, which is outside of the allowed range. Please re-edit this activity, but make sure the maximum player count is between 1 and 99.\n\n${safelyDismissMsg}`,
}],
},
}).catch((e: Error) => utils.commonLoggers.interactionSendError('editActivity.ts@invalidPlayer', interaction, e));
return;
}
if (!activityMaxPlayers || !activitySubtitle || !activityTitle) {
// Verify fields exist
somethingWentWrong(bot, interaction, `missingFieldFromEditCustomActivity@${activityTitle}|${activitySubtitle}|${activityMaxPlayers}$`);
return;
}
selectedCategory = activityTitle;
selectedActivity.name = activitySubtitle;
selectedActivity.maxMembers = activityMaxPlayers;
// Log custom event to see if we should add it as a preset
dbClient.execute(queries.insertCustomActivity, [interaction.guildId, selectedCategory, selectedActivity.name, selectedActivity.maxMembers]).catch((e) =>
utils.commonLoggers.dbError('editActivity.ts@custom', 'insert into', e)
);
} else {
const rawIdxPath: Array<string> = finalizedIdxPath.split(pathIdxSeparator);
const idxPath: Array<number> = rawIdxPath.map((rawIdx) => rawIdx ? parseInt(rawIdx) : -1);
selectedCategory = Activities[idxPath[0]].name;
selectedActivity = getFinalActivity(idxPath, Activities);
}
if (
!selectedActivity.maxMembers || !selectedCategory || !selectedActivity.name || (isNaN(selectedActivity.maxMembers) || (selectedActivity.maxMembers < 1 || selectedActivity.maxMembers > 99))
) {
// Verify fields exist
somethingWentWrong(bot, interaction, `parseFailInEditCustomActivity@${selectedCategory}|${selectedActivity.name}|${selectedActivity.maxMembers || 'undefined'}$`);
return;
}
// Get event to apply edit
const eventMessage = await bot.helpers.getMessage(evtChannelId, evtMessageId).catch((e: Error) => utils.commonLoggers.messageGetError('editActivity.ts', 'get eventMessage', e));
if (eventMessage && eventMessage.embeds[0].fields) {
await deleteTokenEarly(bot, interaction, interaction.guildId, interaction.channelId, interaction.member.id);
// Update member lists
const [currentMemberCount, _oldMaxMemberCount] = getEventMemberCount(eventMessage.embeds[0].fields[LfgEmbedIndexes.JoinedMembers].name);
const currentMembers = getLfgMembers(eventMessage.embeds[0].fields[LfgEmbedIndexes.JoinedMembers].value);
const currentAlternates = getLfgMembers(eventMessage.embeds[0].fields[LfgEmbedIndexes.AlternateMembers].value);
if (currentMemberCount > selectedActivity.maxMembers) {
const membersToAlternate = currentMembers.splice(selectedActivity.maxMembers).map((member): LFGMember => ({
id: member.id,
name: member.name,
joined: true,
}));
currentAlternates.unshift(...membersToAlternate);
}
// Apply edits
eventMessage.embeds[0].fields[LfgEmbedIndexes.Activity].name = `${selectedCategory}:`;
eventMessage.embeds[0].fields[LfgEmbedIndexes.Activity].value = selectedActivity.name;
eventMessage.embeds[0].fields[LfgEmbedIndexes.JoinedMembers].name = generateMemberTitle(currentMembers, selectedActivity.maxMembers);
eventMessage.embeds[0].fields[LfgEmbedIndexes.JoinedMembers].value = generateMemberList(currentMembers);
eventMessage.embeds[0].fields[LfgEmbedIndexes.AlternateMembers].value = generateAlternateList(currentAlternates);
// Send edit confirmation
addTokenToMap(bot, interaction, interaction.guildId, interaction.channelId, interaction.member.id);
bot.helpers.sendInteractionResponse(interaction.id, interaction.token, {
type: InteractionResponseTypes.ChannelMessageWithSource,
data: {
flags: ApplicationCommandFlags.Ephemeral,
content: applyEditMessage(new Date().getTime()),
embeds: [eventMessage.embeds[0]],
components: applyEditButtons(interaction.data.customId.replaceAll(fillerChar, '').split(idSeparator)[1] || ''),
},
}).catch((e: Error) => utils.commonLoggers.interactionSendError('editActivity.ts', interaction, e));
} else {
somethingWentWrong(bot, interaction, 'failedToGetEventMsgInEditActivity');
}
return;
}
// Parse indexPath from the select value
const rawIdxPath: Array<string> = interaction.data.values ? interaction.data.values[0].split(pathIdxSeparator) : [''];
const idxPath: Array<number> = rawIdxPath.map((rawIdx) => rawIdx ? parseInt(rawIdx) : -1);
const selectMenus: Array<ActionRow> = [];
// Use fillerChar to create unique customIds for dropdowns
// We also leverage this to determine if its the first time the user has entered gameSel
let selectMenuCustomId = `${interaction.data.customId.replaceAll(fillerChar, '')}${fillerChar}`;
let currentBaseValue = '';
for (let i = 0; i < idxPath.length; i++) {
const idx = idxPath[i];
const idxPathCopy = [...idxPath].slice(0, i);
selectMenus.push(generateActionRow(currentBaseValue, getNestedActivity(idxPathCopy, Activities), selectMenuCustomId, idx));
selectMenuCustomId = `${selectMenuCustomId}${fillerChar}`;
currentBaseValue = `${currentBaseValue}${idx}${pathIdxSeparator}`;
}
selectMenus.push(makeCustomEventRow(interaction.data.customId.replaceAll(fillerChar, '').split(idSeparator)[1] || ''));
if (interaction.data.customId && interaction.data.customId.includes(fillerChar)) {
// Let discord know we didn't ignore the user
await bot.helpers.sendInteractionResponse(interaction.id, interaction.token, {
type: InteractionResponseTypes.DeferredUpdateMessage,
}).catch((e: Error) => utils.commonLoggers.interactionSendError('editActivity.ts@ping', interaction, e));
// Update the original game selector
await bot.helpers.editOriginalInteractionResponse(tokenMap.get(generateMapId(interaction.guildId, interaction.channelId, interaction.member.id))?.token || '', {
components: selectMenus,
}).catch((e: Error) => utils.commonLoggers.interactionSendError('editActivity.ts@edit', interaction, e));
} else {
// Light Telemetry (placed here so it only gets called on first run)
dbClient.execute(queries.callIncCnt('btn-eeChangeAct')).catch((e) => utils.commonLoggers.dbError('editActivity.ts', 'call sproc INC_CNT on', e));
// Delete old token entry if it exists
await deleteTokenEarly(bot, interaction, interaction.guildId, interaction.channelId, interaction.member.id);
// Store token for later use
addTokenToMap(bot, interaction, interaction.guildId, interaction.channelId, interaction.member.id);
// Send initial interaction
bot.helpers.sendInteractionResponse(interaction.id, interaction.token, {
type: InteractionResponseTypes.ChannelMessageWithSource,
data: {
embeds: [{
title: 'Please select a Game and Activity, or create a Custom Event.',
description: `Changing activity for [this event](${
utils.idsToMessageUrl({
guildId: interaction.guildId,
channelId: evtChannelId,
messageId: evtMessageId,
})
}).\n\n${selfDestructMessage(new Date().getTime())}`,
color: infoColor1,
}],
flags: ApplicationCommandFlags.Ephemeral,
components: selectMenus,
},
}).catch((e: Error) => utils.commonLoggers.interactionSendError('editActivity.ts@init', interaction, e));
}
} else {
somethingWentWrong(bot, interaction, 'missingCoreValuesOnEditActivity');
}
};
export const editActivityButton = {
customId,
execute,
};

View File

@ -0,0 +1,41 @@
import { Bot, Interaction, InteractionResponseTypes } from '../../../deps.ts';
import { dbClient, queries } from '../../db.ts';
import { somethingWentWrong } from '../../commandUtils.ts';
import { dateTimeFields, idSeparator, LfgEmbedIndexes, pathIdxEnder, pathIdxSeparator } from '../eventUtils.ts';
import { monthsShort } from '../event-creation/dateTimeUtils.ts';
import utils from '../../utils.ts';
import { customId as applyDateTimeCustomId } from './applyDateTime.ts';
export const customId = 'editDateTime';
const execute = async (bot: Bot, interaction: Interaction) => {
if (interaction.data?.customId && interaction.member && interaction.channelId && interaction.guildId) {
// Light Telemetry
dbClient.execute(queries.callIncCnt('btn-eeChangeTime')).catch((e) => utils.commonLoggers.dbError('editDateTime.ts', 'call sproc INC_CNT on', e));
const [evtChannelId, evtMessageId] = (interaction.data.customId.replaceAll(pathIdxEnder, '').split(idSeparator)[1] || '').split(pathIdxSeparator).map((id) => BigInt(id || '0'));
const eventMessage = await bot.helpers.getMessage(evtChannelId, evtMessageId).catch((e: Error) => utils.commonLoggers.messageGetError('editDateTime.ts', 'get eventMessage', e));
let rawEventDateTime = eventMessage?.embeds[0].fields ? eventMessage.embeds[0].fields[LfgEmbedIndexes.StartTime].value.trim().split('\n')[0].split(' ') : [];
const monthIdx = rawEventDateTime.findIndex((item) => monthsShort.includes(item.toUpperCase()));
const prefillTime = rawEventDateTime.slice(0, monthIdx - 1).join(' ').trim();
const prefillTimeZone = rawEventDateTime[monthIdx - 1].trim();
const prefillDate = rawEventDateTime.slice(monthIdx).join(' ').trim();
// Open Edit Date/Time Modal
bot.helpers.sendInteractionResponse(interaction.id, interaction.token, {
type: InteractionResponseTypes.Modal,
data: {
title: 'Edit Event Date/Time',
customId: `${applyDateTimeCustomId}${idSeparator}${interaction.data.customId.split(idSeparator)[1] || ''}`,
components: dateTimeFields(prefillTime, prefillTimeZone, prefillDate),
},
}).catch((e: Error) => utils.commonLoggers.interactionSendError('editDateTime.ts', interaction, e));
} else {
somethingWentWrong(bot, interaction, 'noDataFromEditDateTimeButton');
}
};
export const editDateTimeButton = {
customId,
execute,
};

View File

@ -0,0 +1,36 @@
import { Bot, Interaction, InteractionResponseTypes } from '../../../deps.ts';
import { dbClient, queries } from '../../db.ts';
import { somethingWentWrong } from '../../commandUtils.ts';
import { descriptionTextField, idSeparator, LfgEmbedIndexes, pathIdxEnder, pathIdxSeparator } from '../eventUtils.ts';
import utils from '../../utils.ts';
import { customId as applyDescriptionCustomId } from './applyDescription.ts';
export const customId = 'editDescription';
const execute = async (bot: Bot, interaction: Interaction) => {
if (interaction.data?.customId && interaction.member && interaction.channelId && interaction.guildId) {
// Light Telemetry
dbClient.execute(queries.callIncCnt('btn-eeChangeDesc')).catch((e) => utils.commonLoggers.dbError('editDescription.ts', 'call sproc INC_CNT on', e));
const [evtChannelId, evtMessageId] = (interaction.data.customId.replaceAll(pathIdxEnder, '').split(idSeparator)[1] || '').split(pathIdxSeparator).map((id) => BigInt(id || '0'));
const eventMessage = await bot.helpers.getMessage(evtChannelId, evtMessageId).catch((e: Error) => utils.commonLoggers.messageGetError('editDescription.ts', 'get eventMessage', e));
const prefillDescription = eventMessage?.embeds[0].fields ? eventMessage.embeds[0].fields[LfgEmbedIndexes.Description].value.trim() : '';
// Open Edit Description Modal
bot.helpers.sendInteractionResponse(interaction.id, interaction.token, {
type: InteractionResponseTypes.Modal,
data: {
title: 'Edit Event Description',
customId: `${applyDescriptionCustomId}${idSeparator}${interaction.data.customId.split(idSeparator)[1] || ''}`,
components: [descriptionTextField(prefillDescription)],
},
}).catch((e: Error) => utils.commonLoggers.interactionSendError('editDescription.ts', interaction, e));
} else {
somethingWentWrong(bot, interaction, 'noDataFromEditDescriptionButton');
}
};
export const editDescriptionButton = {
customId,
execute,
};

View File

@ -0,0 +1,113 @@
import { ActionRow, ApplicationCommandFlags, Bot, ButtonStyles, Interaction, InteractionResponseTypes, MessageComponentTypes } from '../../../deps.ts';
import { dbClient, generateGuildSettingKey, lfgChannelSettings, queries } from '../../db.ts';
import { infoColor1, somethingWentWrong, stopThat } from '../../commandUtils.ts';
import { idSeparator, pathIdxEnder, pathIdxSeparator } from '../eventUtils.ts';
import { addTokenToMap, selfDestructMessage } from '../tokenCleanup.ts';
import utils from '../../utils.ts';
import { customId as editActivityCustomId } from './editActivity.ts';
import { customId as editDescriptionCustomId } from './editDescription.ts';
import { customId as editDateTimeCustomId } from './editDateTime.ts';
import { customId as toggleWLStatusCustomId } from './toggleWLStatus.ts';
export const customId = 'editEvent';
const execute = async (bot: Bot, interaction: Interaction) => {
if (
interaction.data?.customId && interaction.member && interaction.channelId && interaction.guildId && interaction.message && interaction.message.components &&
interaction.message.components[0].components
) {
// Light Telemetry
dbClient.execute(queries.callIncCnt('btn-editEvent')).catch((e) => utils.commonLoggers.dbError('editEvent.ts', 'call sproc INC_CNT on', e));
const ownerId = BigInt(interaction.message.embeds[0].footer?.iconUrl?.split('#')[1] || '0');
const lfgChannelSetting = lfgChannelSettings.get(generateGuildSettingKey(interaction.guildId, interaction.channelId)) || {
managed: false,
managerRoleId: 0n,
logChannelId: 0n,
};
// Make sure this is being done by the owner or a Group Up Manager
if (interaction.member.id === ownerId || (lfgChannelSetting.managed && interaction.member.roles.includes(lfgChannelSetting.managerRoleId))) {
const actionByManager = interaction.member.id !== ownerId;
const baseEditIdxPath = `${idSeparator}${interaction.channelId}${pathIdxSeparator}${interaction.message.id}`;
const editIdxPath = `${baseEditIdxPath}${actionByManager ? pathIdxEnder : ''}`;
const whitelistedEvent = interaction.message.components[0].components.some((component) => component.customId?.includes(idSeparator));
// Store token for later use
addTokenToMap(bot, interaction, interaction.guildId, interaction.channelId, interaction.member.id);
const editButtons: ActionRow = {
type: MessageComponentTypes.ActionRow,
components: [{
type: MessageComponentTypes.Button,
label: 'Change Activity',
style: ButtonStyles.Primary,
customId: `${editActivityCustomId}${editIdxPath}`,
}, {
type: MessageComponentTypes.Button,
label: 'Change Date/Time',
style: ButtonStyles.Primary,
customId: `${editDateTimeCustomId}${editIdxPath}`,
}, {
type: MessageComponentTypes.Button,
label: 'Edit Description',
style: ButtonStyles.Primary,
customId: `${editDescriptionCustomId}${editIdxPath}`,
}],
};
if (!actionByManager) {
editButtons.components.push(
whitelistedEvent
? {
type: MessageComponentTypes.Button,
label: 'Make Event Public',
style: ButtonStyles.Primary,
customId: `${toggleWLStatusCustomId}${baseEditIdxPath}`,
}
: {
type: MessageComponentTypes.Button,
label: 'Make Event Whitelisted',
style: ButtonStyles.Primary,
customId: `${toggleWLStatusCustomId}${baseEditIdxPath}${pathIdxEnder}`,
},
);
}
// Open Edit Options
bot.helpers.sendInteractionResponse(
interaction.id,
interaction.token,
{
type: InteractionResponseTypes.ChannelMessageWithSource,
data: {
flags: ApplicationCommandFlags.Ephemeral,
embeds: [{
color: infoColor1,
title: 'Edit Menu',
description: `Now editing [this event](${
utils.idsToMessageUrl({
guildId: interaction.guildId,
channelId: interaction.channelId,
messageId: interaction.message.id,
})
}). Please select an option below.
${selfDestructMessage(new Date().getTime())}`,
}],
components: [editButtons],
},
},
).catch((e: Error) => utils.commonLoggers.interactionSendError('editEvent.ts', interaction, e));
} else {
// Not owner or manager, tell user they can't
stopThat(bot, interaction, 'edit');
}
} else {
somethingWentWrong(bot, interaction, 'noDataFromEditEventButton');
}
};
export const editEventButton = {
customId,
execute,
};

View File

@ -0,0 +1,128 @@
import { ApplicationCommandFlags, Bot, Interaction, InteractionResponseTypes } from '../../../deps.ts';
import { dbClient, generateGuildSettingKey, lfgChannelSettings, queries } from '../../db.ts';
import { infoColor1, safelyDismissMsg, sendDirectMessage, somethingWentWrong, successColor, warnColor } from '../../commandUtils.ts';
import { generateMemberList, idSeparator, LfgEmbedIndexes } from '../eventUtils.ts';
import utils from '../../utils.ts';
import config from '../../../config.ts';
import { generateMapId, getGuildName, getLfgMembers, joinMemberToEvent, joinRequestMap, joinRequestResponseButtons, JoinRequestStatus } from './utils.ts';
export const customId = 'joinEvent';
const execute = async (bot: Bot, interaction: Interaction) => {
if (
interaction.data?.customId && interaction.member && interaction.member.user && interaction.channelId && interaction.guildId && interaction.message && interaction.message.embeds[0] &&
interaction.message.embeds[0].fields
) {
// Light Telemetry
dbClient.execute(queries.callIncCnt(interaction.data.customId.includes(idSeparator) ? 'btn-joinWLEvent' : 'btn-joinEvent')).catch((e) =>
utils.commonLoggers.dbError('joinEvent.ts', 'call sproc INC_CNT on', e)
);
const ownerId = BigInt(interaction.message.embeds[0].footer?.iconUrl?.split('#')[1] || '0');
const memberId = interaction.member.id;
// Check if event is whitelisted
if (interaction.data.customId.includes(idSeparator) && memberId !== ownerId) {
// Initialize WL vars
const joinRequestKey = generateMapId(interaction.message.id, interaction.channelId, memberId);
const messageUrl = utils.idsToMessageUrl({
guildId: interaction.guildId,
channelId: interaction.channelId,
messageId: interaction.message.id,
});
const lfgChannelSetting = lfgChannelSettings.get(generateGuildSettingKey(interaction.guildId, interaction.channelId)) || { managed: false };
const urgentManagerStr = lfgChannelSetting.managed ? ` a ${config.name} Manager (members with the <@&${lfgChannelSetting.managerRoleId}> role in this guild) or ` : ' ';
const eventMembers = getLfgMembers(interaction.message.embeds[0].fields[LfgEmbedIndexes.JoinedMembers].value);
if (eventMembers.find((lfgMember) => lfgMember.id === memberId)) {
// User is already joined to event, block request
bot.helpers.sendInteractionResponse(interaction.id, interaction.token, {
type: InteractionResponseTypes.ChannelMessageWithSource,
data: {
flags: ApplicationCommandFlags.Ephemeral,
embeds: [{
color: warnColor,
title: 'Notice: Request Blocked',
description: `To reduce spam, ${config.name} has blocked this request to join as you have already joined this event.
${safelyDismissMsg}`,
}],
},
}).catch((e: Error) => utils.commonLoggers.interactionSendError('joinEvent.ts@userAlreadyJoined', interaction, e));
} else if (joinRequestMap.has(joinRequestKey)) {
// User has already sent request, block new one
bot.helpers.sendInteractionResponse(interaction.id, interaction.token, {
type: InteractionResponseTypes.ChannelMessageWithSource,
data: {
flags: ApplicationCommandFlags.Ephemeral,
embeds: [{
color: warnColor,
title: 'Notice: Request Blocked',
description: `To reduce spam, ${config.name} has blocked this request to join as you have recently sent a request for this event.
If this request is urgent, please speak with${urgentManagerStr}the owner of [this event](${messageUrl}), <@${ownerId}>, to resolve the issue.
The status of your recent Join Request for [this event](${messageUrl}) is: \`${joinRequestMap.get(joinRequestKey)?.status || 'Failed to retrieve status'}\`
${safelyDismissMsg}`,
}],
},
}).catch((e: Error) => utils.commonLoggers.interactionSendError('joinEvent.ts@requestBlocked', interaction, e));
} else {
const guildName = await getGuildName(bot, interaction.guildId);
// User is not joined and this is first request, send the Join Request
sendDirectMessage(bot, ownerId, {
embeds: [{
color: infoColor1,
title: 'New Join Request!',
description: `A member has requested to join [your event](${messageUrl}) in \`${guildName}\`. Please use the buttons below this message to Approve or Deny the request.`,
fields: [{
name: 'Member Details:',
value: generateMemberList([{
id: memberId,
name: interaction.member.user.username,
}]),
}],
}],
components: joinRequestResponseButtons(false),
}).then(() => {
// Alert requester that join request has been sent
bot.helpers.sendInteractionResponse(interaction.id, interaction.token, {
type: InteractionResponseTypes.ChannelMessageWithSource,
data: {
flags: ApplicationCommandFlags.Ephemeral,
embeds: [{
color: successColor,
title: 'Notice: Request Received',
description: `The owner of [this event](${messageUrl}), <@${ownerId}>, has been notified of your request. You will receive a Direct Message when <@${ownerId}> responds to the request.
${safelyDismissMsg}`,
}],
},
}).catch((e: Error) => utils.commonLoggers.interactionSendError('joinEvent.ts@requestReceived', interaction, e));
// Track the request to prevent spam
joinRequestMap.set(joinRequestKey, {
status: JoinRequestStatus.Pending,
timestamp: new Date().getTime(),
});
}).catch((e: Error) => {
somethingWentWrong(bot, interaction, 'failedToDMOwnerInRequestToJoinEventButton');
utils.commonLoggers.messageSendError('joinEvent.ts@dmOwner', 'failed to DM owner for join request', e);
});
}
} else {
// Join user to event
joinMemberToEvent(bot, interaction, interaction.message.embeds[0], interaction.message.id, interaction.channelId, {
id: memberId,
name: interaction.member.user.username,
}, interaction.guildId);
}
} else {
somethingWentWrong(bot, interaction, 'noDataFromJoinEventButton');
}
};
export const joinEventButton = {
customId,
execute,
};

View File

@ -0,0 +1,88 @@
import { Bot, ButtonStyles, Interaction, InteractionResponseTypes, MessageComponentTypes } from '../../../deps.ts';
import { sendDirectMessage, somethingWentWrong, successColor, warnColor } from '../../commandUtils.ts';
import { generateMapId, getLfgMembers, joinMemberToEvent, joinRequestMap, joinRequestResponseButtons, JoinRequestStatus } from './utils.ts';
import { alternateEventBtnStr, idSeparator } from '../eventUtils.ts';
import { dbClient, queries } from '../../db.ts';
import { customId as alternateRequestCustomId } from './alternateRequest.ts';
import utils from '../../utils.ts';
export const customId = 'joinRequest';
export const approveStr = 'approved';
export const denyStr = 'denied';
const execute = async (bot: Bot, interaction: Interaction) => {
if (
interaction.data?.customId && interaction.user && interaction.channelId && interaction.message && interaction.message.embeds[0] && interaction.message.embeds[0].fields &&
interaction.message.embeds[0].description
) {
const memberRequesting = getLfgMembers(interaction.message.embeds[0].fields[0].value || '')[0];
const approved = interaction.data.customId.includes(approveStr);
const responseStr = interaction.data.customId.split(idSeparator)[1] || '';
const capResponseStr = utils.capitalizeFirstChar(responseStr);
const eventIds = utils.messageUrlToIds(interaction.message.embeds[0].description.split(')')[0] || '');
const eventUrl = utils.idsToMessageUrl(eventIds);
const joinRequestMapId = generateMapId(eventIds.messageId, eventIds.channelId, memberRequesting.id);
// Light Telemetry
dbClient.execute(queries.callIncCnt(approved ? 'btn-joinReqApprove' : 'btn-joinReqDeny')).catch((e) => utils.commonLoggers.dbError('joinRequest.ts', 'call sproc INC_CNT on', e));
if (approved) {
// If member was approved, get the event and add them to it
const eventMessage = await bot.helpers.getMessage(eventIds.channelId, eventIds.messageId).catch((e: Error) => utils.commonLoggers.messageGetError('joinRequest.ts', 'get eventMessage', e));
if (eventMessage) {
joinMemberToEvent(bot, interaction, eventMessage.embeds[0], eventIds.messageId, eventIds.channelId, memberRequesting, eventIds.guildId);
} else {
somethingWentWrong(bot, interaction, 'eventMissingFromJoinRequestButton');
return;
}
} else {
// If denied, send deferredUpdate so discord doesn't think we ignored the user (approved is handled in joinMemberToEvent)
bot.helpers.sendInteractionResponse(interaction.id, interaction.token, {
type: InteractionResponseTypes.DeferredUpdateMessage,
}).catch((e: Error) => utils.commonLoggers.interactionSendError('joinRequest.ts', interaction, e));
}
// Update the JoinRequestMap
joinRequestMap.set(joinRequestMapId, {
status: approved ? JoinRequestStatus.Approved : JoinRequestStatus.Denied,
timestamp: new Date().getTime(),
});
// Send DM to the requesting member to let them know of the result
sendDirectMessage(bot, memberRequesting.id, {
embeds: [{
color: approved ? successColor : warnColor,
title: `Notice: Join Request ${capResponseStr}`,
description: `The owner of [this event](${eventUrl}), <@${interaction.user.id}>, has ${responseStr} your join request.${
approved ? '' : ' If you would like to join the event as an alternate, please click on the button below.'
}`,
}],
components: approved ? undefined : [{
type: MessageComponentTypes.ActionRow,
components: [{
type: MessageComponentTypes.Button,
label: alternateEventBtnStr,
style: ButtonStyles.Primary,
customId: alternateRequestCustomId,
}],
}],
}).catch((e: Error) => utils.commonLoggers.messageSendError('joinRequest.ts', 'send DM fail', e));
// Update request DM to indicate if it was approved or denied and disable buttons
interaction.message.embeds[0].fields.push({
name: 'Your response:',
value: capResponseStr,
});
bot.helpers.editMessage(interaction.channelId, interaction.message.id, {
embeds: [interaction.message.embeds[0]],
components: joinRequestResponseButtons(true),
}).catch((e: Error) => utils.commonLoggers.messageEditError('joinRequest.ts', 'event edit fail', e));
} else {
somethingWentWrong(bot, interaction, 'noDataFromJoinRequestButton');
}
};
export const joinRequestButton = {
customId,
execute,
};

View File

@ -0,0 +1,24 @@
import { Bot, Interaction } from '../../../deps.ts';
import { dbClient, queries } from '../../db.ts';
import { somethingWentWrong } from '../../commandUtils.ts';
import utils from '../../utils.ts';
import { removeMemberFromEvent } from './utils.ts';
export const customId = 'leaveEvent';
const execute = async (bot: Bot, interaction: Interaction) => {
if (interaction.data?.customId && interaction.member && interaction.channelId && interaction.guildId && interaction.message && interaction.message.embeds[0]) {
// Light Telemetry
dbClient.execute(queries.callIncCnt('btn-leaveEvent')).catch((e) => utils.commonLoggers.dbError('leaveEvent.ts', 'call sproc INC_CNT on', e));
// Remove user from event
removeMemberFromEvent(bot, interaction, interaction.message.embeds[0], interaction.message.id, interaction.channelId, interaction.member.id, interaction.guildId);
} else {
somethingWentWrong(bot, interaction, 'noDataFromLeaveEventButton');
}
};
export const leaveEventButton = {
customId,
execute,
};

View File

@ -0,0 +1,32 @@
import { Bot, Interaction } from '../../../deps.ts';
import { dbClient, queries } from '../../db.ts';
import { somethingWentWrong } from '../../commandUtils.ts';
import utils from '../../utils.ts';
import { removeMemberFromEvent } from './utils.ts';
import { idSeparator, pathIdxEnder, pathIdxSeparator } from '../eventUtils.ts';
export const customId = 'leaveEventViaDM';
const execute = async (bot: Bot, interaction: Interaction) => {
if (interaction.data?.customId) {
// Light Telemetry
dbClient.execute(queries.callIncCnt('btn-leaveEventViaDM')).catch((e) => utils.commonLoggers.dbError('leaveEventViaDM.ts', 'call sproc INC_CNT on', e));
const [evtGuildId, evtChannelId, evtMessageId] = (interaction.data.customId.replaceAll(pathIdxEnder, '').split(idSeparator)[1] || '').split(pathIdxSeparator).map((id) => BigInt(id || '0'));
const eventMessage = await bot.helpers.getMessage(evtChannelId, evtMessageId).catch((e: Error) => utils.commonLoggers.messageGetError('editActivity.ts', 'get eventMessage', e));
if (eventMessage && eventMessage.embeds[0]) {
// Remove user from event
removeMemberFromEvent(bot, interaction, eventMessage.embeds[0], evtMessageId, evtChannelId, interaction.user.id, evtGuildId, true);
} else {
somethingWentWrong(bot, interaction, 'getEventFailInLeaveEventViaDMButton');
}
} else {
somethingWentWrong(bot, interaction, 'noDataFromLeaveEventViaDMButton');
}
};
export const leaveEventViaDMButton = {
customId,
execute,
};

View File

@ -0,0 +1,66 @@
import { ApplicationCommandFlags, Bot, Interaction, InteractionResponseTypes, MessageComponentTypes } from '../../../deps.ts';
import { dmTestMessage, safelyDismissMsg, sendDirectMessage, somethingWentWrong, successColor, warnColor } from '../../commandUtils.ts';
import { idSeparator, pathIdxEnder, pathIdxSeparator } from '../eventUtils.ts';
import { deleteTokenEarly } from '../tokenCleanup.ts';
import utils from '../../utils.ts';
import { dbClient, queries } from '../../db.ts';
import { generateLFGButtons } from '../event-creation/utils.ts';
export const customId = 'toggleWLStatus';
const execute = async (bot: Bot, interaction: Interaction) => {
if (interaction.data?.customId && interaction.member && interaction.channelId && interaction.guildId) {
deleteTokenEarly(bot, interaction, interaction.guildId, interaction.channelId, interaction.member.id);
const makeWhitelisted = interaction.data.customId.endsWith(pathIdxEnder);
dbClient.execute(queries.callIncCnt(makeWhitelisted ? 'btn-eeMakeWL' : 'btn-eeMakePublic')).catch((e) => utils.commonLoggers.dbError('toggleWLStatus.ts', 'call sproc INC_CNT on', e));
const [evtChannelId, evtMessageId] = (interaction.data.customId.replaceAll(pathIdxEnder, '').split(idSeparator)[1] || '').split(pathIdxSeparator).map((id) => BigInt(id || '0'));
// Check if we need to ensure DMs are open
if (makeWhitelisted) {
const dmSuccess = Boolean(await sendDirectMessage(bot, interaction.member.id, dmTestMessage).catch((e: Error) => utils.commonLoggers.messageSendError('toggleWLStatus.ts', 'send DM fail', e)));
if (!dmSuccess) {
bot.helpers.sendInteractionResponse(interaction.id, interaction.token, {
type: InteractionResponseTypes.ChannelMessageWithSource,
data: {
flags: ApplicationCommandFlags.Ephemeral,
embeds: [{
color: warnColor,
title: 'Event not modified.',
description: `In order to turn the whitelist on, your DMs must be open to receive Join Requests. Please open your DMs and try again.\n\n${safelyDismissMsg}`,
}],
},
}).catch((e: Error) => utils.commonLoggers.interactionSendError('toggleWLStatus.ts@dmFail', interaction, e));
return;
}
}
bot.helpers.editMessage(evtChannelId, evtMessageId, {
components: [{
type: MessageComponentTypes.ActionRow,
components: generateLFGButtons(makeWhitelisted),
}],
}).then(() =>
bot.helpers.sendInteractionResponse(interaction.id, interaction.token, {
type: InteractionResponseTypes.ChannelMessageWithSource,
data: {
flags: ApplicationCommandFlags.Ephemeral,
embeds: [{
color: successColor,
title: 'Update successfully applied.',
description: `This event is now ${makeWhitelisted ? 'whitelisted, meaning you will have to approve join requests from all future members' : 'public'}.\n\n${safelyDismissMsg}`,
}],
},
}).catch((e: Error) => utils.commonLoggers.interactionSendError('toggleWLStatus.ts@dmSuccess', interaction, e))
).catch((e) => {
utils.commonLoggers.messageEditError('toggleWLStatus.ts', 'toggleWLStatusFailed', e);
somethingWentWrong(bot, interaction, 'editFailedInToggleWLStatusButton');
});
} else {
somethingWentWrong(bot, interaction, 'noDataFromToggleWLStatusButton');
}
};
export const toggleWLStatusButton = {
customId,
execute,
};

View File

@ -0,0 +1,114 @@
import { ApplicationCommandFlags, Bot, Interaction, InteractionResponseTypes } from '../../../deps.ts';
import { failColor, infoColor1, infoColor2, safelyDismissMsg, sendDirectMessage, somethingWentWrong, successColor } from '../../commandUtils.ts';
import { generateMemberList, idSeparator, LfgEmbedIndexes, pathIdxEnder, pathIdxSeparator } from '../eventUtils.ts';
import { deleteTokenEarly } from '../tokenCleanup.ts';
import utils from '../../utils.ts';
import config from '../../../config.ts';
import { dbClient, generateGuildSettingKey, lfgChannelSettings, queries } from '../../db.ts';
import { getGuildName } from './utils.ts';
export const customId = 'updateEvent';
const execute = async (bot: Bot, interaction: Interaction) => {
if (interaction.data?.customId && interaction.member?.user && interaction.channelId && interaction.guildId && interaction.message?.embeds[0].fields) {
deleteTokenEarly(bot, interaction, interaction.guildId, interaction.channelId, interaction.member.id);
const lfgChannelSetting = lfgChannelSettings.get(generateGuildSettingKey(interaction.guildId, interaction.channelId)) || {
managed: false,
managerRoleId: 0n,
logChannelId: 0n,
};
const actionByManager = interaction.data.customId.endsWith(pathIdxEnder);
const [evtChannelId, evtMessageId] = (interaction.data.customId.replaceAll(pathIdxEnder, '').split(idSeparator)[1] || '').split(pathIdxSeparator).map((id) => BigInt(id || '0'));
const eventTime: Date = new Date(parseInt(interaction.message.embeds[0].fields[LfgEmbedIndexes.ICSLink].value.split('?t=')[1].split('&n=')[0] || '0'));
const guildName = await getGuildName(bot, interaction.guildId);
const oldEventEmbed = (await bot.helpers.getMessage(evtChannelId, evtMessageId).catch((e: Error) => utils.commonLoggers.messageGetError('updateEvent.ts', 'get eventMessage', e)))?.embeds[0];
const newEventEmbed = interaction.message.embeds[0];
const userId = interaction.member.id;
const userName = interaction.member.user.username;
const eventLink = utils.idsToMessageUrl({
guildId: interaction.guildId,
channelId: evtChannelId,
messageId: evtMessageId,
});
bot.helpers.editMessage(evtChannelId, evtMessageId, { embeds: [interaction.message.embeds[0]] }).then(() => {
dbClient.execute(queries.updateEventTime, [eventTime, evtChannelId, evtMessageId]).then(() => {
// Acknowledge user so discord doesn't get annoyed
bot.helpers.sendInteractionResponse(interaction.id, interaction.token, {
type: InteractionResponseTypes.ChannelMessageWithSource,
data: {
flags: ApplicationCommandFlags.Ephemeral,
embeds: [{
color: successColor,
title: 'Update successfully applied.',
description: safelyDismissMsg,
}],
},
}).catch((e: Error) => utils.commonLoggers.interactionSendError('updateEvent.ts', interaction, e));
if (actionByManager) {
const ownerId = BigInt(oldEventEmbed?.footer?.iconUrl?.split('#')[1] || '0');
const missingOldEmbed = { title: 'Failed to get old event', color: failColor };
if (oldEventEmbed) {
oldEventEmbed.color = infoColor1;
}
bot.helpers.sendMessage(lfgChannelSetting.logChannelId, {
embeds: [
{
color: infoColor2,
title: `Event edited by a ${config.name} Manager`,
description: `[This event](${eventLink}) was edited by ${userName} - <@${userId}>. The old event is listed first and marked with a blue bar.`,
timestamp: new Date().getTime(),
},
oldEventEmbed || missingOldEmbed,
newEventEmbed,
],
}).catch((e: Error) => utils.commonLoggers.messageSendError('updateEvent.ts', 'send log message', e));
sendDirectMessage(bot, ownerId, {
embeds: [
{
color: infoColor2,
title: `Notice: A ${config.name} Manager has edited one of your events in ${guildName}`,
description: `[This event](${eventLink}) was edited. The old event is listed first and marked with a blue bar.`,
fields: [
{
name: `${config.name} Manager:`,
value: generateMemberList([{
id: userId,
name: userName,
}]),
inline: true,
},
{
name: 'Are you unhappy with this action?',
value: `Please reach out to the ${config.name} Manager that performed this action, or the moderators/administrators of ${guildName}.`,
},
],
},
oldEventEmbed || missingOldEmbed,
newEventEmbed,
],
}).catch((e: Error) => utils.commonLoggers.messageSendError('managerJLA.ts', 'send DM fail', e));
}
}).catch((e) => {
utils.commonLoggers.dbError('updateEvent.ts', 'update event in', e);
if (oldEventEmbed) {
bot.helpers.editMessage(evtChannelId, evtMessageId, { embeds: [oldEventEmbed] }).catch((e) => utils.commonLoggers.messageEditError('updateEvent.ts', 'resetEventFailed', e));
}
somethingWentWrong(bot, interaction, 'updateDBInUpdateEventButton');
});
}).catch((e) => {
utils.commonLoggers.messageEditError('updateEvent.ts', 'updateEventFailed', e);
somethingWentWrong(bot, interaction, 'updateEventMessageInUpdateEventButton');
});
} else {
somethingWentWrong(bot, interaction, 'noDataFromUpdateEvent');
}
};
export const updateEventButton = {
customId,
execute,
};

View File

@ -0,0 +1,365 @@
import { ActionRow, ApplicationCommandFlags, Bot, ButtonStyles, Embed, Interaction, InteractionResponseTypes, MessageComponentTypes } from '../../../deps.ts';
import { LFGMember, UrlIds } from '../../types/commandTypes.ts';
import { infoColor1, safelyDismissMsg, sendDirectMessage, somethingWentWrong, successColor } from '../../commandUtils.ts';
import { generateAlternateList, generateMemberList, generateMemberTitle, idSeparator, leaveEventBtnStr, LfgEmbedIndexes, noMembersStr, pathIdxSeparator } from '../eventUtils.ts';
import { selfDestructMessage } from '../tokenCleanup.ts';
import { approveStr, customId as joinRequestCustomId, denyStr } from './joinRequest.ts';
import { customId as updateEventCustomId } from './updateEvent.ts';
import { customId as leaveViaDMCustomId } from './leaveViaDM.ts';
import { dbClient, queries } from '../../db.ts';
import utils from '../../utils.ts';
// Join status map to prevent spamming the system
export enum JoinRequestStatus {
Pending = 'Pending',
Approved = 'Approved',
Denied = 'Denied',
}
export const generateMapId = (messageId: bigint, channelId: bigint, userId: bigint) => `${messageId}-${channelId}-${userId}`;
export const joinRequestMap: Map<string, {
status: JoinRequestStatus;
timestamp: number;
}> = new Map();
// Join request map cleaner
const oneHour = 1000 * 60 * 60;
const oneDay = oneHour * 24;
const oneWeek = oneDay * 7;
setInterval(() => {
const now = new Date().getTime();
joinRequestMap.forEach((joinRequest, key) => {
switch (joinRequest.status) {
case JoinRequestStatus.Approved:
// Delete Approved when over 1 hour old
if (joinRequest.timestamp > now - oneHour) {
joinRequestMap.delete(key);
}
break;
case JoinRequestStatus.Pending:
// Delete Pending when over 1 day old
if (joinRequest.timestamp > now - oneDay) {
joinRequestMap.delete(key);
}
break;
case JoinRequestStatus.Denied:
// Delete Rejected when over 1 week old
if (joinRequest.timestamp > now - oneWeek) {
joinRequestMap.delete(key);
}
break;
}
});
// Run cleaner every hour
}, oneHour);
// Get Member Counts from the title
export const getEventMemberCount = (rawMemberTitle: string): [number, number] => {
const [rawCurrentCount, rawMaxCount] = rawMemberTitle.split('/');
const currentMemberCount = parseInt(rawCurrentCount.split(':')[1] || '0');
const maxMemberCount = parseInt(rawMaxCount || '0');
return [currentMemberCount, maxMemberCount];
};
// Get LFGMember objects from string list
export const getLfgMembers = (rawMemberList: string): Array<LFGMember> =>
rawMemberList.trim() === noMembersStr ? [] : rawMemberList.split('\n').map((rawMember) => {
const [memberName, memberMention] = rawMember.split('-');
const lfgMember: LFGMember = {
id: BigInt(memberMention.split('<@')[1].split('>')[0].trim() || '0'),
name: memberName.trim(),
joined: rawMember.endsWith('*'),
};
return lfgMember;
});
// Remove LFGMember from array filter
const removeLfgMember = (memberList: Array<LFGMember>, memberId: bigint): Array<LFGMember> => memberList.filter((member) => member.id !== memberId);
// Handles updating the fields and editing the event
const editEvent = async (
bot: Bot,
interaction: Interaction,
evtMessageEmbed: Embed,
evtMessageId: bigint,
evtChannelId: bigint,
memberList: Array<LFGMember>,
maxMemberCount: number,
alternateList: Array<LFGMember>,
loudAcknowledge: boolean,
) => {
if (evtMessageEmbed.fields) {
// Update the fields
evtMessageEmbed.fields[LfgEmbedIndexes.JoinedMembers].name = generateMemberTitle(memberList, maxMemberCount);
evtMessageEmbed.fields[LfgEmbedIndexes.JoinedMembers].value = generateMemberList(memberList);
evtMessageEmbed.fields[LfgEmbedIndexes.AlternateMembers].value = generateAlternateList(alternateList);
// Edit the event
await bot.helpers.editMessage(evtChannelId, evtMessageId, {
embeds: [evtMessageEmbed],
}).then(() => {
// Let discord know we didn't ignore the user
if (loudAcknowledge) {
bot.helpers.sendInteractionResponse(interaction.id, interaction.token, {
type: InteractionResponseTypes.ChannelMessageWithSource,
data: {
flags: ApplicationCommandFlags.Ephemeral,
embeds: [{
color: successColor,
title: 'Event Updated',
description: `The action requested was completed successfully.
${safelyDismissMsg}`,
}],
},
}).catch((e: Error) => utils.commonLoggers.interactionSendError('utils.ts', interaction, e));
} else {
bot.helpers.sendInteractionResponse(interaction.id, interaction.token, {
type: InteractionResponseTypes.DeferredUpdateMessage,
}).catch((e: Error) => utils.commonLoggers.interactionSendError('utils.ts', interaction, e));
}
}).catch((e: Error) => {
// Edit failed, try to notify user
utils.commonLoggers.messageEditError('utils.ts', 'event edit fail', e);
somethingWentWrong(bot, interaction, 'editFailedInUpdateEvent');
});
}
};
// Generic no response response
const noEdit = async (bot: Bot, interaction: Interaction, loudAcknowledge: boolean) => {
if (loudAcknowledge) {
bot.helpers.sendInteractionResponse(interaction.id, interaction.token, {
type: InteractionResponseTypes.ChannelMessageWithSource,
data: {
flags: ApplicationCommandFlags.Ephemeral,
embeds: [{
color: infoColor1,
title: 'No Changes Made',
description: `The action requested was not performed as it was not necessary.
${safelyDismissMsg}`,
}],
},
}).catch((e: Error) => utils.commonLoggers.interactionSendError('utils.ts', interaction, e));
} else {
bot.helpers.sendInteractionResponse(interaction.id, interaction.token, {
type: InteractionResponseTypes.DeferredUpdateMessage,
}).catch((e: Error) => utils.commonLoggers.interactionSendError('utils.ts', interaction, e));
}
};
// Get Guild Name
export const getGuildName = async (bot: Bot, guildId: bigint): Promise<string> =>
(await bot.helpers.getGuild(guildId).catch((e: Error) => utils.commonLoggers.messageGetError('utils.ts', 'get guild', e)) || { name: 'failed to get guild name' }).name;
// Remove member from the event
export const removeMemberFromEvent = async (
bot: Bot,
interaction: Interaction,
evtMessageEmbed: Embed,
evtMessageId: bigint,
evtChannelId: bigint,
userId: bigint,
evtGuildId: bigint,
loudAcknowledge = false,
): Promise<boolean> => {
if (evtMessageEmbed.fields) {
// Get old counts
const [oldMemberCount, maxMemberCount] = getEventMemberCount(evtMessageEmbed.fields[LfgEmbedIndexes.JoinedMembers].name);
// Remove user from event
const oldMemberList = getLfgMembers(evtMessageEmbed.fields[LfgEmbedIndexes.JoinedMembers].value);
const memberList = removeLfgMember(oldMemberList, userId);
const oldAlternateList = getLfgMembers(evtMessageEmbed.fields[LfgEmbedIndexes.AlternateMembers].value);
let alternateList = removeLfgMember(oldAlternateList, userId);
// Check if user actually left event
if (oldMemberList.length !== memberList.length || oldAlternateList.length !== alternateList.length) {
// Check if we need to auto-promote a member
const memberToPromote = alternateList.find((member) => member.joined);
if (oldMemberCount !== memberList.length && oldMemberCount === maxMemberCount && memberToPromote) {
// Promote member
alternateList = removeLfgMember(alternateList, memberToPromote.id);
memberList.push(memberToPromote);
const urlIds: UrlIds = {
guildId: evtGuildId,
channelId: evtChannelId,
messageId: evtMessageId,
};
// Notify member of promotion
await sendDirectMessage(bot, memberToPromote.id, {
embeds: [{
color: successColor,
title: 'Good news, you\'ve been promoted!',
description: `A member left [the full event](${utils.idsToMessageUrl(urlIds)}) in \`${await getGuildName(
bot,
evtGuildId,
)}\` you tried to join, leaving space for me to promote you from the alternate list to the joined list.\n\nPlease verify the event details below. If you are no longer available for this event, please click on the '${leaveEventBtnStr}' button below`,
fields: [
evtMessageEmbed.fields[LfgEmbedIndexes.Activity],
evtMessageEmbed.fields[LfgEmbedIndexes.StartTime],
evtMessageEmbed.fields[LfgEmbedIndexes.ICSLink],
],
}],
components: [{
type: MessageComponentTypes.ActionRow,
components: [{
type: MessageComponentTypes.Button,
label: leaveEventBtnStr,
style: ButtonStyles.Danger,
customId: `${leaveViaDMCustomId}${idSeparator}${evtGuildId}${pathIdxSeparator}${evtChannelId}${pathIdxSeparator}${evtMessageId}`,
}],
}],
}).catch((e: Error) => utils.commonLoggers.messageSendError('utils.ts', 'user promotion dm', e));
}
// Update the event
await editEvent(bot, interaction, evtMessageEmbed, evtMessageId, evtChannelId, memberList, maxMemberCount, alternateList, loudAcknowledge);
return true;
} else {
// Send noEdit response because user did not actually leave
await noEdit(bot, interaction, loudAcknowledge);
return false;
}
} else {
await somethingWentWrong(bot, interaction, 'noFieldsInRemoveMember');
return false;
}
};
// Alternate member to the event
export const alternateMemberToEvent = async (
bot: Bot,
interaction: Interaction,
evtMessageEmbed: Embed,
evtMessageId: bigint,
evtChannelId: bigint,
member: LFGMember,
userJoinOnFull = false,
loudAcknowledge = false,
): Promise<boolean> => {
if (evtMessageEmbed.fields) {
member.joined = userJoinOnFull;
// Get current alternates
let alternateList = getLfgMembers(evtMessageEmbed.fields[LfgEmbedIndexes.AlternateMembers].value);
// Verify user is not already on the alternate list
if (!alternateList.find((alternateMember) => alternateMember.id === member.id && alternateMember.joined === member.joined)) {
// Add user to event, remove first to update joined status if necessary
alternateList = removeLfgMember(alternateList, member.id);
alternateList.push(member);
// Get member count and remove user from joined list (if they are there)
const [_oldMemberCount, maxMemberCount] = getEventMemberCount(evtMessageEmbed.fields[LfgEmbedIndexes.JoinedMembers].name);
const memberList = removeLfgMember(getLfgMembers(evtMessageEmbed.fields[LfgEmbedIndexes.JoinedMembers].value), member.id);
// Update the event
evtMessageEmbed.fields[LfgEmbedIndexes.AlternateMembers].value = generateAlternateList(alternateList);
await editEvent(bot, interaction, evtMessageEmbed, evtMessageId, evtChannelId, memberList, maxMemberCount, alternateList, loudAcknowledge);
return true;
} else {
// Send noEdit response because user was already an alternate and joined status did not change
await noEdit(bot, interaction, loudAcknowledge);
return false;
}
} else {
// No fields, can't alternate
await somethingWentWrong(bot, interaction, 'noFieldsInAlternateMember');
return false;
}
};
// Join member to the event
export const joinMemberToEvent = async (
bot: Bot,
interaction: Interaction,
evtMessageEmbed: Embed,
evtMessageId: bigint,
evtChannelId: bigint,
member: LFGMember,
evtGuildId: bigint,
loudAcknowledge = false,
): Promise<boolean> => {
if (evtMessageEmbed.fields) {
// Get current member list and count
const [oldMemberCount, maxMemberCount] = getEventMemberCount(evtMessageEmbed.fields[LfgEmbedIndexes.JoinedMembers].name);
const memberList = getLfgMembers(evtMessageEmbed.fields[LfgEmbedIndexes.JoinedMembers].value);
// Verify user is not already on the joined list
if (memberList.find((joinedMember) => joinedMember.id === member.id)) {
// Send noEdit response because user was already joined
await noEdit(bot, interaction, loudAcknowledge);
return false;
} else if (oldMemberCount === maxMemberCount) {
// Event full, add member to alternate list
return await alternateMemberToEvent(bot, interaction, evtMessageEmbed, evtMessageId, evtChannelId, member, true, loudAcknowledge);
} else {
// Join member to event
memberList.push(member);
// Remove user from alternate list (if they are there)
const alternateList = removeLfgMember(getLfgMembers(evtMessageEmbed.fields[LfgEmbedIndexes.AlternateMembers].value), member.id);
// Update the event
await editEvent(bot, interaction, evtMessageEmbed, evtMessageId, evtChannelId, memberList, maxMemberCount, alternateList, loudAcknowledge);
// Check if we need to notify the owner that their event has filled
if (memberList.length === maxMemberCount) {
dbClient.execute(queries.callIncCnt('lfg-filled')).catch((e) => utils.commonLoggers.dbError('utils.ts@lfg-filled', 'call sproc INC_CNT on', e));
const urlIds: UrlIds = {
guildId: evtGuildId,
channelId: evtChannelId,
messageId: evtMessageId,
};
const guildName = await getGuildName(bot, evtGuildId);
// Notify member of promotion
await sendDirectMessage(bot, BigInt(evtMessageEmbed.footer?.iconUrl?.split('#')[1] || '0'), {
embeds: [{
color: successColor,
title: `Good news, your event in ${guildName} has filled!`,
description: `[Click here](${utils.idsToMessageUrl(urlIds)}) to view the event in ${guildName}.`,
fields: evtMessageEmbed.fields,
}],
}).catch((e: Error) => utils.commonLoggers.messageSendError('utils.ts', 'event filled dm', e));
}
return true;
}
} else {
// No fields, can't join
await somethingWentWrong(bot, interaction, 'noFieldsInJoinMember');
return false;
}
};
// Join Request Approve/Deny Buttons
export const joinRequestResponseButtons = (disabled: boolean): ActionRow[] => [{
type: MessageComponentTypes.ActionRow,
components: [{
type: MessageComponentTypes.Button,
label: 'Approve Request',
style: ButtonStyles.Success,
customId: `${joinRequestCustomId}${idSeparator}${approveStr}`,
disabled,
}, {
type: MessageComponentTypes.Button,
label: 'Deny Request',
style: ButtonStyles.Danger,
customId: `${joinRequestCustomId}${idSeparator}${denyStr}`,
disabled,
}],
}];
export const applyEditButtonName = 'Apply Edit';
export const applyEditMessage = (currentTime: number) =>
`Please verify the updated event below, then click on the \`${applyEditButtonName}\` button. If this does not look right, please dismiss this message and start over.\n\n${
selfDestructMessage(currentTime)
}`;
export const applyEditButtons = (idxPath: string): ActionRow[] => [{
type: MessageComponentTypes.ActionRow,
components: [{
type: MessageComponentTypes.Button,
label: applyEditButtonName,
style: ButtonStyles.Success,
customId: `${updateEventCustomId}${idSeparator}${idxPath}`,
}],
}];

View File

@ -0,0 +1,40 @@
import { Bot, Interaction } from '../../deps.ts';
import utils from '../utils.ts';
// Discord Interaction Tokens last 15 minutes, we will self kill after 14.5 minutes
const tokenTimeoutS = (15 * 60) - 30;
const tokenTimeoutMS = tokenTimeoutS * 1000;
export const selfDestructMessage = (currentTime: number) =>
`**Please note:** This message will self destruct <t:${Math.floor((currentTime + tokenTimeoutMS) / 1000)}:R> due to limits imposed by the Discord API.`;
export const tokenMap: Map<string, {
token: string;
timeoutId: number;
}> = new Map();
export const generateMapId = (guildId: bigint, channelId: bigint, userId: bigint) => `${guildId}-${channelId}-${userId}`;
export const addTokenToMap = (bot: Bot, interaction: Interaction, guildId: bigint, channelId: bigint, userId: bigint) =>
tokenMap.set(generateMapId(guildId, channelId, userId), {
token: interaction.token,
timeoutId: setTimeout(
(guildId, channelId, userId) => {
deleteTokenEarly(bot, interaction, guildId, channelId, userId);
},
tokenTimeoutMS,
guildId,
channelId,
userId,
),
});
export const deleteTokenEarly = async (bot: Bot, interaction: Interaction, guildId: bigint, channelId: bigint, userId: bigint) => {
const tokenMapEntry = tokenMap.get(generateMapId(guildId, channelId, userId));
if (tokenMapEntry && tokenMapEntry.token) {
await bot.helpers.deleteOriginalInteractionResponse(tokenMap.get(generateMapId(guildId, channelId, userId))?.token || '').catch((e: Error) =>
utils.commonLoggers.interactionSendError('tokenCleanup.ts:deleteTokenEarly', interaction, e)
);
clearTimeout(tokenMapEntry.timeoutId);
tokenMap.delete(generateMapId(guildId, channelId, userId));
}
};

100
src/commandUtils.ts Normal file
View File

@ -0,0 +1,100 @@
import { ApplicationCommandFlags, Bot, CreateMessage, Embed, Interaction, InteractionResponseTypes } from '../deps.ts';
import config from '../config.ts';
import { generateGuildSettingKey, lfgChannelSettings } from './db.ts';
import utils from './utils.ts';
import { helpSlashName, infoSlashName, reportSlashName } from './commands/slashCommandNames.ts';
export const failColor = 0xe71212;
export const warnColor = 0xe38f28;
export const successColor = 0x0f8108;
export const infoColor1 = 0x313bf9;
export const infoColor2 = 0x6805e9;
export const safelyDismissMsg = 'You may safely dismiss this message.';
export const getRandomStatus = (guildCount: number): string => {
const statuses = [
`Running V${config.version}`,
`${config.prefix}${infoSlashName} to learn more`,
`${config.prefix}${helpSlashName} to learn more`,
`Running LFGs in ${guildCount} servers`,
];
return statuses[Math.floor(Math.random() * statuses.length)];
};
export const isLFGChannel = (guildId: bigint, channelId: bigint) => {
return (lfgChannelSettings.has(generateGuildSettingKey(guildId, channelId)) || channelId === 0n || guildId === 0n) ? ApplicationCommandFlags.Ephemeral : undefined;
};
// Tell user to try again or report issue
export const somethingWentWrong = async (bot: Bot, interaction: Interaction, errorCode: string) =>
bot.helpers.sendInteractionResponse(interaction.id, interaction.token, {
type: InteractionResponseTypes.ChannelMessageWithSource,
data: {
flags: ApplicationCommandFlags.Ephemeral,
embeds: [{
color: failColor,
title: 'Something went wrong...',
description: `You should not be able to get here. Please try again and if the issue continues, \`/${reportSlashName}\` this issue to the developers with the error code below.`,
fields: [{
name: 'Error Code:',
value: `\`${errorCode}\``,
}],
}],
},
}).catch((e: Error) => utils.commonLoggers.interactionSendError('commandUtils.ts@somethingWentWrong', interaction, e));
// Smack the user for trying to modify an event that isn't theirs
export const stopThat = async (bot: Bot, interaction: Interaction, stopWhat: string) =>
bot.helpers.sendInteractionResponse(interaction.id, interaction.token, {
type: InteractionResponseTypes.ChannelMessageWithSource,
data: {
flags: ApplicationCommandFlags.Ephemeral,
embeds: [{
color: warnColor,
title: 'Hey! Stop that!',
description: `You are neither the owner of this event nor a ${config.name} manager in this guild, meaning you are not allowed to ${stopWhat} this event.
${safelyDismissMsg}`,
}],
},
}).catch((e: Error) => utils.commonLoggers.interactionSendError('commandUtils.ts@stopThat', interaction, e));
// Send DM to User
export const sendDirectMessage = async (bot: Bot, userId: bigint, message: CreateMessage) => {
const userDmChannel = await bot.helpers.getDmChannel(userId).catch((e: Error) => utils.commonLoggers.messageGetError('commandUtils.ts', 'get userDmChannel', e));
// Actually send the DM
return bot.helpers.sendMessage(userDmChannel?.id || 0n, message);
};
// Info Embed Object (for info command and @mention command)
export const infoEmbed: Embed = {
color: infoColor2,
title: `${config.name}, the LFG bot`,
description: `${config.name} is developed by Ean AKA Burn_E99.
Want to check out my source code? Check it out [here](${config.links.sourceCode}).
Need help with this bot? Join my support server [here](${config.links.supportServer}).
Ran into a bug? Report it to my developers using \`/${reportSlashName} [issue description]\`.`,
fields: [{
name: 'Privacy Policy and Terms of Service:',
value: `**${config.name} does not automatically track or collect user information via Discord.**
${config.name} stores data relating to events, event channels, and text from the \`/${reportSlashName}\` command.
For more details, please check out the Privacy Policy on my GitHub [here](${config.links.sourceCode}/blob/master/PRIVACY.md)
Terms of Service can also be found on my GitHub [here](${config.links.sourceCode}/blob/master/TERMS.md).`,
}],
footer: {
text: `Current Version: ${config.version}`,
},
};
export const dmTestMessage: CreateMessage = {
embeds: [{
color: infoColor2,
title: 'Heyo! Just making sure I can reach you.',
description: 'This message is just to make sure I can DM you any Join Requests to your event. If you are reading this message, it means you have everything set up correctly.',
}],
};

30
src/commands/_index.ts Normal file
View File

@ -0,0 +1,30 @@
import { Bot, CreateApplicationCommand, log, LT, MakeRequired } from '../../deps.ts';
import { Command } from '../types/commandTypes.ts';
import utils from '../utils.ts';
import audit from './audit.ts';
import info from './info.ts';
import help from './help.ts';
import report from './report.ts';
import setup from './setup.ts';
import deleteCmd from './delete.ts';
import managerJLA from './managerJLA.ts';
import { gameSelectionCommand } from '../buttons/event-creation/step1-gameSelection.ts';
export const commands: Array<Command> = [deleteCmd, info, report, setup, gameSelectionCommand, managerJLA, help, audit];
export const createSlashCommands = async (bot: Bot) => {
const globalCommands: MakeRequired<CreateApplicationCommand, 'name'>[] = [];
for (const command of commands) {
globalCommands.push({
name: command.details.name,
description: command.details.description,
type: command.details.type,
options: command.details.options ? command.details.options : undefined,
dmPermission: command.details.dmPermission ? command.details.dmPermission : false,
defaultMemberPermissions: command.details.defaultMemberPermissions ? command.details.defaultMemberPermissions : undefined,
});
}
await bot.helpers.upsertGlobalApplicationCommands(globalCommands).catch((errMsg) => log(LT.ERROR, `Failed to upsert application commands | ${utils.jsonStringifyBig(errMsg)}`));
};

88
src/commands/audit.ts Normal file
View File

@ -0,0 +1,88 @@
import config from '../../config.ts';
import { ApplicationCommandTypes, ApplicationCommandOptionTypes, Bot, Interaction, InteractionResponseTypes, DiscordEmbedField } from '../../deps.ts';
import { infoColor2, infoEmbed, isLFGChannel, somethingWentWrong } from '../commandUtils.ts';
import { dbClient, queries } from '../db.ts';
import { CommandDetails } from '../types/commandTypes.ts';
import utils from '../utils.ts';
import { auditSlashName } from './slashCommandNames.ts';
const auditDbName = 'database';
const auditCustomActivities = 'custom-activities';
const auditGuildName = 'guilds';
const details: CommandDetails = {
name: auditSlashName,
description: `Developer Command for checking in on ${config.name}'s health.`,
type: ApplicationCommandTypes.ChatInput,
defaultMemberPermissions: ['ADMINISTRATOR'],
options: [
{
name: auditDbName,
type: ApplicationCommandOptionTypes.SubCommand,
description: `Developer Command: Checks ${config.name}'s DB size.`,
},
{
name: auditCustomActivities,
type: ApplicationCommandOptionTypes.SubCommand,
description: 'Developer Command: Checks for duplicate custom activities.',
},
{
name: auditGuildName,
type: ApplicationCommandOptionTypes.SubCommand,
description: `Developer Command: Checks in on channel and member counts of guilds that ${config.name} is in.`,
},
],
};
const execute = async (bot: Bot, interaction: Interaction) => {
if (interaction.member && interaction.guildId && interaction.data?.options?.[0].options) {
dbClient.execute(queries.callIncCnt('cmd-audit')).catch((e) => utils.commonLoggers.dbError('audit.ts@inc', 'call sproc INC_CNT on', e));
const auditName = interaction.data.options[0].name;
switch (auditName) {
case auditDbName:
// Get DB statistics
const auditQuery = await dbClient.query(`SELECT * FROM db_size;`).catch((e) => utils.commonLoggers.dbError('audit.ts@dbSize', 'query', e));
// Turn all tables into embed fields, currently only properly will handle 25 tables, but we'll fix that when group up gets 26 tables
const embedFields: Array<DiscordEmbedField> = [];
auditQuery.forEach((row: any) => {
embedFields.push({
name: `${row.table}`,
value: `**Size:** ${row.size} MB
**Rows:** ${row.rows}`,
inline: true,
});
});
bot.helpers.sendInteractionResponse(
interaction.id,
interaction.token,
{
type: InteractionResponseTypes.ChannelMessageWithSource,
data: {
flags: isLFGChannel(interaction.guildId || 0n, interaction.channelId || 0n),
embeds: [{
color: infoColor2,
title: 'Database Audit',
description: 'Lists all tables with their current size and row count.',
timestamp: new Date().getTime(),
fields: embedFields.slice(0, 25),
}],
},
},
).catch((e: Error) => utils.commonLoggers.interactionSendError('audit.ts@dbSize', interaction, e));
break;
case auditCustomActivities:
case auditGuildName:
default:
somethingWentWrong(bot, interaction, `auditNameNotHandled@${auditName}`);
break;
}
} else {
somethingWentWrong(bot, interaction, 'auditMissingData')
}
};
export default {
details,
execute,
};

70
src/commands/delete.ts Normal file
View File

@ -0,0 +1,70 @@
import config from '../../config.ts';
import { ApplicationCommandFlags, ApplicationCommandTypes, Bot, Interaction, InteractionResponseTypes } from '../../deps.ts';
import { failColor, safelyDismissMsg, somethingWentWrong, successColor } from '../commandUtils.ts';
import { dbClient, generateGuildSettingKey, lfgChannelSettings, queries } from '../db.ts';
import { CommandDetails } from '../types/commandTypes.ts';
import utils from '../utils.ts';
import { deleteSlashName, setupSlashName } from './slashCommandNames.ts';
const details: CommandDetails = {
name: deleteSlashName,
description: `Removes all settings from ${config.name} related to this LFG channel. Events will not be deleted.`,
type: ApplicationCommandTypes.ChatInput,
defaultMemberPermissions: ['ADMINISTRATOR'],
};
const execute = async (bot: Bot, interaction: Interaction) => {
dbClient.execute(queries.callIncCnt('cmd-delete')).catch((e) => utils.commonLoggers.dbError('delete.ts', 'call sproc INC_CNT on', e));
if (interaction.guildId && interaction.channelId) {
const lfgChannelSettingKey = generateGuildSettingKey(interaction.guildId, interaction.channelId);
if (!lfgChannelSettings.has(lfgChannelSettingKey)) {
// Cannot delete a lfg channel that has not been set up
bot.helpers.sendInteractionResponse(interaction.id, interaction.token, {
type: InteractionResponseTypes.ChannelMessageWithSource,
data: {
flags: ApplicationCommandFlags.Ephemeral,
embeds: [{
color: failColor,
title: 'Unable to delete LFG channel.',
description:
`This channel is already is not an LFG channel. If you need to setup the channel, please run \`/${setupSlashName}\` in this channel.\n\nThis will not harm any active events in this channel and simply resets the settings for this channel.`,
}],
},
}).catch((e: Error) => utils.commonLoggers.interactionSendError('delete.ts', interaction, e));
return;
}
// Remove it from the DB
let dbError = false;
await dbClient.execute('DELETE FROM guild_settings WHERE guildId = ? AND lfgChannelId = ?', [interaction.guildId, interaction.channelId]).catch((e) => {
utils.commonLoggers.dbError('delete.ts', 'delete guild/lfgChannel', e);
dbError = true;
});
if (dbError) {
somethingWentWrong(bot, interaction, 'deleteDBDeleteFail');
return;
}
lfgChannelSettings.delete(lfgChannelSettingKey);
// Complete the interaction
bot.helpers.sendInteractionResponse(interaction.id, interaction.token, {
type: InteractionResponseTypes.ChannelMessageWithSource,
data: {
flags: ApplicationCommandFlags.Ephemeral,
embeds: [{
color: successColor,
title: 'LFG Channel settings removed!',
description: `${config.name} has finished removing the settings for this channel. ${safelyDismissMsg}`,
}],
},
}).catch((e: Error) => utils.commonLoggers.interactionSendError('delete.ts', interaction, e));
} else {
somethingWentWrong(bot, interaction, 'deleteMissingGuildIdChannelId');
}
};
export default {
details,
execute,
};

50
src/commands/help.ts Normal file
View File

@ -0,0 +1,50 @@
import config from '../../config.ts';
import { ApplicationCommandTypes, Bot, Interaction, InteractionResponseTypes } from '../../deps.ts';
import { infoColor1, isLFGChannel } from '../commandUtils.ts';
import { dbClient, queries } from '../db.ts';
import { CommandDetails } from '../types/commandTypes.ts';
import utils from '../utils.ts';
import { createEventSlashName, helpSlashName, setupSlashName } from './slashCommandNames.ts';
const details: CommandDetails = {
name: helpSlashName,
description: `How to set up and use ${config.name} in your guild.`,
type: ApplicationCommandTypes.ChatInput,
};
const execute = (bot: Bot, interaction: Interaction) => {
dbClient.execute(queries.callIncCnt('cmd-help')).catch((e) => utils.commonLoggers.dbError('help.ts', 'call sproc INC_CNT on', e));
bot.helpers.sendInteractionResponse(
interaction.id,
interaction.token,
{
type: InteractionResponseTypes.ChannelMessageWithSource,
data: {
flags: isLFGChannel(interaction.guildId || 0n, interaction.channelId || 0n),
embeds: [{
color: infoColor1,
title: `Getting Started with ${config.name}:`,
description: `Thanks for inviting ${config.name}, the event scheduling bot. There are two ways you can use the bot:`,
fields: [{
name: 'Dedicated Event/LFG Channel:',
value:
`To create a dedicated event/LFG channel, simply have the guild owner or member with the \`ADMINISTRATOR\` permission run the \`/${setupSlashName}\` in the desired channel. This command will walk you through everything necessary to set up the channel.`,
inline: true,
}, {
name: 'Chat channel with events mixed into:',
value: `To create events in any chat channel ${config.name} can see, simply run the \`/${createEventSlashName}\` command.`,
inline: true,
}, {
name: 'Need help or have questions?',
value: `Just join the official support server by [clicking here](${config.links.supportServer}) and ask away!`,
}],
}],
},
},
).catch((e: Error) => utils.commonLoggers.interactionSendError('help.ts', interaction, e));
};
export default {
details,
execute,
};

33
src/commands/info.ts Normal file
View File

@ -0,0 +1,33 @@
import config from '../../config.ts';
import { ApplicationCommandTypes, Bot, Interaction, InteractionResponseTypes } from '../../deps.ts';
import { infoEmbed, isLFGChannel } from '../commandUtils.ts';
import { dbClient, queries } from '../db.ts';
import { CommandDetails } from '../types/commandTypes.ts';
import utils from '../utils.ts';
import { infoSlashName } from './slashCommandNames.ts';
const details: CommandDetails = {
name: infoSlashName,
description: `Information about ${config.name}, its Terms of Service, its Privacy Policy, and its developer.`,
type: ApplicationCommandTypes.ChatInput,
};
const execute = (bot: Bot, interaction: Interaction) => {
dbClient.execute(queries.callIncCnt('cmd-info')).catch((e) => utils.commonLoggers.dbError('info.ts', 'call sproc INC_CNT on', e));
bot.helpers.sendInteractionResponse(
interaction.id,
interaction.token,
{
type: InteractionResponseTypes.ChannelMessageWithSource,
data: {
flags: isLFGChannel(interaction.guildId || 0n, interaction.channelId || 0n),
embeds: [infoEmbed],
},
},
).catch((e: Error) => utils.commonLoggers.interactionSendError('info.ts', interaction, e));
};
export default {
details,
execute,
};

183
src/commands/managerJLA.ts Normal file
View File

@ -0,0 +1,183 @@
import { ApplicationCommandFlags, ApplicationCommandOptionTypes, ApplicationCommandTypes, Bot, Interaction, InteractionResponseTypes } from '../../deps.ts';
import { alternateMemberToEvent, getGuildName, joinMemberToEvent, removeMemberFromEvent } from '../buttons/live-event/utils.ts';
import { generateMemberList } from '../buttons/eventUtils.ts';
import { dbClient, generateGuildSettingKey, lfgChannelSettings, queries } from '../db.ts';
import { infoColor2, safelyDismissMsg, sendDirectMessage, somethingWentWrong, stopThat, warnColor } from '../commandUtils.ts';
import { CommandDetails, LFGMember } from '../types/commandTypes.ts';
import config from '../../config.ts';
import utils from '../utils.ts';
import { managerJLASlashName } from './slashCommandNames.ts';
export const joinName = 'join';
export const leaveName = 'leave';
export const alternateName = 'alternate';
export const eventLinkName = 'event-link';
export const userName = 'user';
// Create command with three nearly identical subcommands
const generateOptions = (commandName: string) => ({
name: commandName,
description: `${config.name} Manager Command: ${utils.capitalizeFirstChar(commandName)}s a user to an event in this channel.`,
type: ApplicationCommandOptionTypes.SubCommand,
options: [
{
name: eventLinkName,
type: ApplicationCommandOptionTypes.String,
description: 'Please copy the message link for the desired event.',
required: true,
minLength: 31,
maxLength: 100,
},
{
name: userName,
type: ApplicationCommandOptionTypes.User,
description: `The user you wish to ${commandName}.`,
required: true,
},
],
});
const details: CommandDetails = {
name: managerJLASlashName,
description: `${config.name} Manager Command`,
type: ApplicationCommandTypes.ChatInput,
options: [generateOptions(joinName), generateOptions(leaveName), generateOptions(alternateName)],
};
const execute = async (bot: Bot, interaction: Interaction) => {
if (interaction.data?.options?.[0].options && interaction.channelId && interaction.guildId && interaction.member && interaction.member.user) {
// Get action and log to db
const actionName = interaction.data.options[0].name;
dbClient.execute(queries.callIncCnt(`cmd-${actionName}`)).catch((e) => utils.commonLoggers.dbError('managerJLA.ts', 'call sproc INC_CNT on', e));
const lfgChannelSetting = lfgChannelSettings.get(generateGuildSettingKey(interaction.guildId, interaction.channelId)) || {
managed: false,
managerRoleId: 0n,
logChannelId: 0n,
};
// Check if guild is managed and if user is a manager
if (lfgChannelSetting.managed && interaction.member.roles.includes(lfgChannelSetting.managerRoleId)) {
// User is a manager, parse out our data
const tempDataMap: Map<string, string> = new Map();
for (const option of interaction.data.options[0].options) {
tempDataMap.set(option.name || 'missingCustomId', option.value as string || '');
}
const eventLink = tempDataMap.get(eventLinkName) || '';
const userToAdd = BigInt(tempDataMap.get(userName) || '0');
const eventIds = utils.messageUrlToIds(eventLink);
// Verify fields exist
if (!eventLink || !userToAdd || !eventIds.guildId || !eventIds.channelId || !eventIds.messageId) {
somethingWentWrong(bot, interaction, 'missingLinkOrUserInManagerJLA');
return;
}
// Get event from link
const eventMessage = await bot.helpers.getMessage(eventIds.channelId, eventIds.messageId).catch((e: Error) => utils.commonLoggers.messageGetError('managerJLA.ts', 'get eventMessage', e));
// Prevent managers from adding people to locked events
if (eventMessage && !eventMessage.components?.length) {
bot.helpers.sendInteractionResponse(interaction.id, interaction.token, {
type: InteractionResponseTypes.ChannelMessageWithSource,
data: {
flags: ApplicationCommandFlags.Ephemeral,
embeds: [{
color: warnColor,
title: 'Hey! Stop that!',
description: `You are not allowed to ${actionName} users to an event that has already started.
${safelyDismissMsg}`,
}],
},
}).catch((e: Error) => utils.commonLoggers.interactionSendError('commandUtils.ts@stopThat', interaction, e));
return;
}
const userDetails = await bot.helpers.getUser(userToAdd).catch((e: Error) => utils.commonLoggers.messageGetError('managerJLA.ts', 'get userDetails', e));
if (eventMessage && userDetails) {
// Perform the action
const userInfo: LFGMember = {
id: userToAdd,
name: userDetails.username,
};
let changeMade = false;
switch (actionName) {
case joinName:
changeMade = await joinMemberToEvent(bot, interaction, eventMessage.embeds[0], eventIds.messageId, eventIds.channelId, userInfo, eventIds.guildId, true);
break;
case leaveName:
changeMade = await removeMemberFromEvent(bot, interaction, eventMessage.embeds[0], eventIds.messageId, eventIds.channelId, userToAdd, eventIds.guildId, true);
break;
case alternateName:
changeMade = await alternateMemberToEvent(bot, interaction, eventMessage.embeds[0], eventIds.messageId, eventIds.channelId, userInfo, false, true);
break;
default:
somethingWentWrong(bot, interaction, 'actionNameWrongManagerJLA');
break;
}
if (changeMade) {
// userToAdd was had JLA done to them, DM them with details\
const guildName = await getGuildName(bot, interaction.guildId);
const commonFields = [{
name: 'Event Link:',
value: `[Click Here](${eventLink}) to view the event.`,
inline: true,
}, {
name: 'Action Performed:',
value: utils.capitalizeFirstChar(actionName),
inline: true,
}];
sendDirectMessage(bot, userToAdd, {
embeds: [{
color: infoColor2,
title: `Notice: A ${config.name} Manager has performed an action for you in ${guildName}`,
fields: [
{
name: `${config.name} Manager:`,
value: generateMemberList([{
id: interaction.member.id,
name: interaction.member.user.username,
}]),
inline: true,
},
...commonFields,
{
name: 'Are you unhappy with this action?',
value: `Please reach out to the ${config.name} Manager that performed this action, or the moderators/administrators of ${guildName}.`,
},
],
}],
}).catch((e: Error) => utils.commonLoggers.messageSendError('managerJLA.ts', 'send DM fail', e));
// Log this action
bot.helpers.sendMessage(lfgChannelSetting.logChannelId, {
embeds: [{
color: infoColor2,
title: `A ${config.name} Manager has performed an action on behalf of a user.`,
description: `The following user had an action by ${interaction.member.user.username} - <@${interaction.member.id}>.`,
fields: [...commonFields, {
name: 'User:',
value: generateMemberList([userInfo]),
inline: true,
}],
timestamp: new Date().getTime(),
}],
}).catch((e: Error) => utils.commonLoggers.messageSendError('deleteConfirmed.ts', 'send log message', e));
}
} else {
somethingWentWrong(bot, interaction, 'eventOrUserMissingFromManagerJLA');
}
} else {
// User not a manager
stopThat(bot, interaction, `${actionName} users to`);
}
} else {
// All data missing
somethingWentWrong(bot, interaction, 'missingDataInManagerJLA');
}
};
export default {
details,
execute,
};

58
src/commands/report.ts Normal file
View File

@ -0,0 +1,58 @@
import config from '../../config.ts';
import { ApplicationCommandOptionTypes, ApplicationCommandTypes, Bot, Interaction, InteractionResponseTypes } from '../../deps.ts';
import { infoColor2, isLFGChannel, somethingWentWrong, successColor } from '../commandUtils.ts';
import { dbClient, queries } from '../db.ts';
import { CommandDetails } from '../types/commandTypes.ts';
import utils from '../utils.ts';
import { reportSlashName } from './slashCommandNames.ts';
const details: CommandDetails = {
name: reportSlashName,
description: `Report an issue with ${config.name} to its developer.`,
type: ApplicationCommandTypes.ChatInput,
options: [
{
name: 'issue',
type: ApplicationCommandOptionTypes.String,
description: 'Please describe the issue you were having.',
required: true,
minLength: 1,
maxLength: 2000,
},
],
};
const execute = (bot: Bot, interaction: Interaction) => {
dbClient.execute(queries.callIncCnt('cmd-report')).catch((e) => utils.commonLoggers.dbError('report.ts', 'call sproc INC_CNT on', e));
if (interaction.data?.options?.[0].value) {
bot.helpers.sendMessage(config.reportChannel, {
embeds: [{
color: infoColor2,
title: 'USER REPORT:',
description: interaction.data.options[0].value as string,
}],
}).catch((e: Error) => utils.commonLoggers.interactionSendError('report.ts:28', interaction, e));
bot.helpers.sendInteractionResponse(
interaction.id,
interaction.token,
{
type: InteractionResponseTypes.ChannelMessageWithSource,
data: {
flags: isLFGChannel(interaction.guildId || 0n, interaction.channelId || 0n),
embeds: [{
color: successColor,
title: 'Failed command has been reported to my developer.',
description: `For more in depth support, and information about planned maintenance, please join the support server [here](${config.links.supportServer}).`,
}],
},
},
).catch((e: Error) => utils.commonLoggers.interactionSendError('report.ts:44', interaction, e));
} else {
somethingWentWrong(bot, interaction, 'reportMissingAllOptions');
}
};
export default {
details,
execute,
};

401
src/commands/setup.ts Normal file
View File

@ -0,0 +1,401 @@
import config from '../../config.ts';
import {
ApplicationCommandFlags,
ApplicationCommandOptionTypes,
ApplicationCommandTypes,
Bot,
botId,
ButtonStyles,
ChannelTypes,
DiscordEmbedField,
Interaction,
InteractionResponseTypes,
MessageComponentTypes,
OverwriteTypes,
} from '../../deps.ts';
import { failColor, infoColor2, safelyDismissMsg, somethingWentWrong, successColor } from '../commandUtils.ts';
import { dbClient, generateGuildSettingKey, lfgChannelSettings, queries } from '../db.ts';
import { CommandDetails } from '../types/commandTypes.ts';
import utils from '../utils.ts';
import { customId as gameSelId } from '../buttons/event-creation/step1-gameSelection.ts';
import { alternateEventBtnStr, joinEventBtnStr, leaveEventBtnStr, LfgEmbedIndexes, requestToJoinEventBtnStr } from '../buttons/eventUtils.ts';
import { alternateName, eventLinkName, joinName, leaveName, userName } from './managerJLA.ts';
import { createEventSlashName, deleteSlashName, managerJLASlashName, reportSlashName, setupSlashName } from './slashCommandNames.ts';
import { generateLFGButtons, generateTimeFieldStr } from '../buttons/event-creation/utils.ts';
import { getLfgMembers } from '../buttons/live-event/utils.ts';
const withoutMgrRole = 'without-manager-role';
const withMgrRole = 'with-manager-role';
const managerRoleStr = 'manager-role';
const logChannelStr = 'log-channel';
const details: CommandDetails = {
name: setupSlashName,
description: `Configures this channel to be a dedicated event channel to be managed by ${config.name}.`,
type: ApplicationCommandTypes.ChatInput,
defaultMemberPermissions: ['ADMINISTRATOR'],
options: [
{
name: withoutMgrRole,
type: ApplicationCommandOptionTypes.SubCommand,
description: `This will configure ${config.name} without a manager role.`,
},
{
name: withMgrRole,
type: ApplicationCommandOptionTypes.SubCommand,
description: `This will configure ${config.name} with a manager role.`,
options: [
{
name: managerRoleStr,
type: ApplicationCommandOptionTypes.Role,
description: 'This role will be allowed to manage all events in this guild.',
required: true,
},
{
name: logChannelStr,
type: ApplicationCommandOptionTypes.Channel,
description: `This channel is where ${config.name} will send Audit Messages whenever a manager updates an event.`,
required: true,
channelTypes: [ChannelTypes.GuildText],
},
],
},
],
};
const execute = async (bot: Bot, interaction: Interaction) => {
dbClient.execute(queries.callIncCnt('cmd-setup')).catch((e) => utils.commonLoggers.dbError('setup.ts', 'call sproc INC_CNT on', e));
const setupOpts = interaction.data?.options?.[0];
if (setupOpts?.name && interaction.channelId && interaction.guildId) {
const lfgChannelSettingKey = generateGuildSettingKey(interaction.guildId, interaction.channelId);
if (lfgChannelSettings.has(lfgChannelSettingKey)) {
// Cannot setup a lfg channel that is already set up
bot.helpers.sendInteractionResponse(interaction.id, interaction.token, {
type: InteractionResponseTypes.ChannelMessageWithSource,
data: {
flags: ApplicationCommandFlags.Ephemeral,
embeds: [{
color: failColor,
title: 'Unable to setup LFG channel.',
description:
`This channel is already set as an LFG channel. If you need to edit the channel, please run \`/${deleteSlashName}\` in this channel and then run \`/${setupSlashName}\` again.\n\nThis will not harm any active events in this channel and simply resets the settings for this channel.`,
}],
},
}).catch((e: Error) => utils.commonLoggers.interactionSendError('setup.ts', interaction, e));
return;
}
const messages = await bot.helpers.getMessages(interaction.channelId, { limit: 100 });
if (messages.size < 100) {
let logChannelId = 0n;
let managerRoleId = 0n;
let logChannelErrorOut = false;
let mgrRoleErrorOut = false;
const introFields: Array<DiscordEmbedField> = [{
name: 'Joining Events:',
value:
`To join an event, simply click on the \`${joinEventBtnStr}\` or \`${requestToJoinEventBtnStr}\` button. If you try to join a full event, you will be placed in the Alternates column with an \`*\` next to your name. Members with an \`*\` next to their name will automatically get promoted to the Joined list if someone leaves the event.`,
}, {
name: 'Leaving Events:',
value: `To leave an event, simply click on the \`${leaveEventBtnStr}\` button.`,
inline: true,
}, {
name: 'Joining Events as an Alternate:',
value: `To join as a backup or indicate you might be available, simply click on the \`${alternateEventBtnStr}\` button.`,
inline: true,
}, {
name: 'Editing/Deleting your event:',
value: 'To edit or delete your event, simply click on the ✏️ or 🗑️ buttons respectively.',
}];
const permissionFields: Array<DiscordEmbedField> = [
{
name: `Please make sure ${config.name} has the following permissions:`,
value: '`MANAGE_GUILD`\n`MANAGE_CHANNELS`\n`MANAGE_ROLES`\n`MANAGE_MESSAGES`\n\nThe only permission that is required after setup completes is `MANAGE_MESSAGES`.',
},
];
if (setupOpts.name === withMgrRole) {
if (setupOpts.options?.length) {
setupOpts.options.forEach((opt) => {
if (opt.name === managerRoleStr) {
managerRoleId = BigInt(opt.value as string || '0');
} else if (opt.name === logChannelStr) {
logChannelId = BigInt(opt.value as string || '0');
}
});
if (logChannelId === 0n || managerRoleId === 0n) {
// One or both Ids did not get parsed
bot.helpers.sendInteractionResponse(interaction.id, interaction.token, {
type: InteractionResponseTypes.ChannelMessageWithSource,
data: {
flags: ApplicationCommandFlags.Ephemeral,
embeds: [{
color: failColor,
title: 'Unable to setup log channel or manager role.',
description:
`${config.name} attempted to set the log channel or manager role, but one or both were undefined. Please try again and if the issue continues, \`/${reportSlashName}\` this issue to the developers with the error code below.`,
fields: [{
name: 'Error Code:',
value: `setupLog${logChannelId}Mgr${managerRoleId}`,
}],
}],
},
}).catch((e: Error) => utils.commonLoggers.interactionSendError('setup.ts', interaction, e));
return;
}
} else {
// Discord broke?
somethingWentWrong(bot, interaction, 'setupMissingRoleMgrOptions');
return;
}
introFields.push({
name: `${config.name} Manager Details:`,
value: `${config.name} Managers with the <@&${managerRoleId}> role may edit or delete events in this guild, along with using the following commands to update the activity members:
\`/${managerJLASlashName} [${joinName} | ${leaveName} | ${alternateName}] [${eventLinkName}] [${userName}]\`
The Discord Slash Command system will ensure you provide all the required details.`,
});
// Set permissions for self, skip if we already failed to set roles
!logChannelErrorOut && await bot.helpers.editChannelPermissionOverrides(logChannelId, {
id: botId,
type: OverwriteTypes.Member,
allow: ['SEND_MESSAGES', 'VIEW_CHANNEL', 'EMBED_LINKS'],
}).catch((e: Error) => {
utils.commonLoggers.channelUpdateError('setup.ts', 'self-allow', e);
mgrRoleErrorOut = true;
});
// Test sending a message to the logChannel
!logChannelErrorOut && await bot.helpers.sendMessage(logChannelId, {
embeds: [{
title: `This is the channel ${config.name} will be logging events to.`,
description: `${config.name} will only send messages here as frequently as your event managers update events.`,
color: infoColor2,
}],
}).catch((e: Error) => {
utils.commonLoggers.messageSendError('setup.ts', 'log-test', e);
logChannelErrorOut = true;
});
if (logChannelErrorOut) {
// Cannot send message into log channel, error out
bot.helpers.sendInteractionResponse(interaction.id, interaction.token, {
type: InteractionResponseTypes.ChannelMessageWithSource,
data: {
flags: ApplicationCommandFlags.Ephemeral,
embeds: [{
color: failColor,
title: 'Unable to setup log channel.',
description: `${config.name} attempted to send a message to the specified log channel.`,
fields: [
{
name: `Please allow ${config.name} to send messages in the requested channel.`,
value: `<#${logChannelId}>`,
},
],
}],
},
}).catch((e: Error) => utils.commonLoggers.interactionSendError('setup.ts', interaction, e));
return;
}
// Set permissions for managerId
await bot.helpers.editChannelPermissionOverrides(interaction.channelId, {
id: managerRoleId,
type: OverwriteTypes.Role,
allow: ['SEND_MESSAGES'],
}).catch((e: Error) => {
utils.commonLoggers.channelUpdateError('setup.ts', 'manager-allow', e);
mgrRoleErrorOut = true;
});
}
// Set permissions for everyone, skip if we already failed to set roles
!mgrRoleErrorOut && await bot.helpers.editChannelPermissionOverrides(interaction.channelId, {
id: interaction.guildId,
type: OverwriteTypes.Role,
deny: ['SEND_MESSAGES'],
}).catch((e: Error) => {
utils.commonLoggers.channelUpdateError('setup.ts', 'everyone-deny', e);
mgrRoleErrorOut = true;
});
// Set permissions for self, skip if we already failed to set roles
!mgrRoleErrorOut && await bot.helpers.editChannelPermissionOverrides(interaction.channelId, {
id: botId,
type: OverwriteTypes.Member,
allow: ['SEND_MESSAGES', 'VIEW_CHANNEL', 'EMBED_LINKS'],
}).catch((e: Error) => {
utils.commonLoggers.channelUpdateError('setup.ts', 'self-allow', e);
mgrRoleErrorOut = true;
});
if (mgrRoleErrorOut) {
// Cannot update role overrides on channel, error out
bot.helpers.sendInteractionResponse(interaction.id, interaction.token, {
type: InteractionResponseTypes.ChannelMessageWithSource,
data: {
flags: ApplicationCommandFlags.Ephemeral,
embeds: [{
color: failColor,
title: 'Unable to set lfg channel permissions.',
description: `${config.name} attempted to update the permissions for the current channel, but could not.`,
fields: permissionFields,
}],
},
}).catch((e: Error) => utils.commonLoggers.interactionSendError('setup.ts', interaction, e));
return;
}
// Delete all messages that are not LFG posts
const msgsToDel: Array<bigint> = [];
const oldLfgMsgs: Array<bigint> = [];
messages.forEach((msg) => {
if (msg.authorId === botId && msg.embeds.length && msg.embeds[0].footer && msg.embeds[0].footer.text.includes('Created by:')) {
oldLfgMsgs.push(msg.id);
} else {
msgsToDel.push(msg.id);
}
});
if (msgsToDel.length) {
for (const msgToDel of msgsToDel) {
await bot.helpers.deleteMessage(interaction.channelId, msgToDel, 'Initial LFG Channel Cleanup').catch((e: Error) =>
utils.commonLoggers.messageDeleteError('setup.ts', 'bulk-msg-cleanup', e)
);
}
}
// Retrofit all old LFG posts that we found
oldLfgMsgs.forEach((oldEventId) => {
const oldEvent = messages.get(oldEventId);
if (oldEvent && oldEvent.embeds[0].fields && oldEvent.embeds[0].footer) {
const eventMembers = [...getLfgMembers(oldEvent.embeds[0].fields[LfgEmbedIndexes.JoinedMembers].value), ...getLfgMembers(oldEvent.embeds[0].fields[LfgEmbedIndexes.AlternateMembers].value)];
const eventDateTime = new Date(parseInt((oldEvent.embeds[0].fields[LfgEmbedIndexes.StartTime].value.split('tz#')[1] || ' ').slice(0, -1)));
if (!isNaN(eventDateTime.getTime())) {
const eventDateTimeStr = (oldEvent.embeds[0].fields[LfgEmbedIndexes.StartTime].value.split('](')[0] || ' ').slice(1);
oldEvent.embeds[0].fields[LfgEmbedIndexes.StartTime].value = generateTimeFieldStr(eventDateTimeStr, eventDateTime);
oldEvent.embeds[0].footer.text = oldEvent.embeds[0].footer.text.split(' | ')[0];
const ownerName = oldEvent.embeds[0].footer.text.split(': ')[1];
const ownerId = eventMembers.find((member) => ownerName === member.name)?.id || 0n;
oldEvent.embeds[0].footer.iconUrl = `${config.links.creatorIcon}#${ownerId}`;
bot.helpers.editMessage(oldEvent.channelId, oldEvent.id, {
content: '',
embeds: [oldEvent.embeds[0]],
components: [{
type: MessageComponentTypes.ActionRow,
components: generateLFGButtons(false),
}],
}).catch((e: Error) => utils.commonLoggers.messageEditError('setup.ts', 'retrofit event', e));
dbClient.execute(queries.insertEvent, [oldEvent.id, oldEvent.channelId, interaction.guildId, ownerId, eventDateTime]).catch((e) =>
utils.commonLoggers.dbError('setup.ts@retrofit', 'INSERT event to DB', e)
);
}
}
});
// Store the ids to the db
let dbErrorOut = false;
await dbClient.execute('INSERT INTO guild_settings(guildId,lfgChannelId,managerRoleId,logChannelId) values(?,?,?,?)', [interaction.guildId, interaction.channelId, managerRoleId, logChannelId])
.catch((e) => {
utils.commonLoggers.dbError('setup.ts', 'insert into guild_settings', e);
dbErrorOut = true;
});
if (dbErrorOut) {
// DB died?
somethingWentWrong(bot, interaction, 'setupDBInsertFailed');
return;
}
// Store the ids to the active map
lfgChannelSettings.set(lfgChannelSettingKey, {
managed: setupOpts.name === withMgrRole,
managerRoleId,
logChannelId,
});
// Send the initial introduction message
const createNewEventBtn = 'Create New Event';
const introMsg = await bot.helpers.sendMessage(interaction.channelId, {
content: `Welcome to <#${interaction.channelId}>, managed by <@${botId}>!`,
embeds: [{
title: `To get started, click on the '${createNewEventBtn}' button below!`,
color: infoColor2,
fields: introFields,
}],
components: [{
type: MessageComponentTypes.ActionRow,
components: [{
type: MessageComponentTypes.Button,
label: createNewEventBtn,
customId: gameSelId,
style: ButtonStyles.Success,
}],
}],
}).catch((e: Error) => utils.commonLoggers.messageSendError('setup.ts', 'init-msg', e));
if (introMsg) {
bot.helpers.pinMessage(interaction.channelId, introMsg.id).catch((e: Error) => utils.commonLoggers.messageSendError('setup.ts', 'pin-init-msg', e));
// Complete the interaction
bot.helpers.sendInteractionResponse(interaction.id, interaction.token, {
type: InteractionResponseTypes.ChannelMessageWithSource,
data: {
flags: ApplicationCommandFlags.Ephemeral,
embeds: [{
color: successColor,
title: 'LFG Channel setup complete!',
description: `${config.name} has finished setting up this channel. ${safelyDismissMsg}`,
}],
},
}).catch((e: Error) => utils.commonLoggers.interactionSendError('setup.ts', interaction, e));
} else {
// Could not send initial message
bot.helpers.sendInteractionResponse(interaction.id, interaction.token, {
type: InteractionResponseTypes.ChannelMessageWithSource,
data: {
flags: ApplicationCommandFlags.Ephemeral,
embeds: [{
color: failColor,
title: 'Failed to send the initial message!',
fields: permissionFields,
}],
},
}).catch((e: Error) => utils.commonLoggers.interactionSendError('setup.ts', interaction, e));
}
} else {
// Too many messages to delete, give up
bot.helpers.sendInteractionResponse(interaction.id, interaction.token, {
type: InteractionResponseTypes.ChannelMessageWithSource,
data: {
flags: ApplicationCommandFlags.Ephemeral,
embeds: [{
color: failColor,
title: 'Unable to setup LFG channel.',
description: `${config.name} attempted to clean this channel, but encountered too many messages (100 or more). There are two ways to move forward:`,
fields: [
{
name: 'Is this channel a dedicated LFG Channel?',
value: 'You either need to manually clean this channel or create a brand new channel for events.',
inline: true,
},
{
name: 'Is this a chat channel that you want events mixed into?',
value: `You do not need to run the \`/${setupSlashName}\` command, and instead should use the \`/${createEventSlashName}\` command.`,
inline: true,
},
],
}],
},
}).catch((e: Error) => utils.commonLoggers.interactionSendError('setup.ts', interaction, e));
}
} else {
// Discord fucked up?
somethingWentWrong(bot, interaction, 'setupMissingAllOptions');
}
};
export default {
details,
execute,
};

View File

@ -0,0 +1,8 @@
export const auditSlashName = 'zzz-audit';
export const createEventSlashName = 'create-event';
export const deleteSlashName = 'delete-lfg-channel';
export const managerJLASlashName = 'event';
export const helpSlashName = 'help';
export const infoSlashName = 'info';
export const reportSlashName = 'report';
export const setupSlashName = 'setup';

View File

@ -1,252 +0,0 @@
import { ActionRow, DiscordButtonStyles } from '../deps.ts';
import config from '../config.ts';
export const constantCmds = {
help: {
embeds: [{
title: `${config.name} Help`,
fields: [
{
name: 'All commands must have the bot\'s prefix before them.',
value: `Default is \`${config.prefix}\`, send <@847256159123013722> to change it.`,
},
{
name: 'LFG Commands',
value: `
\`lfg help\` - More detailed help for the LFG commands
\`lfg create\` - Create a new LFG post
\`lfg edit\` - Edit an existing LFG post
\`lfg delete\` - Delete an existing LFG post
`,
},
{
name: 'Utility Commands',
value: `
\`info\` - Information about the bot
\`ping\` - Pings the bot to check its connection
\`report [TEXT]\` - Report an issue to the developer
\`version\` - Prints the bot's current version
`,
},
],
}],
},
lfgHelp: {
embeds: [{
title: `${config.name} LFG Help`,
fields: [
{
name: 'All commands must have the bot\'s prefix before them.',
value: `Default is \`${config.prefix}\`, send <@847256159123013722> to change it.`,
},
{
name: 'lfg create',
value: `
\`lfg create\`, alternatively \`lfg c\`, will walk you through creating a new LFG post. Simply follow the prompts and the bot will walk you through building a new LFG.
Make sure you run this command in the channel you wish the LFG post to be created in.
`,
inline: true,
},
{
name: 'lfg edit',
value: `
\`lfg edit [id?]\`, alternatively \`lfg e [id?]\`, will walk you through editing an existing LFG. Like \`lfg create\`, the bot will walk you through editing it.
Simply run \`lfg edit\` in the channel where the LFG post lives.
If you only have one LFG in this channel, the editing process will begin.
If you have more than one LFG in this channel, the bot will ask you to specify the LFG post using a two character id.
`,
inline: true,
},
{
name: 'lfg delete',
value: `
\`lfg delete [id?]\`, alternatively \`lfg d [id?]\`, will delete an existing LFG. You only can delete LFG posts that you own.
Simply run \`lfg delete\` in the channel where the LFG post lives.
If you only have one LFG in this channel, the LFG will be deleted.
If you have more than one LFG in this channel, the bot will ask you to specify the LFG post using a two character id.
`,
inline: true,
},
],
}],
},
info: {
embeds: [{
fields: [
{
name: 'Group Up, the LFG bot',
value: `Group Up is developed by Ean AKA Burn_E99.
Want to check out my source code? Check it out [here](https://github.com/Burn-E99/GroupUp).
Need help with this bot? Join my support server [here](https://discord.gg/peHASXMZYv).`,
},
],
}],
},
version: {
embeds: [{
title: `My current version is ${config.version}`,
}],
},
report: {
embeds: [{
fields: [
{
name: 'Failed command has been reported to my developer.',
value: 'For more in depth support, and information about planned maintenance, please join the support server [here](https://discord.gg/peHASXMZYv).',
},
],
}],
},
lfgDelete1: {
embeds: [{
fields: [
{
name: 'Could not find any LFGs to delete.',
value: 'Make sure you are the owner of the LFG and are running this command in the same channel as the LFG',
},
],
}],
},
lfgDelete2: {
embeds: [{
fields: [
{
name: `Multiple LFGs found, please run this command again with the two character ID of the LFG you wish to delete.\n\nExample: \`${config.prefix}lfg delete XX\``,
value: 'Click on the two character IDs below to view the LFG:\n',
},
],
}],
},
lfgDelete3: {
embeds: [{
title: 'LFG deleted.',
}],
},
lfgEdit1: {
embeds: [{
fields: [
{
name: 'Could not find any LFGs to edit.',
value: 'Make sure you are the owner of the LFG and are running this command in the same channel as the LFG',
},
],
}],
},
lfgEdit2: {
embeds: [{
fields: [
{
name: `Multiple LFGs found, please run this command again with the two character ID of the LFG you wish to edit.\n\nExample: \`${config.prefix}lfg edit XX\``,
value: 'Click on the two character IDs below to view the LFG:\n',
},
],
}],
},
announcement: {
content: `Hi! You're receiving this message as you have me in one of your servers. This is a very important announcement regarding how you and your community uses this bot in your guild. Please read the following details carefully.
This announcement feature is reserved for important breaking changes only. Group Up will be reaching version 1.0 with this major update, and will not have any more event breaking changes for a significant amount of time.`,
embeds: [{
color: 0x313bf9,
title: 'Version 1.0.0 is coming!',
description: `Group Up is coming up on a major milestone, giving your community an even better and more user-friendly experience.
**NOTICE:** All Guild settings will be deleted when V1.0.0 is released.
**NOTICE:** All Guilds are forced to use the \`/\` command prefix via the Discord Slash Command system.`,
fields: [
{
name: 'When is this update coming out?',
value: 'This update will be released <t:1683136800:R>, on <t:1683136800:F>. Group Up will be brought offline one hour before the update releases (<t:1683133200:R>, on <t:1683133200:F>) to handle a small migration and the deployment of the major update.',
inline: true,
},
{
name: 'What is changing?',
value: 'Group Up is moving to a fully Button and Slash Command based system. This means less commands to memorize and gives Group Up users more friendly methods to interact with, such as Forms, and Ephemeral messages.',
inline: true,
},
{
name: 'What do you need to do?',
value: `Once the update is live, there are a couple things you will need to do:
1. The bot will now require the \`MANAGE_GUILD\`, \`MANAGE_CHANNELS\`, \`MANAGE_ROLES\`, and \`MANAGE_MESSAGES\` permissions. Simply edit the role named \`Group Up\` in your server to add these permissions. The increased permissions are only necessary for the initial setup. After the setup is complete, the bot only requires the \`MANAGE_MESSAGES\` permission.
2. Go to or create the channel you want Group Up to use for event scheduling. Group Up will be taking full control of this channel, so please make sure you are okay with that before continuing.
3. Once in the desired channel, run \`/setup\` and follow the on-screen prompts. This will discover any pre-existing events, update them to the new design, and reconfigure the channel to work with the new system.`,
},
{
name: 'A Note for Pre-existing Events:',
value: 'If there are any pre-existing events, they will end up above the new instruction message Group Up sends. This is expected and unavoidable, but these events should still function fine.',
},
],
}],
},
announcementPart2: {
embeds: [{
color: 0x313bf9,
fields: [
{
name: 'If you used the Group Up Manager system before V1.0.0:',
value: `The Group Up Manager System is more powerful now. This system now grants managers the ability to edit and delete events in addition to the original Join/Leave/Alternate commands.
This system now requires a log channel which allows you to audit what your Group Up Managers are doing. Additionally, the owner of an event that a Group Up Manager modifies will be notified of any changes.
To enable this system, simply run \`/setup with-manager-role\` and fill in the requested fields.`,
},
{
name: 'What if you don\'t want to update?',
value: `As hosting costs money and I am running this bot for free, only the new system (V1.0.0) will be available from the official Group Up Discord bot.
If you *really* want to keep the old text based system, you may do so by hosting the bot yourself. This is **not recommended and not supported**, but as this project is open source, you may check out [my GitHub](https://github.com/Burn-E99/GroupUp) for details on how to host the last text based version (V0.5.8) privately.`
},
{
name: 'Have more questions?',
value: 'I have tried to anticipate every possible question, but if you still have any, please join [my support server](https://discord.gg/peHASXMZYv).',
},
{
name: 'Final Words',
value: `As I want to avoid these unexpected DMs in the future, please join [my support server](https://discord.gg/peHASXMZYv). All future announcements will be sent via this server.
Thank you for using Group Up, and I hope you enjoy this major update.
~Ean AKA Burn_E99`,
},
],
}],
},
};
export const editBtns: ActionRow['components'] = [
{
type: 2,
label: 'Change Game/Activity',
customId: `editing@set_game`,
style: DiscordButtonStyles.Primary,
},
{
type: 2,
label: 'Change Time',
customId: `editing@set_time`,
style: DiscordButtonStyles.Primary,
},
{
type: 2,
label: 'Change Description',
customId: `editing@set_desc`,
style: DiscordButtonStyles.Primary,
},
];
export const lfgStepQuestions = {
'set_game': 'Please select a game from the list below. If your game is not listed, please type it out:',
'set_activity_with_button':
'Please select an Activity from the list below. Depending on the game selected, these may be categories you can use to drill down to a specific activity.\n\nIf your activity is not listed, please type it out:',
'set_activity_with_text': 'Please type the activity name out:',
'set_activity_from_category': 'Please select an Activity from the list below.\n\nIf your activity is not listed, please type it out:',
'set_player_cnt': 'Please enter the max number of members for this activity:',
'set_time': 'Please enter the time of the activity:\nRecommended format: `h:mm am/pm tz month/day`',
'set_desc': 'Please enter a description for the activity. Enter `none` to skip:',
'set_done': 'Finalizing, please wait. . .',
};

34
src/db.ts Normal file
View File

@ -0,0 +1,34 @@
import config from '../config.ts';
import { Client } from '../deps.ts';
import { LOCALMODE } from '../flags.ts';
import { DBGuildSettings, LfgChannelSetting } from './types/commandTypes.ts';
export const dbClient = await new Client().connect({
hostname: LOCALMODE ? config.db.localhost : config.db.host,
port: config.db.port,
db: config.db.name,
username: config.db.username,
password: config.db.password,
});
export const queries = {
callIncCnt: (cmdName: string) => `CALL INC_CNT("${cmdName}");`,
selectEvents: (notifiedFlag: number, lockedFlag: number) => `SELECT * FROM active_events WHERE notifiedFlag = ${notifiedFlag} AND lockedFlag = ${lockedFlag} AND eventTime < ?`,
selectFailedEvents: 'SELECT * FROM active_events WHERE (notifiedFlag = -1 OR lockedFlag = -1) AND eventTime < ?',
insertEvent: 'INSERT INTO active_events(messageId,channelId,guildId,ownerId,eventTime) values(?,?,?,?,?)',
updateEventTime: 'UPDATE active_events SET eventTime = ? WHERE channelId = ? AND messageId = ?',
updateEventFlags: (notifiedFlag: number, lockedFlag: number) => `UPDATE active_events SET notifiedFlag = ${notifiedFlag}, lockedFlag = ${lockedFlag} WHERE channelId = ? AND messageId = ?`,
deleteEvent: 'DELETE FROM active_events WHERE channelId = ? AND messageId = ?',
insertCustomActivity: 'INSERT INTO custom_activities(guildId,activityTitle,activitySubtitle,maxMembers) values(?,?,?,?)',
};
export const lfgChannelSettings: Map<string, LfgChannelSetting> = new Map();
export const generateGuildSettingKey = (guildId: bigint, channelId: bigint) => `${guildId}-${channelId}`;
const getGuildSettings = await dbClient.query('SELECT * FROM guild_settings');
getGuildSettings.forEach((g: DBGuildSettings) => {
lfgChannelSettings.set(generateGuildSettingKey(g.guildId, g.lfgChannelId), {
managed: g.managerRoleId !== 0n && g.logChannelId !== 0n,
managerRoleId: g.managerRoleId,
logChannelId: g.logChannelId,
});
});

15
src/events.ts Normal file
View File

@ -0,0 +1,15 @@
import { DEVMODE } from '../flags.ts';
import { EventHandlers } from '../deps.ts';
import eventHandlers from './events/_index.ts';
export const events: Partial<EventHandlers> = {};
events.ready = eventHandlers.ready;
events.guildCreate = eventHandlers.guildCreate;
events.guildDelete = eventHandlers.guildDelete;
events.messageCreate = eventHandlers.messageCreate;
events.interactionCreate = eventHandlers.interactionCreate;
if (DEVMODE) {
events.debug = eventHandlers.debug;
}

15
src/events/_index.ts Normal file
View File

@ -0,0 +1,15 @@
import { ready } from './ready.ts';
import { guildCreate } from './guildCreate.ts';
import { guildDelete } from './guildDelete.ts';
import { debug } from './debug.ts';
import { messageCreate } from './messageCreate.ts';
import { interactionCreate } from './interactionCreate.ts';
export default {
ready,
guildCreate,
guildDelete,
debug,
messageCreate,
interactionCreate,
};

8
src/events/debug.ts Normal file
View File

@ -0,0 +1,8 @@
import {
// Log4Deno deps
log,
LT,
} from '../../deps.ts';
import utils from '../utils.ts';
export const debug = (dmsg: string) => log(LT.LOG, `Debug Message | ${utils.jsonStringifyBig(dmsg)}`);

31
src/events/guildCreate.ts Normal file
View File

@ -0,0 +1,31 @@
import config from '../../config.ts';
import { Bot, Guild, log, LT } from '../../deps.ts';
import { infoColor1 } from '../commandUtils.ts';
import utils from '../utils.ts';
export const guildCreate = (bot: Bot, guild: Guild) => {
log(LT.LOG, `Handling joining guild ${utils.jsonStringifyBig(guild)}`);
bot.helpers.sendMessage(config.logChannel, {
embeds: [{
title: 'Guild Joined!',
color: infoColor1,
fields: [
{
name: 'Name:',
value: `${guild.name}`,
inline: true,
},
{
name: 'Id:',
value: `${guild.id}`,
inline: true,
},
{
name: 'Member Count:',
value: `${guild.memberCount}`,
inline: true,
},
],
}],
}).catch((e: Error) => utils.commonLoggers.messageSendError('mod.ts:95', 'Join Guild', e));
};

40
src/events/guildDelete.ts Normal file
View File

@ -0,0 +1,40 @@
import config from '../../config.ts';
import { Bot, log, LT } from '../../deps.ts';
import { warnColor } from '../commandUtils.ts';
import { dbClient, lfgChannelSettings } from '../db.ts';
import utils from '../utils.ts';
export const guildDelete = async (bot: Bot, guildId: bigint) => {
log(LT.LOG, `Handling leaving guild ${utils.jsonStringifyBig(guildId)}`);
// Clean the DB
try {
await dbClient.execute('DELETE FROM guild_settings WHERE guildId = ?', [guildId]);
await dbClient.execute('DELETE FROM active_events WHERE guildId = ?', [guildId]);
await dbClient.execute('DELETE FROM custom_activities WHERE guildId = ?', [guildId]);
} catch (e) {
log(LT.WARN, `Failed to remove guild (${guildId}) from DB: ${utils.jsonStringifyBig(e)}`);
}
// Clean lfgChannelSettings
lfgChannelSettings.forEach((_val, key) => {
if (key.startsWith(`${guildId}-`)) {
lfgChannelSettings.delete(key);
}
});
// Send Log Message
bot.helpers.sendMessage(config.logChannel, {
embeds: [{
title: 'Removed from Guild',
color: warnColor,
fields: [
{
name: 'Id:',
value: `${guildId}`,
inline: true,
},
],
}],
}).catch((e: Error) => utils.commonLoggers.messageSendError('guildDelete.ts:28', 'Leave Guild', e));
};

View File

@ -0,0 +1,28 @@
import { Bot, BotWithCache, Interaction, log, LT } from '../../deps.ts';
import { buttons } from '../buttons/_index.ts';
import { commands } from '../commands/_index.ts';
import { fillerChar, idSeparator } from '../buttons/eventUtils.ts';
const commandNames: Array<string> = commands.map((command) => command.details.name);
const buttonNames: Array<string> = buttons.map((button) => button.customId);
export const interactionCreate = (rawBot: Bot, interaction: Interaction) => {
const bot = rawBot as BotWithCache;
if (interaction.data && interaction.id) {
if (interaction.data.name && commandNames.includes(interaction.data.name)) {
const cmdIdx = commandNames.indexOf(interaction.data.name);
commands[cmdIdx].execute(bot, interaction);
return;
}
const tempCustomId = interaction.data.customId ? interaction.data.customId.replaceAll(fillerChar, '') : '';
const customId = tempCustomId.split(idSeparator)[0] || '';
if (customId && buttonNames.includes(customId)) {
const btnIdx = buttonNames.indexOf(customId);
buttons[btnIdx].execute(bot, interaction);
return;
}
log(LT.WARN, `UNHANDLED INTERACTION!!! customId: ${interaction.data.customId} name: ${interaction.data.name}`);
}
};

View File

@ -0,0 +1,40 @@
import config from '../../config.ts';
import utils from '../utils.ts';
import { Bot, botId, Message } from '../../deps.ts';
import { infoEmbed } from '../commandUtils.ts';
import { dbClient, generateGuildSettingKey, lfgChannelSettings, queries } from '../db.ts';
export const messageCreate = async (bot: Bot, message: Message) => {
// Ignore self
if (botId === message.authorId) return;
// Delete all messages sent to a LFG Channel
if (lfgChannelSettings.has(generateGuildSettingKey(message.guildId || 0n, message.channelId))) {
bot.helpers.deleteMessage(message.channelId, message.id, 'Cleaning LFG Channel').catch((e: Error) => utils.commonLoggers.messageDeleteError('messageCreate.ts', 'Clean LFG Channel', e));
return;
}
// Ignore all messages that are not commands
if (message.content.indexOf(config.prefix) !== 0) {
// Handle @bot messages
if (message.mentionedUserIds[0] === botId && (message.content.trim().startsWith(`<@${botId}>`) || message.content.trim().startsWith(`<@!${botId}>`))) {
dbClient.execute(queries.callIncCnt('msg-mention')).catch((e) => utils.commonLoggers.dbError('info.ts', 'call sproc INC_CNT on', e));
bot.helpers.sendMessage(message.channelId, {
embeds: [infoEmbed],
messageReference: {
messageId: message.id,
channelId: message.channelId,
guildId: message.guildId,
failIfNotExists: false,
},
}).catch((e: Error) => utils.commonLoggers.messageSendError('messageCreate.ts', '@mention', e));
return;
}
// return as we are done handling this command
return;
}
// Ignore all other bots
if (message.isFromBot) return;
};

99
src/events/ready.ts Normal file
View File

@ -0,0 +1,99 @@
import { ActivityTypes, Bot, BotWithCache, log, LT } from '../../deps.ts';
import config from '../../config.ts';
import { LOCALMODE } from '../../flags.ts';
import { getRandomStatus, successColor } from '../commandUtils.ts';
import { ActiveEvent } from '../types/commandTypes.ts';
import utils from '../utils.ts';
import { dbClient, queries } from '../db.ts';
import { deleteEvent, handleFailures, lockEvent, notifyEventMembers, tenMinutes } from '../notificationSystem.ts';
import { updateBotListStatistics } from '../botListPoster.ts';
// Storing intervalIds in case bot soft reboots to prevent multiple of these intervals from stacking
let notificationIntervalId: number;
let botStatusIntervalId: number;
let botListPosterIntervalId: number;
export const ready = (rawBot: Bot) => {
const bot = rawBot as BotWithCache;
log(LT.INFO, `${config.name} Logged in!`);
bot.helpers.editBotStatus({
activities: [{
name: 'Booting up . . .',
type: ActivityTypes.Game,
createdAt: new Date().getTime(),
}],
status: 'online',
}).catch((e) => log(LT.ERROR, `Failed to update status (booting): ${utils.jsonStringifyBig(e)}`));
// Interval to rotate the status text every 30 seconds to show off more commands
if (botStatusIntervalId) clearInterval(botStatusIntervalId);
botStatusIntervalId = setInterval(async () => {
log(LT.LOG, 'Changing bot status');
bot.helpers.editBotStatus({
activities: [{
name: getRandomStatus(bot.guilds.size + bot.dispatchedGuildIds.size),
type: ActivityTypes.Game,
createdAt: new Date().getTime(),
}],
status: 'online',
}).catch((e) => log(LT.ERROR, `Failed to update status (in interval): ${utils.jsonStringifyBig(e)}`));
}, 30000);
// Interval to handle event notifications and cleanup every 30 seconds
if (notificationIntervalId) clearInterval(notificationIntervalId);
notificationIntervalId = setInterval(async () => {
log(LT.LOG, 'Running notification system');
const now = new Date().getTime();
// Get all the events
const eventsToNotify = await dbClient.execute(queries.selectEvents(0, 0), [new Date(now + tenMinutes)]).catch((e) =>
utils.commonLoggers.dbError('ready.ts@notifyMembers', 'SELECT events from', e)
);
const eventsToLock = await dbClient.execute(queries.selectEvents(1, 0), [new Date(now)]).catch((e) => utils.commonLoggers.dbError('ready.ts@notifyAlternates', 'SELECT events from', e));
const eventsToDelete = await dbClient.execute(queries.selectEvents(1, 1), [new Date(now - tenMinutes)]).catch((e) => utils.commonLoggers.dbError('ready.ts@deleteEvent', 'SELECT events from', e));
const eventFailuresToHandle = await dbClient.execute(queries.selectFailedEvents, [new Date(now + tenMinutes)]).catch((e) =>
utils.commonLoggers.dbError('ready.ts@handleFailures', 'SELECT events from', e)
);
// Run all the handlers
eventsToNotify?.rows?.forEach((event) => notifyEventMembers(bot, event as ActiveEvent));
eventsToLock?.rows?.forEach((event) => lockEvent(bot, event as ActiveEvent));
eventsToDelete?.rows?.forEach((event) => deleteEvent(bot, event as ActiveEvent));
eventFailuresToHandle?.rows?.forEach((event) => handleFailures(bot, event as ActiveEvent));
}, 30000);
// Interval to handle updating botList statistics
if (botListPosterIntervalId) clearInterval(botListPosterIntervalId)
LOCALMODE ? log(LT.INFO, 'updateListStatistics not running') : botListPosterIntervalId = setInterval(() => {
log(LT.LOG, 'Updating all bot lists statistics');
updateBotListStatistics(bot.guilds.size + bot.dispatchedGuildIds.size);
}, 86400000);
// setTimeout added to make sure the startup message does not error out
setTimeout(() => {
LOCALMODE && bot.helpers.editBotMember(config.devServer, { nick: `LOCAL - ${config.name}` });
bot.helpers.editBotStatus({
activities: [{
name: 'Booting Complete',
type: ActivityTypes.Game,
createdAt: new Date().getTime(),
}],
status: 'online',
}).catch((e) => log(LT.ERROR, `Failed to update status (boot complete): ${utils.jsonStringifyBig(e)}`));
bot.helpers.sendMessage(config.logChannel, {
embeds: [{
title: `${config.name} is now Online`,
color: successColor,
fields: [
{
name: 'Version:',
value: `${config.version}`,
inline: true,
},
],
}],
}).catch((e: Error) => utils.commonLoggers.messageSendError('ready.ts@startup', 'Startup', e));
}, 1000);
};

View File

@ -1,59 +0,0 @@
export const LFGActivities = {
'Destiny 2': {
'Raids': {
'King\'s Fall': 6,
'Vow of the Disciple': 6,
'Vault of Glass': 6,
'Deep Stone Crypt': 6,
'Garden of Salvation': 6,
'Last Wish': 6,
},
'Dungeons': {
'Spire of the Watcher': 3,
'Duality': 3,
'Grasp of Avarice': 3,
'Prophecy': 3,
'Pit of Heresy': 3,
'Shattered Throne': 3,
},
'Crucible': {
'Crucible (Control)': 6,
'Crucible (Survival)': 3,
'Crucible (Elimination)': 3,
'Crucible (Private Match)': 12,
'Iron Banner': 6,
'Trials of Osiris': 3,
},
'Gambit': {
'Gambit (Classic)': 4,
'Gambit (Private Match)': 8,
},
"Exotic Missions": {
'Operation: Seraph\'s Shield': 3,
// "Presage": 3,
// "Harbinger": 3
},
'Nightfall': 3,
'Miscellaneous': {
'Heist Battlegrounds': 3,
'Ketchrash': 6,
'Expedition': 3,
'Weekly Witch Queen Campaign Mission': 3,
'Wellspring': 6,
'Dares of Eternity': 6,
// "Astral Alignment": 6,
// "Shattered Realm": 3,
// "Override": 6,
// "Expunge": 3,
// "Battlegrounds": 3,
'Wrathborn Hunt': 3,
'Empire Hunt': 3,
'Vanguard Operations': 3,
// "Nightmare Hunt": 3
},
},
'Among Us': {
'Vanilla': 15,
'Modded': 15,
},
};

View File

@ -1,180 +0,0 @@
import {
// Discordeno deps
cache,
deleteMessage,
getGuild,
getMessage,
log,
// Log4Deno deps
LT,
sendDirectMessage,
sendMessage,
} from '../deps.ts';
import { jsonStringifyBig } from './utils.ts';
import { ActiveLFG, BuildingLFG } from './mod.d.ts';
import config from '../config.ts';
// getRandomStatus() returns status as string
// Gets a new random status for the bot
const getRandomStatus = (cachedGuilds: number): string => {
let status = '';
switch (Math.floor((Math.random() * 5) + 1)) {
case 1:
status = `${config.prefix}help for commands`;
break;
case 2:
status = `Running V${config.version}`;
break;
case 3:
status = `${config.prefix}info to learn more`;
break;
case 4:
status = 'Mention me to check my prefix!';
break;
default:
status = `Running LFGs in ${cachedGuilds + cache.dispatchedGuildIds.size} servers`;
break;
}
return status;
};
// updateListStatistics(bot ID, current guild count) returns nothing
// Sends the current server count to all bot list sites we are listed on
const updateListStatistics = (botID: BigInt, serverCount: number): void => {
config.botLists.forEach(async (e) => {
if (e.enabled) {
log(LT.LOG, `Updating statistics for ${jsonStringifyBig(e)}`);
try {
const tempHeaders = new Headers();
tempHeaders.append(e.headers[0].header, e.headers[0].value);
tempHeaders.append('Content-Type', 'application/json');
// ?{} is a template used in config, just need to replace it with the real value
const response = await fetch(e.apiUrl.replace('?{bot_id}', botID.toString()), {
'method': 'POST',
'headers': tempHeaders,
'body': jsonStringifyBig(e.body).replace('"?{server_count}"', serverCount.toString()), // ?{server_count} needs the "" removed from around it aswell to make sure its sent as a number
});
log(LT.INFO, `Posted server count to ${e.name}. Results: ${jsonStringifyBig(response)}`);
} catch (e) {
log(LT.WARN, `Failed to post statistics to ${e.name} | ${jsonStringifyBig(e)}`);
}
}
});
};
const buildingTimeout = async (activeBuilders: Array<BuildingLFG>): Promise<void> => {
const currentTime = new Date().getTime();
for (let i = 0; i < activeBuilders.length; i++) {
if (activeBuilders[i].lastTouch.getTime() + (activeBuilders[i].maxIdle * 1000) < currentTime) {
activeBuilders[i].questionMsg.delete().catch((e) => {
log(LT.WARN, `Failed to clean up active builder | edit | ${activeBuilders[i].userId}-${activeBuilders[i].channelId} | ${jsonStringifyBig(e)}`);
});
if (activeBuilders[i].editing) {
activeBuilders[i].lfgMsg.edit({
content: '',
}).catch((e) => {
log(LT.WARN, `Failed to clean up active builder | edit | ${activeBuilders[i].userId}-${activeBuilders[i].channelId} | ${jsonStringifyBig(e)}`);
});
} else {
activeBuilders[i].lfgMsg.delete().catch((e) => {
log(LT.WARN, `Failed to clean up active builder | delete | ${activeBuilders[i].userId}-${activeBuilders[i].channelId} | ${jsonStringifyBig(e)}`);
});
}
try {
const m = await sendMessage(activeBuilders[i].channelId, `<@${activeBuilders[i].userId}>, your LFG ${activeBuilders[i].editing ? 'editing' : 'creation'} has timed out. Please try again.`);
m.delete('Channel Cleanup', 30000).catch((e) => {
log(LT.WARN, `Failed to delete message | ${jsonStringifyBig(e)}`);
});
} catch (e) {
log(LT.WARN, `Failed to clean up active builder | ${activeBuilders[i].userId}-${activeBuilders[i].channelId} | ${jsonStringifyBig(e)}`);
} finally {
activeBuilders.splice(i, 1);
i--;
}
}
}
};
const lfgNotifier = async (activeLFGPosts: Array<ActiveLFG>): Promise<void> => {
log(LT.INFO, 'Checking for LFG posts to notify/delete/lock');
const tenMin = 10 * 60 * 1000;
const now = new Date().getTime();
for (let i = 0; i < activeLFGPosts.length; i++) {
// Send notifications
if (!activeLFGPosts[i].notified && activeLFGPosts[i].lfgTime < (now + tenMin)) {
log(LT.INFO, `Notifying LFG ${activeLFGPosts[i].ownerId}-${activeLFGPosts[i].lfgUid}`);
try {
const message = await getMessage(activeLFGPosts[i].channelId, activeLFGPosts[i].messageId);
const lfg = message.embeds[0].fields || [];
const lfgActivity = `${lfg[0].name.substr(0, lfg[0].name.length - 1)} - ${lfg[0].value}`;
const guildName = message.guild?.name || (await getGuild(message.guildId, { counts: false, addToCache: false })).name;
const members = lfg[4].value;
let editMsg = '';
members.split('\n').forEach(async (m) => {
if (m !== 'None') {
const [name, tmpId] = m.split(' - <@');
const userId = BigInt(tmpId.substr(0, tmpId.length - 1));
editMsg += `<@${userId}>, `;
await sendDirectMessage(userId, {
embeds: [{
title: `Hello ${name}! Your event in ${guildName} starts in less than 10 minutes.`,
fields: [
lfg[0],
{
name: 'Please start grouping up with the other members of this activity:',
value: members,
},
],
}],
});
}
});
editMsg += `your ${lfgActivity} starts in less than 10 minutes.`;
await message.edit({
content: editMsg,
});
activeLFGPosts[i].notified = true;
} catch (err) {
log(LT.WARN, `Failed to find LFG ${activeLFGPosts[i].ownerId}-${activeLFGPosts[i].lfgUid} | ${jsonStringifyBig(err)}`);
activeLFGPosts.splice(i, 1);
i--;
}
} // Lock LFG from editing/Joining/Leaving
else if (!activeLFGPosts[i].locked && activeLFGPosts[i].lfgTime < now) {
log(LT.INFO, `Locking LFG ${activeLFGPosts[i].ownerId}-${activeLFGPosts[i].lfgUid}`);
try {
const message = await getMessage(activeLFGPosts[i].channelId, activeLFGPosts[i].messageId);
await message.edit({
components: [],
});
activeLFGPosts[i].locked = true;
} catch (err) {
log(LT.WARN, `Failed to find LFG ${activeLFGPosts[i].ownerId}-${activeLFGPosts[i].lfgUid} | ${jsonStringifyBig(err)}`);
activeLFGPosts.splice(i, 1);
i--;
}
} // Delete old LFG post
else if (activeLFGPosts[i].lfgTime < (now - tenMin)) {
log(LT.INFO, `Deleting LFG ${activeLFGPosts[i].ownerId}-${activeLFGPosts[i].lfgUid}`);
await deleteMessage(activeLFGPosts[i].channelId, activeLFGPosts[i].messageId, 'LFG post expired').catch((e) => {
log(LT.WARN, `Failed to delete LFG ${activeLFGPosts[i].ownerId}-${activeLFGPosts[i].lfgUid} | ${jsonStringifyBig(e)}`);
});
activeLFGPosts.splice(i, 1);
i--;
}
}
localStorage.setItem('activeLFGPosts', jsonStringifyBig(activeLFGPosts));
};
export default { getRandomStatus, updateListStatistics, buildingTimeout, lfgNotifier };

14
src/lfgHandlers.d.ts vendored
View File

@ -1,14 +0,0 @@
import { EmbedField } from '../deps.ts';
export type JoinLeaveType = {
embed: EmbedField[];
success: boolean;
full: boolean;
justFilled: boolean;
};
export type UrlIds = {
guildId: bigint;
channelId: bigint;
messageId: bigint;
};

View File

@ -1,496 +0,0 @@
import { ActionRow, ButtonComponent, DiscordButtonStyles, DiscordenoMember, EmbedField, log, LT } from '../deps.ts';
import { JoinLeaveType, UrlIds } from './lfgHandlers.d.ts';
import { BuildingLFG } from './mod.d.ts';
import { LFGActivities } from './games.ts';
import { determineTZ } from './timeUtils.ts';
import { lfgStepQuestions } from './constantCmds.ts';
import { jsonStringifyBig } from './utils.ts';
export const handleLFGStep = async (wipLFG: BuildingLFG, input: string): Promise<BuildingLFG> => {
const currentLFG = (wipLFG.lfgMsg.embeds[0] || { fields: undefined }).fields || [
{
name: '. . .',
value: '. . .',
inline: true,
},
{
name: 'Start Time:',
value: '. . .',
inline: true,
},
{
name: 'Add to Calendar:',
value: '. . .',
inline: true,
},
{
name: 'Description:',
value: '. . .',
inline: false,
},
{
name: `Members Joined: 0/?`,
value: 'None',
inline: true,
},
{
name: 'Alternates:',
value: 'None',
inline: true,
},
];
let nextQuestion = '';
const nextComponents: Array<ActionRow> = [];
let editFlag = true;
switch (wipLFG.step) {
case 'set_game': {
currentLFG[0].name = input.substr(0, 254);
if (Object.prototype.hasOwnProperty.call(LFGActivities, input)) {
nextQuestion = lfgStepQuestions.set_activity_with_button;
let tempObj = {};
Object.entries(LFGActivities).some((e) => {
if (e[0] === input) {
tempObj = e[1];
return true;
}
});
const activityButtons: Array<ButtonComponent> = Object.keys(tempObj).map((activity) => {
return {
type: 2,
label: activity,
customId: `building@set_activity#${activity}`,
style: DiscordButtonStyles.Primary,
};
});
const temp: Array<ActionRow['components']> = [];
activityButtons.forEach((btn, idx) => {
if (!temp[Math.floor(idx / 5)]) {
temp[Math.floor(idx / 5)] = [btn];
} else {
temp[Math.floor(idx / 5)].push(btn);
}
});
temp.forEach((btns) => {
if (btns.length && btns.length <= 5) {
nextComponents.push({
type: 1,
components: btns,
});
}
});
} else {
nextQuestion = lfgStepQuestions.set_activity_with_text;
}
wipLFG.step = 'set_activity';
break;
}
case 'set_activity': {
const game = currentLFG[0].name;
let tempObj;
Object.entries(LFGActivities).some((e) => {
if (e[0] === game) {
Object.entries(e[1]).some((f) => {
if (f[0] === input) {
tempObj = f[1];
return true;
}
});
return true;
}
});
currentLFG[0].name = `${game}:`;
currentLFG[0].value = input.substr(0, 1023);
if (typeof tempObj === 'number') {
// Activity
currentLFG[4].name = `Members Joined: ${currentLFG[4].value === 'None' ? 0 : currentLFG[4].value.split('\n').length}/${tempObj}`;
nextQuestion = wipLFG.editing ? lfgStepQuestions.set_done : lfgStepQuestions.set_time;
wipLFG.step = wipLFG.editing ? 'done' : 'set_time';
} else if (!tempObj) {
// Custom
nextQuestion = lfgStepQuestions.set_player_cnt;
wipLFG.step = 'set_player_cnt';
} else {
// Category
nextQuestion = lfgStepQuestions.set_activity_from_category;
currentLFG[0].name = game;
const activityButtons: Array<ButtonComponent> = Object.keys(tempObj).map((activity) => {
return {
type: 2,
label: activity,
customId: `building@set_activity_from_category#${activity}`,
style: DiscordButtonStyles.Primary,
};
});
const temp: Array<ActionRow['components']> = [];
activityButtons.forEach((btn, idx) => {
if (!temp[Math.floor(idx / 5)]) {
temp[Math.floor(idx / 5)] = [btn];
} else {
temp[Math.floor(idx / 5)].push(btn);
}
});
temp.forEach((btns) => {
if (btns.length && btns.length <= 5) {
nextComponents.push({
type: 1,
components: btns,
});
}
});
wipLFG.step = 'set_activity_from_category';
}
break;
}
case 'set_activity_from_category': {
const game = currentLFG[0].name;
const category = currentLFG[0].value;
let tempObj;
Object.entries(LFGActivities).some((e) => {
if (e[0] === game) {
Object.entries(e[1]).some((f) => {
if (f[0] === category) {
Object.entries(f[1]).some((g) => {
if (g[0] === input) {
tempObj = g[1];
return true;
}
});
return true;
}
});
return true;
}
});
currentLFG[0].name = `${game}:`;
currentLFG[0].value = input.substr(0, 1023);
if (tempObj) {
currentLFG[4].name = `Members Joined: ${currentLFG[4].value === 'None' ? 0 : currentLFG[4].value.split('\n').length}/${tempObj}`;
nextQuestion = wipLFG.editing ? lfgStepQuestions.set_done : lfgStepQuestions.set_time;
wipLFG.step = wipLFG.editing ? 'done' : 'set_time';
} else {
nextQuestion = lfgStepQuestions.set_player_cnt;
wipLFG.step = 'set_player_cnt';
}
break;
}
case 'set_player_cnt': {
if (parseInt(input)) {
currentLFG[4].name = `Members Joined: ${currentLFG[4].value === 'None' ? 0 : currentLFG[4].value.split('\n').length}/${Math.abs(parseInt(input)) || 1}`;
nextQuestion = wipLFG.editing ? lfgStepQuestions.set_done : lfgStepQuestions.set_time;
wipLFG.step = wipLFG.editing ? 'done' : 'set_time';
} else {
editFlag = false;
nextQuestion = `Input max members "${input}" is invalid, please make sure you are only entering a number.\n\n${lfgStepQuestions.set_player_cnt}`;
}
break;
}
case 'set_time': {
const today = new Date();
let lfgDate = `${today.getMonth() + 1}/${today.getDate()}`,
lfgTime = '',
lfgTZ = '',
lfgPeriod = '',
overrodeTZ = false;
input.split(' ').forEach((c) => {
if (c.includes('/')) {
lfgDate = c;
} else if (c.toLowerCase() === 'am' || c.toLowerCase() === 'pm') {
lfgPeriod = c.toLowerCase();
} else if (c.toLowerCase().includes('am') || c.toLowerCase().includes('pm')) {
lfgTime = c.substr(0, c.length - 2);
lfgPeriod = c.toLowerCase().includes('am') ? 'am' : 'pm';
} else if (c.includes(':')) {
lfgTime = c;
} else if (parseInt(c).toString() === (c.replace(/^0+/, '') || '0')) {
if (c.length === 4) {
if (parseInt(c) >= 1300) {
lfgTime = (parseInt(c) - 1200).toString();
lfgPeriod = 'pm';
} else if (parseInt(c) >= 1200) {
lfgTime = c;
lfgPeriod = 'pm';
} else {
lfgTime = c.startsWith('00') ? `12${c.substr(2)}` : c;
lfgPeriod = 'am';
}
const hourLen = lfgTime.length === 4 ? 2 : 1;
lfgTime = `${lfgTime.substr(0, hourLen)}:${lfgTime.substr(hourLen)}`;
} else {
lfgTime = c;
}
} else if (c.match(/^\d/)) {
const tzIdx = c.search(/[a-zA-Z]/);
lfgTime = c.substr(0, tzIdx);
[lfgTZ, overrodeTZ] = determineTZ(c.substr(tzIdx));
} else {
[lfgTZ, overrodeTZ] = determineTZ(c);
}
});
if (!lfgTZ) {
[lfgTZ, overrodeTZ] = determineTZ('ET');
}
if (!lfgTime.includes(':')) {
lfgTime += ':00';
}
if (!lfgPeriod) {
lfgPeriod = today.getHours() >= 12 ? 'pm' : 'am';
}
lfgPeriod = lfgPeriod.toUpperCase();
lfgTZ = lfgTZ.toUpperCase();
lfgDate = `${lfgDate.split('/')[0]}/${lfgDate.split('/')[1]}/${today.getFullYear()}`;
log(LT.LOG, `Date Time Debug | ${lfgTime} ${lfgPeriod} ${lfgTZ} ${lfgDate}`);
const lfgDateTime = new Date(`${lfgTime} ${lfgPeriod} ${lfgTZ} ${lfgDate}`);
lfgDate = `${lfgDate.split('/')[0]}/${lfgDate.split('/')[1]}`;
const lfgDateStr = `[${lfgTime} ${lfgPeriod} ${lfgTZ} ${lfgDate}](https://groupup.eanm.dev/tz#${lfgDateTime.getTime()})`;
const icsDetails = `${currentLFG[0].name} ${currentLFG[0].value}`;
const icsStr = `[Download ICS File](https://groupup.eanm.dev/ics?t=${lfgDateTime.getTime()}&n=${icsDetails.replaceAll(' ', '+')})`;
currentLFG[1].name = 'Start Time (Click for Conversion):';
currentLFG[1].value = lfgDateStr.substr(0, 1023);
currentLFG[2].value = icsStr.substr(0, 1023);
if (isNaN(lfgDateTime.getTime())) {
nextQuestion =
`Input time "${input}" (parsed as "${lfgTime} ${lfgPeriod} ${lfgTZ} ${lfgDate}") is invalid, please make sure you have the timezone set correctly.\n\n${lfgStepQuestions.set_time}`;
editFlag = false;
} else if (lfgDateTime.getTime() <= today.getTime()) {
nextQuestion =
`Input time "${input}" (parsed as "${lfgTime} ${lfgPeriod} ${lfgTZ} ${lfgDate}") is in the past, please make sure you are setting up the event to be in the future.\n\n${lfgStepQuestions.set_time}`;
editFlag = false;
} else {
nextQuestion = wipLFG.editing ? lfgStepQuestions.set_done : lfgStepQuestions.set_desc;
wipLFG.step = wipLFG.editing ? 'done' : 'set_desc';
}
break;
}
case 'set_desc': {
if (input === 'none') {
input = currentLFG[0].value;
}
currentLFG[3].value = input.substr(0, 1023);
nextQuestion = lfgStepQuestions.set_done;
wipLFG.step = 'done';
break;
}
default:
break;
}
try {
if (editFlag) {
wipLFG.lfgMsg = await wipLFG.lfgMsg.edit({
embeds: [{
fields: currentLFG,
}],
});
}
wipLFG.questionMsg = await wipLFG.questionMsg.edit({
content: nextQuestion,
components: nextComponents,
});
} catch (e) {
log(LT.WARN, `Failed to edit active builder | ${wipLFG.userId}-${wipLFG.channelId} | ${jsonStringifyBig(e)}`);
}
return wipLFG;
};
export const handleMemberJoin = (lfg: EmbedField[], member: DiscordenoMember, alternate: boolean): JoinLeaveType => {
let success = false;
let justFilled = false;
const userStr = `${member.username} - <@${member.id}>`;
const tempMembers = lfg[4].name.split(':')[1].split('/');
let currentMembers = parseInt(tempMembers[0]);
const maxMembers = parseInt(tempMembers[1]);
if (alternate && !lfg[5].value.includes(member.id.toString())) {
// remove from joined list
if (lfg[4].value.includes(member.id.toString())) {
const tempArr = lfg[4].value.split('\n');
const memberIdx = tempArr.findIndex((m) => m.includes(member.id.toString()));
tempArr.splice(memberIdx, 1);
lfg[4].value = tempArr.join('\n') || 'None';
if (currentMembers) {
currentMembers--;
}
lfg[4].name = `Members Joined: ${currentMembers}/${maxMembers}`;
}
if (lfg[5].value === 'None') {
lfg[5].value = userStr;
} else {
lfg[5].value += `\n${userStr}`;
}
success = true;
} else if (!alternate && currentMembers < maxMembers && !lfg[4].value.includes(member.id.toString())) {
// remove from alternate list
if (lfg[5].value.includes(member.id.toString())) {
const tempArr = lfg[5].value.split('\n');
const memberIdx = tempArr.findIndex((m) => m.includes(member.id.toString()));
tempArr.splice(memberIdx, 1);
lfg[5].value = tempArr.join('\n') || 'None';
}
if (lfg[4].value === 'None') {
lfg[4].value = userStr;
} else {
lfg[4].value += `\n${userStr}`;
}
currentMembers++;
justFilled = currentMembers === maxMembers;
lfg[4].name = `Members Joined: ${currentMembers}/${maxMembers}`;
success = true;
} else if (!alternate && currentMembers === maxMembers && !lfg[4].value.includes(member.id.toString())) {
// update user in alternate list to include the * to make them autojoin
if (lfg[5].value.includes(member.id.toString())) {
const tempArr = lfg[5].value.split('\n');
const memberIdx = tempArr.findIndex((m) => m.includes(member.id.toString()));
tempArr[memberIdx] = `${tempArr[memberIdx]} *`;
lfg[5].value = tempArr.join('\n');
} else {
if (lfg[5].value === 'None') {
lfg[5].value = `${userStr} *`;
} else {
lfg[5].value += `\n${userStr} *`;
}
success = true;
}
}
return {
embed: lfg,
success: success,
full: currentMembers === maxMembers,
justFilled: justFilled,
};
};
export const handleMemberLeave = (lfg: EmbedField[], member: DiscordenoMember): JoinLeaveType => {
let success = false;
const memberId = member.id.toString();
const tempMembers = lfg[4].name.split(':')[1].split('/');
let currentMembers = parseInt(tempMembers[0]);
const maxMembers = parseInt(tempMembers[1]);
if (lfg[4].value.includes(memberId)) {
const tempArr = lfg[4].value.split('\n');
const memberIdx = tempArr.findIndex((m) => m.includes(memberId));
tempArr.splice(memberIdx, 1);
lfg[4].value = tempArr.join('\n') || 'None';
if (lfg[5].value.includes('*')) {
// find first * user and move them to the joined list
const tempArr2 = lfg[5].value.split('\n');
const memberToMoveIdx = tempArr2.findIndex((m) => m.includes('*'));
let memberToMove = tempArr2[memberToMoveIdx];
memberToMove = memberToMove.substr(0, memberToMove.length - 2);
tempArr.push(memberToMove);
lfg[4].value = tempArr.join('\n') || 'None';
// Remove them from the alt list
tempArr2.splice(memberToMoveIdx, 1);
lfg[5].value = tempArr2.join('\n') || 'None';
} else {
// update count since no users were marked as *
if (currentMembers) {
currentMembers--;
}
lfg[4].name = `Members Joined: ${currentMembers}/${maxMembers}`;
}
success = true;
}
if (lfg[5].value.includes(memberId)) {
const tempArr = lfg[5].value.split('\n');
const memberIdx = tempArr.findIndex((m) => m.includes(memberId));
tempArr.splice(memberIdx, 1);
lfg[5].value = tempArr.join('\n') || 'None';
success = true;
}
return {
embed: lfg,
success: success,
full: currentMembers === maxMembers,
justFilled: false,
};
};
export const urlToIds = (url: string): UrlIds => {
const strIds = {
guildId: '',
channelId: '',
messageId: '',
};
url = url.toLowerCase();
[strIds.guildId, strIds.channelId, strIds.messageId] = url.substr(url.indexOf('channels') + 9).split('/');
return {
guildId: BigInt(strIds.guildId),
channelId: BigInt(strIds.channelId),
messageId: BigInt(strIds.messageId),
};
};

37
src/mod.d.ts vendored
View File

@ -1,37 +0,0 @@
import { DiscordenoMessage } from '../deps.ts';
export type BuildingLFG = {
userId: bigint;
channelId: bigint;
step: string;
lfgMsg: DiscordenoMessage;
questionMsg: DiscordenoMessage;
lastTouch: Date;
maxIdle: number;
editing: boolean;
};
export type ActiveLFG = {
messageId: bigint;
channelId: bigint;
ownerId: bigint;
lfgUid: string;
lfgTime: number;
notified: boolean;
locked: boolean;
};
export type GuildPrefixes = {
guildId: bigint;
prefix: string;
};
export type GuildModRoles = {
guildId: bigint;
roleId: bigint;
};
export type GuildCleanChannels = {
guildId: bigint;
channelId: bigint;
};

237
src/notificationSystem.ts Normal file
View File

@ -0,0 +1,237 @@
import config from '../config.ts';
import { Bot } from '../deps.ts';
import { LfgEmbedIndexes } from './buttons/eventUtils.ts';
import { getEventMemberCount, getGuildName, getLfgMembers } from './buttons/live-event/utils.ts';
import { failColor, infoColor1, sendDirectMessage, warnColor } from './commandUtils.ts';
import { reportSlashName } from './commands/slashCommandNames.ts';
import { dbClient, queries } from './db.ts';
import { ActiveEvent } from './types/commandTypes.ts';
import utils from './utils.ts';
const notifyStepName = 'notify';
const lockStepName = 'lock';
const deleteStepName = 'delete';
export const tenMinutes = 10 * 60 * 1000;
// Join strings with english in mind
const joinWithAnd = (words: string[]) => {
if (words.length === 0) {
return '';
} else if (words.length === 1) {
return words[0];
} else if (words.length === 2) {
return words.join(' and ');
} else {
return words.slice(0, -1).join(', ') + ', and ' + words.slice(-1);
}
};
// Log the failure in a loud sense
const loudLogFailure = async (bot: Bot, event: ActiveEvent, stepName: string, secondFailure = false) => {
const guildName = await getGuildName(bot, event.guildId);
const eventUrl = utils.idsToMessageUrl({
guildId: event.guildId,
channelId: event.channelId,
messageId: event.messageId,
});
// Try to DM owner if this is the second time it has failed
let dmSuccess = false;
if (secondFailure) {
const msg = await sendDirectMessage(bot, event.ownerId, {
embeds: [{
color: failColor,
title: `Attention: Failed to ${stepName} one of your events`,
description:
`${config.name} tried twice to find [this event](${eventUrl}) in ${guildName}, and could not either time. Since ${config.name} has failed twice, ${config.name} has now removed [this event](${eventUrl}) from its list of active events.
[This event](${eventUrl}) was scheduled to start at <t:${event.eventTime.getTime() / 1000}:F>.
The message containing this event may have been deleted by a moderator or administrator in ${guildName}. If [the event](${eventUrl}) still exists when you click on the link above, please \`/${reportSlashName}\` this issue to the developers with the full error code below.`,
fields: [{
name: 'Error Code:',
value: `\`loudLog@${event.guildId}|${event.channelId}|${event.messageId}|${event.ownerId}|${event.eventTime.getTime()}|${event.notifiedFlag}|${event.lockedFlag}@\``,
}],
}],
}).catch((e: Error) => utils.commonLoggers.messageSendError('notificationSystem.ts@loudLog', 'send DM fail', e));
dmSuccess = Boolean(msg);
}
// Log this to bot's log channel
bot.helpers.sendMessage(config.logChannel, {
content: secondFailure ? `Hey <@${config.owner}>, something may have gone wrong. The owner of this event was ${dmSuccess ? 'SUCCESSFULLY' : 'NOT'} notified.` : undefined,
embeds: [{
color: secondFailure ? failColor : warnColor,
title: `Failed to ${stepName} an event in ${guildName}. This is the ${secondFailure ? 'second' : 'first'} attempt.`,
description: `${config.name} failed to ${stepName} [this event](${eventUrl}).\n\nDebug Data:`,
fields: [{
name: 'Guild ID:',
value: `${event.guildId}`,
inline: true,
}, {
name: 'Channel ID:',
value: `${event.channelId}`,
inline: true,
}, {
name: 'Message ID:',
value: `${event.messageId}`,
inline: true,
}, {
name: 'Owner ID:',
value: `${event.ownerId}`,
inline: true,
}, {
name: 'Event Time:',
value: `<t:${event.eventTime.getTime() / 1000}:F>`,
inline: true,
}, {
name: 'Notified Flag:',
value: `${event.notifiedFlag}`,
inline: true,
}, {
name: 'Locked Flag:',
value: `${event.lockedFlag}`,
inline: true,
}],
}],
}).catch((e: Error) => utils.commonLoggers.messageSendError('notificationSystem.ts@loudLog', 'send log message', e));
};
// Notifies all members of the event and edits the event message
export const notifyEventMembers = async (bot: Bot, event: ActiveEvent, secondTry = false): Promise<boolean> => {
const eventMessage = await bot.helpers.getMessage(event.channelId, event.messageId).catch((e: Error) => utils.commonLoggers.messageGetError('notificationSystem.ts@notify', 'get event', e));
if (eventMessage?.embeds[0].fields) {
const activityName = `\`${eventMessage.embeds[0].fields[LfgEmbedIndexes.Activity].name} ${eventMessage.embeds[0].fields[LfgEmbedIndexes.Activity].value}\``;
const members = getLfgMembers(eventMessage.embeds[0].fields[LfgEmbedIndexes.JoinedMembers].value);
const memberMentionString = joinWithAnd(members.map((member) => `<@${member.id}>`));
const guildName = await getGuildName(bot, event.guildId);
// Edit event in guild
await bot.helpers.editMessage(event.channelId, event.messageId, {
content: `Attention ${memberMentionString}, your ${activityName} starts in less than 10 minutes.`,
}).catch((e: Error) => utils.commonLoggers.messageEditError('notificationSystem.ts@notify', 'event edit fail', e));
// Send the notifications to the members
members.forEach(async (member) => {
await sendDirectMessage(bot, member.id, {
embeds: [{
color: infoColor1,
title: `Hello ${member.name}! Your activity in ${guildName} starts in less than 10 minutes.`,
description: 'Please start grouping up with the other members of this activity:',
}, eventMessage.embeds[0]],
}).catch((e: Error) => utils.commonLoggers.messageSendError('notificationSystem.ts@notify', 'send DM fail', e));
});
// Update DB to indicate notifications have been sent out
dbClient.execute(queries.updateEventFlags(1, 0), [event.channelId, event.messageId]).catch((e) => utils.commonLoggers.dbError('notificationSystem.ts@notifySuccess', 'update event in', e));
return true;
} else {
if (!secondTry) loudLogFailure(bot, event, notifyStepName);
// Update DB to indicate notifications have not been sent out
dbClient.execute(queries.updateEventFlags(-1, 0), [event.channelId, event.messageId]).catch((e) => utils.commonLoggers.dbError('notificationSystem.ts@notifyFail', 'update event in', e));
return false;
}
};
// Locks the event message and notifies alternates if necessary
export const lockEvent = async (bot: Bot, event: ActiveEvent, secondTry = false): Promise<boolean> => {
const eventMessage = await bot.helpers.getMessage(event.channelId, event.messageId).catch((e: Error) => utils.commonLoggers.messageGetError('notificationSystem.ts@lock', 'get event', e));
if (eventMessage?.embeds[0].fields) {
const [currentMemberCount, maxMemberCount] = getEventMemberCount(eventMessage.embeds[0].fields[LfgEmbedIndexes.JoinedMembers].name);
const alternates = getLfgMembers(eventMessage.embeds[0].fields[LfgEmbedIndexes.AlternateMembers].value);
const memberMentionString = joinWithAnd(alternates.map((member) => `<@${member.id}>`));
// See if event was filled or not, and if not notify alternates
const alternatesNeeded = alternates.length && currentMemberCount < maxMemberCount;
if (alternatesNeeded) {
const activityName = `\`${eventMessage.embeds[0].fields[LfgEmbedIndexes.Activity].name} ${eventMessage.embeds[0].fields[LfgEmbedIndexes.Activity].value}\``;
const guildName = await getGuildName(bot, event.guildId);
const peopleShort = maxMemberCount - currentMemberCount;
// Send the notifications to the members
alternates.forEach(async (member) => {
await sendDirectMessage(bot, member.id, {
embeds: [{
color: infoColor1,
title: `Hello ${member.name}! An activity in ${guildName} may need your help.`,
description: `The ${activityName} in ${guildName} that you marked yourself as an alternate for may be \`${peopleShort}\` ${
peopleShort === 1 ? 'person' : 'people'
} short. If you are available, please join up with them.`,
}, eventMessage.embeds[0]],
}).catch((e: Error) => utils.commonLoggers.messageSendError('notificationSystem.ts@lock', 'send DM fail', e));
});
}
// Edit event in guild
await bot.helpers.editMessage(
event.channelId,
event.messageId,
alternatesNeeded
? {
content: `${eventMessage.content}\n\nAttention ${memberMentionString}, this activity is \`${maxMemberCount - currentMemberCount}\` people short. Please join up if you are available.`,
components: [],
}
: {
components: [],
},
).catch((e: Error) => utils.commonLoggers.messageEditError('notificationSystem.ts@lock', 'event edit fail', e));
// Update DB to indicate event has been locked
dbClient.execute(queries.updateEventFlags(1, 1), [event.channelId, event.messageId]).catch((e) => utils.commonLoggers.dbError('notificationSystem.ts@lockSuccess', 'update event in', e));
return true;
} else {
if (!secondTry) loudLogFailure(bot, event, lockStepName);
// Update DB to indicate event has not been locked
dbClient.execute(queries.updateEventFlags(1, -1), [event.channelId, event.messageId]).catch((e) => utils.commonLoggers.dbError('notificationSystem.ts@lockFail', 'update event in', e));
return false;
}
};
// Notifies all members of the event and edits the event message
export const deleteEvent = async (bot: Bot, event: ActiveEvent, secondTry = false): Promise<boolean> => {
const eventMessage = await bot.helpers.getMessage(event.channelId, event.messageId).catch((e: Error) => utils.commonLoggers.messageGetError('notificationSystem.ts@delete', 'get event', e));
if (eventMessage?.embeds[0].fields) {
// Delete event in guild
await bot.helpers.deleteMessage(event.channelId, event.messageId, 'Cleaning up activity that has started').catch((e: Error) =>
utils.commonLoggers.messageDeleteError('notificationSystem.ts@delete', 'event delete fail', e)
);
// Remove event from DB
dbClient.execute(queries.deleteEvent, [event.channelId, event.messageId]).catch((e) => utils.commonLoggers.dbError('notificationSystem.ts@deleteSuccess', 'delete event from', e));
return true;
} else {
if (!secondTry) loudLogFailure(bot, event, deleteStepName);
// Update DB to indicate delete failed
dbClient.execute(queries.updateEventFlags(-1, -1), [event.channelId, event.messageId]).catch((e) => utils.commonLoggers.dbError('notificationSystem.ts@deleteFail', 'update event in', e));
return false;
}
};
// Handles trying again once and cleaning up events that died
export const handleFailures = async (bot: Bot, event: ActiveEvent) => {
let rerunSuccess: boolean;
let stepName: string;
// Retry the step that failed
if (event.notifiedFlag === -1 && event.lockedFlag === -1) {
rerunSuccess = await deleteEvent(bot, event, true);
stepName = deleteStepName;
} else if (event.lockedFlag === -1) {
rerunSuccess = await lockEvent(bot, event, true);
stepName = lockStepName;
} else if (event.notifiedFlag === -1) {
rerunSuccess = await notifyEventMembers(bot, event, true);
stepName = notifyStepName;
} else {
// Should never get here as this func should only be called when event has one of the flags as -1
// Set flag to true since it already succeeded?
rerunSuccess = true;
stepName = '';
}
if (!rerunSuccess) {
// Failed at completing a step! Event may have been deleted?
loudLogFailure(bot, event, stepName, true);
dbClient.execute(queries.deleteEvent, [event.channelId, event.messageId]).catch((e) => utils.commonLoggers.dbError('notificationSystem.ts@handleFailures', 'delete event from', e));
}
};

View File

@ -1,22 +0,0 @@
export const determineTZ = (tz: string, userOverride = false): [string, boolean] => {
tz = tz.toUpperCase();
let overrode = false;
const shortHandUSTZ = (tz === 'ET' || tz === 'CT' || tz === 'MT' || tz === 'PT');
const fullUSTZ = (tz.length === 3 && (tz.startsWith('E') || tz.startsWith('C') || tz.startsWith('M') || tz.startsWith('P')) && (tz.endsWith('DT') || tz.endsWith('ST')));
if (!userOverride && (shortHandUSTZ || fullUSTZ)) {
const today = new Date();
const jan = new Date(today.getFullYear(), 0, 1);
const jul = new Date(today.getFullYear(), 6, 1);
if (today.getTimezoneOffset() < Math.max(jan.getTimezoneOffset(), jul.getTimezoneOffset())) {
if (tz.includes('S')) overrode = true;
tz = `${tz.substring(0, 1)}DT`;
} else {
if (tz.includes('D')) overrode = true;
tz = `${tz.substring(0, 1)}ST`;
}
}
return [tz, overrode];
};

55
src/types/commandTypes.ts Normal file
View File

@ -0,0 +1,55 @@
import { ApplicationCommandOption, ApplicationCommandTypes, PermissionStrings } from '../../deps.ts';
export type CommandDetails = {
name: string;
description: string;
type: ApplicationCommandTypes;
options?: ApplicationCommandOption[];
dmPermission?: boolean;
defaultMemberPermissions?: PermissionStrings[];
};
export type Command = {
details: CommandDetails;
execute: Function;
};
export type Button = {
customId: string;
execute: Function;
};
export type LfgChannelSetting = {
managed: boolean;
managerRoleId: bigint;
logChannelId: bigint;
};
export type DBGuildSettings = {
guildId: bigint;
lfgChannelId: bigint;
managerRoleId: bigint;
logChannelId: bigint;
};
export type LFGMember = {
id: bigint;
name: string;
joined?: boolean;
};
export type UrlIds = {
guildId: bigint;
channelId: bigint;
messageId: bigint;
};
export type ActiveEvent = {
messageId: bigint;
channelId: bigint;
guildId: bigint;
ownerId: bigint;
eventTime: Date;
notifiedFlag: number;
lockedFlag: number;
};

View File

@ -1,12 +1,57 @@
export const jsonParseBig = (input: string) => {
return JSON.parse(input, (_key, value) => {
if (typeof value === 'string' && /^\d+n$/.test(value)) {
return BigInt(value.substring(0, value.length - 1));
}
return value;
});
};
import { CreateMessage, Interaction, log, LT, Message } from '../deps.ts';
import { UrlIds } from './types/commandTypes.ts';
export const jsonStringifyBig = (input: any) => {
const jsonStringifyBig = (input: any) => {
return JSON.stringify(input, (_key, value) => typeof value === 'bigint' ? value.toString() + 'n' : value);
};
// Get/Generate Discord Message URL
const idsToMessageUrl = (ids: UrlIds) => `https://discord.com/channels/${ids.guildId}/${ids.channelId}/${ids.messageId}`;
const messageUrlToIds = (url: string): UrlIds => {
url = url.toLowerCase();
const [guildId, channelId, messageId] = (url.split('channels/')[1] || '').split('/');
return {
guildId: BigInt(guildId || '0'),
channelId: BigInt(channelId || '0'),
messageId: BigInt(messageId || '0'),
};
};
const capitalizeFirstChar = (input: string) => `${input.charAt(0).toUpperCase()}${input.slice(1)}`;
const interactionSendError = (location: string, interaction: Interaction | string, err: Error) =>
log(LT.ERROR, `${location} | Failed to respond to interaction: ${jsonStringifyBig(interaction)} | Error: ${err.name} - ${err.message}`);
const messageEditError = (location: string, message: Message | string, err: Error) =>
log(LT.ERROR, `${location} | Failed to edit message: ${jsonStringifyBig(message)} | Error: ${err.name} - ${err.message}`);
const messageGetError = (location: string, message: Message | string, err: Error) =>
log(LT.ERROR, `${location} | Failed to get message: ${jsonStringifyBig(message)} | Error: ${err.name} - ${err.message}`);
const messageSendError = (location: string, message: Message | CreateMessage | string, err: Error) =>
log(LT.ERROR, `${location} | Failed to send message: ${jsonStringifyBig(message)} | Error: ${err.name} - ${err.message}`);
const messageDeleteError = (location: string, message: Message | string, err: Error) =>
log(LT.ERROR, `${location} | Failed to delete message: ${jsonStringifyBig(message)} | Error: ${err.name} - ${err.message}`);
const reactionAddError = (location: string, message: Message | string, err: Error, emoji: string) =>
log(LT.ERROR, `${location} | Failed to add emoji (${emoji}) to message: ${jsonStringifyBig(message)} | Error: ${err.name} - ${err.message}`);
const reactionDeleteError = (location: string, message: Message | string, err: Error, emoji: string) =>
log(LT.ERROR, `${location} | Failed to delete emoji (${emoji}) from message: ${jsonStringifyBig(message)} | Error: ${err.name} - ${err.message}`);
const channelUpdateError = (location: string, message: string, err: Error) => log(LT.ERROR, `${location} | Failed to update channel | ${message} | Error: ${err.name} - ${err.message}`);
const dbError = (location: string, type: string, err: Error) => log(LT.ERROR, `${location} | Failed to ${type} database | Error: ${err.name} - ${err.message}`);
export default {
capitalizeFirstChar,
commonLoggers: {
channelUpdateError,
dbError,
interactionSendError,
messageGetError,
messageEditError,
messageSendError,
messageDeleteError,
reactionAddError,
reactionDeleteError,
},
jsonStringifyBig,
messageUrlToIds,
idsToMessageUrl,
};

View File

@ -1 +1 @@
deno run --allow-write=./logs --location=https://groupup.local --allow-net .\mod.ts
deno run --allow-write=./logs --allow-net .\mod.ts

22
tzData/README.md Normal file
View File

@ -0,0 +1,22 @@
# TZ Data
Since the JS/TS Date implementation does not handle shorthand timezones, we must implement our own solution.
## Data Source
https://en.wikipedia.org/wiki/List_of_tz_database_time_zones?useskin=vector
## How to use this submodule
Simply point Excel to the link above and tell it to rip the `List` data table. Then, pull the four columns listed below into a separate table and save as a CSV without headers.
- UTC offset STD
- UTC offset DST
- Time Zone abbreviation STD
- Time Zone abbreviation DST
Drop that CSV into this directory under the name `tzTable.csv` and run `deno run --allow-read=./tzTable.csv ./generateUpdatedTZList.ts`
## Why not have this built directly into Group Up?
This is implemented as a submodule so that there is some manual screening required to verify things do not get parsed wrong due to a bad Excel/Wikipedia export. This also may require tweaks if any formats change in Wikipedia.
## Current quirks
- Most, if not all, `-` signs copied out as `?`. Don't ask me how or why, it just happened. The script fixes this.
- Due to doubled TZ abbrs, there is an overrides section. This should be cleared out when `tzTable.csv` is updated.

View File

@ -0,0 +1,56 @@
// Get file and inits
const csvTZDataU8 = Deno.readFileSync('./tzTable.csv');
const csvTZData = new TextDecoder().decode(csvTZDataU8);
const csvRows = csvTZData.split('\r\n');
const tzMap: Map<string, string> = new Map();
// Overrides because the world had to be special
const tzOverrides: Array<Array<string>> = [
['CDT', '-05:00'],
['CST', '-06:00'],
['PST', '-08:00'],
['IST', '+05:30'],
];
const abbrOverrides: Array<string> = tzOverrides.map(tzSet => tzSet[0]);
// Prefill the map
for (const override of tzOverrides) {
tzMap.set(override[0], override[1]);
}
// Attempt to add tz to the map
const attemptAdd = (tzAbbr: string, tzOffset: string) => {
if (!abbrOverrides.includes(tzAbbr)) {
if (tzMap.has(tzAbbr) && tzMap.get(tzAbbr) !== tzOffset) {
console.error(`DOUBLED TZ ABBR WITH DIFF OFFSETS: ${tzAbbr} | ${tzOffset} | ${tzMap.get(tzAbbr)}`)
} else {
if (!tzAbbr.includes('+') && !tzAbbr.includes('-')) {
tzMap.set(tzAbbr, tzOffset);
}
}
}
};
// Get each TZ from the csv
for (const row of csvRows) {
const [rawSTDOffset, rawDSTOffset, rawSTDAbbr, rawDSTAbbr] = row.replaceAll('?', '-').toUpperCase().split(',');
const STDOffset = (rawSTDOffset || '');
const DSTOffset = (rawDSTOffset || '');
const STDAbbr = (rawSTDAbbr || '');
const DSTAbbr = (rawDSTAbbr || '');
attemptAdd(STDAbbr, STDOffset);
if (STDAbbr !== DSTAbbr) {
attemptAdd(DSTAbbr, DSTOffset);
}
}
// Log it out to copy to source
const tzIt = tzMap.entries();
let tzVal = tzIt.next()
while (!tzVal.done) {
if (tzVal.value[0]) {
console.log(`['${tzVal.value[0]}','${tzVal.value[1]}'],`);
}
tzVal = tzIt.next();
}

597
tzData/tzTable.csv Normal file
View File

@ -0,0 +1,597 @@
+00:00,+00:00,GMT,GMT
+00:00,+00:00,GMT,GMT
+03:00,+03:00,EAT,EAT
+01:00,+01:00,CET,CET
+03:00,+03:00,EAT,EAT
+03:00,+03:00,EAT,EAT
+00:00,+00:00,GMT,GMT
+01:00,+01:00,WAT,WAT
+00:00,+00:00,GMT,GMT
+00:00,+00:00,GMT,GMT
+02:00,+02:00,CAT,CAT
+01:00,+01:00,WAT,WAT
+02:00,+02:00,CAT,CAT
+02:00,+02:00,EET,EET
+01:00,+00:00,+01,+00
+01:00,+02:00,CET,CEST
+00:00,+00:00,GMT,GMT
+00:00,+00:00,GMT,GMT
+03:00,+03:00,EAT,EAT
+03:00,+03:00,EAT,EAT
+01:00,+01:00,WAT,WAT
+01:00,+00:00,+01,+00
+00:00,+00:00,GMT,GMT
+02:00,+02:00,CAT,CAT
+02:00,+02:00,CAT,CAT
+02:00,+02:00,SAST,SAST
+02:00,+02:00,CAT,CAT
+03:00,+03:00,EAT,EAT
+02:00,+02:00,CAT,CAT
+02:00,+02:00,CAT,CAT
+01:00,+01:00,WAT,WAT
+01:00,+01:00,WAT,WAT
+01:00,+01:00,WAT,WAT
+00:00,+00:00,GMT,GMT
+01:00,+01:00,WAT,WAT
+02:00,+02:00,CAT,CAT
+02:00,+02:00,CAT,CAT
+01:00,+01:00,WAT,WAT
+02:00,+02:00,CAT,CAT
+02:00,+02:00,SAST,SAST
+02:00,+02:00,SAST,SAST
+03:00,+03:00,EAT,EAT
+00:00,+00:00,GMT,GMT
+03:00,+03:00,EAT,EAT
+01:00,+01:00,WAT,WAT
+01:00,+01:00,WAT,WAT
+00:00,+00:00,GMT,GMT
+00:00,+00:00,GMT,GMT
+01:00,+01:00,WAT,WAT
+00:00,+00:00,GMT,GMT
+00:00,+00:00,GMT,GMT
+02:00,+02:00,EET,EET
+01:00,+01:00,CET,CET
+02:00,+02:00,CAT,CAT
?10:00,?09:00,HST,HDT
?09:00,?08:00,AKST,AKDT
?04:00,?04:00,AST,AST
?04:00,?04:00,AST,AST
?03:00,?03:00,-03,-03
?03:00,?03:00,-03,-03
?03:00,?03:00,-03,-03
?03:00,?03:00,-03,-03
?03:00,?03:00,-03,-03
?03:00,?03:00,-03,-03
?03:00,?03:00,-03,-03
?03:00,?03:00,-03,-03
?03:00,?03:00,-03,-03
?03:00,?03:00,-03,-03
?03:00,?03:00,-03,-03
?03:00,?03:00,-03,-03
?03:00,?03:00,-03,-03
?03:00,?03:00,-03,-03
?04:00,?04:00,AST,AST
?04:00,?03:00,-04,-03
?05:00,?05:00,EST,EST
?10:00,?09:00,HST,HDT
?03:00,?03:00,-03,-03
?06:00,?06:00,CST,CST
?04:00,?04:00,AST,AST
?03:00,?03:00,-03,-03
?06:00,?06:00,CST,CST
?04:00,?04:00,AST,AST
?04:00,?04:00,-04,-04
?05:00,?05:00,-05,-05
?07:00,?06:00,MST,MDT
?03:00,?03:00,-03,-03
?07:00,?06:00,MST,MDT
?04:00,?04:00,-04,-04
?05:00,?05:00,EST,EST
?04:00,?04:00,-04,-04
?03:00,?03:00,-03,-03
?03:00,?03:00,-03,-03
?05:00,?05:00,EST,EST
?06:00,?05:00,CST,CDT
?06:00,?06:00,CST,CST
?07:00,?06:00,MST,MDT
?05:00,?05:00,EST,EST
?03:00,?03:00,-03,-03
?06:00,?06:00,CST,CST
?07:00,?07:00,MST,MST
?04:00,?04:00,-04,-04
?04:00,?04:00,AST,AST
+00:00,+00:00,GMT,GMT
?07:00,?07:00,MST,MST
?07:00,?07:00,MST,MST
?07:00,?06:00,MST,MDT
?05:00,?04:00,EST,EDT
?04:00,?04:00,AST,AST
?07:00,?06:00,MST,MDT
?05:00,?05:00,-05,-05
?06:00,?06:00,CST,CST
?08:00,?07:00,PST,PDT
?07:00,?07:00,MST,MST
?05:00,?04:00,EST,EDT
?03:00,?03:00,-03,-03
?04:00,?03:00,AST,ADT
?03:00,?03:00,-03,-03
?04:00,?03:00,AST,ADT
?05:00,?04:00,EST,EDT
?04:00,?04:00,AST,AST
?04:00,?04:00,AST,AST
?06:00,?06:00,CST,CST
?05:00,?05:00,-05,-05
?04:00,?04:00,-04,-04
?04:00,?03:00,AST,ADT
?05:00,?04:00,CST,CDT
?07:00,?07:00,MST,MST
?05:00,?04:00,EST,EDT
?06:00,?05:00,CST,CDT
?05:00,?04:00,EST,EDT
?05:00,?04:00,EST,EDT
?06:00,?05:00,CST,CDT
?05:00,?04:00,EST,EDT
?05:00,?04:00,EST,EDT
?05:00,?04:00,EST,EDT
?05:00,?04:00,EST,EDT
?07:00,?06:00,MST,MDT
?05:00,?04:00,EST,EDT
?05:00,?05:00,EST,EST
?03:00,?03:00,-03,-03
?09:00,?08:00,AKST,AKDT
?05:00,?04:00,EST,EDT
?05:00,?04:00,EST,EDT
?06:00,?05:00,CST,CDT
?04:00,?04:00,AST,AST
?04:00,?04:00,-04,-04
?05:00,?05:00,-05,-05
?08:00,?07:00,PST,PDT
?05:00,?04:00,EST,EDT
?04:00,?04:00,AST,AST
?03:00,?03:00,-03,-03
?06:00,?06:00,CST,CST
?04:00,?04:00,-04,-04
?04:00,?04:00,AST,AST
?04:00,?04:00,AST,AST
?06:00,?05:00,CST,CDT
?07:00,?07:00,MST,MST
?03:00,?03:00,-03,-03
?06:00,?05:00,CST,CDT
?06:00,?06:00,CST,CST
?09:00,?08:00,AKST,AKDT
?06:00,?06:00,CST,CST
?03:00,?02:00,-03,-02
?04:00,?03:00,AST,ADT
?06:00,?06:00,CST,CST
?03:00,?03:00,-03,-03
?05:00,?04:00,EST,EDT
?04:00,?04:00,AST,AST
?05:00,?04:00,EST,EDT
?05:00,?04:00,EST,EDT
?05:00,?04:00,EST,EDT
?09:00,?08:00,AKST,AKDT
?02:00,?02:00,-02,-02
?06:00,?05:00,CST,CDT
?06:00,?05:00,CST,CDT
?06:00,?05:00,CST,CDT
?03:00,?03:00,-03,-03
?06:00,?05:00,CST,CDT
?05:00,?05:00,EST,EST
?05:00,?04:00,EST,EDT
?03:00,?03:00,-03,-03
?07:00,?07:00,MST,MST
?05:00,?04:00,EST,EDT
?04:00,?04:00,AST,AST
?05:00,?05:00,-05,-05
?04:00,?04:00,-04,-04
?04:00,?04:00,AST,AST
?03:00,?03:00,-03,-03
?06:00,?05:00,CST,CDT
?06:00,?05:00,CST,CDT
?03:00,?03:00,-03,-03
?06:00,?06:00,CST,CST
?06:00,?05:00,CST,CDT
?05:00,?05:00,-05,-05
?03:00,?03:00,-03,-03
?08:00,?07:00,PST,PDT
?03:00,?03:00,-03,-03
?04:00,?03:00,-04,-03
?04:00,?04:00,AST,AST
?03:00,?03:00,-03,-03
?01:00,+00:00,-01,+00
?07:00,?06:00,MST,MDT
?09:00,?08:00,AKST,AKDT
?04:00,?04:00,AST,AST
?03:30,?02:30,NST,NDT
?04:00,?04:00,AST,AST
?04:00,?04:00,AST,AST
?04:00,?04:00,AST,AST
?04:00,?04:00,AST,AST
?06:00,?06:00,CST,CST
?06:00,?06:00,CST,CST
?04:00,?03:00,AST,ADT
?05:00,?04:00,EST,EDT
?08:00,?07:00,PST,PDT
?05:00,?04:00,EST,EDT
?04:00,?04:00,AST,AST
?08:00,?07:00,PST,PDT
?04:00,?04:00,AST,AST
?07:00,?07:00,MST,MST
?06:00,?05:00,CST,CDT
?09:00,?08:00,AKST,AKDT
?07:00,?06:00,MST,MDT
+11:00,+11:00,+11,+11
+07:00,+07:00,+07,+07
+10:00,+10:00,+10,+10
+10:00,+11:00,AEST,AEDT
+05:00,+05:00,+05,+05
+12:00,+13:00,NZST,NZDT
?03:00,?03:00,-03,-03
?03:00,?03:00,-03,-03
+12:00,+13:00,NZST,NZDT
+03:00,+03:00,+03,+03
+00:00,+02:00,+00,+02
+06:00,+06:00,+06,+06
+01:00,+02:00,CET,CEST
+03:00,+03:00,+03,+03
+06:00,+06:00,+06,+06
+03:00,+03:00,+03,+03
+12:00,+12:00,+12,+12
+05:00,+05:00,+05,+05
+05:00,+05:00,+05,+05
+05:00,+05:00,+05,+05
+05:00,+05:00,+05,+05
+05:00,+05:00,+05,+05
+03:00,+03:00,+03,+03
+03:00,+03:00,+03,+03
+04:00,+04:00,+04,+04
+07:00,+07:00,+07,+07
+07:00,+07:00,+07,+07
+02:00,+03:00,EET,EEST
+06:00,+06:00,+06,+06
+08:00,+08:00,+08,+08
+05:30,+05:30,IST,IST
+09:00,+09:00,+09,+09
+08:00,+08:00,+08,+08
+08:00,+08:00,CST,CST
+08:00,+08:00,CST,CST
+05:30,+05:30,+0530,+0530
+06:00,+06:00,+06,+06
+03:00,+03:00,+03,+03
+06:00,+06:00,+06,+06
+09:00,+09:00,+09,+09
+04:00,+04:00,+04,+04
+05:00,+05:00,+05,+05
+02:00,+03:00,EET,EEST
+02:00,+03:00,EET,EEST
+08:00,+08:00,CST,CST
+02:00,+03:00,EET,EEST
+07:00,+07:00,+07,+07
+08:00,+08:00,HKT,HKT
+07:00,+07:00,+07,+07
+08:00,+08:00,+08,+08
+03:00,+03:00,+03,+03
+07:00,+07:00,WIB,WIB
+09:00,+09:00,WIT,WIT
+02:00,+03:00,IST,IDT
+04:30,+04:30,+0430,+0430
+12:00,+12:00,+12,+12
+05:00,+05:00,PKT,PKT
+06:00,+06:00,+06,+06
+05:45,+05:45,+0545,+0545
+05:45,+05:45,+0545,+0545
+09:00,+09:00,+09,+09
+05:30,+05:30,IST,IST
+07:00,+07:00,+07,+07
+08:00,+08:00,+08,+08
+08:00,+08:00,+08,+08
+03:00,+03:00,+03,+03
+08:00,+08:00,CST,CST
+08:00,+08:00,CST,CST
+11:00,+11:00,+11,+11
+08:00,+08:00,WITA,WITA
+08:00,+08:00,PST,PST
+04:00,+04:00,+04,+04
+02:00,+03:00,EET,EEST
+07:00,+07:00,+07,+07
+07:00,+07:00,+07,+07
+06:00,+06:00,+06,+06
+05:00,+05:00,+05,+05
+07:00,+07:00,+07,+07
+07:00,+07:00,WIB,WIB
+09:00,+09:00,KST,KST
+03:00,+03:00,+03,+03
+06:00,+06:00,+06,+06
+05:00,+05:00,+05,+05
+06:30,+06:30,+0630,+0630
+03:00,+03:00,+03,+03
+07:00,+07:00,+07,+07
+11:00,+11:00,+11,+11
+05:00,+05:00,+05,+05
+09:00,+09:00,KST,KST
+08:00,+08:00,CST,CST
+08:00,+08:00,+08,+08
+11:00,+11:00,+11,+11
+08:00,+08:00,CST,CST
+05:00,+05:00,+05,+05
+04:00,+04:00,+04,+04
+03:30,+03:30,+0330,+0330
+02:00,+03:00,IST,IDT
+06:00,+06:00,+06,+06
+06:00,+06:00,+06,+06
+09:00,+09:00,JST,JST
+07:00,+07:00,+07,+07
+08:00,+08:00,WITA,WITA
+08:00,+08:00,+08,+08
+08:00,+08:00,+08,+08
+06:00,+06:00,+06,+06
+10:00,+10:00,+10,+10
+07:00,+07:00,+07,+07
+10:00,+10:00,+10,+10
+09:00,+09:00,+09,+09
+06:30,+06:30,+0630,+0630
+05:00,+05:00,+05,+05
+04:00,+04:00,+04,+04
?01:00,+00:00,-01,+00
?04:00,?03:00,AST,ADT
+00:00,+01:00,WET,WEST
?01:00,?01:00,-01,-01
+00:00,+01:00,WET,WEST
+00:00,+01:00,WET,WEST
+01:00,+02:00,CET,CEST
+00:00,+01:00,WET,WEST
+00:00,+00:00,GMT,GMT
?02:00,?02:00,-02,-02
+00:00,+00:00,GMT,GMT
?03:00,?03:00,-03,-03
+10:00,+11:00,AEST,AEDT
+09:30,+10:30,ACST,ACDT
+10:00,+10:00,AEST,AEST
+09:30,+10:30,ACST,ACDT
+10:00,+11:00,AEST,AEDT
+10:00,+11:00,AEST,AEDT
+09:30,+09:30,ACST,ACST
+08:45,+08:45,+0845,+0845
+10:00,+11:00,AEST,AEDT
+10:30,+11:00,+1030,+11
+10:00,+10:00,AEST,AEST
+10:30,+11:00,+1030,+11
+10:00,+11:00,AEST,AEDT
+09:30,+09:30,ACST,ACST
+10:00,+11:00,AEST,AEDT
+08:00,+08:00,AWST,AWST
+10:00,+10:00,AEST,AEST
+09:30,+10:30,ACST,ACDT
+10:00,+11:00,AEST,AEDT
+10:00,+11:00,AEST,AEDT
+10:00,+11:00,AEST,AEDT
+08:00,+08:00,AWST,AWST
+09:30,+10:30,ACST,ACDT
?05:00,?05:00,-05,-05
?02:00,?02:00,-02,-02
?03:00,?03:00,-03,-03
?04:00,?04:00,-04,-04
?04:00,?03:00,AST,ADT
?06:00,?05:00,CST,CDT
?05:00,?04:00,EST,EDT
?07:00,?06:00,MST,MDT
?03:30,?02:30,NST,NDT
?08:00,?07:00,PST,PDT
?06:00,?06:00,CST,CST
?07:00,?07:00,MST,MST
+01:00,+02:00,CET,CEST
?04:00,?03:00,-04,-03
?06:00,?05:00,-06,-05
?06:00,?05:00,CST,CDT
?05:00,?04:00,CST,CDT
+02:00,+03:00,EET,EEST
+02:00,+02:00,EET,EET
+01:00,+00:00,IST,GMT
?05:00,?05:00,EST,EST
?05:00,?04:00,EST,EDT
+00:00,+00:00,GMT,GMT
+00:00,+00:00,GMT,GMT
?01:00,?01:00,-01,-01
?10:00,?10:00,-10,-10
?11:00,?11:00,-11,-11
?12:00,?12:00,-12,-12
?02:00,?02:00,-02,-02
?03:00,?03:00,-03,-03
?04:00,?04:00,-04,-04
?05:00,?05:00,-05,-05
?06:00,?06:00,-06,-06
?07:00,?07:00,-07,-07
?08:00,?08:00,-08,-08
?09:00,?09:00,-09,-09
+00:00,+00:00,GMT,GMT
+01:00,+01:00,+01,+01
+10:00,+10:00,+10,+10
+11:00,+11:00,+11,+11
+12:00,+12:00,+12,+12
+13:00,+13:00,+13,+13
+14:00,+14:00,+14,+14
+02:00,+02:00,+02,+02
+03:00,+03:00,+03,+03
+04:00,+04:00,+04,+04
+05:00,+05:00,+05,+05
+06:00,+06:00,+06,+06
+07:00,+07:00,+07,+07
+08:00,+08:00,+08,+08
+09:00,+09:00,+09,+09
+00:00,+00:00,GMT,GMT
+00:00,+00:00,GMT,GMT
+00:00,+00:00,UTC,UTC
+00:00,+00:00,UTC,UTC
+00:00,+00:00,UTC,UTC
+00:00,+00:00,UTC,UTC
+01:00,+02:00,CET,CEST
+01:00,+02:00,CET,CEST
+04:00,+04:00,+04,+04
+02:00,+03:00,EET,EEST
+00:00,+01:00,GMT,BST
+01:00,+02:00,CET,CEST
+01:00,+02:00,CET,CEST
+01:00,+02:00,CET,CEST
+01:00,+02:00,CET,CEST
+02:00,+03:00,EET,EEST
+01:00,+02:00,CET,CEST
+01:00,+02:00,CET,CEST
+02:00,+03:00,EET,EEST
+01:00,+02:00,CET,CEST
+01:00,+00:00,IST,GMT
+01:00,+02:00,CET,CEST
+00:00,+01:00,GMT,BST
+02:00,+03:00,EET,EEST
+00:00,+01:00,GMT,BST
+03:00,+03:00,+03,+03
+00:00,+01:00,GMT,BST
+02:00,+02:00,EET,EET
+02:00,+03:00,EET,EEST
+03:00,+03:00,+03,+03
+02:00,+03:00,EET,EEST
+00:00,+01:00,WET,WEST
+01:00,+02:00,CET,CEST
+00:00,+01:00,GMT,BST
+01:00,+02:00,CET,CEST
+01:00,+02:00,CET,CEST
+01:00,+02:00,CET,CEST
+02:00,+03:00,EET,EEST
+03:00,+03:00,+03,+03
+01:00,+02:00,CET,CEST
+03:00,+03:00,MSK,MSK
+02:00,+03:00,EET,EEST
+01:00,+02:00,CET,CEST
+01:00,+02:00,CET,CEST
+01:00,+02:00,CET,CEST
+01:00,+02:00,CET,CEST
+02:00,+03:00,EET,EEST
+01:00,+02:00,CET,CEST
+04:00,+04:00,+04,+04
+01:00,+02:00,CET,CEST
+01:00,+02:00,CET,CEST
+04:00,+04:00,+04,+04
+03:00,+03:00,MSK,MSK
+01:00,+02:00,CET,CEST
+02:00,+03:00,EET,EEST
+01:00,+02:00,CET,CEST
+02:00,+03:00,EET,EEST
+01:00,+02:00,CET,CEST
+02:00,+03:00,EET,EEST
+04:00,+04:00,+04,+04
+02:00,+03:00,EET,EEST
+01:00,+02:00,CET,CEST
+01:00,+02:00,CET,CEST
+01:00,+02:00,CET,CEST
+02:00,+03:00,EET,EEST
+03:00,+03:00,+03,+03
+01:00,+02:00,CET,CEST
+01:00,+02:00,CET,CEST
+02:00,+03:00,EET,EEST
+01:00,+02:00,CET,CEST
+00:00,+00:00,-00,-00
+00:00,+01:00,GMT,BST
+00:00,+01:00,GMT,BST
+00:00,+00:00,GMT,GMT
+00:00,+00:00,GMT,GMT
+00:00,+00:00,GMT,GMT
+00:00,+00:00,GMT,GMT
+00:00,+00:00,GMT,GMT
+08:00,+08:00,HKT,HKT
?10:00,?10:00,HST,HST
+00:00,+00:00,GMT,GMT
+03:00,+03:00,EAT,EAT
+06:00,+06:00,+06,+06
+07:00,+07:00,+07,+07
+06:30,+06:30,+0630,+0630
+03:00,+03:00,EAT,EAT
+05:00,+05:00,+05,+05
+04:00,+04:00,+04,+04
+05:00,+05:00,+05,+05
+04:00,+04:00,+04,+04
+03:00,+03:00,EAT,EAT
+04:00,+04:00,+04,+04
+03:30,+03:30,+0330,+0330
+02:00,+03:00,IST,IDT
?05:00,?05:00,EST,EST
+09:00,+09:00,JST,JST
+12:00,+12:00,+12,+12
+02:00,+02:00,EET,EET
+01:00,+02:00,MET,MEST
?08:00,?07:00,PST,PDT
?07:00,?07:00,MST,MST
?06:00,?06:00,CST,CST
?07:00,?07:00,MST,MST
?07:00,?06:00,MST,MDT
?07:00,?06:00,MST,MDT
+12:00,+13:00,NZST,NZDT
+12:45,+13:45,+1245,+1345
+13:00,+13:00,+13,+13
+12:00,+13:00,NZST,NZDT
+11:00,+11:00,+11,+11
+12:45,+13:45,+1245,+1345
+10:00,+10:00,+10,+10
?06:00,?05:00,-06,-05
+11:00,+11:00,+11,+11
+13:00,+13:00,+13,+13
+13:00,+13:00,+13,+13
+12:00,+12:00,+12,+12
+12:00,+12:00,+12,+12
?06:00,?06:00,-06,-06
?09:00,?09:00,-09,-09
+11:00,+11:00,+11,+11
+10:00,+10:00,ChST,ChST
?10:00,?10:00,HST,HST
?10:00,?10:00,HST,HST
+13:00,+13:00,+13,+13
+14:00,+14:00,+14,+14
+11:00,+11:00,+11,+11
+12:00,+12:00,+12,+12
+12:00,+12:00,+12,+12
?09:30,?09:30,-0930,-0930
?11:00,?11:00,SST,SST
+12:00,+12:00,+12,+12
?11:00,?11:00,-11,-11
+11:00,+12:00,+11,+12
+11:00,+11:00,+11,+11
?11:00,?11:00,SST,SST
+09:00,+09:00,+09,+09
?08:00,?08:00,-08,-08
+11:00,+11:00,+11,+11
+11:00,+11:00,+11,+11
+10:00,+10:00,+10,+10
?10:00,?10:00,-10,-10
+10:00,+10:00,ChST,ChST
?11:00,?11:00,SST,SST
?10:00,?10:00,-10,-10
+12:00,+12:00,+12,+12
+13:00,+13:00,+13,+13
+10:00,+10:00,+10,+10
+12:00,+12:00,+12,+12
+12:00,+12:00,+12,+12
+10:00,+10:00,+10,+10
+01:00,+02:00,CET,CEST
+00:00,+01:00,WET,WEST
+08:00,+08:00,CST,CST
?08:00,?07:00,PST,PDT
+08:00,+08:00,CST,CST
+09:00,+09:00,KST,KST
+08:00,+08:00,+08,+08
+03:00,+03:00,+03,+03
+00:00,+00:00,UTC,UTC
+00:00,+00:00,UTC,UTC
?09:00,?08:00,AKST,AKDT
?10:00,?09:00,HST,HDT
?07:00,?07:00,MST,MST
?06:00,?05:00,CST,CDT
?05:00,?04:00,EST,EDT
?05:00,?04:00,EST,EDT
?10:00,?10:00,HST,HST
?06:00,?05:00,CST,CDT
?05:00,?04:00,EST,EDT
?07:00,?06:00,MST,MDT
?08:00,?07:00,PST,PDT
?11:00,?11:00,SST,SST
+00:00,+00:00,UTC,UTC
+03:00,+03:00,MSK,MSK
+00:00,+01:00,WET,WEST
+00:00,+00:00,UTC,UTC
1 +00:00 +00:00 GMT GMT
2 +00:00 +00:00 GMT GMT
3 +03:00 +03:00 EAT EAT
4 +01:00 +01:00 CET CET
5 +03:00 +03:00 EAT EAT
6 +03:00 +03:00 EAT EAT
7 +00:00 +00:00 GMT GMT
8 +01:00 +01:00 WAT WAT
9 +00:00 +00:00 GMT GMT
10 +00:00 +00:00 GMT GMT
11 +02:00 +02:00 CAT CAT
12 +01:00 +01:00 WAT WAT
13 +02:00 +02:00 CAT CAT
14 +02:00 +02:00 EET EET
15 +01:00 +00:00 +01 +00
16 +01:00 +02:00 CET CEST
17 +00:00 +00:00 GMT GMT
18 +00:00 +00:00 GMT GMT
19 +03:00 +03:00 EAT EAT
20 +03:00 +03:00 EAT EAT
21 +01:00 +01:00 WAT WAT
22 +01:00 +00:00 +01 +00
23 +00:00 +00:00 GMT GMT
24 +02:00 +02:00 CAT CAT
25 +02:00 +02:00 CAT CAT
26 +02:00 +02:00 SAST SAST
27 +02:00 +02:00 CAT CAT
28 +03:00 +03:00 EAT EAT
29 +02:00 +02:00 CAT CAT
30 +02:00 +02:00 CAT CAT
31 +01:00 +01:00 WAT WAT
32 +01:00 +01:00 WAT WAT
33 +01:00 +01:00 WAT WAT
34 +00:00 +00:00 GMT GMT
35 +01:00 +01:00 WAT WAT
36 +02:00 +02:00 CAT CAT
37 +02:00 +02:00 CAT CAT
38 +01:00 +01:00 WAT WAT
39 +02:00 +02:00 CAT CAT
40 +02:00 +02:00 SAST SAST
41 +02:00 +02:00 SAST SAST
42 +03:00 +03:00 EAT EAT
43 +00:00 +00:00 GMT GMT
44 +03:00 +03:00 EAT EAT
45 +01:00 +01:00 WAT WAT
46 +01:00 +01:00 WAT WAT
47 +00:00 +00:00 GMT GMT
48 +00:00 +00:00 GMT GMT
49 +01:00 +01:00 WAT WAT
50 +00:00 +00:00 GMT GMT
51 +00:00 +00:00 GMT GMT
52 +02:00 +02:00 EET EET
53 +01:00 +01:00 CET CET
54 +02:00 +02:00 CAT CAT
55 ?10:00 ?09:00 HST HDT
56 ?09:00 ?08:00 AKST AKDT
57 ?04:00 ?04:00 AST AST
58 ?04:00 ?04:00 AST AST
59 ?03:00 ?03:00 -03 -03
60 ?03:00 ?03:00 -03 -03
61 ?03:00 ?03:00 -03 -03
62 ?03:00 ?03:00 -03 -03
63 ?03:00 ?03:00 -03 -03
64 ?03:00 ?03:00 -03 -03
65 ?03:00 ?03:00 -03 -03
66 ?03:00 ?03:00 -03 -03
67 ?03:00 ?03:00 -03 -03
68 ?03:00 ?03:00 -03 -03
69 ?03:00 ?03:00 -03 -03
70 ?03:00 ?03:00 -03 -03
71 ?03:00 ?03:00 -03 -03
72 ?03:00 ?03:00 -03 -03
73 ?04:00 ?04:00 AST AST
74 ?04:00 ?03:00 -04 -03
75 ?05:00 ?05:00 EST EST
76 ?10:00 ?09:00 HST HDT
77 ?03:00 ?03:00 -03 -03
78 ?06:00 ?06:00 CST CST
79 ?04:00 ?04:00 AST AST
80 ?03:00 ?03:00 -03 -03
81 ?06:00 ?06:00 CST CST
82 ?04:00 ?04:00 AST AST
83 ?04:00 ?04:00 -04 -04
84 ?05:00 ?05:00 -05 -05
85 ?07:00 ?06:00 MST MDT
86 ?03:00 ?03:00 -03 -03
87 ?07:00 ?06:00 MST MDT
88 ?04:00 ?04:00 -04 -04
89 ?05:00 ?05:00 EST EST
90 ?04:00 ?04:00 -04 -04
91 ?03:00 ?03:00 -03 -03
92 ?03:00 ?03:00 -03 -03
93 ?05:00 ?05:00 EST EST
94 ?06:00 ?05:00 CST CDT
95 ?06:00 ?06:00 CST CST
96 ?07:00 ?06:00 MST MDT
97 ?05:00 ?05:00 EST EST
98 ?03:00 ?03:00 -03 -03
99 ?06:00 ?06:00 CST CST
100 ?07:00 ?07:00 MST MST
101 ?04:00 ?04:00 -04 -04
102 ?04:00 ?04:00 AST AST
103 +00:00 +00:00 GMT GMT
104 ?07:00 ?07:00 MST MST
105 ?07:00 ?07:00 MST MST
106 ?07:00 ?06:00 MST MDT
107 ?05:00 ?04:00 EST EDT
108 ?04:00 ?04:00 AST AST
109 ?07:00 ?06:00 MST MDT
110 ?05:00 ?05:00 -05 -05
111 ?06:00 ?06:00 CST CST
112 ?08:00 ?07:00 PST PDT
113 ?07:00 ?07:00 MST MST
114 ?05:00 ?04:00 EST EDT
115 ?03:00 ?03:00 -03 -03
116 ?04:00 ?03:00 AST ADT
117 ?03:00 ?03:00 -03 -03
118 ?04:00 ?03:00 AST ADT
119 ?05:00 ?04:00 EST EDT
120 ?04:00 ?04:00 AST AST
121 ?04:00 ?04:00 AST AST
122 ?06:00 ?06:00 CST CST
123 ?05:00 ?05:00 -05 -05
124 ?04:00 ?04:00 -04 -04
125 ?04:00 ?03:00 AST ADT
126 ?05:00 ?04:00 CST CDT
127 ?07:00 ?07:00 MST MST
128 ?05:00 ?04:00 EST EDT
129 ?06:00 ?05:00 CST CDT
130 ?05:00 ?04:00 EST EDT
131 ?05:00 ?04:00 EST EDT
132 ?06:00 ?05:00 CST CDT
133 ?05:00 ?04:00 EST EDT
134 ?05:00 ?04:00 EST EDT
135 ?05:00 ?04:00 EST EDT
136 ?05:00 ?04:00 EST EDT
137 ?07:00 ?06:00 MST MDT
138 ?05:00 ?04:00 EST EDT
139 ?05:00 ?05:00 EST EST
140 ?03:00 ?03:00 -03 -03
141 ?09:00 ?08:00 AKST AKDT
142 ?05:00 ?04:00 EST EDT
143 ?05:00 ?04:00 EST EDT
144 ?06:00 ?05:00 CST CDT
145 ?04:00 ?04:00 AST AST
146 ?04:00 ?04:00 -04 -04
147 ?05:00 ?05:00 -05 -05
148 ?08:00 ?07:00 PST PDT
149 ?05:00 ?04:00 EST EDT
150 ?04:00 ?04:00 AST AST
151 ?03:00 ?03:00 -03 -03
152 ?06:00 ?06:00 CST CST
153 ?04:00 ?04:00 -04 -04
154 ?04:00 ?04:00 AST AST
155 ?04:00 ?04:00 AST AST
156 ?06:00 ?05:00 CST CDT
157 ?07:00 ?07:00 MST MST
158 ?03:00 ?03:00 -03 -03
159 ?06:00 ?05:00 CST CDT
160 ?06:00 ?06:00 CST CST
161 ?09:00 ?08:00 AKST AKDT
162 ?06:00 ?06:00 CST CST
163 ?03:00 ?02:00 -03 -02
164 ?04:00 ?03:00 AST ADT
165 ?06:00 ?06:00 CST CST
166 ?03:00 ?03:00 -03 -03
167 ?05:00 ?04:00 EST EDT
168 ?04:00 ?04:00 AST AST
169 ?05:00 ?04:00 EST EDT
170 ?05:00 ?04:00 EST EDT
171 ?05:00 ?04:00 EST EDT
172 ?09:00 ?08:00 AKST AKDT
173 ?02:00 ?02:00 -02 -02
174 ?06:00 ?05:00 CST CDT
175 ?06:00 ?05:00 CST CDT
176 ?06:00 ?05:00 CST CDT
177 ?03:00 ?03:00 -03 -03
178 ?06:00 ?05:00 CST CDT
179 ?05:00 ?05:00 EST EST
180 ?05:00 ?04:00 EST EDT
181 ?03:00 ?03:00 -03 -03
182 ?07:00 ?07:00 MST MST
183 ?05:00 ?04:00 EST EDT
184 ?04:00 ?04:00 AST AST
185 ?05:00 ?05:00 -05 -05
186 ?04:00 ?04:00 -04 -04
187 ?04:00 ?04:00 AST AST
188 ?03:00 ?03:00 -03 -03
189 ?06:00 ?05:00 CST CDT
190 ?06:00 ?05:00 CST CDT
191 ?03:00 ?03:00 -03 -03
192 ?06:00 ?06:00 CST CST
193 ?06:00 ?05:00 CST CDT
194 ?05:00 ?05:00 -05 -05
195 ?03:00 ?03:00 -03 -03
196 ?08:00 ?07:00 PST PDT
197 ?03:00 ?03:00 -03 -03
198 ?04:00 ?03:00 -04 -03
199 ?04:00 ?04:00 AST AST
200 ?03:00 ?03:00 -03 -03
201 ?01:00 +00:00 -01 +00
202 ?07:00 ?06:00 MST MDT
203 ?09:00 ?08:00 AKST AKDT
204 ?04:00 ?04:00 AST AST
205 ?03:30 ?02:30 NST NDT
206 ?04:00 ?04:00 AST AST
207 ?04:00 ?04:00 AST AST
208 ?04:00 ?04:00 AST AST
209 ?04:00 ?04:00 AST AST
210 ?06:00 ?06:00 CST CST
211 ?06:00 ?06:00 CST CST
212 ?04:00 ?03:00 AST ADT
213 ?05:00 ?04:00 EST EDT
214 ?08:00 ?07:00 PST PDT
215 ?05:00 ?04:00 EST EDT
216 ?04:00 ?04:00 AST AST
217 ?08:00 ?07:00 PST PDT
218 ?04:00 ?04:00 AST AST
219 ?07:00 ?07:00 MST MST
220 ?06:00 ?05:00 CST CDT
221 ?09:00 ?08:00 AKST AKDT
222 ?07:00 ?06:00 MST MDT
223 +11:00 +11:00 +11 +11
224 +07:00 +07:00 +07 +07
225 +10:00 +10:00 +10 +10
226 +10:00 +11:00 AEST AEDT
227 +05:00 +05:00 +05 +05
228 +12:00 +13:00 NZST NZDT
229 ?03:00 ?03:00 -03 -03
230 ?03:00 ?03:00 -03 -03
231 +12:00 +13:00 NZST NZDT
232 +03:00 +03:00 +03 +03
233 +00:00 +02:00 +00 +02
234 +06:00 +06:00 +06 +06
235 +01:00 +02:00 CET CEST
236 +03:00 +03:00 +03 +03
237 +06:00 +06:00 +06 +06
238 +03:00 +03:00 +03 +03
239 +12:00 +12:00 +12 +12
240 +05:00 +05:00 +05 +05
241 +05:00 +05:00 +05 +05
242 +05:00 +05:00 +05 +05
243 +05:00 +05:00 +05 +05
244 +05:00 +05:00 +05 +05
245 +03:00 +03:00 +03 +03
246 +03:00 +03:00 +03 +03
247 +04:00 +04:00 +04 +04
248 +07:00 +07:00 +07 +07
249 +07:00 +07:00 +07 +07
250 +02:00 +03:00 EET EEST
251 +06:00 +06:00 +06 +06
252 +08:00 +08:00 +08 +08
253 +05:30 +05:30 IST IST
254 +09:00 +09:00 +09 +09
255 +08:00 +08:00 +08 +08
256 +08:00 +08:00 CST CST
257 +08:00 +08:00 CST CST
258 +05:30 +05:30 +0530 +0530
259 +06:00 +06:00 +06 +06
260 +03:00 +03:00 +03 +03
261 +06:00 +06:00 +06 +06
262 +09:00 +09:00 +09 +09
263 +04:00 +04:00 +04 +04
264 +05:00 +05:00 +05 +05
265 +02:00 +03:00 EET EEST
266 +02:00 +03:00 EET EEST
267 +08:00 +08:00 CST CST
268 +02:00 +03:00 EET EEST
269 +07:00 +07:00 +07 +07
270 +08:00 +08:00 HKT HKT
271 +07:00 +07:00 +07 +07
272 +08:00 +08:00 +08 +08
273 +03:00 +03:00 +03 +03
274 +07:00 +07:00 WIB WIB
275 +09:00 +09:00 WIT WIT
276 +02:00 +03:00 IST IDT
277 +04:30 +04:30 +0430 +0430
278 +12:00 +12:00 +12 +12
279 +05:00 +05:00 PKT PKT
280 +06:00 +06:00 +06 +06
281 +05:45 +05:45 +0545 +0545
282 +05:45 +05:45 +0545 +0545
283 +09:00 +09:00 +09 +09
284 +05:30 +05:30 IST IST
285 +07:00 +07:00 +07 +07
286 +08:00 +08:00 +08 +08
287 +08:00 +08:00 +08 +08
288 +03:00 +03:00 +03 +03
289 +08:00 +08:00 CST CST
290 +08:00 +08:00 CST CST
291 +11:00 +11:00 +11 +11
292 +08:00 +08:00 WITA WITA
293 +08:00 +08:00 PST PST
294 +04:00 +04:00 +04 +04
295 +02:00 +03:00 EET EEST
296 +07:00 +07:00 +07 +07
297 +07:00 +07:00 +07 +07
298 +06:00 +06:00 +06 +06
299 +05:00 +05:00 +05 +05
300 +07:00 +07:00 +07 +07
301 +07:00 +07:00 WIB WIB
302 +09:00 +09:00 KST KST
303 +03:00 +03:00 +03 +03
304 +06:00 +06:00 +06 +06
305 +05:00 +05:00 +05 +05
306 +06:30 +06:30 +0630 +0630
307 +03:00 +03:00 +03 +03
308 +07:00 +07:00 +07 +07
309 +11:00 +11:00 +11 +11
310 +05:00 +05:00 +05 +05
311 +09:00 +09:00 KST KST
312 +08:00 +08:00 CST CST
313 +08:00 +08:00 +08 +08
314 +11:00 +11:00 +11 +11
315 +08:00 +08:00 CST CST
316 +05:00 +05:00 +05 +05
317 +04:00 +04:00 +04 +04
318 +03:30 +03:30 +0330 +0330
319 +02:00 +03:00 IST IDT
320 +06:00 +06:00 +06 +06
321 +06:00 +06:00 +06 +06
322 +09:00 +09:00 JST JST
323 +07:00 +07:00 +07 +07
324 +08:00 +08:00 WITA WITA
325 +08:00 +08:00 +08 +08
326 +08:00 +08:00 +08 +08
327 +06:00 +06:00 +06 +06
328 +10:00 +10:00 +10 +10
329 +07:00 +07:00 +07 +07
330 +10:00 +10:00 +10 +10
331 +09:00 +09:00 +09 +09
332 +06:30 +06:30 +0630 +0630
333 +05:00 +05:00 +05 +05
334 +04:00 +04:00 +04 +04
335 ?01:00 +00:00 -01 +00
336 ?04:00 ?03:00 AST ADT
337 +00:00 +01:00 WET WEST
338 ?01:00 ?01:00 -01 -01
339 +00:00 +01:00 WET WEST
340 +00:00 +01:00 WET WEST
341 +01:00 +02:00 CET CEST
342 +00:00 +01:00 WET WEST
343 +00:00 +00:00 GMT GMT
344 ?02:00 ?02:00 -02 -02
345 +00:00 +00:00 GMT GMT
346 ?03:00 ?03:00 -03 -03
347 +10:00 +11:00 AEST AEDT
348 +09:30 +10:30 ACST ACDT
349 +10:00 +10:00 AEST AEST
350 +09:30 +10:30 ACST ACDT
351 +10:00 +11:00 AEST AEDT
352 +10:00 +11:00 AEST AEDT
353 +09:30 +09:30 ACST ACST
354 +08:45 +08:45 +0845 +0845
355 +10:00 +11:00 AEST AEDT
356 +10:30 +11:00 +1030 +11
357 +10:00 +10:00 AEST AEST
358 +10:30 +11:00 +1030 +11
359 +10:00 +11:00 AEST AEDT
360 +09:30 +09:30 ACST ACST
361 +10:00 +11:00 AEST AEDT
362 +08:00 +08:00 AWST AWST
363 +10:00 +10:00 AEST AEST
364 +09:30 +10:30 ACST ACDT
365 +10:00 +11:00 AEST AEDT
366 +10:00 +11:00 AEST AEDT
367 +10:00 +11:00 AEST AEDT
368 +08:00 +08:00 AWST AWST
369 +09:30 +10:30 ACST ACDT
370 ?05:00 ?05:00 -05 -05
371 ?02:00 ?02:00 -02 -02
372 ?03:00 ?03:00 -03 -03
373 ?04:00 ?04:00 -04 -04
374 ?04:00 ?03:00 AST ADT
375 ?06:00 ?05:00 CST CDT
376 ?05:00 ?04:00 EST EDT
377 ?07:00 ?06:00 MST MDT
378 ?03:30 ?02:30 NST NDT
379 ?08:00 ?07:00 PST PDT
380 ?06:00 ?06:00 CST CST
381 ?07:00 ?07:00 MST MST
382 +01:00 +02:00 CET CEST
383 ?04:00 ?03:00 -04 -03
384 ?06:00 ?05:00 -06 -05
385 ?06:00 ?05:00 CST CDT
386 ?05:00 ?04:00 CST CDT
387 +02:00 +03:00 EET EEST
388 +02:00 +02:00 EET EET
389 +01:00 +00:00 IST GMT
390 ?05:00 ?05:00 EST EST
391 ?05:00 ?04:00 EST EDT
392 +00:00 +00:00 GMT GMT
393 +00:00 +00:00 GMT GMT
394 ?01:00 ?01:00 -01 -01
395 ?10:00 ?10:00 -10 -10
396 ?11:00 ?11:00 -11 -11
397 ?12:00 ?12:00 -12 -12
398 ?02:00 ?02:00 -02 -02
399 ?03:00 ?03:00 -03 -03
400 ?04:00 ?04:00 -04 -04
401 ?05:00 ?05:00 -05 -05
402 ?06:00 ?06:00 -06 -06
403 ?07:00 ?07:00 -07 -07
404 ?08:00 ?08:00 -08 -08
405 ?09:00 ?09:00 -09 -09
406 +00:00 +00:00 GMT GMT
407 +01:00 +01:00 +01 +01
408 +10:00 +10:00 +10 +10
409 +11:00 +11:00 +11 +11
410 +12:00 +12:00 +12 +12
411 +13:00 +13:00 +13 +13
412 +14:00 +14:00 +14 +14
413 +02:00 +02:00 +02 +02
414 +03:00 +03:00 +03 +03
415 +04:00 +04:00 +04 +04
416 +05:00 +05:00 +05 +05
417 +06:00 +06:00 +06 +06
418 +07:00 +07:00 +07 +07
419 +08:00 +08:00 +08 +08
420 +09:00 +09:00 +09 +09
421 +00:00 +00:00 GMT GMT
422 +00:00 +00:00 GMT GMT
423 +00:00 +00:00 UTC UTC
424 +00:00 +00:00 UTC UTC
425 +00:00 +00:00 UTC UTC
426 +00:00 +00:00 UTC UTC
427 +01:00 +02:00 CET CEST
428 +01:00 +02:00 CET CEST
429 +04:00 +04:00 +04 +04
430 +02:00 +03:00 EET EEST
431 +00:00 +01:00 GMT BST
432 +01:00 +02:00 CET CEST
433 +01:00 +02:00 CET CEST
434 +01:00 +02:00 CET CEST
435 +01:00 +02:00 CET CEST
436 +02:00 +03:00 EET EEST
437 +01:00 +02:00 CET CEST
438 +01:00 +02:00 CET CEST
439 +02:00 +03:00 EET EEST
440 +01:00 +02:00 CET CEST
441 +01:00 +00:00 IST GMT
442 +01:00 +02:00 CET CEST
443 +00:00 +01:00 GMT BST
444 +02:00 +03:00 EET EEST
445 +00:00 +01:00 GMT BST
446 +03:00 +03:00 +03 +03
447 +00:00 +01:00 GMT BST
448 +02:00 +02:00 EET EET
449 +02:00 +03:00 EET EEST
450 +03:00 +03:00 +03 +03
451 +02:00 +03:00 EET EEST
452 +00:00 +01:00 WET WEST
453 +01:00 +02:00 CET CEST
454 +00:00 +01:00 GMT BST
455 +01:00 +02:00 CET CEST
456 +01:00 +02:00 CET CEST
457 +01:00 +02:00 CET CEST
458 +02:00 +03:00 EET EEST
459 +03:00 +03:00 +03 +03
460 +01:00 +02:00 CET CEST
461 +03:00 +03:00 MSK MSK
462 +02:00 +03:00 EET EEST
463 +01:00 +02:00 CET CEST
464 +01:00 +02:00 CET CEST
465 +01:00 +02:00 CET CEST
466 +01:00 +02:00 CET CEST
467 +02:00 +03:00 EET EEST
468 +01:00 +02:00 CET CEST
469 +04:00 +04:00 +04 +04
470 +01:00 +02:00 CET CEST
471 +01:00 +02:00 CET CEST
472 +04:00 +04:00 +04 +04
473 +03:00 +03:00 MSK MSK
474 +01:00 +02:00 CET CEST
475 +02:00 +03:00 EET EEST
476 +01:00 +02:00 CET CEST
477 +02:00 +03:00 EET EEST
478 +01:00 +02:00 CET CEST
479 +02:00 +03:00 EET EEST
480 +04:00 +04:00 +04 +04
481 +02:00 +03:00 EET EEST
482 +01:00 +02:00 CET CEST
483 +01:00 +02:00 CET CEST
484 +01:00 +02:00 CET CEST
485 +02:00 +03:00 EET EEST
486 +03:00 +03:00 +03 +03
487 +01:00 +02:00 CET CEST
488 +01:00 +02:00 CET CEST
489 +02:00 +03:00 EET EEST
490 +01:00 +02:00 CET CEST
491 +00:00 +00:00 -00 -00
492 +00:00 +01:00 GMT BST
493 +00:00 +01:00 GMT BST
494 +00:00 +00:00 GMT GMT
495 +00:00 +00:00 GMT GMT
496 +00:00 +00:00 GMT GMT
497 +00:00 +00:00 GMT GMT
498 +00:00 +00:00 GMT GMT
499 +08:00 +08:00 HKT HKT
500 ?10:00 ?10:00 HST HST
501 +00:00 +00:00 GMT GMT
502 +03:00 +03:00 EAT EAT
503 +06:00 +06:00 +06 +06
504 +07:00 +07:00 +07 +07
505 +06:30 +06:30 +0630 +0630
506 +03:00 +03:00 EAT EAT
507 +05:00 +05:00 +05 +05
508 +04:00 +04:00 +04 +04
509 +05:00 +05:00 +05 +05
510 +04:00 +04:00 +04 +04
511 +03:00 +03:00 EAT EAT
512 +04:00 +04:00 +04 +04
513 +03:30 +03:30 +0330 +0330
514 +02:00 +03:00 IST IDT
515 ?05:00 ?05:00 EST EST
516 +09:00 +09:00 JST JST
517 +12:00 +12:00 +12 +12
518 +02:00 +02:00 EET EET
519 +01:00 +02:00 MET MEST
520 ?08:00 ?07:00 PST PDT
521 ?07:00 ?07:00 MST MST
522 ?06:00 ?06:00 CST CST
523 ?07:00 ?07:00 MST MST
524 ?07:00 ?06:00 MST MDT
525 ?07:00 ?06:00 MST MDT
526 +12:00 +13:00 NZST NZDT
527 +12:45 +13:45 +1245 +1345
528 +13:00 +13:00 +13 +13
529 +12:00 +13:00 NZST NZDT
530 +11:00 +11:00 +11 +11
531 +12:45 +13:45 +1245 +1345
532 +10:00 +10:00 +10 +10
533 ?06:00 ?05:00 -06 -05
534 +11:00 +11:00 +11 +11
535 +13:00 +13:00 +13 +13
536 +13:00 +13:00 +13 +13
537 +12:00 +12:00 +12 +12
538 +12:00 +12:00 +12 +12
539 ?06:00 ?06:00 -06 -06
540 ?09:00 ?09:00 -09 -09
541 +11:00 +11:00 +11 +11
542 +10:00 +10:00 ChST ChST
543 ?10:00 ?10:00 HST HST
544 ?10:00 ?10:00 HST HST
545 +13:00 +13:00 +13 +13
546 +14:00 +14:00 +14 +14
547 +11:00 +11:00 +11 +11
548 +12:00 +12:00 +12 +12
549 +12:00 +12:00 +12 +12
550 ?09:30 ?09:30 -0930 -0930
551 ?11:00 ?11:00 SST SST
552 +12:00 +12:00 +12 +12
553 ?11:00 ?11:00 -11 -11
554 +11:00 +12:00 +11 +12
555 +11:00 +11:00 +11 +11
556 ?11:00 ?11:00 SST SST
557 +09:00 +09:00 +09 +09
558 ?08:00 ?08:00 -08 -08
559 +11:00 +11:00 +11 +11
560 +11:00 +11:00 +11 +11
561 +10:00 +10:00 +10 +10
562 ?10:00 ?10:00 -10 -10
563 +10:00 +10:00 ChST ChST
564 ?11:00 ?11:00 SST SST
565 ?10:00 ?10:00 -10 -10
566 +12:00 +12:00 +12 +12
567 +13:00 +13:00 +13 +13
568 +10:00 +10:00 +10 +10
569 +12:00 +12:00 +12 +12
570 +12:00 +12:00 +12 +12
571 +10:00 +10:00 +10 +10
572 +01:00 +02:00 CET CEST
573 +00:00 +01:00 WET WEST
574 +08:00 +08:00 CST CST
575 ?08:00 ?07:00 PST PDT
576 +08:00 +08:00 CST CST
577 +09:00 +09:00 KST KST
578 +08:00 +08:00 +08 +08
579 +03:00 +03:00 +03 +03
580 +00:00 +00:00 UTC UTC
581 +00:00 +00:00 UTC UTC
582 ?09:00 ?08:00 AKST AKDT
583 ?10:00 ?09:00 HST HDT
584 ?07:00 ?07:00 MST MST
585 ?06:00 ?05:00 CST CDT
586 ?05:00 ?04:00 EST EDT
587 ?05:00 ?04:00 EST EDT
588 ?10:00 ?10:00 HST HST
589 ?06:00 ?05:00 CST CDT
590 ?05:00 ?04:00 EST EDT
591 ?07:00 ?06:00 MST MDT
592 ?08:00 ?07:00 PST PDT
593 ?11:00 ?11:00 SST SST
594 +00:00 +00:00 UTC UTC
595 +03:00 +03:00 MSK MSK
596 +00:00 +01:00 WET WEST
597 +00:00 +00:00 UTC UTC

BIN
www/GroupUpSinglePerson.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@ -64,6 +64,18 @@
<li>Done! You can now view the event in your calendar</li>
</ol>
</p>
<p>
<h2>Mozilla Thunderbird:</h2>
<ol>
<li>Press Alt</li>
<li>Click on File</li>
<li>Hover on Open</li>
<li>Click on Calendar File...</li>
<li>Locate the ICS you just downloaded (if you didn't save it, click the link again and save it)</li>
<li>Click Open</li>
<li>Done! You can now view the event in your calendar</li>
</ol>
</p>
</div>
<script>
if (window.location.search) {