diff --git a/src/util/connections/Connection.ts b/src/util/connections/Connection.ts
new file mode 100644
index 00000000..26279299
--- /dev/null
+++ b/src/util/connections/Connection.ts
@@ -0,0 +1,100 @@
+import crypto from "crypto";
+import { ConnectedAccount } from "../entities";
+import { ConnectedAccountSchema, ConnectionCallbackSchema } from "../schemas";
+import { Config, DiscordApiErrors } from "../util";
+
+/**
+ * A connection that can be used to connect to an external service.
+ */
+export default abstract class Connection {
+ id: string;
+ settings: { enabled: boolean };
+ states: Map<string, string> = new Map();
+
+ abstract init(): void;
+
+ /**
+ * Generates an authorization url for the connection.
+ * @param args
+ */
+ abstract getAuthorizationUrl(userId: string): string;
+
+ /**
+ * Returns the redirect_uri for a connection type
+ * @returns redirect_uri for this connection
+ */
+ getRedirectUri() {
+ const endpointPublic =
+ Config.get().api.endpointPublic ?? "http://localhost:3001";
+ return `${endpointPublic}/connections/${this.id}/callback`;
+ }
+
+ /**
+ * Processes the callback
+ * @param args Callback arguments
+ */
+ abstract handleCallback(
+ params: ConnectionCallbackSchema,
+ ): Promise<ConnectedAccount | null>;
+
+ /**
+ * Gets a user id from state
+ * @param state the state to get the user id from
+ * @returns the user id associated with the state
+ */
+ getUserId(state: string): string {
+ if (!this.states.has(state)) throw DiscordApiErrors.INVALID_OAUTH_STATE;
+ return this.states.get(state) as string;
+ }
+
+ /**
+ * Generates a state
+ * @param user_id The user id to generate a state for.
+ * @returns a new state
+ */
+ createState(userId: string): string {
+ const state = crypto.randomBytes(16).toString("hex");
+ this.states.set(state, userId);
+
+ return state;
+ }
+
+ /**
+ * Takes a state and checks if it is valid, and deletes it.
+ * @param state The state to check.
+ */
+ validateState(state: string): void {
+ if (!this.states.has(state)) throw DiscordApiErrors.INVALID_OAUTH_STATE;
+ this.states.delete(state);
+ }
+
+ /**
+ * Creates a Connected Account in the database.
+ * @param data connected account data
+ * @returns the new connected account
+ */
+ async createConnection(
+ data: ConnectedAccountSchema,
+ ): Promise<ConnectedAccount> {
+ const ca = ConnectedAccount.create({ ...data });
+ await ca.save();
+ return ca;
+ }
+
+ /**
+ * Checks if a user has an exist connected account for the given extenal id.
+ * @param userId the user id
+ * @param externalId the connection id to find
+ * @returns
+ */
+ async hasConnection(userId: string, externalId: string): Promise<boolean> {
+ const existing = await ConnectedAccount.findOne({
+ where: {
+ user_id: userId,
+ external_id: externalId,
+ },
+ });
+
+ return !!existing;
+ }
+}
diff --git a/src/util/connections/ConnectionConfig.ts b/src/util/connections/ConnectionConfig.ts
new file mode 100644
index 00000000..7d1f9857
--- /dev/null
+++ b/src/util/connections/ConnectionConfig.ts
@@ -0,0 +1,80 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import { ConnectionConfigEntity } from "../entities/ConnectionConfigEntity";
+
+let config: any;
+let pairs: ConnectionConfigEntity[];
+
+export const ConnectionConfig = {
+ init: async function init() {
+ if (config) return config;
+ console.log("[Connections] Loading configuration...");
+ pairs = await ConnectionConfigEntity.find();
+ config = pairsToConfig(pairs);
+
+ return this.set(config);
+ },
+ get: function get() {
+ if (!config) {
+ return {};
+ }
+ return config;
+ },
+ set: function set(val: Partial<any>) {
+ if (!config || !val) return;
+ config = val.merge(config);
+
+ // return applyConfig(config);
+ return applyConfig(val);
+ },
+};
+
+function applyConfig(val: any) {
+ async function apply(obj: any, key = ""): Promise<any> {
+ if (typeof obj === "object" && obj !== null && !(obj instanceof Date))
+ return Promise.all(
+ Object.keys(obj).map((k) =>
+ apply(obj[k], key ? `${key}_${k}` : k),
+ ),
+ );
+
+ let pair = pairs.find((x) => x.key === key);
+ if (!pair) pair = new ConnectionConfigEntity();
+
+ pair.key = key;
+
+ if (pair.value !== obj) {
+ pair.value = obj;
+ if (!pair.key || pair.key == null) {
+ console.log(`[Connections] WARN: Empty config key`);
+ console.log(pair);
+ } else return pair.save();
+ }
+ }
+
+ return apply(val);
+}
+
+function pairsToConfig(pairs: ConnectionConfigEntity[]) {
+ const value: any = {};
+
+ pairs.forEach((p) => {
+ const keys = p.key.split("_");
+ let obj = value;
+ let prev = "";
+ let prevObj = obj;
+ let i = 0;
+
+ for (const key of keys) {
+ if (!isNaN(Number(key)) && !prevObj[prev]?.length)
+ prevObj[prev] = obj = [];
+ if (i++ === keys.length - 1) obj[key] = p.value;
+ else if (!obj[key]) obj[key] = {};
+
+ prev = key;
+ prevObj = obj;
+ obj = obj[key];
+ }
+ });
+
+ return value;
+}
diff --git a/src/util/connections/ConnectionLoader.ts b/src/util/connections/ConnectionLoader.ts
new file mode 100644
index 00000000..b32f77cd
--- /dev/null
+++ b/src/util/connections/ConnectionLoader.ts
@@ -0,0 +1,68 @@
+import fs from "fs";
+import path from "path";
+import Connection from "./Connection";
+import { ConnectionConfig } from "./ConnectionConfig";
+import { ConnectionStore } from "./ConnectionStore";
+
+const root = "dist/connections";
+const connectionsLoaded = false;
+
+export class ConnectionLoader {
+ public static async loadConnections() {
+ if (connectionsLoaded) return;
+ ConnectionConfig.init();
+ const dirs = fs.readdirSync(root).filter((x) => {
+ try {
+ fs.readdirSync(path.join(root, x));
+ return true;
+ } catch (e) {
+ return false;
+ }
+ });
+
+ dirs.forEach(async (x) => {
+ const modPath = path.resolve(path.join(root, x));
+ const mod = new (require(modPath).default)() as Connection;
+ ConnectionStore.connections.set(mod.id, mod);
+
+ mod.init();
+ // console.log(`[Connections] Loaded connection '${mod.id}'`);
+ });
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ public static getConnectionConfig(id: string, defaults?: any): any {
+ let cfg = ConnectionConfig.get()[id];
+ if (defaults) {
+ if (cfg) cfg = Object.assign({}, defaults, cfg);
+ else {
+ cfg = defaults;
+ this.setConnectionConfig(id, cfg);
+ }
+ }
+
+ if (cfg?.enabled) console.log(`[Connections] ${id} enabled`);
+
+ // if (!cfg)
+ // console.log(
+ // `[ConnectionConfig/WARN] Getting connection settings for '${id}' returned null! (Did you forget to add settings?)`,
+ // );
+ return cfg;
+ }
+
+ public static async setConnectionConfig(
+ id: string,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ config: Partial<any>,
+ ): Promise<void> {
+ if (!config)
+ console.warn(`[Connections/WARN] ${id} tried to set config=null!`);
+
+ await ConnectionConfig.set({
+ [id]: Object.assign(
+ config,
+ ConnectionLoader.getConnectionConfig(id) || {},
+ ),
+ });
+ }
+}
diff --git a/src/util/connections/ConnectionStore.ts b/src/util/connections/ConnectionStore.ts
new file mode 100644
index 00000000..759b6de7
--- /dev/null
+++ b/src/util/connections/ConnectionStore.ts
@@ -0,0 +1,7 @@
+import Connection from "./Connection";
+import RefreshableConnection from "./RefreshableConnection";
+
+export class ConnectionStore {
+ public static connections: Map<string, Connection | RefreshableConnection> =
+ new Map();
+}
diff --git a/src/util/connections/RefreshableConnection.ts b/src/util/connections/RefreshableConnection.ts
new file mode 100644
index 00000000..87f5f6dd
--- /dev/null
+++ b/src/util/connections/RefreshableConnection.ts
@@ -0,0 +1,30 @@
+import { ConnectedAccount } from "../entities";
+import { ConnectedAccountCommonOAuthTokenResponse } from "../interfaces";
+import Connection from "./Connection";
+
+/**
+ * A connection that can refresh its token.
+ */
+export default abstract class RefreshableConnection extends Connection {
+ refreshEnabled = true;
+ /**
+ * Refreshes the token for a connected account.
+ * @param connectedAccount The connected account to refresh
+ */
+ abstract refreshToken(
+ connectedAccount: ConnectedAccount,
+ ): Promise<ConnectedAccountCommonOAuthTokenResponse>;
+
+ /**
+ * Refreshes the token for a connected account and saves it to the database.
+ * @param connectedAccount The connected account to refresh
+ */
+ async refresh(
+ connectedAccount: ConnectedAccount,
+ ): Promise<ConnectedAccountCommonOAuthTokenResponse> {
+ const tokenData = await this.refreshToken(connectedAccount);
+ connectedAccount.token_data = { ...tokenData, fetched_at: Date.now() };
+ await connectedAccount.save();
+ return tokenData;
+ }
+}
diff --git a/src/util/connections/index.ts b/src/util/connections/index.ts
new file mode 100644
index 00000000..8d20bf27
--- /dev/null
+++ b/src/util/connections/index.ts
@@ -0,0 +1,5 @@
+export * from "./Connection";
+export * from "./ConnectionConfig";
+export * from "./ConnectionLoader";
+export * from "./ConnectionStore";
+export * from "./RefreshableConnection";
|