bot.ts
1 import { createHash } from 'crypto'; 2 import Discord, { ActivityType, Events, GatewayIntentBits, Options, PermissionsBitField } from 'discord.js'; 3 4 import { 5 DBActiveTemporaryRoles, 6 DBCurrentlyActiveActivity, 7 addActivity, 8 getActivityRoles, 9 getUserCount, 10 getGuildConfig, 11 getRolesCount, 12 getStatusRoles, 13 getUserConfig, 14 prepare 15 } from './db'; 16 import config from './config'; 17 import { i18n, log } from './messages'; 18 import CommandHandler from './commandHandler'; 19 import { configureInfluxDB, writeIntPoint } from './metrics'; 20 21 export const client = new Discord.Client({ 22 intents: [ 23 GatewayIntentBits.Guilds, 24 GatewayIntentBits.GuildPresences, 25 ], 26 makeCache: Options.cacheWithLimits({ 27 ...Options.DefaultMakeCacheSettings, 28 MessageManager: 0, 29 // UserManager: { 30 // maxSize: 25000, 31 // keepOverLimit: user => user.id === user.client.user.id, 32 // }, 33 // GuildMemberManager: { 34 // maxSize: 5000, 35 // keepOverLimit: member => member.id === member.client.user.id, 36 // }, 37 // PresenceManager: 50000, 38 }), 39 sweepers: { 40 ...Options.DefaultSweeperSettings, 41 // users: { 42 // interval: 60 * 60, // in seconds, 1 hour 43 // filter: () => user => user.id !== user.client.user.id, // don’t remove the client’s user 44 // }, 45 // guildMembers: { 46 // interval: 60 * 60, 47 // filter: () => member => member.id !== member.client.user.id, 48 // }, 49 // presences: { 50 // interval: 60 * 60, 51 // filter: () => () => true, // remove all presences 52 // }, 53 }, 54 }); 55 56 export let commandHandler: CommandHandler; 57 58 59 export const stats = { 60 presenceUpdates: 0, 61 rolesAdded: 0, 62 rolesRemoved: 0, 63 webSocketErrors: 0 64 }; 65 export function resetStats() { 66 stats.presenceUpdates = 0; 67 stats.rolesAdded = 0; 68 stats.rolesRemoved = 0; 69 stats.webSocketErrors = 0; 70 } 71 72 client.on(Events.ClientReady, () => { 73 configureInfluxDB(); 74 commandHandler = new CommandHandler(client); 75 const setActivityGuilds = () => { 76 client.user?.setPresence({ 77 status: 'online', 78 afk: false, 79 activities: [ 80 { 81 name: i18n.__n({ 82 singular: '%s guild', 83 plural: '%s guilds', 84 locale: 'en-US', 85 count: client.guilds.cache.size 86 }), 87 type: ActivityType.Watching 88 } 89 ] 90 }); 91 setTimeout(setActivityUsers, 10 * 1000); 92 }; 93 const setActivityUsers = () => { 94 client.user?.setPresence({ 95 activities: [ 96 { 97 name: i18n.__n({ 98 singular: '%s user', 99 plural: '%s users', 100 locale: 'en-US', 101 count: getUserCount() 102 }), 103 type: ActivityType.Watching 104 } 105 ] 106 }); 107 setTimeout(setActivityActivityRoles, 10 * 1000); 108 }; 109 const setActivityActivityRoles = () => { 110 client.user?.setPresence({ 111 activities: [ 112 { 113 name: i18n.__n({ 114 singular: '%s role', 115 plural: '%s roles', 116 locale: 'en-US', 117 count: getRolesCount() 118 }), 119 type: ActivityType.Watching 120 } 121 ] 122 }); 123 setTimeout(setActivityGuilds, 10 * 1000); 124 }; 125 setActivityGuilds(); 126 127 log.info( 128 `Logged in as ${client.user?.username}#${client.user?.discriminator} (${client.user?.id})` 129 ); 130 log.info( 131 `The bot is currently on ${client.guilds.cache.size} guilds with ${getUserCount()} users and manages ${getRolesCount()} roles` 132 ); 133 134 const activityCountInCurrentlyActiveActivities = ( 135 prepare('SELECT COUNT(*) FROM currentlyActiveActivities').get() as any 136 )['COUNT(*)']; 137 if (activityCountInCurrentlyActiveActivities > 0) { 138 log.info( 139 `There are still ${activityCountInCurrentlyActiveActivities} activites in currentlyActiveActivites left.` 140 ); 141 } else { 142 log.info( 143 'There are no activites in currentlyActiveActivites left and it can safely be deleted :)' 144 ); 145 } 146 }); 147 148 // PresenceUpdate fires once for every guild the bot shares with the user 149 client.on(Events.PresenceUpdate, async (oldMember, newMember) => { 150 const startTime = Date.now(); 151 stats.presenceUpdates++; 152 let logTime = false; 153 154 // no activities changed 155 // if (oldMember?.activities.toString() === newMember?.activities.toString()) return; 156 157 // write timings only for tippfehlr (me) in ASTRONEER, because it has many members 158 if (newMember.user?.username === 'tippfehlr' && newMember.guild?.name === 'ASTRONEER' && log.isLevelEnabled('debug')) { 159 log.debug(`PRESENCE UPDATE: User ${newMember.user?.username}, ${newMember.activities.toString()}, ${oldMember?.activities.toString() === newMember?.activities.toString()}`) 160 logTime = true; 161 } 162 163 if (logTime) console.time('pre member-fetch'); 164 if (!newMember.guild) return; 165 const guildID = newMember.guild.id; 166 if (!newMember.guild.members.me?.permissions.has(PermissionsBitField.Flags.ManageRoles)) { 167 await newMember.guild.leave(); 168 log.warn( 169 `MISSING ACCESS: LEFT guild: ${newMember.guild.name} (ID: ${guildID}, OwnerID: ${newMember.guild.ownerId}), Permission: MANAGE_ROLES` 170 ); 171 return; 172 } 173 if (!newMember.user || !newMember.guild || newMember.member?.user.bot) return; 174 175 const userConfig = getUserConfig(newMember.userId); 176 if (!userConfig.autoRole) return; 177 178 const highestBotRolePosition = newMember.guild.members.me?.roles.highest.position; 179 const userIDHash = createHash('sha256').update(newMember.user.id).digest('base64'); 180 const guildConfig = getGuildConfig(guildID); 181 if (logTime) console.timeEnd('pre member-fetch'); 182 // if (debug) console.time('fetch member ' + date); 183 // await newMember.member?.fetch(true); 184 // if (debug) console.timeEnd('fetch member ' + date); 185 if (logTime) console.time('roles'); 186 187 if ( 188 guildConfig.requiredRoleID !== null && 189 newMember.member?.roles.cache.has(guildConfig.requiredRoleID) 190 ) { 191 return; 192 } 193 194 const addedActivities = newMember?.activities.filter(activity => { 195 return !oldMember?.activities.find(oldActivity => oldActivity.name === activity.name); 196 }); 197 198 for (const activity of addedActivities) { 199 if (activity.name !== 'Custom Status') addActivity(guildID, activity.name); 200 } 201 202 const statusRoles = getStatusRoles(guildID); 203 const activityRoles = getActivityRoles(guildID); 204 const activeTemporaryRoles = 205 prepare('SELECT * FROM activeTemporaryRoles WHERE userIDHash = ? AND guildID = ?') 206 .all(userIDHash, guildID) as DBActiveTemporaryRoles[]; 207 208 if (logTime) console.timeEnd('roles'); 209 210 // return if guild doesn’t have any roles 211 if (statusRoles.length === 0 && activityRoles.length === 0 && activeTemporaryRoles.length === 0) { 212 return; 213 } 214 215 const permanentRoleIDsToBeAdded: Set<string> = new Set(); 216 const tempRoleIDsToBeAdded: Set<string> = new Set(); 217 218 // if user is offline, skip checking for added activities 219 if (newMember.status !== 'offline') { 220 if (logTime) console.time('detect changes'); 221 222 const addRole = ({ roleID, permanent }: { roleID: string, permanent: boolean }) => { 223 if (permanent) { 224 permanentRoleIDsToBeAdded.add(roleID); 225 } else { 226 tempRoleIDsToBeAdded.add(roleID); 227 } 228 }; 229 230 // ------------ status roles ------------ 231 const states: Set<ActivityType> = new Set(); 232 for (const activity of newMember.activities) { 233 states.add(activity.type); 234 } 235 statusRoles.forEach(statusRole => { 236 if (states.has(statusRole.type)) addRole({ roleID: statusRole.roleID, permanent: false }); 237 }); 238 239 // ------------ activity roles ------------ 240 const userActivities = newMember.activities.map(activity => activity.name); 241 242 activityRoles.forEach(activityRole => { 243 if ( 244 // exactActivityName 245 (activityRole.exactActivityName && userActivities.includes(activityRole.activityName)) || 246 // not exactActivityName 247 (!activityRole.exactActivityName && 248 userActivities.find(userActivity => 249 userActivity.toLowerCase().includes(activityRole.activityName.toLowerCase()) 250 )) 251 ) { 252 addRole({ roleID: activityRole.roleID, permanent: !activityRole.live }); 253 } 254 }); 255 256 if (logTime) console.timeEnd('detect changes'); 257 if (logTime) console.time('apply changes'); 258 259 // ------------ “apply changes” ------------ 260 const addDiscordRoleToMember = ({ roleID, permanent }: { roleID: string, permanent: boolean }) => { 261 const role = newMember.guild?.roles.cache.get(roleID); 262 if (!role) { 263 prepare('DELETE FROM statusRoles WHERE guildID = ? AND roleID = ?').run( 264 newMember.guild?.id, 265 roleID 266 ); 267 prepare('DELETE FROM activityRoles WHERE guildID = ? AND roleID = ?').run(guildID, roleID); 268 prepare('DELETE FROM activeTemporaryRoles WHERE guildID = ? AND roleID = ?').run(guildID, roleID); 269 return; 270 } 271 if (!highestBotRolePosition || highestBotRolePosition <= role.position) return; 272 if (newMember.member?.roles.cache.has(role.id)) return; 273 if (permanent) { 274 writeIntPoint('roles_added', 'permanent_roles_added', 1); 275 } else { 276 writeIntPoint('roles_added', 'temporary_roles_added', 1); 277 prepare( 278 'INSERT OR IGNORE INTO activeTemporaryRoles (userIDHash, guildID, roleID) VALUES (?, ?, ?)' 279 ).run(userIDHash, guildID, roleID); 280 } 281 newMember.member?.roles.add(role); 282 stats.rolesAdded++; 283 }; 284 permanentRoleIDsToBeAdded.forEach(roleID => { 285 addDiscordRoleToMember({ roleID, permanent: true }); 286 }); 287 tempRoleIDsToBeAdded.forEach(roleID => { 288 addDiscordRoleToMember({ roleID, permanent: false }); 289 }); 290 } else { 291 if (logTime) console.time('apply changes'); 292 } 293 294 // remove temporary roles --- new activeTemporaryRoles 295 activeTemporaryRoles.forEach(activeTemporaryRole => { 296 if (!tempRoleIDsToBeAdded.has(activeTemporaryRole.roleID)) { 297 const role = newMember.guild?.roles.cache.get(activeTemporaryRole.roleID); 298 // this purposefully does not check if the user has the role: 299 // trying to remove it when the user doesn’t have it does nothing 300 // and the local role cache *could* be invalid resulting in roles getting stuck 301 if (role) newMember.member?.roles.remove(role); 302 prepare( 303 'DELETE FROM activeTemporaryRoles WHERE guildID = ? AND userIDHash = ? AND roleID = ?' 304 ).run(newMember.guild?.id, userIDHash, activeTemporaryRole.roleID); 305 stats.rolesRemoved++; 306 } 307 }); 308 309 // @deprecated remove all roles still in currentlyActiveActivities 310 ( 311 312 prepare('SELECT * FROM currentlyActiveActivities WHERE userIDHash = ? AND guildID = ?') 313 .all(userIDHash, guildID) as DBCurrentlyActiveActivity[] 314 ).forEach(activeActivity => { 315 activityRoles 316 .map(activityRole => activityRole.roleID) 317 .forEach(roleID => { 318 const role = newMember.guild?.roles.cache.get(roleID); 319 if (role && newMember.member?.roles.cache.has(role.id)) { 320 newMember.member?.roles.remove(role); 321 } 322 prepare( 323 'DELETE FROM currentlyActiveActivities WHERE userIDHash = ? AND guildID = ? AND activityName = ?' 324 ).run(userIDHash, guildID, activeActivity.activityName); 325 stats.rolesRemoved++; 326 }); 327 }); 328 if (logTime) console.timeEnd('apply changes'); 329 if (logTime) console.time('writeIntPoint'); 330 writeIntPoint('presence_updates', 'took_time', Date.now() - startTime) 331 if (logTime) console.timeEnd('writeIntPoint'); 332 }); 333 334 client.on(Events.GuildCreate, guild => { 335 log.info(`Joined guild ${guild.name} (${guild.id})`); 336 getGuildConfig(guild.id); 337 }); 338 339 client.on(Events.GuildDelete, guild => log.info(`Left guild ${guild.name} (${guild.id})`)); 340 341 client.on(Events.Error, error => { 342 log.error(error, 'The Discord WebSocket has encountered an error') 343 stats.webSocketErrors++; 344 }); 345 346 client.on(Events.GuildRoleDelete, async role => { 347 prepare('DELETE FROM activityRoles WHERE roleID = ? AND guildID = ?').run( 348 role.id, 349 role.guild.id 350 ); 351 }); 352 353 export function connect() { 354 return client.login(config.TOKEN); 355 }