Skip to content

Commit

Permalink
Merge pull request #414 from uwcsc/388-ping-office
Browse files Browse the repository at this point in the history
DM "Office Ping" users when office is open
  • Loading branch information
SaurontheMighty authored Dec 5, 2022
2 parents bfa4fa5 + 5928071 commit 166e0fb
Show file tree
Hide file tree
Showing 9 changed files with 129 additions and 72 deletions.
1 change: 1 addition & 0 deletions config/production/vars.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"TARGET_GUILD_ID": "667823274201448469",
"COFFEE_ROLE_ID": "936899047816589354",
"OFFICE_PING_ROLE_ID": "1031379039614677082",
"NOTIF_CHANNEL_ID": "872305316522508329",
"ANNOUNCEMENTS_CHANNEL_ID": "669294029804011540",
"OFFICE_STATUS_CHANNEL_ID": "997926133083422740",
Expand Down
1 change: 1 addition & 0 deletions config/staging/vars.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"TARGET_GUILD_ID": "701335585272627200",
"COFFEE_ROLE_ID": "861744590775779358",
"OFFICE_PING_ROLE_ID": "1031378676077572188",
"NOTIF_CHANNEL_ID": "866861080301797386",
"ANNOUNCEMENTS_CHANNEL_ID": "1014739389206777867",
"OFFICE_STATUS_CHANNEL_ID": "866861080301797386",
Expand Down
1 change: 1 addition & 0 deletions config/vars.template.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"TARGET_GUILD_ID": "GUILD_ID",
"COFFEE_ROLE_ID": "ROLE_ID",
"OFFICE_PING_ROLE_ID": "ROLE_ID",
"NOTIF_CHANNEL_ID": "CHANNEL_ID",
"ANNOUNCEMENTS_CHANNEL_ID": "CHANNEL_ID",
"OFFICE_STATUS_CHANNEL_ID": "CHANNEL_ID",
Expand Down
48 changes: 3 additions & 45 deletions src/commands/coffeeChat/coffeeChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ import {
SubCommandPluginCommand,
SubCommandPluginCommandOptions,
} from '@sapphire/plugin-subcommands';
import { Message, MessageEmbed, User } from 'discord.js';
import { Message, MessageEmbed } from 'discord.js';
import { getMatch, testPerformance, writeHistoricMatches } from '../../components/coffeeChat';
import { logger } from '../../logger/default';
import { DEFAULT_EMBED_COLOUR } from '../../utils/embeds';
import { alertMatches } from '../../components/coffeeChat';

