diff --git a/.env b/.env new file mode 100644 index 0000000..fb60787 --- /dev/null +++ b/.env @@ -0,0 +1,5 @@ +DISCORD_TOKEN= +VERIFIED_ROLE= + +MINIMUM_MESSAGES=30 +MESSAGE_COOLDOWN=3 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..c55f547 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,30 @@ +name: Build + +on: + push: + branches: + - main + workflow_dispatch: + +jobs: + run: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: latest + + - name: Build + run: | + bun install --production --frozen-lockfile + bun build.ts + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: Binaries + path: build/* diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9a991b3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,182 @@ +# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore + +# Logs + +logs +_.log +npm-debug.log_ +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Caches + +.cache + +# Diagnostic reports (https://nodejs.org/api/report.html) + +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# Runtime data + +pids +_.pid +_.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover + +lib-cov + +# Coverage directory used by tools like istanbul + +coverage +*.lcov + +# nyc test coverage + +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) + +.grunt + +# Bower dependency directory (https://bower.io/) + +bower_components + +# node-waf configuration + +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) + +build/Release + +# Dependency directories + +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) + +web_modules/ + +# TypeScript cache + +*.tsbuildinfo + +# Optional npm cache directory + +.npm + +# Optional eslint cache + +.eslintcache + +# Optional stylelint cache + +.stylelintcache + +# Microbundle cache + +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history + +.node_repl_history + +# Output of 'npm pack' + +*.tgz + +# Yarn Integrity file + +.yarn-integrity + +# dotenv environment variable files + +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) + +.parcel-cache + +# Next.js build output + +.next +out + +# Nuxt.js build / generate output + +.nuxt +dist + +# Gatsby files + +# Comment in the public line in if your project uses Gatsby and not Next.js + +# https://nextjs.org/blog/next-9-1#public-directory-support + +# public + +# vuepress build output + +.vuepress/dist + +# vuepress v2.x temp and cache directory + +.temp + +# Docusaurus cache and generated files + +.docusaurus + +# Serverless directories + +.serverless/ + +# FuseBox cache + +.fusebox/ + +# DynamoDB Local files + +.dynamodb/ + +# TernJS port file + +.tern-port + +# Stores VSCode versions used for testing VSCode extensions + +.vscode-test + +# yarn v2 + +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store + +# Runtime files +!/.env +/db.sqlite* + +# Compiled binaries +/build \ No newline at end of file diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..0c90abd --- /dev/null +++ b/biome.json @@ -0,0 +1,59 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.5.3/schema.json", + "organizeImports": { + "enabled": false + }, + "linter": { + "enabled": true, + "ignore": ["./public", "./build", "./dist"], + "rules": { + "recommended": true, + "complexity": { + "useLiteralKeys": "off" + }, + "performance": { + "noDelete": "off" + }, + "style": { + "useBlockStatements": "error", + "useNamingConvention": { + "level": "error", + "options": { + "strictCase": false + } + }, + "useShorthandArrayType": "error", + "noImplicitBoolean": "error", + "noNegationElse": "error", + "useCollapsedElseIf": "error", + "useFilenamingConvention": { + "level": "error", + "options": { + "requireAscii": true, + "filenameCases": ["camelCase", "PascalCase"] + } + } + }, + "suspicious": { + "noConsoleLog": "warn" + }, + "correctness": { + "noUnusedImports": "error" + } + } + }, + "formatter": { + "enabled": true, + "ignore": ["./public", "./build", "./dist"], + "indentStyle": "tab" + }, + "javascript": { + "formatter": { + "quoteProperties": "preserve", + "trailingComma": "none", + "lineWidth": 192, + "indentStyle": "tab", + "bracketSameLine": true + } + } +} diff --git a/build.ts b/build.ts new file mode 100644 index 0000000..3ca89a8 --- /dev/null +++ b/build.ts @@ -0,0 +1,55 @@ +import { $ } from "bun"; +import { log } from "./src/log"; +import meta from "./package.json"; + +interface BuildOptions { + "fileName": string; + "target": string; + "arch"?: string; +} + +const targets = { + "linux": { + "label": "Linux", + "arm": true + }, + "darwin": { + "label": "macOS", + "arm": true + }, + "windows": { + "label": "Windows", + "arm": false + } +}; + +async function build({ fileName, target, arch = "x64-modern" }: BuildOptions) { + if (arch === "arm64") { + fileName += "-arm"; + } + + const result = await $`bun build --compile --minify --sourcemap src/index.ts --target=bun-${target}-${arch} --outfile build/${fileName}`.text(); + log.debug(result); +} + +for (const [target, value] of Object.entries(targets)) { + log.info(`Building for ${value.label}...`); + + const options: BuildOptions = { + "fileName": `${meta.name}-${value.label.toLowerCase()}`, + "target": target + }; + + await build(options); + + if (value.arm) { + await build({ + ...options, + "arch": "arm64" + }); + + log.info("ARM binary built!"); + } + + log.info(`Finished building for ${value.label}!`); +} diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..5262742 Binary files /dev/null and b/bun.lockb differ diff --git a/license.md b/license.md new file mode 100644 index 0000000..992fa07 --- /dev/null +++ b/license.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 encode42 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/package.json b/package.json new file mode 100644 index 0000000..2120e27 --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "ohm", + "module": "src/index.ts", + "type": "module", + "devDependencies": { + "@biomejs/biome": "^1.7.1", + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "dependencies": { + "discord.js": "^14.14.1", + "pino": "^9.0.0", + "pino-pretty": "^11.0.0" + } +} diff --git a/src/database.ts b/src/database.ts new file mode 100644 index 0000000..30feeac --- /dev/null +++ b/src/database.ts @@ -0,0 +1,92 @@ +import { Database } from "bun:sqlite"; +import { log } from "./log"; +import { number } from "./util/env"; + +interface User { + "id": number; + // biome-ignore lint/style/useNamingConvention: SQLite convention is in snake_case + "message_count": number; + // biome-ignore lint/style/useNamingConvention: SQLite convention is in snake_case + "last_seen": number; + "verified": boolean; +} + +const messageCooldown = number("MESSAGE_COOLDOWN"); + +const database = new Database("db.sqlite", { + "create": true +}); + +database.exec("PRAGMA journal_mode = WAL;"); +database + .query(` + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + message_count INTEGER DEFAULT 1, + last_seen INTEGER DEFAULT 0, + verified BOOLEAN DEFAULT FALSE + ); + `) + .run(); + +const query = { + "select": database.query("SELECT $id, message_count, last_seen, verified FROM users;"), + "update": database.query("UPDATE users SET last_seen = unixepoch(CURRENT_TIMESTAMP), message_count = $count WHERE ID = $id;"), + "verify": database.query("UPDATE users SET verified = true WHERE ID = $id;"), + "create": database.query(` + INSERT INTO users (id) + VALUES ($id); + `) +}; + +export function getUser(id: string) { + const result = query.select.get({ + "$id": id + }); + + return result as User | undefined; +} + +export function seeUser(id: string) { + const user = getUser(id); + + if (!user) { + query.create.run({ + "$id": id + }); + + return { + "count": 1, + "verified": false + }; + } + + if (user.verified) { + return { + "count": user.message_count, + "verified": true + }; + } + + let count = user.message_count; + if (Date.now() / 1000 - user.last_seen > messageCooldown) { + log.debug("Message cooldown satisfied, incrementing count..."); + count++; + } + + query.update.run({ + "$id": id, + "$count": count + }); + + return { + "count": count, + "verified": false + }; +} + +export function verifyUser(id: string) { + query.verify.run({ + "$id": id + }); +} diff --git a/src/discord/client.ts b/src/discord/client.ts new file mode 100644 index 0000000..dfdd961 --- /dev/null +++ b/src/discord/client.ts @@ -0,0 +1,17 @@ +import { Client, GatewayIntentBits } from "discord.js"; +import { log } from "../log"; +import { string } from "../util/env"; + +log.info("Creating Discord client..."); + +export const client = new Client({ + "intents": [GatewayIntentBits.MessageContent, GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages] +}); + +const success = !!(await client.login(string("DISCORD_TOKEN"))); + +if (success) { + log.info("Logged in!"); +} else { + throw "Client unable to log in."; +} diff --git a/src/discord/events/onMessage.ts b/src/discord/events/onMessage.ts new file mode 100644 index 0000000..2d644db --- /dev/null +++ b/src/discord/events/onMessage.ts @@ -0,0 +1,68 @@ +import type { Role } from "discord.js"; +import { Events } from "discord.js"; +import { client } from "../client"; +import { seeUser, verifyUser } from "../../database"; +import { number, string } from "../../util/env"; +import { log } from "../../log"; + +const roleId = string("VERIFIED_ROLE"); +const minimumMessages = number("MINIMUM_MESSAGES"); + +let roleValid = false; +for (const [_, guild] of client.guilds.cache) { + const roles = await guild.roles.fetch(); + + let verifiedRole: Role | undefined; + let selfRole: Role | undefined; + for (const [_, role] of roles) { + if (role.id === roleId) { + verifiedRole = role; + continue; + } + + if (role.name === "Ohm" && role.managed) { + selfRole = role; + } + } + + log.debug(`Verified role name: ${verifiedRole?.name}`); + log.debug(`Managed role ID: ${selfRole?.id}`); + if (!verifiedRole || !selfRole) { + continue; + } + + if (verifiedRole.position > selfRole.position) { + throw "Verified role is below the bot's managed role!"; + } + + roleValid = true; + break; +} + +if (!roleValid) { + throw "Role setup is not valid."; +} + +client.on(Events.MessageCreate, (event) => { + log.debug(`New message creation event for user ${event.author.username}...`); + if (!event.member) { + return; + } + + const user = seeUser(event.author.id); + if (user.verified) { + log.debug("Member has already been verified!"); + return; + } + + log.debug(`Message count is ${user.count}.`); + if (user.count > minimumMessages) { + log.debug("Verifying member..."); + if (event.member.moderatable) { + event.member.roles.add(roleId); + } + + verifyUser(event.member.id); + log.debug("Verified member!"); + } +}); diff --git a/src/discord/events/register.ts b/src/discord/events/register.ts new file mode 100644 index 0000000..bfc7882 --- /dev/null +++ b/src/discord/events/register.ts @@ -0,0 +1,23 @@ +import { opendir } from "node:fs/promises"; +import { basename, resolve, join } from "node:path"; +import { log } from "../../log"; + +const self = basename(__filename); +const path = resolve(__dirname); + +export async function register() { + log.info("Registering all Discord listeners..."); + + const directory = await opendir(path); + for await (const entry of directory) { + if (entry.name === self) { + continue; + } + + log.debug(`Registering ${entry.name}...`); + await import(join(path, entry.name)); + log.debug("Done! Moving on..."); + } + + log.info("Finished registering!"); +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..74134a0 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,3 @@ +import { register } from "./discord/events/register"; + +await register(); diff --git a/src/log.ts b/src/log.ts new file mode 100644 index 0000000..bebdeff --- /dev/null +++ b/src/log.ts @@ -0,0 +1,11 @@ +import { pino } from "pino"; +import pretty from "pino-pretty"; + +export const log = pino( + { + "level": process.env.VERBOSE ? "debug" : "info" + }, + pretty({ + "ignore": "pid,hostname" + }) +); diff --git a/src/util/env.ts b/src/util/env.ts new file mode 100644 index 0000000..013a103 --- /dev/null +++ b/src/util/env.ts @@ -0,0 +1,17 @@ +function env(key: string): string { + const value = process.env[key]; + + if (!value) { + throw `The environment variable "${key}" is undefined! Change it in the ".env" file.`; + } + + return value; +} + +export function string(key: string): string { + return env(key); +} + +export function number(key: string): number { + return Number.parseInt(env(key)); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..d175cad --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true + } +}