generated from nhcarrigan/template
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
name: Node.js CI
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint and Test
|
||||
|
||||
steps:
|
||||
- name: Checkout Source Files
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Use Node.js v22
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Lint Source Files
|
||||
run: pnpm run lint
|
||||
|
||||
- name: Verify Build
|
||||
run: pnpm run build
|
||||
|
||||
- name: Run Tests
|
||||
run: pnpm run test
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
prod
|
||||
@@ -0,0 +1,9 @@
|
||||
/.gitea/
|
||||
/.vscode/
|
||||
eslint.config.js
|
||||
.gitattributes
|
||||
.gitignore
|
||||
/src/
|
||||
|
||||
# Ignore packed files so that npm pack can be run locally
|
||||
*.tgz
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
},
|
||||
"eslint.validate": ["typescript"]
|
||||
}
|
||||
@@ -1,20 +1,10 @@
|
||||
# New Repository Template
|
||||
# Discord Analytics
|
||||
|
||||
This template contains all of our basic files for a new GitHub repository. There is also a handy workflow that will create an issue on a new repository made from this template, with a checklist for the steps we usually take in setting up a new repository.
|
||||
|
||||
If you're starting a Node.JS project with TypeScript, we have a [specific template](https://github.com/naomi-lgbt/nodejs-typescript-template) for that purpose.
|
||||
|
||||
## Readme
|
||||
|
||||
Delete all of the above text (including this line), and uncomment the below text to use our standard readme template.
|
||||
|
||||
<!-- # Project Name
|
||||
|
||||
Project Description
|
||||
This package pairs with our logging tool to pipe some common Discord analytics from all of our bots into our telemetry.
|
||||
|
||||
## Live Version
|
||||
|
||||
This page is currently deployed. [View the live website.]
|
||||
This page is currently deployed. [View the live website.](https://www.npmjs.com/package/@nhcarrigan/discord-analytics)
|
||||
|
||||
## Feedback and Bugs
|
||||
|
||||
@@ -36,4 +26,4 @@ Copyright held by Naomi Carrigan.
|
||||
|
||||
## Contact
|
||||
|
||||
We may be contacted through our [Chat Server](http://chat.nhcarrigan.com) or via email at `contact@nhcarrigan.com`. -->
|
||||
We may be contacted through our [Chat Server](http://chat.nhcarrigan.com) or via email at `contact@nhcarrigan.com`.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import NaomisConfig from '@nhcarrigan/eslint-config';
|
||||
|
||||
export default [
|
||||
...NaomisConfig,
|
||||
]
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "@nhcarrigan/discord-analytics",
|
||||
"version": "0.0.0",
|
||||
"description": "Package that pairs with our logging tool to provide analytics for our Discord bots.",
|
||||
"type": "module",
|
||||
"private": false,
|
||||
"main": "prod/index.js",
|
||||
"scripts": {
|
||||
"lint": "eslint src --max-warnings 0",
|
||||
"build": "rm -rf prod && tsc",
|
||||
"test": "echo \"Error: no test specified\" && exit 0"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"packageManager": "pnpm@10.18.0",
|
||||
"devDependencies": {
|
||||
"@nhcarrigan/eslint-config": "5.2.0",
|
||||
"@nhcarrigan/typescript-config": "4.0.0",
|
||||
"@types/node": "24.7.0",
|
||||
"@types/node-schedule": "2.1.8",
|
||||
"eslint": "9.37.0",
|
||||
"typescript": "5.9.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"discord.js": "^14.0.0",
|
||||
"@nhcarrigan/logger": ">=1.1.0-hotfix"
|
||||
},
|
||||
"dependencies": {
|
||||
"node-schedule": "2.1.1"
|
||||
}
|
||||
}
|
||||
Generated
+4395
File diff suppressed because it is too large
Load Diff
+155
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { Logger } from "@nhcarrigan/logger";
|
||||
import { scheduleJob, type Job } from "node-schedule";
|
||||
import type { Events, Client } from "discord.js";
|
||||
|
||||
// eslint-disable-next-line complexity, max-lines-per-function, max-statements -- Justified
|
||||
const flatten = (
|
||||
object: Record<string, unknown>,
|
||||
): Record<string, string | number | boolean> => {
|
||||
const result: Record<string, string | number | boolean> = {};
|
||||
for (const key in object) {
|
||||
const value = object[key];
|
||||
if (value === null || value === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
typeof value === "string"
|
||||
|| typeof value === "number"
|
||||
|| typeof value === "boolean"
|
||||
) {
|
||||
result[key] = value;
|
||||
continue;
|
||||
}
|
||||
if (typeof value === "object" && !Array.isArray(value)) {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Justified
|
||||
const nested = flatten(value as Record<string, unknown>);
|
||||
for (const nestedKey in nested) {
|
||||
const nestedValue = nested[nestedKey];
|
||||
if (nestedValue === undefined) {
|
||||
continue;
|
||||
}
|
||||
result[`${key}_${nestedKey}`] = nestedValue;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (const [ index, arrayValue ] of value.entries()) {
|
||||
if (
|
||||
typeof arrayValue === "string"
|
||||
|| typeof arrayValue === "number"
|
||||
|| typeof arrayValue === "boolean"
|
||||
) {
|
||||
result[`${key}_${index.toString()}`] = arrayValue;
|
||||
continue;
|
||||
}
|
||||
if (typeof arrayValue === "object" && arrayValue !== null) {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Justified
|
||||
const nested = flatten(arrayValue as Record<string, unknown>);
|
||||
for (const nestedKey in nested) {
|
||||
const nestedValue = nested[nestedKey];
|
||||
// eslint-disable-next-line max-depth -- Justified
|
||||
if (nestedValue === undefined) {
|
||||
continue;
|
||||
}
|
||||
result[`${key}_${index.toString()}_${nestedKey}`] = nestedValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* A class for logging Discord bot analytics.
|
||||
*/
|
||||
export class DiscordAnalytics {
|
||||
private readonly logger: Logger;
|
||||
private job: Job | null = null;
|
||||
|
||||
/**
|
||||
* Creates a new instance of the DiscordAnalytics class.
|
||||
* @param client -- The Discord client to monitor.
|
||||
* @param name -- The name of the application.
|
||||
* @param logToken -- Auth token for our logging service.
|
||||
*/
|
||||
public constructor(
|
||||
private readonly client: Client,
|
||||
name: string,
|
||||
logToken: string,
|
||||
) {
|
||||
this.logger = new Logger(name, logToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a CRON job to run at midnight (system time) daily.
|
||||
* This job will fetch all guilds the bot is in, and log the total number of guilds
|
||||
* and members across all guilds. It will also log the approximate number of user installs
|
||||
* for the application.
|
||||
*/
|
||||
public startCron(): void {
|
||||
if (this.job) {
|
||||
return;
|
||||
}
|
||||
this.job = scheduleJob("metrics", "0 * * * *", async() => {
|
||||
try {
|
||||
const fakeGuilds = await this.client.guilds.fetch();
|
||||
const guilds = await Promise.all(
|
||||
fakeGuilds.map(async(guild) => {
|
||||
return await guild.fetch();
|
||||
}),
|
||||
);
|
||||
const members = guilds.reduce((accumulator, guild) => {
|
||||
return accumulator + guild.memberCount;
|
||||
}, 0);
|
||||
const userInstalls = await this.client.application?.fetch();
|
||||
await this.logger.metric(
|
||||
"user_installs",
|
||||
userInstalls?.approximateUserInstallCount ?? 0,
|
||||
{},
|
||||
);
|
||||
await this.logger.metric("guilds", guilds.length, {});
|
||||
await this.logger.metric("members", members, {});
|
||||
} catch (error) {
|
||||
await this.logger.error(
|
||||
"Discord Analytics CRON Job",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the CRON job if it is running.
|
||||
*/
|
||||
public stopCron(): void {
|
||||
if (!this.job) {
|
||||
return;
|
||||
}
|
||||
this.job.cancel();
|
||||
this.job = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Use this to log Discord gateway events. Call this method from your client's event listeners.
|
||||
* Pass whatever payload you receive from Discord to attach as metadata. Or pass an empty object
|
||||
* if you don't want to log any metadata.
|
||||
* @param event - The type of event to log.
|
||||
* @param payload - The payload to log.
|
||||
*/
|
||||
public async logGatewayEvent(
|
||||
event: Events,
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
await this.logger.metric(event, 1, flatten(payload));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "@nhcarrigan/typescript-config",
|
||||
"compilerOptions": {
|
||||
"outDir": "./prod",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user