@ApplyOptions<SubCommandPluginCommandOptions>({
aliases: ['coffee'],
Expand All @@ -21,52 +21,10 @@ import { DEFAULT_EMBED_COLOUR } from '../../utils/embeds';
requiredUserPermissions: ['ADMINISTRATOR'],
})
export class CoffeeChatCommand extends SubCommandPluginCommand {
static async alertMatches(matches: string[][]): Promise<void> {
const { client } = container;
const outputMap: Map<string, string[]> = new Map();
const userMap: Map<string, User> = new Map();
//map them to find what to send a specific person
for (const pair of matches) {
if (!outputMap.get(pair[0])) {
outputMap.set(pair[0], []);
userMap.set(pair[0], await client.users.fetch(pair[0]));
}
if (!outputMap.get(pair[1])) {
outputMap.set(pair[1], []);
userMap.set(pair[1], await client.users.fetch(pair[1]));
}
outputMap.get(pair[0])!.push(pair[1]);
outputMap.get(pair[1])!.push(pair[0]);
}
//send out messages
outputMap.forEach(async (targets, user) => {
const discordUser = userMap.get(user)!;
//we use raw discord id ping format to minimize fetch numbers on our end
const userTargets = targets.map((value) => userMap.get(value)!);
try {
if (targets.length > 1) {
await discordUser.send(
`Your coffee chat :coffee: matches for this week are... **${userTargets[0].tag}** and **${userTargets[1].tag}**! Feel free to contact ${userTargets[0]} and ${userTargets[1]} at your earliest convenience. :wink: If you have any suggestions, please use the suggestion feature to give us feedback!`,
);
} else {
await discordUser.send(
`Your coffee chat :coffee: match for this week is... **${userTargets[0].tag}**! Feel free to contact ${userTargets[0]} at your earliest convenience. :wink: If you have any suggestions, please use the .suggestion feature to give us feedback!`,
);
}
} catch (err) {
logger.error({
event: 'client_error',
where: 'coffeeChat alertMatches',
});
logger.error(err);
}
});
}

async match(message: Message): Promise<Message> {
//makes sure future matches are valid (made for the current group / still has matches left)
const matches = await getMatch();
await CoffeeChatCommand.alertMatches(matches);
await alertMatches(matches);
await writeHistoricMatches(matches);
return message.reply(`Sent ${matches.length} match(es).`);
}
Expand Down
74 changes: 51 additions & 23 deletions src/components/coffeeChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ import _ from 'lodash';
import { Person, stableMarriage } from 'stable-marriage';
import { vars } from '../config';
import { openDB } from './db';
import { loadRoleUsers } from '../utils/roles';
import { User } from 'discord.js';
import { sendMessage } from '../utils/dm';

const COFFEE_ROLE_ID: string = vars.COFFEE_ROLE_ID;
const TARGET_GUILD_ID: string = vars.TARGET_GUILD_ID;
//since we might fully hit hundreds of people if we release this into the wider server, set iterations at around 100-200 to keep time at a reasonable number
//averages around 90% for test sizes 10-20, 75% for test sizes 100-200 people
const RANDOM_ITERATIONS = 100;
Expand All @@ -22,31 +24,22 @@ interface historic_match {
* Returns a potential single chatter in the single field, null otherwise
*/
export const getMatch = async (): Promise<string[][]> => {
//get list of users and their historic chat history
const userList = await loadCoffeeChatUsers();
const matched = await loadMatched(userList);
//generate one week of matches, and updates match freq tables accordingly
const matches = stableMatch(userList, matched);
return matches;
};
// Gets the list of users that are currently "enrolled" in role
const userList = await loadRoleUsers(COFFEE_ROLE_ID);

/*
* Gets the list of users that are currently "enrolled" in coffee chat
* Returns a mapping of string -> int, where string is their ID, while int is an index assigned to the ID
* The index is used in place of the ID for match tallying
*/
const loadCoffeeChatUsers = async (): Promise<Map<string, number>> => {
const { client } = container;
//gets list of users with the coffee chat role
const userList = (await (await client.guilds.fetch(TARGET_GUILD_ID)).members.fetch())
?.filter((member) => member.roles.cache.has(COFFEE_ROLE_ID))
.map((member) => member.user.id);
//assigns each user ID a unique index
// Assigns each user ID a unique index
const notMatched: Map<string, number> = new Map();
userList.forEach((val: string, index: number) => {
notMatched.set(val, index);

// Returns a mapping of string -> int, where string is their ID, while int is an index assigned to the ID
// The index is used in place of the ID for match tallying
userList.forEach((val: User, index: number) => {
notMatched.set(val.id, index);
});
return notMatched;

const matched = await loadMatched(notMatched);
//generate one week of matches, and updates match freq tables accordingly
const matches = stableMatch(notMatched, matched);
return matches;
};

/*
Expand Down Expand Up @@ -249,6 +242,41 @@ export const testPerformance = async (testSize: number): Promise<Map<string, num
return output;
};

export const alertMatches = async (matches: string[][]): Promise<void> => {
const { client } = container;
const outputMap: Map<string, string[]> = new Map();
const userMap: Map<string, User> = new Map();
//map them to find what to send a specific person
for (const pair of matches) {
if (!outputMap.get(pair[0])) {
outputMap.set(pair[0], []);
userMap.set(pair[0], await client.users.fetch(pair[0]));
}
if (!outputMap.get(pair[1])) {
outputMap.set(pair[1], []);
userMap.set(pair[1], await client.users.fetch(pair[1]));
}
outputMap.get(pair[0])!.push(pair[1]);
outputMap.get(pair[1])!.push(pair[0]);
}
//send out messages
outputMap.forEach(async (targets, user) => {
const discordUser = userMap.get(user)!;
//we use raw discord id ping format to minimize fetch numbers on our end
const userTargets = targets.map((value) => userMap.get(value)!);

let message: string;

if (targets.length > 1) {
message = `Your coffee chat :coffee: matches for this week are... **${userTargets[0].tag}** and **${userTargets[1].tag}**! Feel free to contact ${userTargets[0]} and ${userTargets[1]} at your earliest convenience. :wink: If you have any suggestions, please use the suggestion feature to give us feedback!`;
} else {
message = `Your coffee chat :coffee: match for this week is... **${userTargets[0].tag}**! Feel free to contact ${userTargets[0]} at your earliest convenience. :wink: If you have any suggestions, please use the .suggestion feature to give us feedback!`;
}

await sendMessage(discordUser, message);
});
};

/*setting random iteration count to 1000 allows stable marriage to find the optimized matching for most cases, despite
*the random aspect of this approach.
*may want to consider a more efficient algorithm that doesn't need so many iterations in the future
Expand Down
24 changes: 21 additions & 3 deletions src/components/cron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ import { CronJob } from 'cron';
import { Client, MessageEmbed, TextChannel } from 'discord.js';
import _ from 'lodash';
import fetch from 'node-fetch';
import { CoffeeChatCommand } from '../commands/coffeeChat/coffeeChat';
import { alertMatches } from '../components/coffeeChat';
import { alertUsers } from './officeOpenDM';
import { vars } from '../config';
import { DEFAULT_EMBED_COLOUR } from '../utils/embeds';
import { getMatch, writeHistoricMatches } from './coffeeChat';
import { getMatch, writeHistoricMatches } from '../components/coffeeChat';
import { adjustCoinBalanceByUserId, BonusType, coinBonusMap } from './coin';
import { getInterviewers } from './interviewer';
import {
Expand All @@ -19,6 +20,11 @@ const NOTIF_CHANNEL_ID: string = vars.NOTIF_CHANNEL_ID;
const OFFICE_STATUS_CHANNEL_ID: string = vars.OFFICE_STATUS_CHANNEL_ID;
const OFFICE_HOURS_STATUS_API = 'https://csclub.ca/office-status/json';

// The last known status of the office
// false if closed
// true if open
let office_last_status = false;

export const initCrons = async (client: Client): Promise<void> => {
createSuggestionCron(client).start();
createBonusInterviewerListCron().start();
Expand All @@ -35,6 +41,7 @@ export const createOfficeStatusCron = (client: Client): CronJob =>
new CronJob('0 */10 * * * *', async function () {
const response = (await (await fetch(OFFICE_HOURS_STATUS_API)).json()) as officeStatus;
const messageChannel = client.channels.cache.get(OFFICE_STATUS_CHANNEL_ID);

if (!messageChannel) {
throw 'Bad channel ID';
} else if (messageChannel.type === 'GUILD_TEXT') {
Expand All @@ -50,6 +57,17 @@ export const createOfficeStatusCron = (client: Client): CronJob =>
} else {
throw 'Bad channel type';
}

if (office_last_status == false && response['status'] == 1) {
// The office was closed and is now open
// Send all the users with the "Office Ping" role a DM:
// Get all users with "Office Ping" role
await alertUsers();
office_last_status = true;
} else if (office_last_status == true && response['status'] == 0) {
// the office was open and is now closed
office_last_status = false;
}
});

// Checks for new suggestions every min
Expand Down Expand Up @@ -96,7 +114,7 @@ export const createBonusInterviewerListCron = (): CronJob =>
export const createCoffeeChatCron = (client: Client): CronJob =>
new CronJob('0 0 14 * * 5', async function () {
const matches = await getMatch();
await CoffeeChatCommand.alertMatches(matches);
await alertMatches(matches);
await writeHistoricMatches(matches);

const messageChannel = client.channels.cache.get(NOTIF_CHANNEL_ID);
Expand Down
18 changes: 18 additions & 0 deletions src/components/officeOpenDM.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { User } from 'discord.js';
import { loadRoleUsers } from '../utils/roles';
import { vars } from '../config';
import { sendMessage } from '../utils/dm';

const OFFICE_PING_ROLE_ID: string = vars.OFFICE_PING_ROLE_ID;

/* alertUsers
* loads all the users that have the "Office Ping" role using roles.ts in utils
* and sends them a message using sendMessage from dm.ts
*/
export const alertUsers = async (): Promise<void> => {
const users: User[] = await loadRoleUsers(OFFICE_PING_ROLE_ID);

users.forEach(async (user) => {
await sendMessage(user, 'The office is now open!');
});
};
14 changes: 14 additions & 0 deletions src/utils/dm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { User } from 'discord.js';
import { logger } from '../logger/default';

export const sendMessage = async (discordUser: User, message: string): Promise<void> => {
try {
await discordUser.send(message);
} catch (err) {
logger.error({
event: 'client_error',
where: 'dm sendMessage',
});
logger.error(err);
}
};
20 changes: 19 additions & 1 deletion src/utils/roles.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { ColorResolvable, GuildMember, Role, RoleManager } from 'discord.js';
import { ColorResolvable, GuildMember, Role, RoleManager, User } from 'discord.js';
import { container } from '@sapphire/framework';
import { vars } from '../config';

const TARGET_GUILD_ID: string = vars.TARGET_GUILD_ID;

export const addOrRemove = {
add: true,
Expand Down Expand Up @@ -45,3 +49,17 @@ export const updateMemberRole = async (
);
}
};

/*
* Given a role id, returns a list of users that have that role
*/
export const loadRoleUsers = async (role_id: string): Promise<User[]> => {
const { client } = container;

// fetches all the users in the server and then filters based on the role
const userList = (await (await client.guilds.fetch(TARGET_GUILD_ID)).members.fetch())
?.filter((member) => member.roles.cache.has(role_id))
.map((member) => member.user);

return userList;
};

0 comments on commit 166e0fb

Please sign in to comment.