diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index ba22b1230..8a4f1e0b6 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -20,13 +20,13 @@ jobs:
steps:
- uses: actions/checkout@v4
- - uses: pnpm/action-setup@v3 # Install pnpm using packageManager key in package.json
+ - uses: pnpm/action-setup@v4 # Install pnpm using packageManager key in package.json
- name: Use Node.js 20
uses: actions/setup-node@v4
with:
node-version: 20
- cache: "pnpm"
+ cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
diff --git a/.github/workflows/change-reporter.yml b/.github/workflows/change-reporter.yml
new file mode 100644
index 000000000..b49e41d03
--- /dev/null
+++ b/.github/workflows/change-reporter.yml
@@ -0,0 +1,68 @@
+name: Change Reporter
+on:
+ workflow_dispatch:
+ schedule:
+ # Every day at midnight
+ - cron: 0 0 * * *
+
+jobs:
+ change-reporter:
+ if: github.repository == 'Vendicated/Vencord'
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+ if: ${{ github.event_name == 'schedule' }}
+ with:
+ ref: dev
+
+ - uses: actions/checkout@v4
+ if: ${{ github.event_name == 'workflow_dispatch' }}
+
+ - uses: pnpm/action-setup@v4 # Install pnpm using packageManager key in package.json
+
+ - name: Use Node.js 20
+ uses: actions/setup-node@v4
+ with:
+ node-version: ^20.11.0
+ cache: pnpm
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile
+
+ - name: Install Google Chrome
+ id: setup-chrome
+ uses: browser-actions/setup-chrome@v1
+ with:
+ chrome-version: stable
+ install-dependencies: true
+
+ - name: Build Vencord web standalone
+ run: pnpm buildWebStandalone --skip-extension
+
+ - name: Create Report (Stable)
+ timeout-minutes: 10
+ run: |
+ cd packages/discord-types
+ pnpm change-reporter
+ env:
+ CHANNEL: stable
+ CHROMIUM_BIN: ${{ steps.setup-chrome.outputs.chrome-path }}
+ CHROMIUM_VERSION: ${{ steps.setup-chrome.outputs.chrome-version }}
+ DISCORD_TOKEN: ${{ secrets.DISCORD_TOKEN }}
+ DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
+ VENCORD_DIST: ../../dist/browser.js
+
+ - name: Create Report (Canary)
+ timeout-minutes: 10
+ if: ${{ !cancelled() }} # run even if previous one failed
+ run: |
+ cd packages/discord-types
+ pnpm change-reporter
+ env:
+ CHANNEL: canary
+ CHROMIUM_BIN: ${{ steps.setup-chrome.outputs.chrome-path }}
+ CHROMIUM_VERSION: ${{ steps.setup-chrome.outputs.chrome-version }}
+ DISCORD_TOKEN: ${{ secrets.DISCORD_TOKEN }}
+ DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
+ VENCORD_DIST: ../../dist/browser.js
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 190e3069c..eaf6795dd 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -20,13 +20,13 @@ jobs:
exit 1
fi
- - uses: pnpm/action-setup@v3 # Install pnpm using packageManager key in package.json
+ - uses: pnpm/action-setup@v4 # Install pnpm using packageManager key in package.json
- - name: Use Node.js 19
+ - name: Use Node.js 20
uses: actions/setup-node@v4
with:
node-version: 20
- cache: "pnpm"
+ cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
diff --git a/.github/workflows/reportBrokenPlugins.yml b/.github/workflows/reportBrokenPlugins.yml
index a669c1a27..28d983cbf 100644
--- a/.github/workflows/reportBrokenPlugins.yml
+++ b/.github/workflows/reportBrokenPlugins.yml
@@ -19,13 +19,13 @@ jobs:
- uses: actions/checkout@v4
if: ${{ github.event_name == 'workflow_dispatch' }}
- - uses: pnpm/action-setup@v3 # Install pnpm using packageManager key in package.json
+ - uses: pnpm/action-setup@v4 # Install pnpm using packageManager key in package.json
- name: Use Node.js 20
uses: actions/setup-node@v4
with:
node-version: 20
- cache: "pnpm"
+ cache: pnpm
- name: Install dependencies
run: |
diff --git a/.github/workflows/test-packages-discord-types.yml b/.github/workflows/test-packages-discord-types.yml
new file mode 100644
index 000000000..1e8a8fcf4
--- /dev/null
+++ b/.github/workflows/test-packages-discord-types.yml
@@ -0,0 +1,34 @@
+name: Test packages/discord-types
+on:
+ pull_request:
+ paths:
+ - packages/discord-types/**
+ push:
+ paths:
+ - packages/discord-types/**
+jobs:
+ test:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: pnpm/action-setup@v4 # Install pnpm using packageManager key in package.json
+
+ - name: Use Node.js 20
+ uses: actions/setup-node@v4
+ with:
+ node-version: ^20.9.0
+ cache: pnpm
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile
+
+ - name: Check packages/discord-types for TypeScript errors and lint
+ run: | # https://github.com/microsoft/TypeScript/issues/40431
+ pnpm tspc --emitDeclarationOnly
+ cd packages/discord-types
+ pnpm test
+
+ - name: Check if packages/discord-types is compatible with Vencord
+ run: pnpm testTsc && pnpm lint
diff --git a/.github/workflows/test-scripts.yml b/.github/workflows/test-scripts.yml
new file mode 100644
index 000000000..884eb11dd
--- /dev/null
+++ b/.github/workflows/test-scripts.yml
@@ -0,0 +1,33 @@
+name: Test scripts
+on:
+ pull_request:
+ paths:
+ - scripts/**
+ - eslint.config.mjs
+ push:
+ paths:
+ - scripts/**
+ - eslint.config.mjs
+jobs:
+ test:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: pnpm/action-setup@v4 # Install pnpm using packageManager key in package.json
+
+ - name: Use Node.js 20
+ uses: actions/setup-node@v4
+ with:
+ node-version: ^20.9.0
+ cache: pnpm
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile
+
+ - name: Check scripts for TypeScript errors
+ run: | # https://github.com/microsoft/TypeScript/issues/40431
+ pnpm tspc --emitDeclarationOnly
+ cd scripts
+ pnpm tsc --noEmit
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 7a2b320bf..cfe9a0ca5 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -1,23 +1,27 @@
name: test
on:
push:
+ paths-ignore:
+ - packages/discord-types
pull_request:
branches:
- main
- dev
+ paths-ignore:
+ - packages/discord-types
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- - uses: pnpm/action-setup@v3 # Install pnpm using packageManager key in package.json
+ - uses: pnpm/action-setup@v4 # Install pnpm using packageManager key in package.json
- name: Use Node.js 20
uses: actions/setup-node@v4
with:
- node-version: 20
- cache: "pnpm"
+ node-version: ^20.9.0
+ cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
diff --git a/.stylelintrc.json b/.stylelintrc.json
index 682695c07..ffe761323 100644
--- a/.stylelintrc.json
+++ b/.stylelintrc.json
@@ -1,6 +1,13 @@
{
- "extends": "stylelint-config-standard",
+ "extends": [
+ "stylelint-config-standard",
+ "@stylistic/stylelint-config"
+ ],
+ "plugins": [
+ "@stylistic/stylelint-plugin"
+ ],
"rules": {
+ "@stylistic/indentation": 4,
"selector-class-pattern": [
"^[a-z][a-zA-Z0-9]*(-[a-z0-9][a-zA-Z0-9]*)*$",
{
diff --git a/.vscode/settings.json b/.vscode/settings.json
index fa543b38c..1c27fac7e 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -1,5 +1,4 @@
{
- "editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
@@ -9,6 +8,8 @@
"[typescriptreact]": {
"editor.defaultFormatter": "vscode.typescript-language-features"
},
+ "files.eol": "\n",
+ "files.insertFinalNewline": true,
"javascript.format.semicolons": "insert",
"typescript.format.semicolons": "insert",
"typescript.preferences.quoteStyle": "double",
@@ -19,5 +20,8 @@
"domain": "codeberg.org",
"type": "Gitea"
}
- ]
+ ],
+
+ "typescript.tsdk": "./node_modules/typescript/lib",
+ "typescript.enablePromptUseWorkspaceTsdk": true
}
diff --git a/browser/VencordNativeStub.ts b/browser/VencordNativeStub.ts
index 79f0f2cd3..071c90726 100644
--- a/browser/VencordNativeStub.ts
+++ b/browser/VencordNativeStub.ts
@@ -36,7 +36,7 @@ const cssListeners = new Set<(css: string) => void>();
const NOOP = () => { };
const NOOP_ASYNC = async () => { };
-const setCssDebounced = debounce((css: string) => VencordNative.quickCss.set(css));
+const setCssDebounced = debounce((css: string) => { VencordNative.quickCss.set(css); });
const themeStore = DataStore.createStore("VencordThemes", "VencordThemeData");
@@ -46,9 +46,8 @@ window.VencordNative = {
uploadTheme: (fileName: string, fileData: string) => DataStore.set(fileName, fileData, themeStore),
deleteTheme: (fileName: string) => DataStore.del(fileName, themeStore),
getThemesDir: async () => "",
- getThemesList: () => DataStore.entries(themeStore).then(entries =>
- entries.map(([name, css]) => getThemeInfo(css, name.toString()))
- ),
+ getThemesList: async () => (await DataStore.entries(themeStore))
+ .map(([name, css]) => getThemeInfo(css, name.toString())),
getThemeData: (fileName: string) => DataStore.get(fileName, themeStore),
getSystemValues: async () => ({}),
},
@@ -66,10 +65,10 @@ window.VencordNative = {
},
quickCss: {
- get: () => DataStore.get("VencordQuickCss").then(s => s ?? ""),
+ get: async () => await DataStore.get("VencordQuickCss") ?? "",
set: async (css: string) => {
await DataStore.set("VencordQuickCss", css);
- cssListeners.forEach(l => l(css));
+ cssListeners.forEach(l => { l(css); });
},
addChangeListener(cb) {
cssListeners.add(cb);
@@ -109,5 +108,5 @@ window.VencordNative = {
getSettingsDir: async () => "LocalStorage"
},
- pluginHelpers: {} as any,
+ pluginHelpers: {},
};
diff --git a/browser/monaco.ts b/browser/monaco.ts
index ead061d65..706d74bb7 100644
--- a/browser/monaco.ts
+++ b/browser/monaco.ts
@@ -6,7 +6,9 @@
import "./patch-worker";
+// @ts-expect-error
import * as monaco from "monaco-editor/esm/vs/editor/editor.main.js";
+declare const monaco: typeof import("monaco-editor");
declare global {
const baseUrl: string;
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 54f53f745..6036a7373 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -1,126 +1,221 @@
-/*
- * Vencord, a Discord client mod
- * Copyright (c) 2023 Vendicated and contributors
- * SPDX-License-Identifier: GPL-3.0-or-later
- */
-
-// @ts-check
-
import stylistic from "@stylistic/eslint-plugin";
+// @ts-expect-error: No types
import pathAlias from "eslint-plugin-path-alias";
-import header from "eslint-plugin-simple-header";
+// @ts-expect-error: https://github.com/jsx-eslint/eslint-plugin-react/issues/3776
+import eslintPluginReact from "eslint-plugin-react";
+import simpleHeader from "eslint-plugin-simple-header";
import simpleImportSort from "eslint-plugin-simple-import-sort";
import unusedImports from "eslint-plugin-unused-imports";
import tseslint from "typescript-eslint";
export default tseslint.config(
- { ignores: ["dist", "browser", "packages/vencord-types"] },
+ { ignores: ["browser", "dist", "packages", "src/**/*.?(c|m)js?(x)", "*.*"] },
{
- files: ["src/**/*.{tsx,ts,mts,mjs,js,jsx}", "eslint.config.mjs"],
- plugins: {
- "simple-header": header,
- "@stylistic": stylistic,
- "@typescript-eslint": tseslint.plugin,
- "simple-import-sort": simpleImportSort,
- "unused-imports": unusedImports,
- "path-alias": pathAlias,
- },
- settings: {
- "import/resolver": {
- map: [
- ["@webpack", "./src/webpack"],
- ["@webpack/common", "./src/webpack/common"],
- ["@utils", "./src/utils"],
- ["@api", "./src/api"],
- ["@components", "./src/components"]
- ]
- }
- },
+ files: ["**/*.?(c|m)[jt]s?(x)"],
languageOptions: {
parser: tseslint.parser,
parserOptions: {
- project: ["./tsconfig.json"],
- tsconfigRootDir: import.meta.dirname
+ projectService: true,
+ warnOnUnsupportedTypeScriptVersion: false
}
},
+ plugins: {
+ // @ts-expect-error: https://github.com/eslint-stylistic/eslint-stylistic/issues/398#issuecomment-2178212946
+ "@stylistic": stylistic,
+ "@typescript-eslint": tseslint.plugin,
+ "path-alias": pathAlias,
+ "react": eslintPluginReact,
+ "simple-header": simpleHeader,
+ "simple-import-sort": simpleImportSort,
+ "unused-imports": unusedImports,
+ },
+ settings: {
+ react: { version: "18.2" },
+ },
rules: {
- /*
- * Since it's only been a month and Vencord has already been stolen
- * by random skids who rebranded it to "AlphaCord" and erased all license
- * information
- */
- "simple-header/header": [
- "error",
- {
- "files": ["scripts/header-new.txt", "scripts/header-old.txt"],
- "templates": { "author": [".*", "Vendicated and contributors"] }
- }
- ],
-
- // Style Rules
- "@stylistic/jsx-quotes": ["error", "prefer-double"],
- "@stylistic/quotes": ["error", "double", { "avoidEscape": true }],
- "@stylistic/no-mixed-spaces-and-tabs": "error",
+ // Since it's only been a month and Vencord has already been stolen
+ // by random skids who rebranded it to "AlphaCord" and erased all license
+ // information
+ "simple-header/header": ["error", {
+ files: ["scripts/header-new.txt", "scripts/header-old.txt"],
+ templates: { author: [".*", "Vendicated and contributors"] }
+ }],
+ "@stylistic/array-bracket-spacing": "error",
"@stylistic/arrow-parens": ["error", "as-needed"],
- "@stylistic/eol-last": ["error", "always"],
- "@stylistic/no-multi-spaces": "error",
+ "@stylistic/arrow-spacing": "error",
+ "@stylistic/block-spacing": "error",
+ "@stylistic/brace-style": ["error", "1tbs", { allowSingleLine: true }],
+ "@stylistic/comma-spacing": "error",
+ "@stylistic/comma-style": "error",
+ "@stylistic/computed-property-spacing": "error",
+ "@stylistic/dot-location": ["error", "property"],
+ "@stylistic/eol-last": "error",
+ "@stylistic/func-call-spacing": "error",
+ "@stylistic/generator-star-spacing": ["error", { before: false, after: true }],
+ "@stylistic/indent": ["error", 4, {
+ SwitchCase: 1,
+ flatTernaryExpressions: true
+ }],
+ "@stylistic/jsx-closing-bracket-location": "error",
+ "@stylistic/jsx-closing-tag-location": "error",
+ "@stylistic/jsx-curly-brace-presence": ["error", { propElementValues: "always" }],
+ "@stylistic/jsx-curly-spacing": ["error", { children: true }],
+ "@stylistic/jsx-equals-spacing": "error",
+ "@stylistic/jsx-first-prop-new-line": ["error", "multiline"],
+ "@stylistic/jsx-quotes": "error",
+ "@stylistic/jsx-self-closing-comp": "error",
+ "@stylistic/jsx-tag-spacing": ["error", { beforeClosing: "never" }],
+ "@stylistic/jsx-wrap-multilines": ["error", {
+ declaration: "parens-new-line",
+ assignment: "parens-new-line",
+ return: "parens-new-line",
+ arrow: "parens-new-line",
+ condition: "parens-new-line",
+ logical: "parens-new-line",
+ propertyValue: "parens-new-line"
+ }],
+ "@stylistic/key-spacing": "error",
+ "@stylistic/keyword-spacing": "error",
+ "@stylistic/linebreak-style": "error",
+ "@stylistic/member-delimiter-style": ["error", { singleline: { requireLast: true } }],
+ "@stylistic/new-parens": "error",
+ "@stylistic/no-extra-semi": "error",
+ "@stylistic/no-floating-decimal": "error",
+ "@stylistic/no-multi-spaces": ["error", { exceptions: { Property: false } }],
+ "@stylistic/no-tabs": "error",
"@stylistic/no-trailing-spaces": "error",
"@stylistic/no-whitespace-before-property": "error",
- "@stylistic/semi": ["error", "always"],
- "@stylistic/semi-style": ["error", "last"],
- "@stylistic/space-in-parens": ["error", "never"],
- "@stylistic/block-spacing": ["error", "always"],
"@stylistic/object-curly-spacing": ["error", "always"],
- "@stylistic/spaced-comment": ["error", "always", { "markers": ["!"] }],
- "@stylistic/no-extra-semi": "error",
-
- // TS Rules
- "@stylistic/func-call-spacing": ["error", "never"],
-
- // ESLint Rules
- "yoda": "error",
- "eqeqeq": ["error", "always", { "null": "ignore" }],
- "prefer-destructuring": ["error", {
- "VariableDeclarator": { "array": false, "object": true },
- "AssignmentExpression": { "array": false, "object": false }
+ "@stylistic/padded-blocks": ["error", "never"],
+ "@stylistic/quotes": ["error", "double", { avoidEscape: true }],
+ "@stylistic/rest-spread-spacing": "error",
+ "@stylistic/semi": "error",
+ "@stylistic/semi-spacing": "error",
+ "@stylistic/semi-style": "error",
+ "@stylistic/space-before-blocks": "error",
+ "@stylistic/space-before-function-paren": ["error", { named: "never" }],
+ "@stylistic/space-in-parens": "error",
+ "@stylistic/space-infix-ops": "error",
+ "@stylistic/space-unary-ops": "error",
+ "@stylistic/spaced-comment": ["error", "always", { markers: ["!"] }],
+ "@stylistic/switch-colon-spacing": "error",
+ "@stylistic/template-curly-spacing": "error",
+ "@stylistic/template-tag-spacing": "error",
+ "@stylistic/type-annotation-spacing": "error",
+ "@stylistic/type-generic-spacing": "error",
+ "@stylistic/type-named-tuple-spacing": "error",
+ "@stylistic/yield-star-spacing": "error",
+ "@typescript-eslint/array-type": "error",
+ "@typescript-eslint/await-thenable": "error",
+ "@typescript-eslint/consistent-generic-constructors": "error",
+ "@typescript-eslint/consistent-type-assertions": ["error", {
+ assertionStyle: "as",
+ objectLiteralTypeAssertions: "allow-as-parameter"
}],
- "operator-assignment": ["error", "always"],
- "no-useless-computed-key": "error",
- "no-unneeded-ternary": ["error", { "defaultAssignment": false }],
- "no-invalid-regexp": "error",
- "no-constant-condition": ["error", { "checkLoops": false }],
- "no-duplicate-imports": "error",
- "dot-notation": "error",
- "no-useless-escape": [
- "error",
- {
- "extra": "i"
- }
- ],
- "no-fallthrough": "error",
+ "@typescript-eslint/consistent-type-exports": ["error", {
+ fixMixedExportsWithInlineTypeSpecifier: true
+ }],
+ "@typescript-eslint/consistent-type-imports": ["error", {
+ disallowTypeAnnotations: false,
+ fixStyle: "inline-type-imports"
+ }],
+ "@typescript-eslint/dot-notation": "error",
+ "@typescript-eslint/method-signature-style": "error",
+ "@typescript-eslint/no-confusing-void-expression": "error",
+ "@typescript-eslint/no-duplicate-type-constituents": "error",
+ "@typescript-eslint/no-extra-non-null-assertion": "error",
+ "@typescript-eslint/no-import-type-side-effects": "error",
+ "@typescript-eslint/no-misused-promises": ["error", { checksVoidReturn: false }],
+ "@typescript-eslint/no-non-null-asserted-nullish-coalescing": "error",
+ "@typescript-eslint/no-non-null-asserted-optional-chain": "error",
+ "@typescript-eslint/no-unnecessary-condition": ["error", { allowConstantLoopConditions: true }],
+ "@typescript-eslint/no-unnecessary-type-assertion": "error",
+ "@typescript-eslint/no-unnecessary-type-parameters": "error",
+ "@typescript-eslint/no-unsafe-function-type": "error",
+ "@typescript-eslint/no-unused-expressions": ["error", { enforceForJSX: true }],
+ "@typescript-eslint/no-wrapper-object-types": "error",
+ "@typescript-eslint/non-nullable-type-assertion-style": "error",
+ "@typescript-eslint/prefer-as-const": "error",
+ "@typescript-eslint/prefer-destructuring": "error",
+ "@typescript-eslint/prefer-find": "error",
+ "@typescript-eslint/prefer-function-type": "error",
+ "@typescript-eslint/prefer-includes": "error",
+ "@typescript-eslint/prefer-reduce-type-parameter": "error",
+ "@typescript-eslint/require-await": "error",
+ "@typescript-eslint/return-await": "error",
+ "eqeqeq": ["error", "always", { null: "ignore" }],
"for-direction": "error",
+ "no-array-constructor": "error",
"no-async-promise-executor": "error",
"no-cond-assign": "error",
"no-dupe-else-if": "error",
"no-duplicate-case": "error",
+ "no-duplicate-imports": "error",
+ "no-eval": ["error", { allowIndirect: true }],
+ "no-extra-boolean-cast": "error",
+ "no-extra-label": "error",
+ "no-fallthrough": "error",
+ "no-invalid-regexp": "error",
"no-irregular-whitespace": "error",
+ "no-lone-blocks": "error",
+ "no-lonely-if": "error",
"no-loss-of-precision": "error",
"no-misleading-character-class": "error",
+ "no-new-wrappers": "error",
+ "no-object-constructor": "error",
"no-prototype-builtins": "error",
"no-regex-spaces": "error",
+ "no-restricted-globals": ["error", "_", "Diff", "JSX", "React", "ReactDOM"],
+ "no-restricted-imports": ["error", {
+ patterns: [{
+ regex: "^discord-types(/|$)",
+ message: "Use @vencord/discord-types instead."
+ }]
+ }],
+ "no-restricted-syntax": ["error",
+ "SequenceExpression:not(.update):matches(:not(.callee), [expressions.length!=2])",
+ "SequenceExpression:not(.update) > :first-child:not(Literal)",
+ ],
"no-shadow-restricted-names": "error",
+ "no-undef-init": "error",
"no-unexpected-multiline": "error",
- "no-unsafe-optional-chaining": "error",
+ "no-unneeded-ternary": ["error", { defaultAssignment: false }],
+ "no-unreachable": "error",
+ "no-unreachable-loop": "error",
+ "no-unused-labels": "error",
"no-useless-backreference": "error",
- "use-isnan": "error",
+ "no-useless-catch": "error",
+ "no-useless-computed-key": "error",
+ "no-useless-escape": ["error", { extra: "i" }],
+ "no-useless-rename": "error",
+ "no-void": "error",
+ "operator-assignment": "error",
+ "path-alias/no-relative": "error",
"prefer-const": "error",
+ "prefer-numeric-literals": "error",
+ "prefer-object-spread": "error",
+ "prefer-regex-literals": ["error", { disallowRedundantWrapping: true }],
"prefer-spread": "error",
-
- // Plugin Rules
- "simple-import-sort/imports": "error",
+ "react/forbid-dom-props": ["error", {
+ forbid: ["version", "xlinkActuate", "xlinkArcrole", "xlinkHref", "xlinkRole", "xlinkShow", "xlinkTitle", "xlinkType", "xmlBase", "xmlLang", "xmlns", "xmlnsXlink", "xmlSpace"]
+ }],
+ "react/jsx-fragments": "error",
+ "react/jsx-no-useless-fragment": "error",
"simple-import-sort/exports": "error",
+ "simple-import-sort/imports": "error",
"unused-imports/no-unused-imports": "error",
- "path-alias/no-relative": "error"
+ "use-isnan": "error",
+ "yoda": "error",
}
- }
+ },
+ // Declarations are not emitted for '.d.ts' files: https://github.com/microsoft/TypeScript/issues/38146
+ {
+ files: ["src/webpack/common/types/**"],
+ rules: {
+ "no-restricted-syntax": ["error", {
+ selector: ":expression:not([declare=true] *, [type=/^TS/] *, ExportAllDeclaration *, ExportNamedDeclaration *, ImportDeclaration *)",
+ message: "This file is intended to contain only types."
+ }],
+ }
+ },
);
diff --git a/package.json b/package.json
index e65e1b0a7..336b3b6ca 100644
--- a/package.json
+++ b/package.json
@@ -23,16 +23,16 @@
"watch": "pnpm build --watch",
"dev": "pnpm watch",
"watchWeb": "pnpm buildWeb --watch",
- "generatePluginJson": "tsx scripts/generatePluginList.ts",
+ "generatePluginJson": "tsx scripts/generatePluginList.mts",
"generateTypes": "tspc --emitDeclarationOnly --declaration --outDir packages/vencord-types",
"inject": "node scripts/runInstaller.mjs",
"uninject": "node scripts/runInstaller.mjs",
- "lint": "eslint",
+ "lint": "eslint . --ignore-pattern src/userplugins",
"lint-styles": "stylelint \"src/**/*.css\" --ignore-pattern src/userplugins",
"lint:fix": "pnpm lint --fix",
"test": "pnpm buildStandalone && pnpm lint && pnpm lint-styles && pnpm testTsc && pnpm generatePluginJson",
"testWeb": "pnpm lint && pnpm buildWeb && pnpm testTsc",
- "testTsc": "tsc --noEmit"
+ "testTsc": "tsc --noEmit --emitDeclarationOnly false"
},
"dependencies": {
"@sapphi-red/web-noise-suppressor": "0.3.5",
@@ -40,61 +40,92 @@
"@vap/shiki": "0.10.5",
"fflate": "^0.8.2",
"gifenc": "github:mattdesl/gifenc#64842fca317b112a8590f8fef2bf3825da8f6fe3",
- "monaco-editor": "^0.50.0",
- "nanoid": "^5.0.7",
- "virtual-merge": "^1.0.1"
+ "monaco-editor": "^0.51.0",
+ "nanoid": "^5.0.7"
},
"devDependencies": {
- "@stylistic/eslint-plugin": "^2.6.1",
- "@types/chrome": "^0.0.269",
- "@types/diff": "^5.2.1",
- "@types/lodash": "^4.17.7",
- "@types/node": "^22.0.3",
- "@types/react": "^18.3.3",
- "@types/react-dom": "^18.3.0",
+ "@stylistic/eslint-plugin": "^2.8.0",
+ "@stylistic/stylelint-config": "^2.0.0",
+ "@stylistic/stylelint-plugin": "^3.0.1",
+ "@types/chrome": "^0.0.271",
+ "@types/diff": "^5.2.2",
+ "@types/html-minifier-terser": "^7.0.2",
+ "@types/lodash": "~4.17.7",
+ "@types/node": "^18.19.50",
+ "@types/react": "~18.2.79",
+ "@types/react-dom": "~18.2.25",
"@types/yazl": "^2.4.5",
- "diff": "^5.2.0",
- "discord-types": "^1.3.26",
- "esbuild": "^0.15.18",
- "eslint": "^9.8.0",
- "eslint-import-resolver-alias": "^1.1.2",
- "eslint-plugin-path-alias": "2.1.0",
- "eslint-plugin-simple-header": "^1.1.1",
+ "@vencord/discord-types": "workspace:^",
+ "diff": "^7.0.0",
+ "discord-types": "latest",
+ "esbuild": "^0.23.1",
+ "eslint": "^9.10.0",
+ "eslint-plugin-path-alias": "^2.1.0",
+ "eslint-plugin-react": "^7.36.1",
+ "eslint-plugin-simple-header": "^1.2.1",
"eslint-plugin-simple-import-sort": "^12.1.1",
- "eslint-plugin-unused-imports": "^4.0.1",
- "highlight.js": "10.7.3",
+ "eslint-plugin-unused-imports": "^4.1.4",
+ "highlight.js": "11.8.0",
"html-minifier-terser": "^7.2.0",
- "moment": "^2.30.1",
- "puppeteer-core": "^22.15.0",
+ "moment": "2.22.2",
+ "puppeteer-core": "^23.4.0",
"standalone-electron-types": "^1.0.0",
- "stylelint": "^16.8.1",
+ "stylelint": "^16.9.0",
"stylelint-config-standard": "^36.0.1",
"ts-patch": "^3.2.1",
- "ts-pattern": "^5.3.1",
- "tsx": "^4.16.5",
- "type-fest": "^4.23.0",
- "typescript": "^5.5.4",
- "typescript-eslint": "^8.0.0",
- "typescript-transform-paths": "^3.4.7",
+ "ts-pattern": "5.0.4",
+ "tsx": "^4.19.1",
+ "type-fest": "^4.26.1",
+ "typescript": "^5.6.2",
+ "typescript-eslint": "^8.6.0",
+ "typescript-transform-paths": "^3.5.1",
"zip-local": "^0.3.5"
},
- "packageManager": "pnpm@9.1.0",
+ "packageManager": "pnpm@9.10.0",
"pnpm": {
"patchedDependencies": {
- "eslint@9.8.0": "patches/eslint@9.8.0.patch",
- "eslint-plugin-path-alias@2.1.0": "patches/eslint-plugin-path-alias@2.1.0.patch"
+ "@stylistic/eslint-plugin@2.8.0": "patches/@stylistic__eslint-plugin@2.8.0.patch",
+ "@typescript-eslint/eslint-plugin@8.6.0": "patches/@typescript-eslint__eslint-plugin@8.6.0.patch",
+ "eslint-plugin-path-alias@2.1.0": "patches/eslint-plugin-path-alias@2.1.0.patch",
+ "eslint@9.10.0": "patches/eslint@9.10.0.patch",
+ "standalone-electron-types@1.0.0": "patches/standalone-electron-types@1.0.0.patch",
+ "typescript-transform-paths@3.5.1": "patches/typescript-transform-paths@3.5.1.patch"
+ },
+ "packageExtensions": {
+ "eslint": {
+ "dependencies": {
+ "@types/estree": "^1.0.5",
+ "@types/json-schema": "^7.0.15"
+ }
+ }
},
"peerDependencyRules": {
- "ignoreMissing": [
- "eslint-plugin-import",
- "eslint"
- ]
+ "allowedVersions": {
+ "eslint": "9"
+ }
},
"allowedDeprecatedVersions": {
"source-map-resolve": "*",
"resolve-url": "*",
"source-map-url": "*",
"urix": "*"
+ },
+ "overrides": {
+ "array-includes": "npm:@nolyfill/array-includes@^1",
+ "array.prototype.findlast": "npm:@nolyfill/array.prototype.findlast@^1",
+ "array.prototype.flat": "npm:@nolyfill/array.prototype.flat@^1",
+ "array.prototype.flatmap": "npm:@nolyfill/array.prototype.flatmap@^1",
+ "array.prototype.tosorted": "npm:@nolyfill/array.prototype.tosorted@^1",
+ "es-iterator-helpers": "npm:@nolyfill/es-iterator-helpers@^1",
+ "hasown": "npm:@nolyfill/hasown@^1",
+ "is-core-module": "npm:@nolyfill/is-core-module@^1",
+ "isarray": "npm:@nolyfill/isarray@^1",
+ "object.assign": "npm:@nolyfill/object.assign@^1",
+ "object.entries": "npm:@nolyfill/object.entries@^1",
+ "object.fromentries": "npm:@nolyfill/object.fromentries@^1",
+ "object.values": "npm:@nolyfill/object.values@^1",
+ "string.prototype.matchall": "npm:@nolyfill/string.prototype.matchall@^1",
+ "string.prototype.repeat": "npm:@nolyfill/string.prototype.repeat@^1"
}
},
"webExt": {
diff --git a/packages/discord-types/LICENSE b/packages/discord-types/LICENSE
new file mode 100644
index 000000000..f288702d2
--- /dev/null
+++ b/packages/discord-types/LICENSE
@@ -0,0 +1,674 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users. We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors. You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights. Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received. You must make sure that they, too, receive
+or can get the source code. And you must show them these terms so they
+know their rights.
+
+ Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+ For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software. For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+ Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so. This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software. The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable. Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products. If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+ Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary. To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Use with the GNU Affero General Public License.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+ Copyright (C)
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+.
+
+ The GNU General Public License does not permit incorporating your program
+into proprietary programs. If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License. But first, please read
+.
diff --git a/packages/discord-types/README.md b/packages/discord-types/README.md
new file mode 100644
index 000000000..9cc145bb1
--- /dev/null
+++ b/packages/discord-types/README.md
@@ -0,0 +1 @@
+discord-types
diff --git a/packages/discord-types/eslint.config.mjs b/packages/discord-types/eslint.config.mjs
new file mode 100644
index 000000000..911322fa3
--- /dev/null
+++ b/packages/discord-types/eslint.config.mjs
@@ -0,0 +1,257 @@
+import stylistic from "@stylistic/eslint-plugin";
+// @ts-expect-error: No types
+import checkFile from "eslint-plugin-check-file";
+import eslintPluginImport from "eslint-plugin-import-x";
+import simpleHeader from "eslint-plugin-simple-header";
+import simpleImportSort from "eslint-plugin-simple-import-sort";
+import eslintPluginUnicorn from "eslint-plugin-unicorn";
+import unusedImports from "eslint-plugin-unused-imports";
+import tseslint from "typescript-eslint";
+
+export default tseslint.config(
+ {
+ files: ["**/*.?(c|m)[jt]s?(x)"],
+ languageOptions: {
+ parser: tseslint.parser,
+ parserOptions: {
+ projectService: true,
+ warnOnUnsupportedTypeScriptVersion: false
+ }
+ },
+ plugins: {
+ // @ts-expect-error: https://github.com/eslint-stylistic/eslint-stylistic/issues/398#issuecomment-2178212946
+ "@stylistic": stylistic,
+ "@typescript-eslint": tseslint.plugin,
+ "check-file": checkFile,
+ import: eslintPluginImport,
+ "simple-header": simpleHeader,
+ "simple-import-sort": simpleImportSort,
+ unicorn: eslintPluginUnicorn,
+ "unused-imports": unusedImports,
+ },
+ rules: {
+ "@stylistic/array-bracket-newline": ["error", "consistent"],
+ "@stylistic/array-bracket-spacing": "error",
+ "@stylistic/array-element-newline": ["error", "consistent"],
+ "@stylistic/arrow-parens": ["error", "as-needed"],
+ "@stylistic/block-spacing": "error",
+ "@stylistic/brace-style": ["error", "1tbs", { allowSingleLine: true }],
+ "@stylistic/comma-dangle": ["error", "only-multiline"],
+ "@stylistic/comma-spacing": "error",
+ "@stylistic/comma-style": "error",
+ "@stylistic/computed-property-spacing": "error",
+ "@stylistic/dot-location": ["error", "property"],
+ "@stylistic/eol-last": "error",
+ "@stylistic/function-call-argument-newline": ["error", "consistent"],
+ "@stylistic/function-call-spacing": "error",
+ "@stylistic/function-paren-newline": ["error", "consistent"],
+ "@stylistic/indent": ["error", 4, {
+ SwitchCase: 1,
+ flatTernaryExpressions: true
+ }],
+ "@stylistic/key-spacing": "error",
+ "@stylistic/keyword-spacing": "error",
+ "@stylistic/linebreak-style": "error",
+ "@stylistic/member-delimiter-style": ["error", { singleline: { requireLast: true } }],
+ "@stylistic/new-parens": "error",
+ "@stylistic/no-extra-semi": "error",
+ "@stylistic/no-floating-decimal": "error",
+ "@stylistic/no-multi-spaces": ["error", { exceptions: { Property: false } }],
+ "@stylistic/no-multiple-empty-lines": ["error", { max: 1, maxBOF: 0, maxEOF: 0 }],
+ "@stylistic/no-trailing-spaces": "error",
+ "@stylistic/no-whitespace-before-property": "error",
+ "@stylistic/object-curly-newline": "error",
+ "@stylistic/object-curly-spacing": ["error", "always"],
+ "@stylistic/quote-props": ["error", "as-needed"],
+ "@stylistic/quotes": ["error", "double", { avoidEscape: true }],
+ "@stylistic/rest-spread-spacing": "error",
+ "@stylistic/semi": "error",
+ "@stylistic/semi-spacing": "error",
+ "@stylistic/semi-style": "error",
+ "@stylistic/space-before-blocks": "error",
+ "@stylistic/space-before-function-paren": ["error", { named: "never" }],
+ "@stylistic/space-in-parens": "error",
+ "@stylistic/space-infix-ops": "error",
+ "@stylistic/space-unary-ops": "error",
+ "@stylistic/spaced-comment": "error",
+ "@stylistic/switch-colon-spacing": "error",
+ "@stylistic/template-curly-spacing": "error",
+ "@stylistic/template-tag-spacing": "error",
+ "@stylistic/type-annotation-spacing": "error",
+ "@stylistic/type-generic-spacing": "error",
+ "@stylistic/type-named-tuple-spacing": "error",
+ "@typescript-eslint/adjacent-overload-signatures": "error",
+ "@typescript-eslint/array-type": "error",
+ "@typescript-eslint/await-thenable": "error",
+ "@typescript-eslint/ban-ts-comment": "error",
+ "@typescript-eslint/class-literal-property-style": "error",
+ "@typescript-eslint/consistent-generic-constructors": "error",
+ "@typescript-eslint/consistent-type-assertions": ["error", {
+ assertionStyle: "as",
+ objectLiteralTypeAssertions: "allow-as-parameter"
+ }],
+ "@typescript-eslint/consistent-type-definitions": "error",
+ "@typescript-eslint/consistent-type-exports": ["error", { fixMixedExportsWithInlineTypeSpecifier: true }],
+ "@typescript-eslint/consistent-type-imports": ["error", { fixStyle: "inline-type-imports" }],
+ "@typescript-eslint/dot-notation": "error",
+ "@typescript-eslint/method-signature-style": "error",
+ "@typescript-eslint/naming-convention": ["error", {
+ selector: "typeLike",
+ format: ["PascalCase"]
+ }],
+ "@typescript-eslint/no-confusing-void-expression": "error",
+ "@typescript-eslint/no-duplicate-enum-values": "error",
+ "@typescript-eslint/no-duplicate-type-constituents": "error",
+ "@typescript-eslint/no-extra-non-null-assertion": "error",
+ "@typescript-eslint/no-import-type-side-effects": "error",
+ "@typescript-eslint/no-invalid-void-type": "error",
+ "@typescript-eslint/no-misused-new": "error",
+ "@typescript-eslint/no-misused-promises": ["error", { checksVoidReturn: false }],
+ "@typescript-eslint/no-non-null-asserted-nullish-coalescing": "error",
+ "@typescript-eslint/no-non-null-asserted-optional-chain": "error",
+ "@typescript-eslint/no-redundant-type-constituents": "error",
+ "@typescript-eslint/no-require-imports": "error",
+ "@typescript-eslint/no-unnecessary-condition": ["error", { allowConstantLoopConditions: true }],
+ "@typescript-eslint/no-unnecessary-qualifier": "error",
+ "@typescript-eslint/no-unnecessary-type-arguments": "error",
+ "@typescript-eslint/no-unnecessary-type-assertion": "error",
+ "@typescript-eslint/no-unnecessary-type-constraint": "error",
+ "@typescript-eslint/no-unsafe-declaration-merging": "error",
+ "@typescript-eslint/no-unsafe-function-type": "error",
+ "@typescript-eslint/no-unused-expressions": "error",
+ "@typescript-eslint/no-useless-empty-export": "error",
+ "@typescript-eslint/non-nullable-type-assertion-style": "error",
+ "@typescript-eslint/prefer-as-const": "error",
+ "@typescript-eslint/prefer-find": "error",
+ "@typescript-eslint/prefer-function-type": "error",
+ "@typescript-eslint/prefer-includes": "error",
+ "@typescript-eslint/prefer-reduce-type-parameter": "error",
+ "@typescript-eslint/require-await": "error",
+ "@typescript-eslint/return-await": "error",
+ "@typescript-eslint/triple-slash-reference": "error",
+ "@typescript-eslint/unified-signatures": ["error", { ignoreDifferentlyNamedParameters: true }],
+ "check-file/filename-naming-convention": ["error", { "**/*": "+([.0-9A-Za-z])" }],
+ "check-file/folder-naming-convention": ["error", { "**/": "CAMEL_CASE" }],
+ "import/first": "error",
+ // https://github.com/import-js/eslint-plugin-import/issues/2913
+ // "import/newline-after-import": ["error", { considerComments: true }],
+ "import/no-absolute-path": "error",
+ "import/no-duplicates": "error",
+ "import/no-empty-named-blocks": "error",
+ "import/no-extraneous-dependencies": ["error", { includeTypes: true }],
+ "import/no-relative-packages": "error",
+ "import/no-self-import": "error",
+ "import/no-unassigned-import": "error",
+ "import/no-useless-path-segments": "error",
+ "no-useless-computed-key": "error",
+ "simple-import-sort/exports": "error",
+ "simple-import-sort/imports": ["error", { groups: [["^[^.]"]] }],
+ "unicorn/escape-case": "error",
+ "unicorn/no-hex-escape": "error",
+ "unicorn/no-zero-fractions": "error",
+ "unicorn/number-literal-case": "error",
+ "unicorn/prefer-export-from": ["error", { ignoreUsedVariables: true }],
+ "unused-imports/no-unused-imports": "error",
+ "unused-imports/no-unused-vars": ["error", {
+ args: "all",
+ argsIgnorePattern: "^_",
+ destructuredArrayIgnorePattern: "^_",
+ varsIgnorePattern: "^_"
+ }],
+ }
+ },
+ {
+ files: ["**/*"],
+ ignores: ["src/**"],
+ rules: {
+ "@typescript-eslint/no-unnecessary-type-parameters": "error",
+ "simple-import-sort/imports": ["error", {
+ groups: [
+ ["^((node:)?(assert(/strict)?|async_hooks|buffer|child_process|cluster|console|constants|crypto|dgram|diagnostics_channel|dns(/promises)?|domain|events|fs(/promises)?|http|http2|https|module|net|os|path(/(posix|win32))?|perf_hooks|process|punycode|querystring|readline(/promises)?|repl|stream(/(consumers|promises|web))?|string_decoder|timers(/promises)?|tls|trace_events|tty|url|util(/types)?|v8|vm|wasi|worker_threads|zlib)|node:test(/reporters)?)$"],
+ ["^[^.]"]
+ ]
+ }],
+ "unicorn/prefer-node-protocol": "error",
+ }
+ },
+ {
+ files: ["scripts/**", "src/**"],
+ rules: {
+ "simple-header/header": ["error", {
+ text: [
+ "discord-types",
+ "Copyright (C) {year} Vencord project contributors",
+ "SPDX-License-Identifier: GPL-3.0-or-later"
+ ],
+ templates: {
+ year: ["\\d+(-\\d+)?(, \\d+(-\\d+)?)*", `${new Date().getFullYear()}`]
+ }
+ }],
+ }
+ },
+ {
+ files: ["src/**"],
+ rules: {
+ "@typescript-eslint/ban-ts-comment": ["error", { "ts-expect-error": true }],
+ "@typescript-eslint/member-ordering": ["error", {
+ default: {
+ memberTypes: [
+ "call-signature",
+ "constructor",
+ ["static-accessor", "static-field", "static-get", "static-method", "static-set"],
+ ["accessor", "get", "method", "set"],
+ "signature",
+ "field"
+ ],
+ order: "alphabetically-case-insensitive"
+ }
+ }],
+ "@typescript-eslint/prefer-enum-initializers": "error",
+ // Disallow .d.ts files so that package consumers can use exported enums
+ "check-file/filename-blocklist": ["error", { "!**/!(*.d).ts": "!(*.d).ts" }],
+ "import/extensions": "error",
+ // Does not work with ESLint 9
+ // "import/no-default-export": "error",
+ "import/no-extraneous-dependencies": ["error", {
+ devDependencies: false,
+ includeTypes: true
+ }],
+ "import/no-unassigned-import": "error",
+ "no-restricted-globals": ["error", "_", "IntlMessageFormat", "JSX", "NodeJS", "React", "SimpleMarkdown"],
+ "no-restricted-syntax": [
+ "error",
+ `:expression:not(${[
+ // Allow ambient classes
+ "[declare=true] *",
+ // Allow enums, interfaces, and type aliases
+ "[type=/^TS/] *",
+ // Allow re-exporting of all named exports
+ "ExportAllDeclaration *",
+ // Allow imports
+ "ImportDeclaration *",
+ ].join(", ")})`,
+ // Prefer naming function parameters instead of destructuring them
+ ":matches(ArrayPattern, ObjectPattern).params",
+ // Prefer getters and setters
+ "[type=/^(TSAbstract)?AccessorProperty$/]",
+ // Disallow default exports
+ "ExportDefaultDeclaration",
+ // Disallow redundant constructor definitions
+ "ClassDeclaration[superClass=null] MethodDefinition[kind=constructor][value.params.length=0]",
+ // Disallow enums that are const or ambient since package consumers cannot use them
+ "TSEnumDeclaration:matches([const=true], [declare=true])",
+ // Disallow variance annotations
+ "TSTypeParameter:matches([in=true], [out=true])",
+ ],
+ "unicorn/numeric-separators-style": ["error", { number: { minimumDigits: 0 } }],
+ }
+ },
+ {
+ // https://github.com/import-js/eslint-plugin-import/issues/2414
+ files: ["src/**"],
+ ignores: ["src/**/index.ts"],
+ rules: {
+ "import/no-unused-modules": ["error", { missingExports: true }],
+ }
+ },
+);
diff --git a/packages/discord-types/package.json b/packages/discord-types/package.json
new file mode 100644
index 000000000..29c2c5697
--- /dev/null
+++ b/packages/discord-types/package.json
@@ -0,0 +1,50 @@
+{
+ "name": "@vencord/discord-types",
+ "version": "1.0.0",
+ "description": "",
+ "license": "GPL-3.0-or-later",
+ "author": "Vencord",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/Vendicated/Vencord.git",
+ "directory": "packages/discord-types"
+ },
+ "main": "./src/index.ts",
+ "files": ["src/**/!(tsconfig?(.*).json)"],
+ "scripts": {
+ "change-reporter": "tsx ./scripts/changeReporter/index.mts",
+ "lint": "eslint .",
+ "lint:fix": "eslint . --fix",
+ "test": "tsc --noEmit && eslint .",
+ "test-ts": "tsc --noEmit"
+ },
+ "sideEffects": false,
+ "dependencies": {
+ "@types/events": "~3.0.3",
+ "@types/intl-messageformat": "~1.3.1",
+ "@types/lodash": "~4.17.7",
+ "@types/react": "~18.2.79",
+ "dependency-graph": "0.9.0",
+ "moment": "2.22.2",
+ "simple-markdown": "0.7.2"
+ },
+ "devDependencies": {
+ "@stylistic/eslint-plugin": "^2.8.0",
+ "@types/node": "^20.16.5",
+ "@types/semver": "^7.5.8",
+ "@typescript-eslint/typescript-estree": "^8.6.0",
+ "eslint": "^9.10.0",
+ "eslint-plugin-check-file": "^2.8.0",
+ "eslint-plugin-import-x": "^4.2.1",
+ "eslint-plugin-simple-header": "^1.2.1",
+ "eslint-plugin-simple-import-sort": "^12.1.1",
+ "eslint-plugin-unicorn": "^55.0.0",
+ "eslint-plugin-unused-imports": "^4.1.4",
+ "puppeteer-core": "^23.4.0",
+ "semver": "^7.6.3",
+ "tsx": "^4.19.1",
+ "type-fest": "^4.26.1",
+ "typescript": "^5.6.2",
+ "typescript-eslint": "^8.6.0"
+ }
+}
diff --git a/packages/discord-types/scripts/changeReporter/config.mts b/packages/discord-types/scripts/changeReporter/config.mts
new file mode 100644
index 000000000..56faf3413
--- /dev/null
+++ b/packages/discord-types/scripts/changeReporter/config.mts
@@ -0,0 +1,774 @@
+/*
+ * discord-types
+ * Copyright (C) 2024 Vencord project contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+import { join } from "node:path";
+
+import type { CR } from "./types.mts";
+
+export default {
+ rootDir: join(import.meta.dirname, "../../src"),
+ deps: {
+ "../package.json": {
+ "@types/lodash": {
+ find() {
+ return this.Webpack.Common.lodash.VERSION;
+ },
+ overrides: [["4.17.x", "4.17.x"]],
+ },
+ "@types/react": {
+ find() {
+ return this.Webpack.Common.React.version;
+ },
+ overrides: [["18.2.x", "18.2.x"]],
+ },
+ moment: {
+ find() {
+ return this.Webpack.Common.moment.version;
+ },
+ },
+ },
+ },
+ src: {
+ "./flux/ActionHandlersGraph.ts": {
+ ActionHandlersGraph: {
+ type: "class",
+ },
+ },
+ "./flux/ActionLog.ts": {
+ ActionLog: {
+ type: "class",
+ find() {
+ return this.Webpack.Common.FluxDispatcher.actionLogger.log(
+ { type: Math.random().toString() as any },
+ () => {}
+ ).constructor;
+ },
+ },
+ },
+ "./flux/ActionLogger.ts": {
+ ActionLogger: {
+ type: "class",
+ },
+ },
+ "./flux/BatchedStoreListener.ts": {
+ BatchedStoreListener: {
+ type: "class",
+ },
+ },
+ "./flux/ChangeListeners.ts": {
+ ChangeListeners: {
+ type: "class",
+ },
+ },
+ "./flux/Dispatcher.ts": {
+ Dispatcher: {
+ type: "class",
+ },
+ DispatchBand: {
+ type: "enum",
+ // Screaming snake case to pascal case (source enum's keys have no underscores)
+ keyMapper: key => key.replace(/(?<=^.).+/, s => s.toLowerCase()),
+ },
+ SeverityLevel: {
+ type: "enum",
+ },
+ },
+ "./flux/Emitter.ts": {
+ Emitter: {
+ type: "class",
+ },
+ },
+ "./flux/PersistedStore.ts": {
+ PersistedStore: {
+ type: "class",
+ ignoredAdditions: {
+ // Overrides
+ staticMethodsAndFields: ["destroy"],
+ methods: ["initializeIfNeeded"],
+ },
+ },
+ },
+ "./flux/SnapshotStore.ts": {
+ SnapshotStore: {
+ type: "class",
+ ignoredRemovals: {
+ // Exists on type to enforce that subclasses have `displayName`
+ staticMethodsAndFields: ["displayName"],
+ },
+ },
+ },
+ "./flux/Store.ts": {
+ Store: {
+ type: "class",
+ },
+ },
+ "./flux/UserAgnosticStore.ts": {
+ UserAgnosticStore: {
+ type: "class",
+ find() {
+ return Object.getPrototypeOf(this.Webpack.Common.Flux.DeviceSettingsStore);
+ },
+ ignoredAdditions: {
+ // Overrides
+ methods: ["initializeFromState", "initializeIfNeeded", "getState"],
+ },
+ },
+ },
+ "./general/channels/ChannelRecord.ts": {
+ ChannelRecordBase: {
+ type: "class",
+ },
+ ChannelRecordProperties: {
+ type: "class",
+ find() {
+ const { findByCode } = this.Webpack;
+ const constructor = findByCode("}isGroupDM(") ?? findByCode("{isGroupDM(");
+ return constructor && Object.getPrototypeOf(constructor);
+ },
+ includeOptional: true,
+ ignoredRemovals: {
+ // Seems to have been removed
+ fields: ["voiceBackgroundDisplay"],
+ },
+ },
+ ForumLayout: {
+ type: "enum",
+ },
+ ThreadSortOrder: {
+ type: "enum",
+ },
+ ChannelFlags: {
+ type: "enum",
+ },
+ ThreadMemberFlags: {
+ type: "enum",
+ },
+ SafetyWarningType: {
+ type: "enum",
+ find() {
+ let key: string;
+ return new Promise(res => {
+ this.Webpack.waitFor(exps => {
+ for (const k in exps) {
+ try {
+ if (typeof exps[k]?.STRANGER_DANGER === "number") {
+ key = k;
+ return true;
+ }
+ } catch {}
+ }
+ return false;
+ }, exps => { res(exps[key]); });
+ });
+ },
+ },
+ ChannelType: {
+ type: "enum",
+ },
+ VideoQualityMode: {
+ type: "enum",
+ },
+ /*
+ // This seems to have been removed
+ VoiceCallBackgroundType: {
+ type: "enum",
+ },
+ */
+ },
+ "./general/channels/ForumChannelRecord.ts": {
+ ForumChannelRecord: {
+ type: "class",
+ find() {
+ const castChannelRecord = this.Webpack.findByCode(".GUILD_TEXT]", "return(");
+ return castChannelRecord({ type: /* GUILD_FORUM */ 15 }).constructor;
+ },
+ ignoredRemovals: {
+ fields: true,
+ },
+ },
+ },
+ "./general/channels/GuildTextualChannelRecord.ts": {
+ GuildTextualChannelRecordBase: {
+ type: "class",
+ find() {
+ const castChannelRecord = this.Webpack.findByCode(".GUILD_TEXT]", "return(");
+ return Object.getPrototypeOf(castChannelRecord({ type: /* GUILD_TEXT */ 0 }).constructor);
+ },
+ ignoredRemovals: {
+ fields: true,
+ },
+ },
+ },
+ "./general/channels/GuildVocalChannelRecord.ts": {
+ GuildVocalChannelRecordBase: {
+ type: "class",
+ find() {
+ const castChannelRecord = this.Webpack.findByCode(".GUILD_TEXT]", "return(");
+ return Object.getPrototypeOf(castChannelRecord({ type: /* GUILD_VOICE */ 2 }).constructor);
+ },
+ ignoredRemovals: {
+ fields: true,
+ },
+ },
+ },
+ "./general/channels/PrivateChannelRecord.ts": {
+ PrivateChannelRecordBase: {
+ type: "class",
+ find() {
+ const castChannelRecord = this.Webpack.findByCode(".GUILD_TEXT]", "return(");
+ return Object.getPrototypeOf(castChannelRecord({ type: /* GROUP_DM */ 3 }).constructor);
+ },
+ ignoredAdditions: {
+ // Overrides
+ methods: ["isSystemDM"],
+ },
+ ignoredRemovals: {
+ fields: true,
+ },
+ },
+ },
+ "./general/channels/ThreadChannelRecord.ts": {
+ ThreadChannelRecord: {
+ type: "class",
+ find() {
+ const castChannelRecord = this.Webpack.findByCode(".GUILD_TEXT]", "return(");
+ return castChannelRecord({ type: /* PUBLIC_THREAD */ 11 }).constructor;
+ },
+ ignoredRemovals: {
+ fields: true,
+ },
+ },
+ },
+ "./general/channels/UnknownChannelRecord.ts": {
+ UnknownChannelRecord: {
+ type: "class",
+ find() {
+ return this.Webpack.findByCode(".UNKNOWN", "static fromServer(");
+ },
+ ignoredRemovals: {
+ fields: true,
+ },
+ },
+ },
+ "./general/emojis/Emoji.ts": {
+ UnicodeEmoji: {
+ type: "class",
+ },
+ EmojiType: {
+ type: "enum",
+ },
+ },
+ "./general/emojis/EmojiDisambiguations.ts": {
+ EmojiDisambiguations: {
+ type: "class",
+ find() {
+ return this.Webpack.Common.EmojiStore.getDisambiguatedEmojiContext().constructor;
+ },
+ },
+ },
+ "./general/emojis/GuildEmojis.ts": {
+ GuildEmojis: {
+ type: "class",
+ async find() {
+ const { Common } = this.Webpack;
+ let values = Object.values(Common.EmojiStore.getGuilds());
+ if (values.length <= 0)
+ await new Promise(res => {
+ Common.EmojiStore.addConditionalChangeListener(() => {
+ values = Object.values(Common.EmojiStore.getGuilds());
+ if (values.length > 0) {
+ res();
+ return false;
+ }
+ });
+ Common.InstantInviteActionCreators.acceptInvite({
+ inviteKey: "discord-townhall"
+ }).catch(() => {});
+ });
+ return values[0]?.constructor;
+ },
+ },
+ },
+ "./general/messages/ChannelMessages.ts": {
+ ChannelMessages: {
+ type: "class",
+ },
+ JumpType: {
+ type: "enum",
+ },
+ },
+ "./general/messages/InteractionRecord.ts": {
+ InteractionRecord: {
+ type: "class",
+ },
+ InteractionType: {
+ type: "enum",
+ // From the API documentation
+ ignoredRemovals: [["PING"]],
+ },
+ },
+ "./general/messages/MessageCache.ts": {
+ MessageCache: {
+ type: "class",
+ find() {
+ return this.Webpack.Common.MessageStore.getMessages("")._after.constructor;
+ },
+ },
+ },
+ "./general/messages/MessageRecord.ts": {
+ MessageRecord: {
+ type: "class",
+ },
+ ActivityActionType: {
+ type: "enum",
+ // From the API documentation
+ ignoredRemovals: [["SPECTATE"]],
+ },
+ MessageReferenceType: {
+ type: "enum",
+ },
+ PollLayoutType: {
+ type: "enum",
+ },
+ PurchaseNotificationType: {
+ type: "enum",
+ },
+ ReactionType: {
+ type: "enum",
+ },
+ MessageState: {
+ type: "enum",
+ },
+ },
+ "./general/messages/MessageSnapshotRecord.ts": {
+ MessageSnapshotRecord: {
+ type: "class",
+ },
+ },
+ "./general/messages/MinimalMessageRecord.ts": {
+ MinimalMessageRecord: {
+ type: "class",
+ },
+ MessageAttachmentFlags: {
+ type: "enum",
+ },
+ CodedLinkType: {
+ type: "enum",
+ },
+ MessageButtonComponentStyle: {
+ type: "enum",
+ },
+ MessageSelectComponentOptionType: {
+ type: "enum",
+ },
+ MessageSelectComponentDefaultValueType: {
+ type: "enum",
+ find() {
+ let key: string;
+ return new Promise(res => {
+ this.Webpack.waitFor(exps => {
+ for (const k in exps) {
+ try {
+ if (exps[k]?.ROLE === "role") {
+ key = k;
+ return true;
+ }
+ } catch {}
+ }
+ return false;
+ }, exps => { res(exps[key]); });
+ });
+ },
+ },
+ MessageTextInputComponentStyle: {
+ type: "enum",
+ },
+ ContentScanFlags: {
+ type: "enum",
+ },
+ SeparatorSpacingSize: {
+ type: "enum",
+ },
+ MessageComponentType: {
+ type: "enum",
+ },
+ MessageEmbedFlags: {
+ type: "enum",
+ },
+ MessageEmbedType: {
+ type: "enum",
+ },
+ MessageFlags: {
+ type: "enum",
+ },
+ StickerFormat: {
+ type: "enum",
+ },
+ MetaStickerType: {
+ type: "enum"
+ },
+ MessageType: {
+ type: "enum",
+ },
+ },
+ "./general/Activity.ts": {
+ ActivityFlags: {
+ type: "enum",
+ },
+ ActivityGamePlatform: {
+ type: "enum",
+ },
+ ActivityPlatform: {
+ type: "enum",
+ },
+ ActivityType: {
+ type: "enum",
+ },
+ },
+ "./general/ApplicationCommand.ts": {
+ InteractionContextType: {
+ type: "enum",
+ },
+ ApplicationCommandType: {
+ type: "enum",
+ },
+ ApplicationCommandOptionType: {
+ type: "enum",
+ },
+ },
+ "./general/ApplicationRecord.ts": {
+ ApplicationRecord: {
+ type: "class",
+ },
+ EmbeddedActivitySupportedPlatform: {
+ type: "enum",
+ },
+ EmbeddedActivityLabelType: {
+ type: "enum",
+ },
+ OrientationLockState: {
+ type: "enum",
+ },
+ ApplicationIntegrationType: {
+ type: "enum",
+ },
+ OAuth2Scope: {
+ type: "enum",
+ },
+ ApplicationFlags: {
+ type: "enum",
+ },
+ ApplicationOverlayMethodFlags: {
+ type: "enum",
+ },
+ ApplicationType: {
+ type: "enum",
+ },
+ },
+ "./general/Clan.ts": {
+ ClanBadgeKind: {
+ type: "enum",
+ },
+ ClanBannerKind: {
+ type: "enum",
+ },
+ ClanPlaystyle: {
+ type: "enum",
+ },
+ },
+ "./general/CompanyRecord.ts": {
+ CompanyRecord: {
+ type: "class",
+ },
+ },
+ "./general/DisplayProfile.ts": {
+ DisplayProfile: {
+ type: "class",
+ },
+ },
+ "./general/Draft.ts": {
+ DraftType: {
+ type: "enum",
+ // Screaming snake case to pascal case
+ keyMapper: key => key.replaceAll(
+ /(?:^|_)(.)([^_]*)/g,
+ (_, first: string, rest: string) => first.toUpperCase() + rest.toLowerCase()
+ ),
+ },
+ },
+ "./general/Frecency.ts": {
+ Frecency: {
+ type: "class",
+ },
+ },
+ "./general/GuildMember.ts": {
+ GuildMemberFlags: {
+ type: "enum",
+ },
+ },
+ "./general/GuildRecord.ts": {
+ GuildRecord: {
+ type: "class",
+ ignoredAdditions: {
+ // Overrides
+ methods: ["merge", "toString"],
+ },
+ },
+ UserNotificationSetting: {
+ type: "enum",
+ },
+ GuildExplicitContentFilterType: {
+ type: "enum",
+ },
+ GuildFeature: {
+ type: "enum",
+ },
+ /*
+ // Not exported; cannot be found
+ GuildHubType: {
+ type: "enum",
+ },
+ */
+ MFALevel: {
+ type: "enum",
+ },
+ GuildNSFWContentLevel: {
+ type: "enum",
+ },
+ BoostedGuildTier: {
+ type: "enum",
+ },
+ SystemChannelFlags: {
+ type: "enum",
+ },
+ VerificationLevel: {
+ type: "enum",
+ },
+ },
+ "./general/Permissions.ts": {
+ /*
+ // bigint enums are not yet possible: https://github.com/microsoft/TypeScript/issues/37783
+ Permissions: {
+ type: "enum",
+ },
+ */
+ PermissionOverwriteType: {
+ type: "enum",
+ },
+ },
+ "./general/ReadState.ts": {
+ ReadState: {
+ type: "class",
+ async find() {
+ const { Common } = this.Webpack;
+ let me = Common.UserStore.getCurrentUser();
+ if (!me)
+ await new Promise(res => {
+ Common.UserStore.addConditionalChangeListener(() => {
+ me = Common.UserStore.getCurrentUser();
+ if (me) {
+ res();
+ return false;
+ }
+ });
+ });
+ return Common.ReadStateStore.getNotifCenterReadState(me!.id)?.constructor;
+ },
+ },
+ ChannelNotificationSettingsFlags: {
+ type: "enum",
+ },
+ /*
+ // Not exported; cannot be found
+ ReadStateFlags: {
+ type: "enum",
+ },
+ */
+ ReadStateType: {
+ type: "enum",
+ },
+ },
+ "./general/Record.ts": {
+ RecordBase: {
+ type: "class",
+ ignoredRemovals: {
+ // Exists on type to enforce that subclasses have a valid constructor
+ constructorDefinition: true,
+ },
+ },
+ },
+ "./general/Role.ts": {
+ RoleFlags: {
+ type: "enum",
+ },
+ },
+ "./general/UserProfile.ts": {
+ PlatformType: {
+ type: "enum",
+ },
+ },
+ "./general/UserRecord.ts": {
+ UserRecord: {
+ type: "class",
+ ignoredAdditions: {
+ // Overrides
+ methods: ["toString"],
+ },
+ },
+ UserFlags: {
+ type: "enum",
+ // From the API documentation
+ ignoredRemovals: [["TEAM_PSEUDO_USER"]],
+ },
+ PremiumType: {
+ type: "enum",
+ },
+ },
+ "./i18n/FormattedMessage.ts": {
+ FormattedMessage: {
+ type: "class",
+ },
+ ASTNodeType: {
+ type: "enum",
+ // Undocumented
+ ignoredRemovals: [["HOOK"]],
+ },
+ },
+ "./i18n/I18N.ts": {
+ I18N: {
+ type: "class",
+ },
+ },
+ "./i18n/Provider.ts": {
+ Provider: {
+ type: "class",
+ find() {
+ const { constructor } = this.Webpack.Common.i18n._provider;
+ return [Object.getPrototypeOf(constructor), constructor];
+ },
+ },
+ },
+ "./stores/ApplicationStore.ts": {
+ ApplicationStore: {
+ type: "class",
+ },
+ },
+ "./stores/ChannelStore.ts": {
+ ChannelStore: {
+ type: "class",
+ },
+ },
+ "./stores/DraftStore.ts": {
+ DraftStore: {
+ type: "class",
+ ignoredAdditions: {
+ // Overrides
+ staticMethodsAndFields: ["migrations"],
+ },
+ },
+ },
+ "./stores/EmojiStore.ts": {
+ EmojiStore: {
+ type: "class",
+ ignoredAdditions: {
+ // Overrides
+ staticMethodsAndFields: ["migrations"],
+ },
+ },
+ EmojiIntention: {
+ type: "enum",
+ },
+ },
+ "./stores/GuildChannelStore.ts": {
+ GuildChannelStore: {
+ type: "class",
+ },
+ },
+ "./stores/GuildMemberStore.ts": {
+ GuildMemberStore: {
+ type: "class",
+ },
+ },
+ "./stores/GuildStore.ts": {
+ GuildStore: {
+ type: "class",
+ },
+ },
+ "./stores/MessageStore.ts": {
+ MessageStore: {
+ type: "class",
+ },
+ },
+ "./stores/PermissionStore.ts": {
+ PermissionStore: {
+ type: "class",
+ },
+ },
+ "./stores/PresenceStore.ts": {
+ PresenceStore: {
+ type: "class",
+ },
+ ClientType: {
+ type: "enum",
+ // Undocumented
+ ignoredRemovals: [["EMBEDDED"]],
+ },
+ StatusType: {
+ type: "enum",
+ },
+ },
+ "./stores/ReadStateStore.ts": {
+ ReadStateStore: {
+ type: "class",
+ },
+ },
+ "./stores/RelationshipStore.ts": {
+ RelationshipStore: {
+ type: "class",
+ },
+ RelationshipType: {
+ type: "enum",
+ },
+ },
+ "./stores/SelectedChannelStore.ts": {
+ SelectedChannelStore: {
+ type: "class",
+ },
+ },
+ "./stores/SelectedGuildStore.ts": {
+ SelectedGuildStore: {
+ type: "class",
+ },
+ },
+ "./stores/ThemeStore.ts": {
+ ThemeStore: {
+ type: "class",
+ ignoredAdditions: {
+ // Overrides
+ staticMethodsAndFields: ["migrations"],
+ },
+ },
+ Theme: {
+ type: "enum",
+ },
+ },
+ "./stores/UserProfileStore.ts": {
+ UserProfileStore: {
+ type: "class",
+ },
+ },
+ "./stores/UserStore.ts": {
+ UserStore: {
+ type: "class",
+ },
+ },
+ "./stores/WindowStore.ts": {
+ WindowStore: {
+ type: "class",
+ },
+ },
+ },
+} satisfies CR.ReporterConfig;
diff --git a/packages/discord-types/scripts/changeReporter/finds/classes.mts b/packages/discord-types/scripts/changeReporter/finds/classes.mts
new file mode 100644
index 000000000..e57939d90
--- /dev/null
+++ b/packages/discord-types/scripts/changeReporter/finds/classes.mts
@@ -0,0 +1,279 @@
+/*
+ * discord-types
+ * Copyright (C) 2024 Vencord project contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+// eslint-disable-next-line import/no-relative-packages
+import type * as Vencord from "../../../../../src/Vencord.ts";
+import type { CR } from "../types.mts";
+
+export function autoFindStore(this: typeof Vencord, source: CR.ClassMembers, name: string) {
+ const persistKeyRE = new RegExp(`^${name}(?:V\\d+)?$`);
+
+ const store: { constructor: CR.Class; } | undefined = this.Webpack.find(exp => {
+ // Find stores from exported instances
+ const { constructor } = exp;
+ return typeof constructor === "function" && (
+ constructor.displayName === name
+ || persistKeyRE.test(constructor.persistKey)
+ );
+ });
+
+ if (store)
+ return getClassChanges(source, [store.constructor]);
+}
+
+export function autoFindClass(this: typeof Vencord, source: CR.ClassMembers) {
+ let bestMatch: CR.ClassChanges | undefined;
+ let lowestChangedCount = Infinity;
+
+ const checked = new WeakSet();
+ this.Webpack.find(exps => {
+ for (const name in exps) {
+ let constructor: CR.Class;
+ // Some getters throw errors
+ try {
+ // Find classes from exported constructors
+ if (isValidClass(exps[name]))
+ constructor = exps[name];
+ // Find classes from exported instances
+ else if (isValidClass(exps[name]?.constructor))
+ ({ constructor } = exps[name]);
+ else
+ continue;
+ } catch {
+ continue;
+ }
+
+ if (!checked.has(constructor)) {
+ checked.add(constructor);
+
+ const changes = getClassChanges(source, [constructor]);
+ const { changedCount } = changes;
+ if (changedCount < lowestChangedCount) {
+ lowestChangedCount = changedCount;
+ bestMatch = changes;
+ }
+ }
+ }
+
+ return false;
+ }, { isIndirect: true });
+
+ return bestMatch;
+}
+
+export function isValidClass(value: unknown): value is CR.Class {
+ if (typeof value !== "function")
+ return false;
+ const { prototype } = value;
+ return typeof prototype === "object" && prototype !== null;
+}
+
+export function getClassChanges(
+ source: CR.ClassMembers,
+ constructors: readonly [CR.Class, ...CR.Class[]] | readonly [...CR.Class[], CR.Class]
+): CR.ClassChanges {
+ let hasConstructorDefinition = false;
+ const constructorDescriptors = new Map();
+ const prototypeDescriptors = new Map();
+ const matchedFields = new Set();
+
+ // Ignore constructor definitions without parameters
+ const constructorRE = /[{}]constructor\([^)]/;
+ const fieldRE = /(?<=[{}]constructor\(.+?{.+\(this,")[^"]+(?=",)/g;
+ for (const constructor of constructors) {
+ const constructorString = constructor.toString();
+
+ if (constructorRE.test(constructorString))
+ hasConstructorDefinition = true;
+
+ const constDescriptors = Object.getOwnPropertyDescriptors(constructor);
+ for (const key of Object.getOwnPropertyNames(constructor))
+ constructorDescriptors.set(key, constDescriptors[key]!);
+ for (const key of Object.getOwnPropertySymbols(constructor))
+ constructorDescriptors.set(key, constDescriptors[key]!);
+
+ const { prototype } = constructor;
+ const protoDescriptors = Object.getOwnPropertyDescriptors(prototype);
+ for (const key of Object.getOwnPropertyNames(prototype))
+ prototypeDescriptors.set(key, protoDescriptors[key]!);
+ for (const key of Object.getOwnPropertySymbols(prototype))
+ prototypeDescriptors.set(key, protoDescriptors[key]!);
+
+ for (const [field] of constructorString.matchAll(fieldRE))
+ matchedFields.add(field);
+ }
+
+ const additions: CR.ClassMembers = {
+ constructorDefinition: false,
+ staticMethodsAndFields: [],
+ staticGetters: [],
+ staticSetters: [],
+ methods: [],
+ getters: [],
+ setters: [],
+ fields: []
+ };
+ let unchangedCount = 0;
+ let changedCount = 0;
+
+ // Constructor definition with parameters removal
+ let constructorDefinition = false;
+
+ if (hasConstructorDefinition) {
+ if (source.constructorDefinition) {
+ unchangedCount++;
+ } else {
+ additions.constructorDefinition = true;
+ changedCount++;
+ }
+ } else if (source.constructorDefinition) {
+ constructorDefinition = true;
+ changedCount++;
+ } else {
+ unchangedCount++;
+ }
+
+ // Static member removals
+ const staticMethodsAndFields = new Set(source.staticMethodsAndFields);
+ const staticGetters = new Set(source.staticGetters);
+ const staticSetters = new Set(source.staticSetters);
+
+ const ignoredConstructorKeys = new Set(["length", "name", "prototype"]);
+ for (const [rawKey, descriptor] of constructorDescriptors) {
+ if (ignoredConstructorKeys.has(rawKey)) continue;
+
+ const key = rawKey.toString();
+
+ if (descriptor.get) {
+ if (staticGetters.has(key)) {
+ staticGetters.delete(key);
+ unchangedCount++;
+ } else {
+ additions.staticGetters.push(key);
+ changedCount++;
+ }
+
+ if (descriptor.set) {
+ if (staticSetters.has(key)) {
+ staticSetters.delete(key);
+ unchangedCount++;
+ } else {
+ additions.staticSetters.push(key);
+ changedCount++;
+ }
+ }
+
+ continue;
+ }
+
+ if (descriptor.set) {
+ if (staticSetters.has(key)) {
+ staticSetters.delete(key);
+ unchangedCount++;
+ } else {
+ additions.staticSetters.push(key);
+ changedCount++;
+ }
+ continue;
+ }
+
+ if (staticMethodsAndFields.has(key)) {
+ staticMethodsAndFields.delete(key);
+ unchangedCount++;
+ } else {
+ additions.staticMethodsAndFields.push(key);
+ changedCount++;
+ }
+ }
+
+ changedCount += staticMethodsAndFields.size + staticGetters.size + staticSetters.size;
+
+ // Instance method and getter/setter removals
+ const methods = new Set(source.methods);
+ const getters = new Set(source.getters);
+ const setters = new Set(source.setters);
+
+ const ignoredPrototypeKeys = new Set(["constructor"]);
+ for (const [rawKey, descriptor] of prototypeDescriptors) {
+ if (ignoredPrototypeKeys.has(rawKey)) continue;
+
+ const key = rawKey.toString();
+
+ if (descriptor.get) {
+ if (getters.has(key)) {
+ getters.delete(key);
+ unchangedCount++;
+ } else {
+ additions.getters.push(key);
+ changedCount++;
+ }
+
+ if (descriptor.set) {
+ if (setters.has(key)) {
+ setters.delete(key);
+ unchangedCount++;
+ } else {
+ additions.setters.push(key);
+ changedCount++;
+ }
+ }
+
+ continue;
+ }
+
+ if (descriptor.set) {
+ if (setters.has(key)) {
+ setters.delete(key);
+ unchangedCount++;
+ } else {
+ additions.setters.push(key);
+ changedCount++;
+ }
+ continue;
+ }
+
+ if (methods.has(key)) {
+ methods.delete(key);
+ unchangedCount++;
+ } else {
+ additions.methods.push(key);
+ changedCount++;
+ }
+ }
+
+ changedCount += methods.size + getters.size + setters.size;
+
+ // Field removals
+ const fields = new Set(source.fields);
+
+ for (const field of matchedFields) {
+ if (fields.has(field)) {
+ fields.delete(field);
+ unchangedCount++;
+ } else {
+ additions.fields.push(field);
+ changedCount++;
+ }
+ }
+
+ changedCount += fields.size;
+
+ return {
+ additions,
+ removals: {
+ constructorDefinition,
+ staticMethodsAndFields: [...staticMethodsAndFields],
+ staticGetters: [...staticGetters],
+ staticSetters: [...staticSetters],
+ methods: [...methods],
+ getters: [...getters],
+ setters: [...setters],
+ fields: [...fields]
+ },
+ unchangedCount,
+ changedCount
+ };
+}
diff --git a/packages/discord-types/scripts/changeReporter/finds/enums.mts b/packages/discord-types/scripts/changeReporter/finds/enums.mts
new file mode 100644
index 000000000..860d05547
--- /dev/null
+++ b/packages/discord-types/scripts/changeReporter/finds/enums.mts
@@ -0,0 +1,90 @@
+/*
+ * discord-types
+ * Copyright (C) 2024 Vencord project contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+// eslint-disable-next-line import/no-relative-packages
+import type * as Vencord from "../../../../../src/Vencord.ts";
+import type { CR } from "../types.mts";
+
+export function autoFindEnum(this: typeof Vencord, source: CR.EnumSource) {
+ let bestMatch: CR.EnumChanges | undefined;
+ let lowestChangedCount = Infinity;
+
+ const checked = new WeakSet();
+ this.Webpack.find(exps => {
+ for (const name in exps) {
+ let exp: unknown;
+ // Some getters throw errors
+ try {
+ exp = exps[name];
+ } catch {
+ continue;
+ }
+
+ if (isValidEnum(exp) && !checked.has(exp)) {
+ checked.add(exp);
+
+ const changes = getEnumChanges(source, exp);
+ const { changedCount } = changes;
+ if (
+ changedCount < lowestChangedCount
+ // If changedCount is the same as lowestChangedCount, keep the match with the least removals.
+ || changedCount === lowestChangedCount
+ && bestMatch
+ && Object.keys(changes.removals).length < Object.keys(bestMatch.removals).length
+ ) {
+ lowestChangedCount = changedCount;
+ bestMatch = changes;
+ }
+ }
+ }
+
+ return false;
+ }, { isIndirect: true });
+
+ return bestMatch;
+}
+
+export function isValidEnum(value: unknown): value is CR.EnumMembers {
+ return typeof value === "object"
+ && value !== null
+ && !Array.isArray(value);
+}
+
+export function getEnumChanges(source: CR.EnumSource, obj: CR.EnumMembers): CR.EnumChanges {
+ const additions: CR.EnumMembers = {};
+ const removals: CR.EnumMembers = { ...source };
+ let unchangedCount = 0;
+ let changedCount = 0;
+
+ for (const key in obj) {
+ // Ignore numeric enum reverse mapping
+ if (parseFloat(key) === Number(key)) continue;
+
+ // Some getters throw errors
+ try {
+ const value = obj[key]!;
+ if (key in source && value === source[key]) {
+ delete removals[key];
+ unchangedCount++;
+ } else {
+ additions[key] = value;
+ changedCount++;
+ }
+ } catch {
+ changedCount = Infinity;
+ break;
+ }
+ }
+
+ changedCount += Object.keys(removals).length;
+
+ return {
+ additions,
+ removals,
+ unchangedCount,
+ changedCount
+ };
+}
diff --git a/packages/discord-types/scripts/changeReporter/index.mts b/packages/discord-types/scripts/changeReporter/index.mts
new file mode 100644
index 000000000..931526619
--- /dev/null
+++ b/packages/discord-types/scripts/changeReporter/index.mts
@@ -0,0 +1,83 @@
+/*
+ * discord-types
+ * Copyright (C) 2024 Vencord project contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import process from "node:process";
+
+import puppeteer from "puppeteer-core";
+
+import { assertEnvValidity } from "../utils.mjs";
+import config from "./config.mjs";
+import { autoFindClass, autoFindStore, getClassChanges, isValidClass } from "./finds/classes.mjs";
+import { autoFindEnum, getEnumChanges, isValidEnum } from "./finds/enums.mjs";
+import { logSummary } from "./logging/summaries.mjs";
+import { postError, postReport } from "./logging/webhooks.mjs";
+import { getChangeReport } from "./reports/getChangeReport.mjs";
+
+process.on("uncaughtExceptionMonitor", error => {
+ const { DISCORD_WEBHOOK, CHANNEL } = process.env;
+ postError(error, DISCORD_WEBHOOK, CHANNEL);
+});
+
+assertEnvValidity(process.env, {
+ CHANNEL: ["stable", "ptb", "canary"],
+ CHROMIUM_BIN: true,
+ CHROMIUM_VERSION: /^\d+(?:\.|$)/,
+ DISCORD_TOKEN: true,
+ DISCORD_WEBHOOK: false,
+ VENCORD_DIST: true,
+});
+
+const { CHANNEL, CHROMIUM_BIN, CHROMIUM_VERSION, DISCORD_TOKEN, DISCORD_WEBHOOK, VENCORD_DIST } = process.env;
+const CWD = process.cwd();
+
+const browser = await puppeteer.launch({
+ executablePath: CHROMIUM_BIN,
+ headless: true,
+ args: ["--no-sandbox"]
+});
+
+const page = await browser.newPage();
+await page.setUserAgent(`Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${CHROMIUM_VERSION.split(".", 1)[0]}.0.0.0 Safari/537.36`);
+await page.setBypassCSP(true);
+
+await page.evaluateOnNewDocument(`(() => {
+ "use strict";
+ if (/(?:^|\\.)discord\\.com$/.test(location.hostname)) {
+ ${readFileSync(join(CWD, VENCORD_DIST), "utf-8")};
+ window.Vencord = Vencord;
+ window.CHANGE_REPORTER_LOGGED_IN = new Promise(res => {
+ Vencord.Webpack.waitFor("loginToken", async exps => {
+ await exps.loginToken(${JSON.stringify(DISCORD_TOKEN)});
+ res();
+ });
+ });
+ }
+})();
+`);
+
+await page.goto(`https://${CHANNEL === "stable" ? "" : CHANNEL + "."}discord.com/login`);
+await page.evaluate(`(async () => {
+ "use strict";
+ await CHANGE_REPORTER_LOGGED_IN;
+ window.autoFindStore = (${autoFindStore}).bind(Vencord);
+ window.autoFindClass = (${autoFindClass}).bind(Vencord);
+ window.isValidClass = ${isValidClass};
+ window.getClassChanges = ${getClassChanges};
+ window.autoFindEnum = (${autoFindEnum}).bind(Vencord);
+ window.isValidEnum = ${isValidEnum};
+ window.getEnumChanges = ${getEnumChanges};
+})();
+`);
+
+const report = await getChangeReport(page, config);
+browser.close();
+
+logSummary(report, CHANNEL);
+
+if (DISCORD_WEBHOOK)
+ postReport(report, DISCORD_WEBHOOK, CHANNEL);
diff --git a/packages/discord-types/scripts/changeReporter/logging/summaries.mts b/packages/discord-types/scripts/changeReporter/logging/summaries.mts
new file mode 100644
index 000000000..a182a174d
--- /dev/null
+++ b/packages/discord-types/scripts/changeReporter/logging/summaries.mts
@@ -0,0 +1,277 @@
+/*
+ * discord-types
+ * Copyright (C) 2024 Vencord project contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+import console from "node:console";
+import { writeFile } from "node:fs/promises";
+import process from "node:process";
+
+import type { CR } from "../types.mts";
+import { capitalize, codeBlock, formatChannel, formatEnumEntryList, formatKeyList, formatWarnList } from "./utils.mjs";
+
+export function logSummary(report: CR.ChangeReport, channel?: string) {
+ const { deps, src } = report;
+
+ let summary = `# Change Report (${formatChannel(channel)})\n`;
+
+ let sections = "";
+
+ if (deps.length > 0) {
+ sections += `## ${deps.length} file${deps.length === 1 ? "" : "s"} with watched dependencies:\n`;
+
+ let fileToLogCount = 0;
+ let section = "";
+
+ for (const report of deps) {
+ const { fileName, fileError, fileWarns, passed, warned, failed, errored } = report;
+
+ const toLogCount = warned.length + failed.length + errored.length;
+ if (
+ toLogCount <= 0
+ && fileError === undefined
+ && fileWarns.length <= 0
+ ) continue;
+ fileToLogCount++;
+
+ const count = passed.length + toLogCount;
+ section += `### \`${fileName}\`:\n`
+ + `${fileWarns.length} file-level warning${fileWarns.length === 1 ? "" : "s"}`
+ + (fileWarns.length > 0 ? ": \n" + formatWarnList(fileWarns) : ". \n")
+ + `${count} watched dependenc${count === 1 ? "y" : "ies"}: \n`;
+
+ if (fileError === undefined) {
+ section += `* ${passed.length} passed without warnings.\n`;
+
+ section += `* ${warned.length} passed with warnings`;
+ if (warned.length > 0) {
+ section += ":\n";
+ for (const { name, warns } of warned)
+ section += ` * The report for \`${name}\` has ${warns.length} warning${warns.length === 1 ? "" : "s"}:\n`
+ + formatWarnList(warns, 1);
+ } else
+ section += ".\n";
+
+ section += `* ${failed.length} failed`;
+ if (failed.length > 0) {
+ section += ":\n";
+ for (const { name, packageVersionRange, discordVersion, expectedVersionRange, warns } of failed)
+ section += ` * \`${name}\`: Expected range \`${expectedVersionRange}\` given range \`${discordVersion}\``
+ + `, but got range \`${packageVersionRange}\`.\n`
+ + (warns.length > 0
+ ? ` * The report for \`${name}\` has ${warns.length} warning${warns.length === 1 ? "" : "s"}:\n`
+ + formatWarnList(warns, 1)
+ : "");
+ } else
+ section += ".\n";
+
+ section += `* ${errored.length} errored`;
+ if (errored.length > 0) {
+ section += ":\n";
+ for (const { name, packageVersionRange, discordVersion, expectedVersionRange, error, warns } of errored)
+ section += ` * \`${name}\`: \`${fileName}\` version range: \`${packageVersionRange}\``
+ + `, Found version: \`${discordVersion}\``
+ + `, Expected version range: \`${expectedVersionRange}\`\n`
+ + (warns.length > 0
+ ? ` * The report for \`${name}\` has ${warns.length} warning${warns.length === 1 ? "" : "s"}:\n`
+ + formatWarnList(warns, 1)
+ : "")
+ + ` * The report for \`${name}\` has an error:\n` + codeBlock(error, 1);
+ } else
+ section += ".\n\n";
+ } else
+ section += "File-level error: \n" + codeBlock(fileError);
+ }
+
+ if (fileToLogCount > 0) {
+ const fileToNotLogCount = deps.length - fileToLogCount;
+ sections += `### ${fileToNotLogCount} file${fileToNotLogCount === 1 ? " has" : "s have"}`
+ + " no file-level errors, file-level warnings, or watched dependencies that failed or have warnings.\n" + section;
+ } else
+ sections += "### No file-level errors or warnings.\n"
+ + "### All watched dependencies passed without warnings.\n";
+ }
+
+ if (src.length > 0) {
+ sections += `## ${src.length} file${src.length === 1 ? "" : "s"} with watched declarations:\n`;
+
+ let fileToLogCount = 0;
+ let section = "";
+
+ for (const report of src) {
+ const { fileName, fileError, fileWarns, unchanged, warned, changed, errored } = report;
+
+ const toLogCount = warned.length + changed.length + errored.length;
+ if (
+ toLogCount <= 0
+ && fileError === undefined
+ && fileWarns.length <= 0
+ ) continue;
+ fileToLogCount++;
+
+ const count = unchanged.length + toLogCount;
+ section += `### \`${fileName}\`:\n`
+ + `${fileWarns.length} file-level warning${fileWarns.length === 1 ? "" : "s"}`
+ + (fileWarns.length > 0 ? ": \n" + formatWarnList(fileWarns) : ". \n")
+ + `${count} watched declaration${count === 1 ? "" : "s"}: \n`;
+
+ if (fileError === undefined) {
+ section += `* ${unchanged.length} ${unchanged.length === 1 ? "is" : "are"} unchanged without warnings.\n`;
+
+ section += `* ${warned.length} ${warned.length === 1 ? "is" : "are"} unchanged with warnings`;
+ if (warned.length > 0) {
+ section += ":\n";
+ for (const { type, identifier, warns } of warned)
+ section += ` * The report for ${type} \`${identifier}\` has ${warns.length} warning${warns.length === 1 ? "" : "s"}:\n`
+ + formatWarnList(warns, 1);
+ } else
+ section += ".\n";
+
+ section += `* ${changed.length} ha${changed.length === 1 ? "s" : "ve"} changes`;
+ if (changed.length > 0) {
+ section += ":\n";
+ for (const { type, identifier, changes, warns } of changed) {
+ let additionCount = 0;
+ let added = "";
+
+ let removalCount = 0;
+ let removed = "";
+
+ switch (type) {
+ case "class": {
+ const { additions, removals } = changes;
+
+ if (additions.constructorDefinition) {
+ additionCount++;
+ added += " * Constructor definition with parameters\n";
+ }
+ if (additions.staticMethodsAndFields.length > 0) {
+ additionCount += additions.staticMethodsAndFields.length;
+ added += " * Static methods and fields:\n" + formatKeyList(additions.staticMethodsAndFields, 4);
+ }
+ if (additions.staticGetters.length > 0) {
+ additionCount += additions.staticGetters.length;
+ added += " * Static getters:\n" + formatKeyList(additions.staticGetters, 4);
+ }
+ if (additions.staticSetters.length > 0) {
+ additionCount += additions.staticSetters.length;
+ added += " * Static setters:\n" + formatKeyList(additions.staticSetters, 4);
+ }
+ if (additions.methods.length > 0) {
+ additionCount += additions.methods.length;
+ added += " * Instance methods:\n" + formatKeyList(additions.methods, 4);
+ }
+ if (additions.getters.length > 0) {
+ additionCount += additions.getters.length;
+ added += " * Getters:\n" + formatKeyList(additions.getters, 4);
+ }
+ if (additions.setters.length > 0) {
+ additionCount += additions.setters.length;
+ added += " * Setters:\n" + formatKeyList(additions.setters, 4);
+ }
+ if (additions.fields.length > 0) {
+ additionCount += additions.fields.length;
+ added += " * Fields:\n" + formatKeyList(additions.fields, 4);
+ }
+
+ if (removals.constructorDefinition) {
+ removalCount++;
+ removed += " * Constructor definition with parameters\n";
+ }
+ if (removals.staticMethodsAndFields.length > 0) {
+ removalCount += removals.staticMethodsAndFields.length;
+ removed += " * Static methods and fields:\n" + formatKeyList(removals.staticMethodsAndFields, 4);
+ }
+ if (removals.staticGetters.length > 0) {
+ removalCount += removals.staticGetters.length;
+ removed += " * Static getters:\n" + formatKeyList(removals.staticGetters, 4);
+ }
+ if (removals.staticSetters.length > 0) {
+ removalCount += removals.staticSetters.length;
+ removed += " * Static setters:\n" + formatKeyList(removals.staticSetters, 4);
+ }
+ if (removals.methods.length > 0) {
+ removalCount += removals.methods.length;
+ removed += " * Instance methods:\n" + formatKeyList(removals.methods, 4);
+ }
+ if (removals.getters.length > 0) {
+ removalCount += removals.getters.length;
+ removed += " * Getters:\n" + formatKeyList(removals.getters, 4);
+ }
+ if (removals.setters.length > 0) {
+ removalCount += removals.setters.length;
+ removed += " * Setters:\n" + formatKeyList(removals.setters, 4);
+ }
+ if (removals.fields.length > 0) {
+ removalCount += removals.fields.length;
+ removed += " * Fields:\n" + formatKeyList(removals.fields, 4);
+ }
+
+ break;
+ }
+ case "enum": {
+ const { additions, removals } = changes;
+
+ const addedEntries = Object.entries(additions);
+ if (addedEntries.length > 0) {
+ additionCount = addedEntries.length;
+ added += formatEnumEntryList(addedEntries, 3);
+ }
+
+ const removedEntries = Object.entries(removals);
+ if (removedEntries.length > 0) {
+ removalCount = removedEntries.length;
+ removed += formatEnumEntryList(removedEntries, 3);
+ }
+
+ break;
+ }
+ }
+
+ const changeCount = additionCount + removalCount;
+ section += ` * ${capitalize(type)} \`${identifier}\` has ${changeCount} change${changeCount === 1 ? "" : "s"}:\n`
+ + ` * ${additionCount} addition${additionCount === 1 ? "" : "s"}`
+ + (additionCount > 0 ? ":\n" + added : ".\n")
+ + ` * ${removalCount} removal${removalCount === 1 ? "" : "s"}`
+ + (removalCount > 0 ? ":\n" + removed : ".\n")
+ + (warns.length > 0
+ ? ` * The report for ${type} \`${identifier}\` has ${warns.length} warning${warns.length === 1 ? "" : "s"}:\n`
+ + formatWarnList(warns, 1)
+ : "");
+ }
+ } else
+ section += ".\n";
+
+ section += `* ${errored.length} errored`;
+ if (errored.length > 0) {
+ section += ":\n";
+ for (const { type, identifier, warns, error } of errored)
+ section += (warns.length > 0
+ ? ` * The report for ${type} \`${identifier}\` has ${warns.length} warning${warns.length === 1 ? "" : "s"}:\n`
+ + formatWarnList(warns, 1)
+ : "")
+ + ` * The report for ${type} \`${identifier}\` has an error:\n` + codeBlock(error, 1);
+ } else
+ section += ".\n\n";
+ } else
+ section += "File-level error: \n" + codeBlock(fileError);
+ }
+
+ if (fileToLogCount > 0) {
+ const fileToNotLogCount = src.length - fileToLogCount;
+ sections += `### ${fileToNotLogCount} file${fileToNotLogCount === 1 ? " has" : "s have"}`
+ + " no file-level errors, file-level warnings, or watched declarations with changes or warnings.\n" + section;
+ } else
+ sections += "### No file-level errors or warnings.\n"
+ + "### All watched declarations are unchanged without warnings.\n";
+ }
+
+ summary += sections || "## There are 0 watched dependencies and declarations.\n";
+
+ const { GITHUB_STEP_SUMMARY } = process.env;
+ if (GITHUB_STEP_SUMMARY)
+ writeFile(GITHUB_STEP_SUMMARY, summary, "utf-8");
+ else
+ console.log(summary);
+}
diff --git a/packages/discord-types/scripts/changeReporter/logging/utils.mts b/packages/discord-types/scripts/changeReporter/logging/utils.mts
new file mode 100644
index 000000000..de8a1408e
--- /dev/null
+++ b/packages/discord-types/scripts/changeReporter/logging/utils.mts
@@ -0,0 +1,62 @@
+/*
+ * discord-types
+ * Copyright (C) 2024 Vencord project contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+import process from "node:process";
+
+export function capitalize(string: string) {
+ return string.replace(/^./, c => c.toUpperCase());
+}
+
+export function codeBlock(content?: unknown, indentLevel = 0) {
+ const indent = " ".repeat(indentLevel);
+ return `\`\`\`\n${content}\n\`\`\``.replaceAll(/^/gm, indent) + "\n";
+}
+
+export function formatWarnList(warns: readonly string[], indentLevel = 0) {
+ return warns.reduce((list, warn) => list + codeBlock(warn, indentLevel), "");
+}
+
+export function formatKeyList(keys: readonly string[], indentLevel = 0) {
+ const indent = " ".repeat(indentLevel);
+ return keys.reduce((list, key) => list + indent + `* \`${key}\`\n`, "");
+}
+
+export function formatValue(value?: unknown) {
+ switch (typeof value) {
+ case "string":
+ return JSON.stringify(value);
+ case "bigint":
+ return value + "n";
+ default:
+ return String(value);
+ }
+}
+
+export function formatEnumEntryList(entries: readonly (readonly [key: string, value: unknown])[], indentLevel = 0) {
+ const indent = " ".repeat(indentLevel);
+ return entries.reduce((list, [key, value]) => list + indent + `* \`${key} = ${formatValue(value)}\`\n`, "");
+}
+
+export function formatChannel(channel?: string) {
+ switch (channel) {
+ case "stable":
+ case "canary":
+ return capitalize(channel);
+ case "ptb":
+ return channel.toUpperCase();
+ default:
+ return "Unknown";
+ }
+}
+
+export function getSummaryURL(channel?: string) {
+ const { GITHUB_SERVER_URL, GITHUB_REPOSITORY, GITHUB_RUN_ID, GITHUB_RUN_ATTEMPT } = process.env;
+ if (GITHUB_SERVER_URL && GITHUB_REPOSITORY && GITHUB_RUN_ID && GITHUB_RUN_ATTEMPT)
+ return encodeURI(
+ `${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/attempts/${GITHUB_RUN_ATTEMPT}`
+ + `#:~:text=Change Report (${formatChannel(channel)})`
+ );
+}
diff --git a/packages/discord-types/scripts/changeReporter/logging/webhooks.mts b/packages/discord-types/scripts/changeReporter/logging/webhooks.mts
new file mode 100644
index 000000000..71abe2bc2
--- /dev/null
+++ b/packages/discord-types/scripts/changeReporter/logging/webhooks.mts
@@ -0,0 +1,145 @@
+/*
+ * discord-types
+ * Copyright (C) 2024 Vencord project contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+import console from "node:console";
+
+import type { CR } from "../types.mts";
+import { codeBlock, formatChannel, getSummaryURL } from "./utils.mjs";
+
+export async function postError(error: Error, webhookURL?: string, channel?: string) {
+ if (!webhookURL) return;
+
+ const res = await fetch(webhookURL, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ username: "Change Reporter",
+ embeds: [{
+ title: `Change Report (${formatChannel(channel)})`,
+ description: "### Fatal error:\n" + codeBlock(error.stack),
+ url: getSummaryURL(channel),
+ color: 0xF23F42
+ }]
+ })
+ });
+
+ if (!res.ok)
+ console.error(`Failed to exectute webhook (status '${res.status} ${res.statusText}').`);
+}
+
+export async function postReport(report: CR.ChangeReport, webhookURL: string, channel?: string) {
+ const { deps, src } = report;
+
+ let areChanges = false;
+ let description = "";
+
+ if (deps.length > 0) {
+ let warnedFileCount = 0;
+ let erroredFileCount = 0;
+ let passedCount = 0;
+ let warnedCount = 0;
+ let failedCount = 0;
+ let erroredCount = 0;
+ for (const report of deps) {
+ if (report.fileWarns.length > 0)
+ warnedFileCount++;
+ if (report.fileError !== undefined)
+ erroredFileCount++;
+ passedCount += report.passed.length;
+ warnedCount += report.warned.length;
+ failedCount += report.failed.length;
+ erroredCount += report.errored.length;
+ }
+
+ const fileToLogCount = warnedFileCount + erroredFileCount;
+ const toLogCount = warnedCount + failedCount + erroredCount;
+ const count = passedCount + toLogCount;
+ if (fileToLogCount + count > 0) {
+ description += "### Dependencies:\n";
+
+ if (fileToLogCount > 0) {
+ areChanges = true;
+ description += `${deps.length} file${deps.length === 1 ? "" : "s"}:\n`
+ + `* ${warnedFileCount} file${warnedFileCount === 1 ? " has" : "s have"} file-level warnings.\n`
+ + `* ${erroredFileCount} file${erroredFileCount === 1 ? " has" : "s have"} a file-level error.\n`;
+ } else
+ description += "No file-level errors or warnings.\n";
+
+ if (toLogCount > 0) {
+ areChanges = true;
+ description += `${count} watched dependenc${count === 1 ? "y" : "ies"}:\n`
+ + `* ${passedCount} passed without warnings.\n`
+ + `* ${warnedCount} passed with warnings.\n`
+ + `* ${failedCount} failed.\n`
+ + `* ${erroredCount} errored.\n`;
+ } else
+ description += "All watched dependencies passed without warnings.\n";
+ }
+ }
+
+ if (src.length > 0) {
+ let warnedFileCount = 0;
+ let erroredFileCount = 0;
+ let unchangedCount = 0;
+ let warnedCount = 0;
+ let changedCount = 0;
+ let erroredCount = 0;
+ for (const report of src) {
+ if (report.fileWarns.length > 0)
+ warnedFileCount++;
+ if (report.fileError !== undefined)
+ erroredFileCount++;
+ unchangedCount += report.unchanged.length;
+ warnedCount += report.warned.length;
+ changedCount += report.changed.length;
+ erroredCount += report.errored.length;
+ }
+
+ const fileToLogCount = warnedFileCount + erroredFileCount;
+ const toLogCount = warnedCount + changedCount + erroredCount;
+ const count = unchangedCount + toLogCount;
+ if (fileToLogCount + count > 0) {
+ description += "### Declarations:\n";
+
+ if (fileToLogCount > 0) {
+ areChanges = true;
+ description += `${src.length} file${src.length === 1 ? "" : "s"}:\n`
+ + `* ${warnedFileCount} file${warnedFileCount === 1 ? " has" : "s have"} file-level warnings.\n`
+ + `* ${erroredFileCount} file${erroredFileCount === 1 ? " has" : "s have"} a file-level error.\n`;
+ } else
+ description += "No file-level errors or warnings.\n";
+
+ if (toLogCount > 0) {
+ areChanges = true;
+ description += `${count} watched declaration${count === 1 ? "" : "s"}:\n`
+ + `* ${unchangedCount} ${unchangedCount === 1 ? "is" : "are"} unchanged without warnings.\n`
+ + `* ${warnedCount} ${warnedCount === 1 ? "is" : "are"} unchanged with warnings.\n`
+ + `* ${changedCount} ha${changedCount === 1 ? "s" : "ve"} changes.\n`
+ + `* ${erroredCount} errored.\n`;
+ } else
+ description += "All watched declarations are unchanged without warnings.\n";
+ }
+ }
+
+ description ||= "### There are 0 watched dependencies and declarations.\n";
+
+ const res = await fetch(webhookURL, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ username: "Change Reporter",
+ embeds: [{
+ title: `Change Report (${formatChannel(channel)})`,
+ description,
+ url: getSummaryURL(channel),
+ color: areChanges ? 0xF0B132 : 0x23A559
+ }]
+ })
+ });
+
+ if (!res.ok)
+ console.error(`Failed to exectute webhook (status '${res.status} ${res.statusText}').`);
+}
diff --git a/packages/discord-types/scripts/changeReporter/reports/getChangeReport.mts b/packages/discord-types/scripts/changeReporter/reports/getChangeReport.mts
new file mode 100644
index 000000000..703ae7d37
--- /dev/null
+++ b/packages/discord-types/scripts/changeReporter/reports/getChangeReport.mts
@@ -0,0 +1,32 @@
+/*
+ * discord-types
+ * Copyright (C) 2024 Vencord project contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+import { join } from "node:path";
+
+import type { Page } from "puppeteer-core";
+
+import type { CR } from "../types.mts";
+import { getDependenciesReport } from "./getDependenciesReport.mjs";
+import { getSrcFileReport } from "./getSrcFileReport.mjs";
+
+export async function getChangeReport(page: Page, config: CR.ReporterConfig): Promise {
+ const { rootDir, deps, src } = config;
+
+ const depsReports: Promise[] = [];
+ if (deps)
+ for (const filePath in deps)
+ depsReports.push(getDependenciesReport(page, join(rootDir, filePath), deps[filePath]!));
+
+ const srcReports: Promise[] = [];
+ if (src)
+ for (const filePath in src)
+ srcReports.push(getSrcFileReport(page, join(rootDir, filePath), src[filePath]!));
+
+ return {
+ deps: await Promise.all(depsReports),
+ src: await Promise.all(srcReports)
+ };
+}
diff --git a/packages/discord-types/scripts/changeReporter/reports/getClassReport.mts b/packages/discord-types/scripts/changeReporter/reports/getClassReport.mts
new file mode 100644
index 000000000..068dfa9a6
--- /dev/null
+++ b/packages/discord-types/scripts/changeReporter/reports/getClassReport.mts
@@ -0,0 +1,289 @@
+/*
+ * discord-types
+ * Copyright (C) 2024 Vencord project contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/typescript-estree";
+import type { Page } from "puppeteer-core";
+
+import type { autoFindClass, autoFindStore } from "../finds/classes.mts";
+import type { CR } from "../types.mts";
+import { funcToString, getErrorStack, getSanitizedConfig, pageAsyncFunction, pageFunction } from "./utils.mjs";
+
+export async function getClassReport(
+ page: Page,
+ declaration: TSESTree.ClassDeclarationWithName,
+ rawConfig: CR.DeclarationConfig
+) {
+ const { name } = declaration.id;
+ const report: CR.ClassReport = {
+ type: "class",
+ identifier: name,
+ changes: undefined,
+ error: undefined,
+ warns: []
+ };
+
+ const config = getSanitizedConfig(rawConfig, report);
+ const source = getClassMembers(declaration.body.body, config, report);
+
+ const { find } = config;
+ if (find) {
+ try {
+ const changes = await page.evaluate<[CR.ClassMembers], CR.FindFunction<[CR.ClassMembers], CR.ClassChanges>>(
+ pageAsyncFunction("s", `const c = await ${funcToString(find)}.call(Vencord, s);`
+ + "if (Array.isArray(c)) { if (c.length > 0 && c.every(isValidClass)) return getClassChanges(s, c); }"
+ + "else if (isValidClass(c)) return getClassChanges(s, [c]);"),
+ source
+ );
+ if (changes) {
+ checkClassIgnores(changes, config, report);
+ report.changes = changes;
+ return report;
+ }
+ report.warns.push(`Find for class '${name}' failed; attempting automatic class find.`);
+ } catch (error) {
+ report.warns.push(`Find for class '${name}' errored; attempting automatic class find:\n` + getErrorStack(error));
+ }
+ }
+
+ // Fast path for stores
+ if (/Store$/.test(name) && !declaration.abstract) {
+ try {
+ const changes = await page.evaluate, typeof autoFindStore>(
+ pageFunction("s", "n", "return autoFindStore(s, n);"),
+ source,
+ name
+ );
+ if (changes) {
+ checkClassIgnores(changes, config, report);
+ report.changes = changes;
+ return report;
+ }
+ report.warns.push(`Automatic store find for store '${name}' failed; attempting automatic class find.`);
+ } catch (error) {
+ report.warns.push(`Automatic store find for store '${name}' errored; attempting automatic class find:\n` + getErrorStack(error));
+ }
+ }
+
+ try {
+ const changes = await page.evaluate, typeof autoFindClass>(
+ pageFunction("s", "return autoFindClass(s);"),
+ source
+ );
+ if (changes) {
+ if (
+ // Ignore classes that do not have any members in common
+ // or classes that only have a constructor in common
+ changes.unchangedCount > 1
+ || changes.unchangedCount > 0
+ && (changes.additions.constructorDefinition
+ || changes.removals.constructorDefinition)
+ ) {
+ checkClassIgnores(changes, config, report);
+ report.changes = changes;
+ } else
+ report.error = `Automatic class find for class '${name}' failed. The target class may have too many changes.`;
+ } else
+ report.error = `Automatic class find for class '${name}' failed.`;
+ } catch (error) {
+ report.error = `Automatic class find for class '${name}' errored:\n` + getErrorStack(error);
+ }
+ return report;
+}
+
+function getClassMembers(
+ members: readonly TSESTree.ClassElement[],
+ config: CR.ClassConfig,
+ report: CR.ClassReport
+): CR.ClassMembers {
+ let constructorDefinition = false;
+ // Ignore duplicate members from overload signatures and config ignores
+ const staticMethodsAndFields = new Set();
+ const staticGetters = new Set();
+ const staticSetters = new Set();
+ const methods = new Set();
+ const getters = new Set();
+ const setters = new Set();
+ const fields = new Set();
+
+ const { includeOptional } = config;
+ for (const [index, member] of members.entries()) {
+ switch (member.type) {
+ // Exclude abstract methods
+ case AST_NODE_TYPES.MethodDefinition: {
+ if (member.optional && !includeOptional) continue;
+
+ const name = getClassMemberName(member, index, report);
+ if (name === undefined) continue;
+
+ switch (member.kind) {
+ case "constructor":
+ // Ignore constructor definitions without parameters
+ if (member.value.params.length > 0)
+ constructorDefinition = true;
+ break;
+ case "method":
+ if (member.static)
+ staticMethodsAndFields.add(name);
+ else
+ methods.add(name);
+ break;
+ case "get":
+ if (member.static)
+ staticGetters.add(name);
+ else
+ getters.add(name);
+ break;
+ case "set":
+ if (member.static)
+ staticSetters.add(name);
+ else
+ setters.add(name);
+ break;
+ }
+
+ break;
+ }
+ // Exclude abstract properties
+ case AST_NODE_TYPES.PropertyDefinition: {
+ if (member.optional && !includeOptional) continue;
+
+ const name = getClassMemberName(member, index, report);
+ if (name === undefined) continue;
+
+ if (member.static)
+ staticMethodsAndFields.add(name);
+ else
+ fields.add(name);
+
+ break;
+ }
+ }
+ }
+
+ const { ignoredAdditions, ignoredRemovals } = config;
+
+ if (ignoredAdditions) {
+ if (ignoredAdditions.constructorDefinition)
+ constructorDefinition = true;
+ applyClassIgnoredAdditions(staticMethodsAndFields, ignoredAdditions.staticMethodsAndFields);
+ applyClassIgnoredAdditions(staticGetters, ignoredAdditions.staticGetters);
+ applyClassIgnoredAdditions(staticSetters, ignoredAdditions.staticSetters);
+ applyClassIgnoredAdditions(methods, ignoredAdditions.methods);
+ applyClassIgnoredAdditions(getters, ignoredAdditions.getters);
+ applyClassIgnoredAdditions(setters, ignoredAdditions.setters);
+ applyClassIgnoredAdditions(fields, ignoredAdditions.fields);
+ }
+
+ if (ignoredRemovals) {
+ if (ignoredRemovals.constructorDefinition)
+ constructorDefinition = false;
+ applyClassIgnoredRemovals(staticMethodsAndFields, ignoredRemovals.staticMethodsAndFields);
+ applyClassIgnoredRemovals(staticGetters, ignoredRemovals.staticGetters);
+ applyClassIgnoredRemovals(staticSetters, ignoredRemovals.staticSetters);
+ applyClassIgnoredRemovals(methods, ignoredRemovals.methods);
+ applyClassIgnoredRemovals(getters, ignoredRemovals.getters);
+ applyClassIgnoredRemovals(setters, ignoredRemovals.setters);
+ applyClassIgnoredRemovals(fields, ignoredRemovals.fields);
+ }
+
+ return {
+ constructorDefinition: constructorDefinition,
+ staticMethodsAndFields: [...staticMethodsAndFields],
+ staticGetters: [...staticGetters],
+ staticSetters: [...staticSetters],
+ methods: [...methods],
+ getters: [...getters],
+ setters: [...setters],
+ fields: [...fields]
+ };
+}
+
+function getClassMemberName(
+ member: TSESTree.MethodDefinition | TSESTree.PropertyDefinition,
+ index: number,
+ report: CR.ClassReport
+) {
+ const { key } = member;
+ switch (key.type) {
+ case AST_NODE_TYPES.Identifier: {
+ const { name } = key;
+ if (!member.computed)
+ return name;
+ report.warns.push(`Computed key '[${name}]' of member at index '${index}' of class '${report.identifier}' is unsupported; ignoring member.`);
+ return;
+ }
+ case AST_NODE_TYPES.Literal:
+ return String(key.value);
+ case AST_NODE_TYPES.MemberExpression: {
+ const { object, property } = key;
+ if (
+ object.type === AST_NODE_TYPES.Identifier
+ && property.type === AST_NODE_TYPES.Identifier
+ ) return `Symbol(${object.name}.${property.name})`;
+ break;
+ }
+ }
+ report.warns.push(`Computed key of member at index '${index}' of class '${report.identifier}' is unsupported; ignoring member.`);
+}
+
+/** Adds ignored additions so as to not affect `changedCount`. */
+function applyClassIgnoredAdditions(members: Set, ignored?: readonly string[]) {
+ if (ignored)
+ for (const key of ignored)
+ members.add(key);
+}
+
+/** Removes ignored removals so as to not affect `changedCount`. */
+function applyClassIgnoredRemovals(members: Set, ignored?: readonly string[] | boolean) {
+ if (Array.isArray(ignored)) {
+ for (const key of ignored)
+ members.delete(key);
+ } else if (ignored)
+ members.clear();
+}
+
+function checkClassIgnores(
+ changes: CR.ClassChanges,
+ config: CR.ClassConfig,
+ report: CR.ClassReport
+) {
+ const { additions, removals } = changes;
+ const { ignoredAdditions, ignoredRemovals } = config;
+
+ if (ignoredAdditions) {
+ const { constructorDefinition, ...rest } = ignoredAdditions;
+
+ if (constructorDefinition && removals.constructorDefinition)
+ report.warns.push(`Ignored addition 'constructorDefinition' in config for class '${report.identifier}' had no effect.`);
+
+ for (const key in rest) {
+ const memberCategory = key as keyof typeof rest;
+ const removedKeys = new Set(removals[memberCategory]);
+ for (const ignoredKey in rest[memberCategory])
+ if (removedKeys.has(ignoredKey))
+ report.warns.push(`Ignored addition '${ignoredKey}' of '${memberCategory}' in config for class '${report.identifier}' had no effect.`);
+ }
+ }
+
+ if (ignoredRemovals) {
+ const { constructorDefinition, ...rest } = ignoredRemovals;
+
+ if (constructorDefinition && additions.constructorDefinition)
+ report.warns.push(`Ignored removal 'constructorDefinition' in config for class '${report.identifier}' had no effect.`);
+
+ for (const key in rest) {
+ const memberCategory = key as keyof typeof rest;
+ const ignoredKeys = rest[memberCategory];
+ if (Array.isArray(ignoredKeys)) {
+ const addedKeys = new Set(additions[memberCategory]);
+ for (const ignoredKey in ignoredKeys)
+ if (addedKeys.has(ignoredKey))
+ report.warns.push(`Ignored removal '${ignoredKey}' of '${memberCategory}' in config for class '${report.identifier}' had no effect.`);
+ } else if (ignoredKeys && additions[memberCategory].length > 0)
+ report.warns.push(`Ignored removal for members of category '${memberCategory}' in config for class '${report.identifier}' had no effect.`);
+ }
+ }
+}
diff --git a/packages/discord-types/scripts/changeReporter/reports/getDependenciesReport.mts b/packages/discord-types/scripts/changeReporter/reports/getDependenciesReport.mts
new file mode 100644
index 000000000..6380d742f
--- /dev/null
+++ b/packages/discord-types/scripts/changeReporter/reports/getDependenciesReport.mts
@@ -0,0 +1,112 @@
+/*
+ * discord-types
+ * Copyright (C) 2024 Vencord project contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+import { readFile } from "node:fs/promises";
+import { basename } from "node:path";
+
+import type { Page } from "puppeteer-core";
+import { satisfies, subset, valid, validRange } from "semver";
+import type { JsonObject, JsonValue } from "type-fest";
+
+import type { CR } from "../types.mts";
+import { funcToString, getErrorStack, pageFunction } from "./utils.mjs";
+
+export async function getDependenciesReport(page: Page, filePath: string, config: CR.DependenciesConfig) {
+ const fileName = basename(filePath);
+ const fileReport: CR.DependenciesReport = {
+ filePath,
+ fileName,
+ fileWarns: [],
+ passed: [],
+ warned: [],
+ failed: [],
+ errored: []
+ };
+
+ let dependencies: JsonValue | undefined;
+ try {
+ dependencies = JSON.parse(await readFile(filePath, "utf-8"))?.dependencies;
+ } catch (error) {
+ fileReport.fileError = `Failed to read and parse file '${fileName}':\n` + error;
+ return fileReport;
+ }
+
+ if (
+ typeof dependencies !== "object"
+ || dependencies === null
+ || Array.isArray(dependencies)
+ ) {
+ fileReport.fileError = `File '${fileName}' does not have a valid dependencies object.`;
+ return fileReport;
+ }
+
+ for (const key in config) {
+ const dependencyConfig = config[key]!;
+
+ const report: CR.DependencyReport = {
+ name: key,
+ packageVersionRange: undefined,
+ discordVersion: undefined,
+ expectedVersionRange: undefined,
+ error: undefined,
+ warns: []
+ };
+
+ // https://github.com/microsoft/TypeScript/issues/53395
+ const packageVersionRange = (dependencies as JsonObject)[key];
+ if (typeof packageVersionRange !== "string") {
+ report.error = `File '${fileName}' does not have a dependency with name '${key}'.`;
+ fileReport.errored.push(report);
+ continue;
+ }
+ report.packageVersionRange = packageVersionRange;
+
+ if (!validRange(packageVersionRange)) {
+ report.error = `Version range '${packageVersionRange}' for dependency '${key}' in file '${fileName}' is invalid.`;
+ fileReport.errored.push(report);
+ continue;
+ }
+
+ let discordVersion: unknown;
+ try {
+ discordVersion = await page.evaluate<[], CR.FindFunction<[]>>(
+ pageFunction(`return ${funcToString(dependencyConfig.find)}.call(Vencord);`)
+ );
+ } catch (error) {
+ report.error = `Find for version of dependency '${key}' errored:\n` + getErrorStack(error);
+ fileReport.errored.push(report);
+ continue;
+ }
+
+ if (typeof discordVersion !== "string") {
+ report.error = `Find for version of dependency '${key}' failed.`;
+ fileReport.errored.push(report);
+ continue;
+ }
+ report.discordVersion = discordVersion;
+
+ if (!valid(discordVersion)) {
+ report.error = `Find for version of dependency '${key}' returned an invalid SemVer version ('${discordVersion}').`;
+ fileReport.errored.push(report);
+ continue;
+ }
+
+ const { overrides } = dependencyConfig;
+ const expectedVersionRange = overrides?.find(([range]) => satisfies(discordVersion, range))?.[1]
+ ?? discordVersion;
+ report.expectedVersionRange = expectedVersionRange;
+
+ if (subset(packageVersionRange, expectedVersionRange)) {
+ if (report.warns.length > 0)
+ fileReport.warned.push(report);
+ else
+ fileReport.passed.push(report);
+ } else
+ fileReport.failed.push(report);
+ }
+
+ return fileReport;
+}
diff --git a/packages/discord-types/scripts/changeReporter/reports/getEnumReport.mts b/packages/discord-types/scripts/changeReporter/reports/getEnumReport.mts
new file mode 100644
index 000000000..b717dd0a5
--- /dev/null
+++ b/packages/discord-types/scripts/changeReporter/reports/getEnumReport.mts
@@ -0,0 +1,171 @@
+/*
+ * discord-types
+ * Copyright (C) 2024 Vencord project contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/typescript-estree";
+import type { Page } from "puppeteer-core";
+
+import type { autoFindEnum } from "../finds/enums.mts";
+import { formatValue } from "../logging/utils.mjs";
+import type { CR } from "../types.mts";
+import { funcToString, getErrorStack, getSanitizedConfig, pageAsyncFunction, pageFunction } from "./utils.mjs";
+
+export async function getEnumReport(
+ page: Page,
+ declaration: TSESTree.TSEnumDeclaration,
+ rawConfig: CR.DeclarationConfig
+) {
+ const { name } = declaration.id;
+ const report: CR.EnumReport = {
+ type: "enum",
+ identifier: name,
+ changes: undefined,
+ error: undefined,
+ warns: []
+ };
+
+ const config = getSanitizedConfig(rawConfig, report);
+ const source = getEnumMembers(declaration.body.members, config, report);
+
+ const { find } = config;
+ if (find) {
+ try {
+ const changes = await page.evaluate<[CR.EnumSource], CR.FindFunction<[CR.EnumSource], CR.EnumChanges>>(
+ pageAsyncFunction("s", `const o = await ${funcToString(find)}.call(Vencord, s);`
+ + "if (isValidEnum(o)) return getEnumChanges(s, o);"),
+ source
+ );
+ if (changes) {
+ checkEnumIgnores(changes, config, report);
+ report.changes = changes;
+ return report;
+ }
+ report.warns.push(`Find for enum '${name}' failed; attempting automatic enum find.`);
+ } catch (error) {
+ report.warns.push(`Find for enum '${name}' errored; attempting automatic enum find:\n` + getErrorStack(error));
+ }
+ }
+
+ try {
+ const changes = await page.evaluate, typeof autoFindEnum>(
+ pageFunction("s", "return autoFindEnum(s);"),
+ source
+ );
+ if (changes) {
+ // Ignore enums that do not have any members in common
+ if (changes.unchangedCount > 0) {
+ checkEnumIgnores(changes, config, report);
+ report.changes = changes;
+ } else
+ report.error = `Automatic enum find for enum '${name}' failed. The target enum may have too many changes.`;
+ } else
+ report.error = `Automatic enum find for enum '${name}' failed.`;
+ } catch (error) {
+ report.error = `Automatic enum find for enum '${name}' errored:\n` + getErrorStack(error);
+ }
+ return report;
+}
+
+function getEnumMembers(
+ members: readonly TSESTree.TSEnumMember[],
+ config: CR.EnumConfig,
+ report: CR.EnumReport
+) {
+ const source: CR.EnumSource = {};
+
+ for (const [index, member] of members.entries()) {
+ if (member.computed) {
+ report.warns.push(`Key of member at index '${index}' of enum '${report.identifier}' is computed. Computed enum member keys are unsupported; ignoring member.`);
+ continue;
+ }
+ const { id } = member;
+ const key = id.type === AST_NODE_TYPES.Literal ? id.value : id.name;
+
+ const { initializer } = member;
+ if (!initializer) {
+ report.warns.push(`Member '${key}' of enum '${report.identifier}' has no initializer. Enum members without initializers are unsupported; ignoring member.`);
+ continue;
+ }
+
+ const { type } = initializer;
+ switch (type) {
+ case AST_NODE_TYPES.Literal: {
+ const { value } = initializer;
+ if (typeof value === "string" || typeof value === "number")
+ source[key] = value;
+ else
+ report.warns.push(`Literal initializer type '${typeof value}' of member '${key}' of enum '${report.identifier}' is unsupported; ignoring member.`);
+ break;
+ }
+ case AST_NODE_TYPES.BinaryExpression: {
+ const { operator } = initializer;
+ if (operator !== "<<") {
+ report.warns.push(`BinaryExpression initializer operator '${operator}' of member '${key}' of enum '${report.identifier}' is unsupported; ignoring member.`);
+ continue;
+ }
+
+ const { left, right } = initializer;
+ if (
+ left.type !== AST_NODE_TYPES.Literal
+ || typeof left.value !== "number"
+ || right.type !== AST_NODE_TYPES.Literal
+ || typeof right.value !== "number"
+ ) {
+ report.warns.push(`BinaryExpression initializer of member '${key}' of enum '${report.identifier}' is unsupported; ignoring member.`);
+ continue;
+ }
+
+ source[key] = left.value << right.value;
+
+ break;
+ }
+ default:
+ report.warns.push(`Initializer type '${type}' of member '${key}' of enum '${report.identifier}' is unsupported; ignoring member.`);
+ break;
+ }
+ }
+
+ const { keyMapper } = config;
+ if (keyMapper)
+ for (const key in source) {
+ const value = source[key]!;
+ delete source[key];
+ source[keyMapper(key)] = value;
+ }
+
+ const { ignoredAdditions, ignoredRemovals } = config;
+
+ // Add ignored additions so as to not affect similarity score
+ if (ignoredAdditions)
+ for (const [key, value] of ignoredAdditions)
+ source[key] = value;
+
+ // Remove ignored removals so as to not affect similarity score
+ if (ignoredRemovals)
+ for (const [key, value] of ignoredRemovals)
+ if (value === undefined || source[key] === value)
+ delete source[key];
+
+ return source;
+}
+
+function checkEnumIgnores(
+ changes: CR.EnumChanges,
+ config: CR.EnumConfig,
+ report: CR.EnumReport
+) {
+ const { additions, removals } = changes;
+ const { ignoredAdditions, ignoredRemovals } = config;
+
+ if (ignoredAdditions)
+ for (const [key, value] of ignoredAdditions)
+ if (removals[key] === value)
+ report.warns.push(`Ignored addition '${key} = ${formatValue(value)}' in config for enum '${report.identifier}' had no effect.`);
+
+ if (ignoredRemovals)
+ for (const [key, value] of ignoredRemovals)
+ if (value === undefined ? key in additions : additions[key] === value)
+ report.warns.push(`Ignored removal '${key}${value === undefined ? "" : " = " + formatValue(value)}' in config for enum '${report.identifier}' had no effect.`);
+}
diff --git a/packages/discord-types/scripts/changeReporter/reports/getSrcFileReport.mts b/packages/discord-types/scripts/changeReporter/reports/getSrcFileReport.mts
new file mode 100644
index 000000000..33145d682
--- /dev/null
+++ b/packages/discord-types/scripts/changeReporter/reports/getSrcFileReport.mts
@@ -0,0 +1,105 @@
+/*
+ * discord-types
+ * Copyright (C) 2024 Vencord project contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+import { readFile } from "node:fs/promises";
+import { basename } from "node:path";
+
+import { AST_NODE_TYPES, parse, type TSESTree } from "@typescript-eslint/typescript-estree";
+import type { Page } from "puppeteer-core";
+
+import type { CR } from "../types.mts";
+import { getClassReport } from "./getClassReport.mjs";
+import { getEnumReport } from "./getEnumReport.mjs";
+
+export async function getSrcFileReport(page: Page, filePath: string, config: CR.SrcFileConfig) {
+ const fileName = basename(filePath);
+ const fileReport: CR.SrcFileReport = {
+ filePath,
+ fileName,
+ fileWarns: [],
+ unchanged: [],
+ warned: [],
+ changed: [],
+ errored: []
+ };
+
+ let ast: TSESTree.Program;
+ try {
+ ast = parse(await readFile(filePath, "utf-8"));
+ } catch (error) {
+ fileReport.fileError = `Failed to read and parse '${fileName}':\n` + error;
+ return fileReport;
+ }
+
+ const unfoundDeclarations = new Set(Object.keys(config));
+ const reports: Promise[] = [];
+ for (const node of ast.body) {
+ if (unfoundDeclarations.size <= 0) break;
+
+ const declaration = node.type === AST_NODE_TYPES.ExportNamedDeclaration
+ ? node.declaration
+ : node;
+ if (!declaration) continue;
+
+ switch (declaration.type) {
+ case AST_NODE_TYPES.ClassDeclaration: {
+ const { id } = declaration;
+ if (!id) continue;
+
+ const { name } = id;
+ const declarationConfig = config[name];
+ if (!declarationConfig) continue;
+
+ unfoundDeclarations.delete(name);
+ reports.push(getClassReport(
+ page,
+ // @ts-expect-error: Control flow narrowing bug
+ declaration,
+ declarationConfig
+ ));
+ break;
+ }
+ case AST_NODE_TYPES.TSEnumDeclaration: {
+ const { name } = declaration.id;
+ const declarationConfig = config[name];
+ if (!declarationConfig) continue;
+
+ unfoundDeclarations.delete(name);
+ reports.push(getEnumReport(
+ page,
+ declaration,
+ declarationConfig
+ ));
+ break;
+ }
+ }
+ }
+
+ for (const report of await Promise.all(reports)) {
+ if (report.error !== undefined) {
+ fileReport.errored.push(report);
+ } else {
+ const { changes } = report;
+ if (changes!.changedCount > 0)
+ fileReport.changed.push(report);
+ else if (report.warns.length > 0)
+ fileReport.warned.push(report);
+ else
+ fileReport.unchanged.push(report);
+ }
+ }
+
+ for (const identifier of unfoundDeclarations)
+ fileReport.errored.push({
+ type: config[identifier]!.type,
+ identifier,
+ changes: undefined,
+ error: `File '${fileName}' does not have a declaration with identifier '${identifier}'.`,
+ warns: []
+ });
+
+ return fileReport;
+}
diff --git a/packages/discord-types/scripts/changeReporter/reports/utils.mts b/packages/discord-types/scripts/changeReporter/reports/utils.mts
new file mode 100644
index 000000000..a47198406
--- /dev/null
+++ b/packages/discord-types/scripts/changeReporter/reports/utils.mts
@@ -0,0 +1,53 @@
+/*
+ * discord-types
+ * Copyright (C) 2024 Vencord project contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+import type { CR } from "../types.mts";
+
+/** Ensures config's type matches the type of the declaration found by the parser. */
+export function getSanitizedConfig(config: CR.DeclarationConfig, report: CR.ClassReport): CR.ClassConfig;
+export function getSanitizedConfig(config: CR.DeclarationConfig, report: CR.EnumReport): CR.EnumConfig;
+export function getSanitizedConfig(config: CR.DeclarationConfig, report: CR.DeclarationReport) {
+ const { type } = config;
+ const expectedType = report.type;
+ if (type === expectedType)
+ return config;
+ report.warns.push(`Expected config type for '${report.identifier}' to be '${expectedType}', but got '${type}'; config values will be ignored.`);
+ return { type: expectedType };
+}
+
+export function pageFunction(...args: [string, ...string[]]): any {
+ const body = args.pop()!;
+ return new Function(...args, `"use strict";${body}`);
+}
+
+// https://github.com/microsoft/TypeScript/issues/36177
+const AsyncFunction: any = async function () {}.constructor;
+
+export function pageAsyncFunction(...args: [string, ...string[]]): any {
+ const body = args.pop()!;
+ return new AsyncFunction(...args, `"use strict";${body}`);
+}
+
+// Based on ECMA-262
+const functionDeclarationOrArrowFunctionDefinitionRE = /^(?:async(?:[\t\v\f\uFEFF\p{Zs}]|\/\*.*\*\/)+)?(?:function[(*/\t\n\v\f\r\uFEFF\p{Zs}]|(?:\(|(?:[\p{IDS}$_]|\\u(?:[0-9A-Fa-f]{4}|\{[0-9A-Fa-f]{5}\}))(?:[\p{IDC}$]|\\u(?:[0-9A-Fa-f]{4}|\{[0-9A-Fa-f]{5}\}))+(?:[\t\n\v\f\r\uFEFF\p{Zs}]|\/\*.*\*\/)*=))/u;
+
+/**
+ * Makes the result of `func.toString()` a callable expression when evaled.
+ * Does not support getters, setters, static methods, private methods/getters/setters,
+ * or method definitions with symbol keys.
+ */
+export function funcToString(func: (...args: never) => unknown) {
+ const funcString = func.toString();
+ if (functionDeclarationOrArrowFunctionDefinitionRE.test(funcString))
+ return `(${funcString})`;
+ return `({${funcString}})[${JSON.stringify(func.name)}]`;
+}
+
+export function getErrorStack(error: unknown) {
+ return typeof error === "object" && error !== null && "stack" in error
+ ? error.stack
+ : error;
+}
diff --git a/packages/discord-types/scripts/changeReporter/types.mts b/packages/discord-types/scripts/changeReporter/types.mts
new file mode 100644
index 000000000..9c7c1088c
--- /dev/null
+++ b/packages/discord-types/scripts/changeReporter/types.mts
@@ -0,0 +1,227 @@
+/*
+ * discord-types
+ * Copyright (C) 2024 Vencord project contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+// eslint-disable-next-line import/no-relative-packages
+import type * as Vencord from "../../../../src/Vencord.ts";
+
+export namespace CR {
+ export interface ChangeReport {
+ deps: DependenciesReport[];
+ src: SrcFileReport[];
+ }
+
+ export type FileReport = DependenciesReport | SrcFileReport;
+
+ interface FileReportBase {
+ filePath: string;
+ fileName: string;
+ /** Error that caused the report to be returned early. */
+ fileError?: string | undefined;
+ /** Contains warnings not specific to any dependency or declaration. */
+ fileWarns: string[];
+ }
+
+ export interface DependenciesReport extends FileReportBase {
+ /** Contains reports that passed with no warns and no error. */
+ passed: DependencyReport[];
+ /** Contains reports that passed with warns and no error. */
+ warned: DependencyReport[];
+ /** Contains reports that failed with no error and maybe warns. */
+ failed: DependencyReport[];
+ /** Contains reports that have an error. */
+ errored: DependencyReport[];
+ }
+
+ export interface DependencyReport {
+ /** The name of the dependency. */
+ name: string;
+ /** The version of the dependency in `package.json`. */
+ packageVersionRange: Errored extends true ? string | undefined : string;
+ /** The version of the dependency bundled with Discord. */
+ discordVersion: Errored extends true ? string | undefined : string;
+ /**
+ * The matched version range from the `overrides` property of {@link DependencyConfig}
+ * or, if none matched, the version of the dependency bundled with Discord.
+ */
+ expectedVersionRange: Errored extends true ? string | undefined : string;
+ /** Error that caused the report to return early. */
+ error: Errored extends true ? string : undefined;
+ warns: string[];
+ }
+
+ export interface SrcFileReport extends FileReportBase {
+ /** Contains reports that have no changes, no warns, and no error. */
+ unchanged: DeclarationReport[];
+ /** Contains reports that have warns, no changes, and no error. */
+ warned: DeclarationReport[];
+ /** Contains reports that have changes, maybe warns, and no error. */
+ changed: DeclarationReport[];
+ /** Contains reports that have an error. */
+ errored: DeclarationReport[];
+ }
+
+ export type DeclarationReport
+ = ClassReport | EnumReport;
+
+ interface DeclarationReportBase {
+ type: string;
+ /** The declaration's identifier. */
+ identifier: string;
+ changes: Errored extends true ? undefined : object;
+ /** Error that caused the report to return early. */
+ error: Errored extends true ? string : undefined;
+ warns: string[];
+ }
+
+ export interface ClassReport extends DeclarationReportBase {
+ type: "class";
+ identifier: string;
+ changes: Errored extends true ? undefined : ClassChanges;
+ }
+
+ export interface EnumReport extends DeclarationReportBase {
+ type: "enum";
+ identifier: string;
+ changes: Errored extends true ? undefined : EnumChanges;
+ }
+
+ export interface ReporterConfig {
+ /** The directory file paths are relative to. */
+ rootDir: string;
+ deps?: { [filePath: string]: DependenciesConfig; } | undefined;
+ src?: { [filePath: string]: SrcFileConfig; } | undefined;
+ }
+
+ export interface DependenciesConfig {
+ [dependencyName: string]: DependencyConfig;
+ }
+
+ export interface DependencyConfig {
+ /**
+ * @returns The version of the dependency bundled with Discord or undefined if the version could not be found.
+ */
+ find: FindFunction<[], string>;
+ /**
+ * Specifies the version range to expect in `package.json` given the version bundled with Discord.
+ */
+ overrides?: [discordVersionRange: string, packageVersionRange: string][] | undefined;
+ }
+
+ export interface SrcFileConfig {
+ [identifier: string]: DeclarationConfig;
+ }
+
+ export type DeclarationConfig = ClassConfig | EnumConfig;
+
+ interface DeclarationConfigBase {
+ type: string;
+ find?: FindFunction | undefined;
+ }
+
+ export interface ClassConfig extends DeclarationConfigBase {
+ type: "class";
+ /**
+ * An automatic find will be performed if omitted.
+ * If multiple classes are returned, their members will be merged.
+ * @param source The source class.
+ * @returns The class or undefined if it could not be found.
+ */
+ // https://github.com/microsoft/TypeScript/issues/20007
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
+ find?: FindFunction<[source: ClassMembers], Function[] | Function> | undefined;
+ /** Whether to include optional class members. */
+ includeOptional?: boolean | undefined;
+ /** Members expected to be added. */
+ ignoredAdditions?: { [Key in keyof ClassMembers]?: ClassMembers[Key] | undefined; } | undefined;
+ /**
+ * Members expected to be removed.
+ * If a category is `true`, all members in that category will be expected to be removed.
+ */
+ ignoredRemovals?: { [Key in keyof ClassMembers]?: ClassMembers[Key] | boolean | undefined; } | undefined;
+ }
+
+ export interface EnumConfig extends DeclarationConfigBase {
+ type: "enum";
+ /**
+ * An automatic find will be performed if omitted.
+ * @param source The source enum.
+ * @returns The enum or undefined if it could not be found.
+ */
+ find?: FindFunction<[source: EnumSource], EnumMembers> | undefined;
+ /**
+ * Mapper function to modifiy the source enum's keys.
+ * Ignored additions and removals will not be modified by this function.
+ */
+ keyMapper?: ((key: string) => string) | undefined;
+ /** Members expected to be added. */
+ ignoredAdditions?: [key: string, value: string | number][] | undefined;
+ /** Members expected to be removed. */
+ ignoredRemovals?: [key: string, value?: string | number | undefined][] | undefined;
+ }
+
+ export type FindFunction
+ = (this: typeof Vencord, ...args: Args) =>
+ Promise | Return | undefined;
+
+ export type DeclarationChanges = ClassChanges | EnumChanges;
+
+ interface DeclarationChangesBase {
+ additions: object;
+ removals: object;
+ /** The number of added/removed members. */
+ unchangedCount: number;
+ /** The number of added/removed members. */
+ changedCount: number;
+ }
+
+ export interface ClassChanges extends DeclarationChangesBase {
+ additions: ClassMembers;
+ removals: ClassMembers;
+ }
+
+ export interface ClassMembers {
+ /** Whether the class has a constructor definition with parameters. */
+ constructorDefinition: boolean;
+ staticMethodsAndFields: string[];
+ staticGetters: string[];
+ staticSetters: string[];
+ methods: string[];
+ getters: string[];
+ setters: string[];
+ fields: string[];
+ }
+
+ export interface Class {
+ /** Constructor function */
+ new (...args: never): unknown;
+ /** Static members */
+ [key: PropertyKey]: unknown;
+ /** Instance methods and accessors */
+ readonly prototype: Record;
+ }
+
+ export interface EnumChanges extends DeclarationChangesBase {
+ additions: EnumMembers;
+ removals: EnumMembers;
+ }
+
+ export type EnumMembers = Record;
+
+ export type EnumSource = Record | Record;
+
+ /** Excludes heterogeneous enums. */
+ export type Enum = NumericEnum | StringEnum;
+
+ /** Numeric enums have reverse mapping. */
+ export type NumericEnum
+ = { readonly [key: string]: number; }
+ & { readonly [value: number]: string; };
+
+ /** String enums do not have reverse mapping. */
+ export interface StringEnum {
+ readonly [key: string]: string;
+ }
+}
diff --git a/packages/discord-types/scripts/utils.mts b/packages/discord-types/scripts/utils.mts
new file mode 100644
index 000000000..7b2a437b9
--- /dev/null
+++ b/packages/discord-types/scripts/utils.mts
@@ -0,0 +1,51 @@
+/*
+ * discord-types
+ * Copyright (C) 2024 Vencord project contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+type EnvConfig = Record;
+
+type ValidEnv = NodeJS.ProcessEnv & {
+ [Key in keyof Config as false extends Config[Key]
+ ? never
+ : Key
+ ]: Config[Key] extends string[]
+ ? Config[Key][number]
+ : string;
+} & Partial>;
+
+export function assertEnvValidity(
+ env: NodeJS.ProcessEnv,
+ config: Config
+): asserts env is ValidEnv {
+ const errors: string[] = [];
+
+ for (const key in config) {
+ const varValue = env[key];
+ const varConfig = config[key]!;
+
+ if (varValue === undefined) {
+ if (varConfig)
+ errors.push(`TypeError: A value must be provided for required environment variable '${key}'.`);
+ } else if (Array.isArray(varConfig)) {
+ if (!varConfig.includes(varValue))
+ errors.push(`RangeError: The value provided for environment variable '${key}' must be one of ${formatChoices(varConfig)}.`);
+ } else if (typeof varConfig === "object" && !varConfig.test(varValue))
+ errors.push(`RangeError: The value provided for environment variable '${key}' must match ${varConfig}.`);
+ }
+
+ if (errors.length > 0) {
+ for (const error of errors)
+ console.error(error);
+ process.exit(1);
+ }
+}
+
+function formatChoices(choices: string[]) {
+ const quotedChoices = choices.map(c => `'${c}'`);
+ if (choices.length < 3)
+ return quotedChoices.join(" or ");
+ const lastChoice = quotedChoices.pop()!;
+ return quotedChoices.join(", ") + ", or " + lastChoice;
+}
diff --git a/packages/discord-types/src/flux/ActionHandlersGraph.ts b/packages/discord-types/src/flux/ActionHandlersGraph.ts
new file mode 100644
index 000000000..c85ae8eb3
--- /dev/null
+++ b/packages/discord-types/src/flux/ActionHandlersGraph.ts
@@ -0,0 +1,70 @@
+/*
+ * discord-types
+ * Copyright (C) 2024 Vencord project contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+import type { DepGraph } from "dependency-graph";
+
+import type { IsAny, Nullish, UnionToIntersection } from "../internal";
+import type { Action, ActionHandler, ActionType, ExtractAction } from "./actions";
+import type { DispatchBand } from "./Dispatcher";
+
+export declare class ActionHandlersGraph {
+ _addToBand(dispatchToken: string, dispatchBand: DispatchBand): void;
+ _bandToken(dispatchBand: DispatchBand): string;
+ _computeOrderedActionHandlers(
+ actionType: T
+ ): OrderedActionHandlers>[];
+ _computeOrderedCallbackTokens(): string[];
+ _invalidateCaches(): void;
+ _validateDependencies(fromDispatchToken: string, toDispatchToken: string): void;
+ addDependencies(fromDispatchToken: string, toDispatchTokens: readonly string[]): void;
+ createToken(): string;
+ getOrderedActionHandlers(partialAction: {
+ type: T;
+ }): OrderedActionHandlers>;
+ register(
+ storeName: string,
+ actionHandlers: ActionHandlerMap,
+ storeDidChange: ActionHandler,
+ dispatchBand: DispatchBand,
+ dispatchToken?: string /* = this.createToken() */
+ ): string;
+
+ _dependencyGraph: DepGraph;
+ _lastID: number;
+ _orderedActionHandlers: {
+ [T in ActionType]?: OrderedActionHandlers> | Nullish;
+ };
+ _orderedCallbackTokens: string[] | Nullish;
+}
+
+export interface ActionHandlersGraphNode {
+ actionHandler: Partial;
+ band: DispatchBand;
+ /** Store name */
+ name: string;
+ storeDidChange: ActionHandler;
+}
+
+export type ActionHandlerMap
+ // Workaround to avoid ts(2589)
+ = UnionToIntersection<
+ A extends unknown
+ ? unknown extends (
+ IsAny]>
+ & IsAny]>
+ & IsAny]>
+ )
+ ? Record void>
+ : { [T in A["type"]]: (action: A & { type: T; }) => void; }
+ : never
+ >;
+
+export type OrderedActionHandlers = {
+ actionHandler: ActionHandler;
+ /** Store name */
+ name: string;
+ storeDidChange: ActionHandler;
+}[];
diff --git a/packages/discord-types/src/flux/ActionLog.ts b/packages/discord-types/src/flux/ActionLog.ts
new file mode 100644
index 000000000..91fd39d02
--- /dev/null
+++ b/packages/discord-types/src/flux/ActionLog.ts
@@ -0,0 +1,30 @@
+/*
+ * discord-types
+ * Copyright (C) 2024 Vencord project contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+import type { Action } from "./actions";
+
+export declare class ActionLog {
+ constructor(actionType: A["type"]);
+
+ get name(): A["type"];
+ toJSON(): Pick, "action" | "createdAt" | "traces"> & {
+ created_at: ActionLog["createdAt"];
+ };
+
+ action: A;
+ createdAt: Date;
+ error: Error | undefined;
+ id: number;
+ startTime: number;
+ totalTime: number;
+ traces: ActionLogTrace[];
+}
+
+export interface ActionLogTrace {
+ /** Store name */
+ name: string;
+ time: number;
+}
diff --git a/packages/discord-types/src/flux/ActionLogger.ts b/packages/discord-types/src/flux/ActionLogger.ts
new file mode 100644
index 000000000..d673f716b
--- /dev/null
+++ b/packages/discord-types/src/flux/ActionLogger.ts
@@ -0,0 +1,30 @@
+/*
+ * discord-types
+ * Copyright (C) 2024 Vencord project contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+import type { EventEmitter } from "events";
+
+import type { ActionLog } from "./ActionLog";
+import type { Action, ActionType } from "./actions";
+
+export declare class ActionLogger extends EventEmitter {
+ constructor(options?: { persist?: boolean | undefined; });
+
+ getLastActionMetrics(title: string, limit?: number /* = 20 */): ActionMetric[];
+ getSlowestActions(
+ actionType?: T | null,
+ limit?: number /* = 20 */
+ ): ActionMetric[];
+ log(
+ action: A,
+ callback: (func: (storeName: string, func: () => T) => T) => void
+ ): ActionLog;
+
+ logs: ActionLog[];
+ persist: boolean;
+}
+
+export type ActionMetric
+ = [storeName: string, actionType: T, totalTime: number];
diff --git a/packages/discord-types/src/flux/BatchedStoreListener.ts b/packages/discord-types/src/flux/BatchedStoreListener.ts
new file mode 100644
index 000000000..77d98498f
--- /dev/null
+++ b/packages/discord-types/src/flux/BatchedStoreListener.ts
@@ -0,0 +1,20 @@
+/*
+ * discord-types
+ * Copyright (C) 2024 Vencord project contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+import type { Stringable } from "../internal";
+import type { Store } from "./Store";
+
+export declare class BatchedStoreListener {
+ constructor(stores: Store[], changeCallback: () => void);
+
+ attach(debugName?: Stringable): void;
+ detach(): void;
+
+ changeCallback: () => void;
+ handleStoreChange: () => void;
+ stores: Store[];
+ storeVersionHandled: number | undefined;
+}
diff --git a/packages/discord-types/src/flux/ChangeListeners.ts b/packages/discord-types/src/flux/ChangeListeners.ts
new file mode 100644
index 000000000..2fa49054b
--- /dev/null
+++ b/packages/discord-types/src/flux/ChangeListeners.ts
@@ -0,0 +1,27 @@
+/*
+ * discord-types
+ * Copyright (C) 2024 Vencord project contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+export declare class ChangeListeners {
+ has(listener: ChangeListener): boolean;
+ hasAny(): boolean;
+ invokeAll(): void;
+
+ add: (listener: ChangeListener) => void;
+ /**
+ * @param listener The change listener to add. It will be removed when it returns false.
+ */
+ addConditional: (
+ listener: ChangeListener,
+ immediatelyCall?: boolean /* = true */
+ ) => void;
+ listeners: Set;
+ remove: (listener: ChangeListener) => void;
+}
+
+export type ChangeListener
+ = true extends Conditional
+ ? () => unknown
+ : () => void;
diff --git a/packages/discord-types/src/flux/Dispatcher.ts b/packages/discord-types/src/flux/Dispatcher.ts
new file mode 100644
index 000000000..5a21f7666
--- /dev/null
+++ b/packages/discord-types/src/flux/Dispatcher.ts
@@ -0,0 +1,87 @@
+/*
+ * discord-types
+ * Copyright (C) 2024 Vencord project contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+import type { Nullish } from "../internal";
+import type { ActionHandlerMap, ActionHandlersGraph } from "./ActionHandlersGraph";
+import type { ActionLogger } from "./ActionLogger";
+import type { Action, ActionHandler, ActionType, ExtractAction } from "./actions";
+
+export declare class Dispatcher {
+ constructor(
+ defaultBand?: DispatchBand /* = DispatchBand.EARLY */,
+ actionLogger?: ActionLogger | null,
+ sentryUtils?: SentryUtils | null
+ );
+
+ _dispatch(
+ action: Action,
+ func: (storeName: string, func: () => T) => T
+ ): boolean | undefined;
+ _dispatchWithDevtools(action: Action): void;
+ _dispatchWithLogging(action: Action): void;
+ addDependencies(fromDispatchToken: string, toDispatchTokens: readonly string[]): void;
+ addInterceptor(interceptor: ActionHandler): void;
+ createToken(): string;
+ dispatch(action: Action): Promise;
+ flushWaitQueue(): void;
+ isDispatching(): boolean;
+ register(
+ storeName: string,
+ actionHandlers: ActionHandlerMap,
+ storeDidChange: ActionHandler,
+ dispatchBand?: DispatchBand | null, /* = this._defaultBand */
+ dispatchToken?: string /* = this._actionHandlers.createToken() */
+ ): string;
+ subscribe(
+ actionType: T,
+ listener: ActionHandler>): void;
+ unsubscribe(
+ actionType: T,
+ listener: ActionHandler>
+ ): void;
+ wait(callback: () => void): void;
+
+ _actionHandlers: ActionHandlersGraph;
+ _currentDispatchActionType: ActionType | Nullish;
+ _defaultBand: DispatchBand;
+ _interceptors: ((action: Action) => boolean)[];
+ _processingWaitQueue: boolean;
+ _sentryUtils: SentryUtils | Nullish;
+ _subscriptions: {
+ [T in ActionType]?: Set>> | Nullish;
+ };
+ _waitQueue: (() => void)[];
+ actionLogger: ActionLogger;
+ functionCache: Partial;
+}
+
+// Enum keys made screaming snake case for consistency.
+export enum DispatchBand {
+ EARLY = 0,
+ DATABASE = 1,
+ DEFAULT = 2,
+}
+
+export interface SentryUtils {
+ addBreadcrumb: (breadcrumb: {
+ category?: string | undefined;
+ data?: Record | undefined;
+ event_id?: string | undefined;
+ level?: SeverityLevel | undefined;
+ message?: string | undefined;
+ timestamp?: number | undefined;
+ type?: string | undefined;
+ }) => void;
+}
+
+export enum SeverityLevel {
+ DEBUG = "debug",
+ ERROR = "error",
+ FATAL = "fatal",
+ INFO = "info",
+ LOG = "log",
+ WARNING = "warning",
+}
diff --git a/packages/discord-types/src/flux/Emitter.ts b/packages/discord-types/src/flux/Emitter.ts
new file mode 100644
index 000000000..4fbffb24f
--- /dev/null
+++ b/packages/discord-types/src/flux/Emitter.ts
@@ -0,0 +1,30 @@
+/*
+ * discord-types
+ * Copyright (C) 2024 Vencord project contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+import type { Store } from "./Store";
+
+export declare class Emitter {
+ batched(callback: () => T): T;
+ destroy(): void;
+ emit(): void;
+ emitNonReactOnce(syncWiths: Set<() => unknown>, changedStores: Set): void;
+ emitReactOnce(): void;
+ getChangeSentinel(): number;
+ getIsPaused(): boolean;
+ injectBatchEmitChanges(batchEmitChanges: () => unknown): void;
+ markChanged(store: Store): void;
+ /** If timeout is omitted, Emitter will pause until resume is called. */
+ pause(timeout?: number): void;
+ resume(shouldEmit?: boolean /* = true */): void;
+
+ changedStores: Set;
+ changeSentinel: number;
+ isBatchEmitting: boolean;
+ isDispatching: boolean;
+ isPaused: boolean;
+ pauseTimer: number | null;
+ reactChangedStores: Set;
+}
diff --git a/packages/discord-types/src/flux/PersistedStore.ts b/packages/discord-types/src/flux/PersistedStore.ts
new file mode 100644
index 000000000..bedb6e247
--- /dev/null
+++ b/packages/discord-types/src/flux/PersistedStore.ts
@@ -0,0 +1,108 @@
+/*
+ * discord-types
+ * Copyright (C) 2024 Vencord project contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+import type { GenericConstructor, Nullish } from "../internal";
+import type { ActionHandlerMap } from "./ActionHandlersGraph";
+import type { Dispatcher } from "./Dispatcher";
+import type { Store } from "./Store";
+
+export declare abstract class PersistedStore<
+ Constructor extends GenericConstructor = GenericConstructor,
+ State = unknown
+> extends Store {
+ constructor(dispatcher: Dispatcher, actionHandlers: Partial);
+
+ static _clearAllPromise: Promise | Nullish;
+ static _writePromises: Map* persistKey: */string, Promise>;
+ static _writeResolvers: Map* persistKey: */string, [resolver: () => void, callbackId: number]>;
+ static allPersistKeys: Set;
+ static clearAll(options: PersistedStoreClearOptions): Promise;
+ static clearPersistQueue(options: PersistedStoreClearOptions): void;
+ static disableWrite: boolean;
+ static disableWrites: boolean;
+ static getAllStates(): Promise<{ [persistKey: string]: unknown; }>;
+ static initializeAll(stateMap: { [persistKey: string]: unknown; } & Pick