diff --git a/Types.ts b/Types.ts index 219b664..9da9a64 100644 --- a/Types.ts +++ b/Types.ts @@ -85,5 +85,9 @@ export type Config = { leaderboardCooldown: number, commandChannels?: any }, - vcPing?: any + vcPing?: any, + activePing?: { + timeout: number, + roles: any + } } \ No newline at end of file diff --git a/config.json.example b/config.json.example index 939a707..6d25365 100644 --- a/config.json.example +++ b/config.json.example @@ -26,5 +26,12 @@ "vcPing": { "guild1": "role1", "guild2": "role2" + }, + "activePing": { + "timeout": 900000, + "roles": { + "guild1": "role1", + "guild2": "role2" + } } } diff --git a/helpers/active.ts b/helpers/active.ts new file mode 100644 index 0000000..e74a2cc --- /dev/null +++ b/helpers/active.ts @@ -0,0 +1,97 @@ +import { Collection, GuildMember, RoleResolvable, Snowflake } from "discord.js"; +import { config, client } from ".."; +import { get, getLastUserMessageTimestamp, getLastUserReactionTimestamp, getUserEnabled, set } from "../util/levels"; + +export const activeConfig = config.activePing; +const memberActiveTimeouts = new Collection(); + +export function readyActive() { + if (!activeConfig) return; + + for (const guildId in activeConfig.roles) { + const guild = client.guilds.cache.get(guildId); + let role = guild.roles.cache.get(activeConfig.roles[guildId]); + const memberCollection = guild.members.cache; + memberCollection.forEach(async member => { + shouldHaveRole(member, role, await isMemberActive(member)); + }); + } +} + +export function setupActiveListeners() { + if (!activeConfig) return; + + client.on('message', message => memberActive(message.member)); + client.on('messageUpdate', message => memberActive(message.member)); + client.on('messageReactionAdd', (reaction, user) => { + memberActive(reaction.message.guild.members.cache.get(user.id)); + set(`${user.id}:react`, new Date().getTime()); + }); + client.on('messageReactionRemove', (reaction, user) => { + memberActive(reaction.message.guild.members.cache.get(user.id)); + set(`${user.id}:react`, new Date().getTime()); + }); + client.on('guildMemberAdd', memberUpdate); + client.on('presenceUpdate', (old, presence) => memberUpdate(presence.member)); + client.on('voiceStateUpdate', state => memberUpdate(state.member)); +} + +export function shouldHaveRole(member:GuildMember, role:RoleResolvable, shouldHaveRole:boolean) { + let r = typeof role == 'object' ? role : member.guild.roles.cache.get(role); + if (shouldHaveRole) { + if (!member.roles.cache.has(r.id)) { + console.log(`Attempting to give ${member.displayName} @${r.name}`); + member.roles.add(role).then(()=>{ + console.log(`Gave ${member.displayName} @${r.name}`); + }).catch((e)=>{ + console.warn(`Could not give ${member.displayName} @${r.name}!`, e); + }); + } + } else { + if (member.roles.cache.has(r.id)) { + console.log(`Attempting to remove @${r.name} from ${member.displayName}`); + member.roles.remove(role).then(()=>{ + console.log(`Removed @${r.name} from ${member.displayName}`); + }).catch((e)=>{ + console.warn(`Could not remove ${member.displayName} from @${r.name}!`, e); + }); + } + } +} + +async function isMemberActive(member:GuildMember) { + if (!await getUserEnabled(member)) return false; + + let online = member.presence.status == 'online'; // If their status is online + let lastMessageTimeDifference = new Date().getTime() - (await getLastUserMessageTimestamp(member.id)).getTime(); // How long ago their last message was sent + let lastReactionTimeDifference = new Date().getTime() - (await getLastUserReactionTimestamp(member.id)).getTime(); // How long ago their last reaction was + let isInVc = member.voice.channel != undefined && !member.voice.deaf; // If they're in VC and not deafened, we can probably assume they're active + + return isInVc || ( + online && ( + lastMessageTimeDifference < activeConfig.timeout || + lastReactionTimeDifference < activeConfig.timeout + ) + ); +} + +async function memberUpdate(member:GuildMember) { + if (member?.guild.id in activeConfig.roles) { + shouldHaveRole(member, activeConfig.roles[member.guild.id], await isMemberActive(member)); + } +} + +function memberActive(member:GuildMember) { + if (member?.guild.id in activeConfig.roles) { + shouldHaveRole(member, activeConfig.roles[member.guild.id], true); + if (memberActiveTimeouts.has(member.id)) { + clearTimeout(memberActiveTimeouts.get(member.id)); + memberActiveTimeouts.delete(member.id); + } + memberActiveTimeouts.set(member.id, + setTimeout(async (member:GuildMember) => { + shouldHaveRole(member, activeConfig.roles[member.guild.id], await isMemberActive(member)); + }, activeConfig.timeout, member) + ); + } +} \ No newline at end of file diff --git a/index.ts b/index.ts index b82cb0b..32fe1d9 100644 --- a/index.ts +++ b/index.ts @@ -12,6 +12,7 @@ import { readyMembers, setupMemberListeners } from "./helpers/members"; import { readyVC, setupVCListeners } from "./helpers/vc"; import { setupMessageListeners } from "./helpers/messageHandler"; import { setupReactionListeners } from "./helpers/reactionHandler"; +import { readyActive, setupActiveListeners } from "./helpers/active"; client.on('ready', ()=>{ console.log(`Logged in as ${client.user.tag}`); @@ -19,6 +20,7 @@ client.on('ready', ()=>{ readyMembers(); readyVC(); + readyActive(); }); setupMessageListeners(); @@ -26,5 +28,6 @@ setupReactionListeners(); setupVCListeners(); setupMemberListeners(); +setupActiveListeners(); client.login(config.token); diff --git a/triggers/commands/active.ts b/triggers/commands/active.ts new file mode 100644 index 0000000..18a1dd3 --- /dev/null +++ b/triggers/commands/active.ts @@ -0,0 +1,47 @@ +import { GuildMember, Message, MessageEmbed, MessageMentions } from "discord.js"; +import { client } from "../.."; +import { Command } from "../../Types"; +import { getUsage } from "../../util/commands"; +import { get, getUserEnabled, set } from "../../util/levels"; +import { getIDFromMention } from "../../util/text"; + +module.exports = { + name: 'active', + args: '[status [user]| toggle]', + description: 'Set or view your preference for the @active role...', + guildOnly: true, + minArgs: 0, + async execute(message, args) { + switch (args[0]) { + case 'status': + case undefined: + // Query the user's status + let userMatch = message.content.match(MessageMentions.USERS_PATTERN); + let member = message.member; + if (userMatch) { + member = message.guild.members.cache.get(getIDFromMention(userMatch[0])); + } + let enabled = await getUserEnabled(member); + sendStatus(message, member, enabled); + break; + + case 'toggle': + let enabledStatus = !(await getUserEnabled(message.author)); + await set(`${message.author.id}:enabled`, enabledStatus); + sendStatus(message, message.member, enabledStatus); + break; + + default: + // Throw usage + message.reply(`You must use the command like: \`${getUsage(module.exports)}\``); + } + } +} + +function sendStatus(message:Message, member:GuildMember, enabled:boolean) { + message.channel.send(new MessageEmbed() + .setAuthor(member.nickname, member.user.displayAvatarURL()) + .setColor(enabled ? '#4caf50' : '#f44336') + .setDescription(`${member}, you **will${enabled?'':' not'}** be included in \`@active\` pings`) + ); +} \ No newline at end of file diff --git a/triggers/triggers/levels.ts b/triggers/triggers/levels.ts index b9740a3..2762fc5 100644 --- a/triggers/triggers/levels.ts +++ b/triggers/triggers/levels.ts @@ -1,6 +1,6 @@ import { TriggeredCommand } from "../../Types"; import { config } from "../.."; -import { rand } from "../../util/math"; +import { rand } from "../../util/general"; import { getLevelNumber, getUserLevel, redisClient, setUserLevel } from "../../util/levels"; module.exports = { diff --git a/util/general.ts b/util/general.ts new file mode 100644 index 0000000..994fc32 --- /dev/null +++ b/util/general.ts @@ -0,0 +1,16 @@ +/** Get a random value within a range of the max and min value in range */ +export function rand(range:number[]) { + return Math.random() * ( + Math.max(...range) - Math.min(...range) + ) + Math.min(...range); +} + +/** + * Remove `item` from `array` + * `array` is modified so you don't have to reassign it + */ +export function remove(item:T, array:T[]) { + let index = array.indexOf(item); + array.splice(index, 1); + return array; +} \ No newline at end of file diff --git a/util/levels.ts b/util/levels.ts index 356d816..cc14cde 100644 --- a/util/levels.ts +++ b/util/levels.ts @@ -1,4 +1,4 @@ -import { Collection, Snowflake } from "discord.js"; +import { Collection, GuildMember, Snowflake, User } from "discord.js"; import { createClient, RedisClient } from "redis"; import { promisify } from "util"; import { client, config } from ".."; @@ -27,6 +27,38 @@ export async function getUserLevel(uid:Snowflake):Promise { return data; } +/** + * Query the databse for the time of a user's last message + * @param uid the ID of the user to query for + * @param failover the value to return if database misses, `Date(0)` if not specified + */ +export async function getLastUserMessageTimestamp(uid:Snowflake, failover = new Date(0)) { + if (!redisClient) return failover; + let lastTimestamp = await get(`${uid}:last`); + return lastTimestamp ? new Date(parseInt(lastTimestamp)) : failover; +} + +/** + * Query the databse for the time of a user's last reaction + * @param uid the ID of the user to query for + * @param failover the value to return if database misses, `Date(0)` if not specified + */ +export async function getLastUserReactionTimestamp(uid:Snowflake, failover = new Date(0)) { + if (!redisClient) return failover; + let lastTimestamp = await get(`${uid}:react`); + return lastTimestamp ? new Date(parseInt(lastTimestamp)) : failover; +} + +/** + * Query the database for whether the active role is enabled for the user + * @param user the user to search for + */ +export async function getUserEnabled({id}:User|GuildMember) { + let enabledStatus = await get(`${id}:enabled`); + // TODO: make opt-in/out a config option (server-by-server?) + return enabledStatus == null || enabledStatus == 'true'; // Opt-out: `isEnabled == null ||`; opt-in: `isEnabled &&` +} + export function getLevelNumber(xp:number) { return Math.max(Math.floor(Math.log2(xp / 10)), 0); } diff --git a/util/math.ts b/util/math.ts deleted file mode 100644 index 5074493..0000000 --- a/util/math.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** Get a random value within a range of the max and min value in range */ -export function rand(range:number[]) { - return Math.random() * ( - Math.max(...range) - Math.min(...range) - ) + Math.min(...range); -} \ No newline at end of file diff --git a/util/text.ts b/util/text.ts index 701aea4..b0540c0 100644 --- a/util/text.ts +++ b/util/text.ts @@ -1,7 +1,5 @@ import { Snowflake } from "discord.js"; -import { type } from "os"; -import { client } from ".."; -import { rand } from "./math"; +import { rand } from "./general"; export const getTextAfter = (fragment:string, main:string) => main.substr(main.indexOf(fragment) + fragment.length);