/ src / modules / bot.ts
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  }