Added src/util files

This commit is contained in:
Toastie 2024-09-05 19:26:58 +12:00
parent 13c00a9bbe
commit 99e27306d0
Signed by: toastie_t0ast
GPG key ID: 27F3B6855AFD40A4
308 changed files with 12291 additions and 0 deletions

46
src/util/config/Config.ts Normal file
View file

@ -0,0 +1,46 @@
import {
ApiConfiguration,
CdnConfiguration,
DefaultsConfiguration,
EmailConfiguration,
EndpointConfiguration,
ExternalTokensConfiguration,
GeneralConfiguration,
GifConfiguration,
GuildConfiguration,
KafkaConfiguration,
LimitsConfiguration,
LoginConfiguration,
MetricsConfiguration,
PasswordResetConfiguration,
RabbitMQConfiguration,
RegionConfiguration,
RegisterConfiguration,
SecurityConfiguration,
SentryConfiguration,
TemplateConfiguration,
} from "../config";
export class ConfigValue {
gateway: EndpointConfiguration = new EndpointConfiguration();
cdn: CdnConfiguration = new CdnConfiguration();
api: ApiConfiguration = new ApiConfiguration();
general: GeneralConfiguration = new GeneralConfiguration();
limits: LimitsConfiguration = new LimitsConfiguration();
security: SecurityConfiguration = new SecurityConfiguration();
login: LoginConfiguration = new LoginConfiguration();
register: RegisterConfiguration = new RegisterConfiguration();
regions: RegionConfiguration = new RegionConfiguration();
guild: GuildConfiguration = new GuildConfiguration();
gif: GifConfiguration = new GifConfiguration();
rabbitmq: RabbitMQConfiguration = new RabbitMQConfiguration();
kafka: KafkaConfiguration = new KafkaConfiguration();
templates: TemplateConfiguration = new TemplateConfiguration();
metrics: MetricsConfiguration = new MetricsConfiguration();
sentry: SentryConfiguration = new SentryConfiguration();
defaults: DefaultsConfiguration = new DefaultsConfiguration();
external: ExternalTokensConfiguration = new ExternalTokensConfiguration();
email: EmailConfiguration = new EmailConfiguration();
passwordReset: PasswordResetConfiguration =
new PasswordResetConfiguration();
}

2
src/util/config/index.ts Normal file
View file

@ -0,0 +1,2 @@
export * from "./Config";
export * from "./types";

View file

@ -0,0 +1,5 @@
export class ApiConfiguration {
defaultVersion: string = "9";
activeVersions: string[] = ["6", "7", "8", "9"];
endpointPublic: string | null = null;
}

View file

@ -0,0 +1,12 @@
import { EndpointConfiguration } from "./EndpointConfiguration";
export class CdnConfiguration extends EndpointConfiguration {
resizeHeightMax: number = 1000;
resizeWidthMax: number = 1000;
imagorServerUrl: string | null = null;
endpointPublic: string | null = null;
endpointPrivate: string | null = null;
proxyCacheHeaderSeconds: number = 60 * 60 * 24;
}

View file

@ -0,0 +1,6 @@
import { GuildDefaults, UserDefaults } from ".";
export class DefaultsConfiguration {
guild: GuildDefaults = new GuildDefaults();
user: UserDefaults = new UserDefaults();
}

View file

@ -0,0 +1,15 @@
import {
MailGunConfiguration,
MailJetConfiguration,
SMTPConfiguration,
} from "./subconfigurations/email";
import { SendGridConfiguration } from "./subconfigurations/email/SendGrid";
export class EmailConfiguration {
provider: string | null = null;
senderAddress: string | null = null;
smtp: SMTPConfiguration = new SMTPConfiguration();
mailgun: MailGunConfiguration = new MailGunConfiguration();
mailjet: MailJetConfiguration = new MailJetConfiguration();
sendgrid: SendGridConfiguration = new SendGridConfiguration();
}

View file

@ -0,0 +1,5 @@
export class EndpointConfiguration {
endpointClient: string | null = null;
endpointPrivate: string | null = null;
endpointPublic: string | null = null;
}

View file

@ -0,0 +1,3 @@
export class ExternalTokensConfiguration {
twitter: string | null = null;
}

View file

@ -0,0 +1,14 @@
import { Snowflake } from "@valkyrie/util";
export class GeneralConfiguration {
instanceName: string = "Valkyrie Instance";
instanceDescription: string | null =
"This is a ValkyrieChat instance made in the pre-release days";
frontPage: string | null = null;
tosPage: string | null = null;
correspondenceEmail: string | null = null;
correspondenceUserID: string | null = null;
image: string | null = null;
instanceId: string = Snowflake.generate();
autoCreateBotUsers: boolean = false;
}

View file

@ -0,0 +1,5 @@
export class GifConfiguration {
enabled: boolean = true;
provider = "tenor" as const; // more coming soon
apiKey?: string = "LIVDSRZULELA";
}

View file

@ -0,0 +1,7 @@
import { DiscoveryConfiguration, AutoJoinConfiguration } from ".";
export class GuildConfiguration {
discovery: DiscoveryConfiguration = new DiscoveryConfiguration();
autoJoin: AutoJoinConfiguration = new AutoJoinConfiguration();
defaultFeatures: string[] = [];
}

View file

@ -0,0 +1,5 @@
import { KafkaBroker } from ".";
export class KafkaConfiguration {
brokers: KafkaBroker[] | null = null;
}

View file

@ -0,0 +1,17 @@
import {
ChannelLimits,
GlobalRateLimits,
GuildLimits,
MessageLimits,
RateLimits,
UserLimits,
} from ".";
export class LimitsConfiguration {
user: UserLimits = new UserLimits();
guild: GuildLimits = new GuildLimits();
message: MessageLimits = new MessageLimits();
channel: ChannelLimits = new ChannelLimits();
rate: RateLimits = new RateLimits();
absoluteRate: GlobalRateLimits = new GlobalRateLimits();
}

View file

@ -0,0 +1,4 @@
export class LoginConfiguration {
requireCaptcha: boolean = false;
requireVerification: boolean = false;
}

View file

@ -0,0 +1,3 @@
export class MetricsConfiguration {
timeout: number = 30000;
}

View file

@ -0,0 +1,3 @@
export class PasswordResetConfiguration {
requireCaptcha: boolean = false;
}

View file

@ -0,0 +1,3 @@
export class RabbitMQConfiguration {
host: string | null = null;
}

View file

@ -0,0 +1,16 @@
import { Region } from ".";
export class RegionConfiguration {
default: string = "valkyrie";
useDefaultAsOptimal: boolean = true;
available: Region[] = [
{
id: "valkyrie",
name: "valkyrie",
endpoint: "127.0.0.1:3004",
vip: false,
custom: false,
deprecated: false,
},
];
}

View file

@ -0,0 +1,21 @@
import {
DateOfBirthConfiguration,
PasswordConfiguration,
RegistrationEmailConfiguration,
} from ".";
export class RegisterConfiguration {
email: RegistrationEmailConfiguration =
new RegistrationEmailConfiguration();
dateOfBirth: DateOfBirthConfiguration = new DateOfBirthConfiguration();
password: PasswordConfiguration = new PasswordConfiguration();
disabled: boolean = false;
requireCaptcha: boolean = true;
requireInvite: boolean = false;
guestsRequireInvite: boolean = true;
allowNewRegistration: boolean = true;
allowMultipleAccounts: boolean = true;
blockProxies: boolean = true;
incrementingDiscriminators: boolean = false; // random otherwise
defaultRights: string = "875069521787904"; // See `npm run generate:rights`
}

View file

@ -0,0 +1,19 @@
import crypto from "crypto";
import { CaptchaConfiguration, TwoFactorConfiguration } from ".";
export class SecurityConfiguration {
captcha: CaptchaConfiguration = new CaptchaConfiguration();
twoFactor: TwoFactorConfiguration = new TwoFactorConfiguration();
autoUpdate: boolean | number = true;
requestSignature: string = crypto.randomBytes(32).toString("base64");
jwtSecret: string = crypto.randomBytes(256).toString("base64");
// header to get the real user ip address
// X-Forwarded-For for nginx/reverse proxies
// CF-Connecting-IP for cloudflare
forwardedFor: string | null = null;
ipdataApiKey: string | null =
"eca677b284b3bac29eb72f5e496aa9047f26543605efe99ff2ce35c9";
mfaBackupCodeCount: number = 10;
statsWorldReadable: boolean = true;
defaultRegistrationTokenExpiration: number = 1000 * 60 * 60 * 24 * 7; //1 week
}

View file

@ -0,0 +1,9 @@
import { hostname } from "os";
export class SentryConfiguration {
enabled: boolean = false;
endpoint: string =
"https://ac1113c8cf9bf615a6e6b5a729d61bfa@o565598.ingest.us.sentry.io/4507888903520256";
traceSampleRate: number = 1.0;
environment: string = hostname();
}

View file

@ -0,0 +1,6 @@
export class TemplateConfiguration {
enabled: boolean = true;
allowTemplateCreation: boolean = true;
allowDiscordTemplates: boolean = true;
allowRaws: boolean = true;
}

View file

@ -0,0 +1,21 @@
export * from "./ApiConfiguration";
export * from "./CdnConfiguration";
export * from "./DefaultsConfiguration";
export * from "./EmailConfiguration";
export * from "./EndpointConfiguration";
export * from "./ExternalTokensConfiguration";
export * from "./GeneralConfiguration";
export * from "./GifConfiguration";
export * from "./GuildConfiguration";
export * from "./KafkaConfiguration";
export * from "./LimitConfigurations";
export * from "./LoginConfiguration";
export * from "./MetricsConfiguration";
export * from "./PasswordResetConfiguration";
export * from "./RabbitMQConfiguration";
export * from "./RegionConfiguration";
export * from "./RegisterConfiguration";
export * from "./SecurityConfiguration";
export * from "./SentryConfiguration";
export * from "./subconfigurations";
export * from "./TemplateConfiguration";

View file

@ -0,0 +1,4 @@
export class ClientReleaseConfiguration {
useLocalRelease: boolean = true; //TODO
upstreamVersion: string = "0.0.264";
}

View file

@ -0,0 +1 @@
export * from "./ClientReleaseConfiguration";

View file

@ -0,0 +1,7 @@
export class GuildDefaults {
maxPresences: number = 250000;
maxVideoChannelUsers: number = 200;
afkTimeout: number = 300;
defaultMessageNotifications: number = 1;
explicitContentFilter: number = 0;
}

View file

@ -0,0 +1,5 @@
export class UserDefaults {
premium: boolean = true;
premiumType: number = 2;
verified: boolean = true;
}

View file

@ -0,0 +1,2 @@
export * from "./GuildDefaults";
export * from "./UserDefaults";

View file

@ -0,0 +1,4 @@
export class MailGunConfiguration {
apiKey: string | null = null;
domain: string | null = null;
}

View file

@ -0,0 +1,4 @@
export class MailJetConfiguration {
apiKey: string | null = null;
apiSecret: string | null = null;
}

View file

@ -0,0 +1,7 @@
export class SMTPConfiguration {
host: string | null = null;
port: number | null = null;
secure: boolean | null = null;
username: string | null = null;
password: string | null = null;
}

View file

@ -0,0 +1,3 @@
export class SendGridConfiguration {
apiKey: string | null = null;
}

View file

@ -0,0 +1,3 @@
export * from "./MailGun";
export * from "./MailJet";
export * from "./SMTP";

View file

@ -0,0 +1,5 @@
export class AutoJoinConfiguration {
enabled: boolean = true;
guilds: string[] = [];
canLeave: boolean = true;
}

View file

@ -0,0 +1,6 @@
export class DiscoveryConfiguration {
showAllGuilds: boolean = false;
useRecommendation: boolean = false; // TODO: Recommendation, privacy concern?
offset: number = 0;
limit: number = 24;
}

View file

@ -0,0 +1,2 @@
export * from "./AutoJoin";
export * from "./Discovery";

View file

@ -0,0 +1,8 @@
export * from "./client";
export * from "./defaults";
export * from "./guild";
export * from "./kafka";
export * from "./limits";
export * from "./region";
export * from "./register";
export * from "./security";

View file

@ -0,0 +1,4 @@
export interface KafkaBroker {
ip: string;
port: number;
}

View file

@ -0,0 +1 @@
export * from "./KafkaBroker";

View file

@ -0,0 +1,5 @@
export class ChannelLimits {
maxPins: number = 500;
maxTopic: number = 1024;
maxWebhooks: number = 100;
}

View file

@ -0,0 +1,18 @@
export class GlobalRateLimits {
register: GlobalRateLimit = {
limit: 25,
window: 60 * 60 * 1000,
enabled: true,
};
sendMessage: GlobalRateLimit = {
limit: 200,
window: 60 * 1000,
enabled: true,
};
}
export class GlobalRateLimit {
limit: number = 100;
window: number = 60 * 60 * 1000;
enabled: boolean = true;
}

View file

@ -0,0 +1,8 @@
export class GuildLimits {
maxRoles: number = 1000;
maxEmojis: number = 2000;
maxMembers: number = 25000000;
maxChannels: number = 65535;
maxBulkBanUsers: number = 200;
maxChannelsInCategory: number = 65535;
}

View file

@ -0,0 +1,8 @@
export class MessageLimits {
maxCharacters: number = 1048576;
maxTTSCharacters: number = 160;
maxReactions: number = 2048;
maxAttachmentSize: number = 1024 * 1024 * 1024;
maxBulkDelete: number = 1000;
maxEmbedDownloadSize: number = 1024 * 1024 * 5;
}

View file

@ -0,0 +1,18 @@
import { RateLimitOptions, RouteRateLimit } from ".";
export class RateLimits {
enabled: boolean = false;
ip: RateLimitOptions = {
count: 500,
window: 5,
};
global: RateLimitOptions = {
count: 250,
window: 5,
};
error: RateLimitOptions = {
count: 10,
window: 5,
};
routes: RouteRateLimit = new RouteRateLimit();
}

View file

@ -0,0 +1,6 @@
export class UserLimits {
maxGuilds: number = 1048576;
maxUsername: number = 32;
maxFriends: number = 5000;
maxBio: number = 190;
}

View file

@ -0,0 +1,7 @@
export * from "./ChannelLimits";
export * from "./GlobalRateLimits";
export * from "./GuildLimits";
export * from "./MessageLimits";
export * from "./RateLimits";
export * from "./UserLimits";
export * from "./ratelimits/index";

View file

@ -0,0 +1,12 @@
import { RateLimitOptions } from "./RateLimitOptions";
export class AuthRateLimit {
login: RateLimitOptions = {
count: 5,
window: 60,
};
register: RateLimitOptions = {
count: 2,
window: 60 * 60 * 12,
};
}

View file

@ -0,0 +1,6 @@
export interface RateLimitOptions {
bot?: number;
count: number;
window: number;
onyIp?: boolean;
}

View file

@ -0,0 +1,19 @@
import { AuthRateLimit } from ".";
import { RateLimitOptions } from "./RateLimitOptions";
export class RouteRateLimit {
guild: RateLimitOptions = {
count: 5,
window: 5,
};
webhook: RateLimitOptions = {
count: 10,
window: 5,
};
channel: RateLimitOptions = {
count: 10,
window: 5,
};
auth: AuthRateLimit = new AuthRateLimit();
// TODO: rate limit configuration for all routes
}

View file

@ -0,0 +1,3 @@
export * from "./Auth";
export * from "./RateLimitOptions";
export * from "./Route";

View file

@ -0,0 +1,12 @@
export interface Region {
id: string;
name: string;
endpoint: string;
location?: {
latitude: number;
longitude: number;
};
vip: boolean;
custom: boolean;
deprecated: boolean;
}

View file

@ -0,0 +1 @@
export * from "./Region";

View file

@ -0,0 +1,4 @@
export class DateOfBirthConfiguration {
required: boolean = true;
minimum: number = 13; // in years
}

View file

@ -0,0 +1,7 @@
export class RegistrationEmailConfiguration {
required: boolean = false;
allowlist: boolean = false;
blocklist: boolean = true;
domains: string[] = []; // TODO: efficiently save domain blocklist in database
// domains: fs.readFileSync(__dirname + "/blockedEmailDomains.txt", { encoding: "utf8" }).split("\n"),
}

View file

@ -0,0 +1,7 @@
export class PasswordConfiguration {
required: boolean = false;
minLength: number = 8;
minNumbers: number = 2;
minUpperCase: number = 2;
minSymbols: number = 0;
}

View file

@ -0,0 +1,3 @@
export * from "./DateOfBirth";
export * from "./Email";
export * from "./Password";

View file

@ -0,0 +1,6 @@
export class CaptchaConfiguration {
enabled: boolean = false;
service: "recaptcha" | "hcaptcha" | null = null; // TODO: hcaptcha, custom
sitekey: string | null = null;
secret: string | null = null;
}

View file

@ -0,0 +1,3 @@
export class TwoFactorConfiguration {
generateBackupCodes: boolean = true;
}

View file

@ -0,0 +1,2 @@
export * from "./Captcha";
export * from "./TwoFactor";

View file

@ -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 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().general.frontPage ?? "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;
}
}

View file

@ -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;
}

View file

@ -0,0 +1,66 @@
import { Connection } from "@valkyrie/util";
import fs from "fs";
import path from "path";
import { ConnectionConfig } from "./ConnectionConfig";
import { ConnectionStore } from "./ConnectionStore";
const root = path.join(__dirname, "..", "..", "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}'`);
});
}
public static getConnectionConfig<T>(id: string, defaults?: unknown): T {
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,
config: Partial<unknown>,
): Promise<void> {
if (!config)
console.warn(`[Connections/WARN] ${id} tried to set config=null!`);
await ConnectionConfig.set({
[id]: Object.assign(
config,
ConnectionLoader.getConnectionConfig(id) || {},
),
});
}
}

View file

@ -0,0 +1,7 @@
import { Connection } from "./Connection";
import { RefreshableConnection } from "./RefreshableConnection";
export class ConnectionStore {
public static connections: Map<string, Connection | RefreshableConnection> =
new Map();
}

View file

@ -0,0 +1,31 @@
import { ConnectedAccount } from "../entities";
import { ConnectedAccountCommonOAuthTokenResponse } from "../interfaces";
import { Connection } from "./Connection";
/**
* A connection that can refresh its token.
*/
export 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;
}
}

View file

@ -0,0 +1,5 @@
export * from "./Connection";
export * from "./ConnectionConfig";
export * from "./ConnectionLoader";
export * from "./ConnectionStore";
export * from "./RefreshableConnection";

View file

@ -0,0 +1,43 @@
import { ConnectedAccount } from "../entities";
export class ConnectedAccountDTO {
id: string;
user_id: string;
access_token?: string;
friend_sync?: boolean;
name: string;
revoked?: boolean;
show_activity?: number;
type: string;
verified?: boolean;
visibility?: number;
integrations?: string[];
metadata_?: unknown;
metadata_visibility?: number;
two_way_link?: boolean;
constructor(
connectedAccount: ConnectedAccount,
with_token: boolean = false,
) {
this.id = connectedAccount.external_id;
this.user_id = connectedAccount.user_id;
this.access_token =
connectedAccount.token_data && with_token
? connectedAccount.token_data.access_token
: undefined;
this.friend_sync = connectedAccount.friend_sync;
this.name = connectedAccount.name;
this.revoked = connectedAccount.revoked;
this.show_activity = connectedAccount.show_activity;
this.type = connectedAccount.type;
this.verified = connectedAccount.verified;
this.visibility = +(connectedAccount.visibility || false);
this.integrations = connectedAccount.integrations;
this.metadata_ = connectedAccount.metadata_;
this.metadata_visibility = +(
connectedAccount.metadata_visibility || false
);
this.two_way_link = connectedAccount.two_way_link;
}
}

View file

@ -0,0 +1,50 @@
import { MinimalPublicUserDTO } from "./UserDTO";
import { Channel, PublicUserProjection, User } from "../entities";
export class DmChannelDTO {
icon: string | null;
id: string;
last_message_id: string | null;
name: string | null;
origin_channel_id: string | null;
owner_id?: string;
recipients: MinimalPublicUserDTO[];
type: number;
static async from(
channel: Channel,
excluded_recipients: string[] = [],
origin_channel_id?: string,
) {
const obj = new DmChannelDTO();
obj.icon = channel.icon || null;
obj.id = channel.id;
obj.last_message_id = channel.last_message_id || null;
obj.name = channel.name || null;
obj.origin_channel_id = origin_channel_id || null;
obj.owner_id = channel.owner_id;
obj.type = channel.type;
obj.recipients = (
await Promise.all(
channel.recipients
?.filter((r) => !excluded_recipients.includes(r.user_id))
.map(async (r) => {
return await User.findOneOrFail({
where: { id: r.user_id },
select: PublicUserProjection,
});
}) || [],
)
).map((u) => new MinimalPublicUserDTO(u));
return obj;
}
excludedRecipients(excluded_recipients: string[]): DmChannelDTO {
return {
...this,
recipients: this.recipients.filter(
(r) => !excluded_recipients.includes(r.id),
),
};
}
}

View file

@ -0,0 +1,226 @@
import {
Channel,
ChannelOverride,
ChannelType,
Emoji,
Guild,
PublicUser,
Role,
Sticker,
UserGuildSettings,
PublicMember,
} from "../entities";
// TODO: this is not the best place for this type
export type ReadyUserGuildSettingsEntries = Omit<
UserGuildSettings,
"channel_overrides"
> & {
channel_overrides: (ChannelOverride & { channel_id: string })[];
};
// TODO: probably should move somewhere else
export interface ReadyPrivateChannel {
id: string;
flags: number;
is_spam: boolean;
last_message_id?: string;
recipients: PublicUser[];
type: ChannelType.DM | ChannelType.GROUP_DM;
}
export type GuildOrUnavailable =
| { id: string; unavailable: boolean }
| (Guild & { joined_at?: Date; unavailable: undefined });
const guildIsAvailable = (
guild: GuildOrUnavailable,
): guild is Guild & { joined_at: Date; unavailable: false } => {
return guild.unavailable != true;
};
export interface IReadyGuildDTO {
application_command_counts?: { 1: number; 2: number; 3: number }; // ????????????
channels: Channel[];
data_mode: string; // what is this
emojis: Emoji[];
guild_scheduled_events: unknown[]; // TODO
id: string;
large: boolean | undefined;
lazy: boolean;
member_count: number | undefined;
members: PublicMember[];
premium_subscription_count: number | undefined;
properties: {
name: string;
description?: string | null;
icon?: string | null;
splash?: string | null;
banner?: string | null;
features: string[];
preferred_locale?: string | null;
owner_id?: string | null;
application_id?: string | null;
afk_channel_id?: string | null;
afk_timeout: number | undefined;
system_channel_id?: string | null;
verification_level: number | undefined;
explicit_content_filter: number | undefined;
default_message_notifications: number | undefined;
mfa_level: number | undefined;
vanity_url_code?: string | null;
premium_tier: number | undefined;
premium_progress_bar_enabled: boolean;
system_channel_flags: number | undefined;
discovery_splash?: string | null;
rules_channel_id?: string | null;
public_updates_channel_id?: string | null;
max_video_channel_users: number | undefined;
max_members: number | undefined;
nsfw_level: number | undefined;
hub_type?: unknown | null; // ????
home_header: null; // TODO
latest_onboarding_question_id: null; // TODO
safety_alerts_channel_id: null; // TODO
max_stage_video_channel_users: 50; // TODO
nsfw: boolean;
id: string;
};
roles: Role[];
stage_instances: unknown[];
stickers: Sticker[];
threads: unknown[];
version: string;
guild_hashes: unknown;
unavailable: boolean;
}
export class ReadyGuildDTO implements IReadyGuildDTO {
application_command_counts?: { 1: number; 2: number; 3: number }; // ????????????
channels: Channel[];
data_mode: string; // what is this
emojis: Emoji[];
guild_scheduled_events: unknown[];
id: string;
large: boolean | undefined;
lazy: boolean;
member_count: number | undefined;
members: PublicMember[];
premium_subscription_count: number | undefined;
properties: {
name: string;
description?: string | null;
icon?: string | null;
splash?: string | null;
banner?: string | null;
features: string[];
preferred_locale?: string | null;
owner_id?: string | null;
application_id?: string | null;
afk_channel_id?: string | null;
afk_timeout: number | undefined;
system_channel_id?: string | null;
verification_level: number | undefined;
explicit_content_filter: number | undefined;
default_message_notifications: number | undefined;
mfa_level: number | undefined;
vanity_url_code?: string | null;
premium_tier: number | undefined;
premium_progress_bar_enabled: boolean;
system_channel_flags: number | undefined;
discovery_splash?: string | null;
rules_channel_id?: string | null;
public_updates_channel_id?: string | null;
max_video_channel_users: number | undefined;
max_members: number | undefined;
nsfw_level: number | undefined;
hub_type?: unknown | null; // ????
home_header: null; // TODO
latest_onboarding_question_id: null; // TODO
safety_alerts_channel_id: null; // TODO
max_stage_video_channel_users: 50; // TODO
nsfw: boolean;
id: string;
};
roles: Role[];
stage_instances: unknown[];
stickers: Sticker[];
threads: unknown[];
version: string;
guild_hashes: unknown;
unavailable: boolean;
joined_at: Date;
constructor(guild: GuildOrUnavailable) {
if (!guildIsAvailable(guild)) {
this.id = guild.id;
this.unavailable = true;
return;
}
this.application_command_counts = {
1: 5,
2: 2,
3: 2,
}; // ?????
this.channels = guild.channels;
this.data_mode = "full";
this.emojis = guild.emojis;
this.guild_scheduled_events = [];
this.id = guild.id;
this.large = guild.large;
this.lazy = true; // ??????????
this.member_count = guild.member_count;
this.members = guild.members?.map((x) => x.toPublicMember());
this.premium_subscription_count = guild.premium_subscription_count;
this.properties = {
name: guild.name,
description: guild.description,
icon: guild.icon,
splash: guild.splash,
banner: guild.banner,
features: guild.features,
preferred_locale: guild.preferred_locale,
owner_id: guild.owner_id,
application_id: null, // ?????
afk_channel_id: guild.afk_channel_id,
afk_timeout: guild.afk_timeout,
system_channel_id: guild.system_channel_id,
verification_level: guild.verification_level,
explicit_content_filter: guild.explicit_content_filter,
default_message_notifications: guild.default_message_notifications,
mfa_level: guild.mfa_level,
vanity_url_code: null, // ?????
premium_tier: guild.premium_tier,
premium_progress_bar_enabled: guild.premium_progress_bar_enabled,
system_channel_flags: guild.system_channel_flags,
discovery_splash: guild.discovery_splash,
rules_channel_id: guild.rules_channel_id,
public_updates_channel_id: guild.public_updates_channel_id,
max_video_channel_users: guild.max_video_channel_users,
max_members: guild.max_members,
nsfw_level: guild.nsfw_level,
hub_type: null,
home_header: null,
id: guild.id,
latest_onboarding_question_id: null,
max_stage_video_channel_users: 50, // TODO
nsfw: guild.nsfw,
safety_alerts_channel_id: null,
};
this.roles = guild.roles.map((x) => x.toJSON());
this.stage_instances = [];
this.stickers = guild.stickers;
this.threads = [];
this.version = "1"; // ??????
this.guild_hashes = {};
this.joined_at = guild.joined_at;
}
toJSON() {
return this as IReadyGuildDTO;
}
}

19
src/util/dtos/UserDTO.ts Normal file
View file

@ -0,0 +1,19 @@
import { User } from "../entities";
export class MinimalPublicUserDTO {
avatar?: string | null;
discriminator: string;
id: string;
public_flags: number;
username: string;
badge_ids?: string[] | null;
constructor(user: User) {
this.avatar = user.avatar;
this.discriminator = user.discriminator;
this.id = user.id;
this.public_flags = user.public_flags;
this.username = user.username;
this.badge_ids = user.badge_ids;
}
}

4
src/util/dtos/index.ts Normal file
View file

@ -0,0 +1,4 @@
export * from "./ConnectedAccountDTO";
export * from "./DmChannelDTO";
export * from "./ReadyGuildDTO";
export * from "./UserDTO";

View file

@ -0,0 +1,174 @@
import {
Column,
Entity,
JoinColumn,
ManyToOne,
OneToOne,
RelationId,
} from "typeorm";
import { BaseClass } from "./BaseClass";
import { Team } from "./Team";
import { User } from "./User";
import { dbEngine } from "../util/Database";
import { Guild } from "./Guild";
@Entity({
name: "applications",
engine: dbEngine,
})
export class Application extends BaseClass {
@Column()
name: string;
@Column({ nullable: true })
icon?: string;
@Column({ nullable: true })
description: string;
@Column({ nullable: true })
summary: string = "";
@Column({ type: "simple-json", nullable: true })
type?: object; // TODO: this type is bad
@Column()
hook: boolean = true;
@Column()
bot_public?: boolean = true;
@Column()
bot_require_code_grant?: boolean = false;
@Column()
verify_key: string;
@JoinColumn({ name: "owner_id" })
@ManyToOne(() => User, { onDelete: "CASCADE" })
owner: User;
// TODO: enum this? https://discord.com/developers/docs/resources/application#application-object-application-flags
@Column()
flags: number = 0;
@Column({ type: "simple-array", nullable: true })
redirect_uris: string[] = [];
@Column({ nullable: true })
rpc_application_state: number = 0;
@Column({ nullable: true })
store_application_state: number = 1;
@Column({ nullable: true })
verification_state: number = 1;
@Column({ nullable: true })
interactions_endpoint_url?: string;
@Column({ nullable: true })
integration_public: boolean = true;
@Column({ nullable: true })
integration_require_code_grant: boolean = false;
@Column({ nullable: true })
discoverability_state: number = 1;
@Column({ nullable: true })
discovery_eligibility_flags: number = 2240;
@JoinColumn({ name: "bot_user_id" })
@OneToOne(() => User, { onDelete: "CASCADE" })
bot?: User;
@Column({ type: "simple-array", nullable: true })
tags?: string[];
@Column({ nullable: true })
cover_image?: string; // the application's default rich presence invite cover image hash
@Column({ type: "simple-json", nullable: true })
install_params?: { scopes: string[]; permissions: string };
@Column({ nullable: true })
terms_of_service_url?: string;
@Column({ nullable: true })
privacy_policy_url?: string;
@Column({ nullable: true })
@RelationId((application: Application) => application.guild)
guild_id?: string;
@JoinColumn({ name: "guild_id" })
@ManyToOne(() => Guild)
guild?: Guild; // guild to which the app is linked, e.g. a developer support server
@Column({ nullable: true })
custom_install_url?: string;
//just for us
//@Column({ type: "simple-array", nullable: true })
//rpc_origins?: string[];
//@Column({ nullable: true })
//primary_sku_id?: string; // if this application is a game sold, this field will be the id of the "Game SKU" that is created,
//@Column({ nullable: true })
//slug?: string; // if this application is a game sold, this field will be the URL slug that links to the store page
@JoinColumn({ name: "team_id" })
@ManyToOne(() => Team, {
onDelete: "CASCADE",
nullable: true,
})
team?: Team;
}
export interface ApplicationCommand {
id: string;
application_id: string;
name: string;
description: string;
options?: ApplicationCommandOption[];
}
export interface ApplicationCommandOption {
type: ApplicationCommandOptionType;
name: string;
description: string;
required?: boolean;
choices?: ApplicationCommandOptionChoice[];
options?: ApplicationCommandOption[];
}
export interface ApplicationCommandOptionChoice {
name: string;
value: string | number;
}
export enum ApplicationCommandOptionType {
SUB_COMMAND = 1,
SUB_COMMAND_GROUP = 2,
STRING = 3,
INTEGER = 4,
BOOLEAN = 5,
USER = 6,
CHANNEL = 7,
ROLE = 8,
}
export interface ApplicationCommandInteractionData {
id: string;
name: string;
options?: ApplicationCommandInteractionDataOption[];
}
export interface ApplicationCommandInteractionDataOption {
name: string;
value?: unknown;
options?: ApplicationCommandInteractionDataOption[];
}

View file

@ -0,0 +1,58 @@
import {
BeforeRemove,
Column,
Entity,
JoinColumn,
ManyToOne,
RelationId,
} from "typeorm";
import { URL } from "url";
import { deleteFile } from "../util/cdn";
import { BaseClass } from "./BaseClass";
import { dbEngine } from "../util/Database";
@Entity({
name: "attachments",
engine: dbEngine,
})
export class Attachment extends BaseClass {
@Column()
filename: string; // name of file attached
@Column()
size: number; // size of file in bytes
@Column()
url: string; // source url of file
@Column()
proxy_url: string; // a proxied url of file
@Column({ nullable: true })
height?: number; // height of file (if image)
@Column({ nullable: true })
width?: number; // width of file (if image)
@Column({ nullable: true })
content_type?: string;
@Column({ nullable: true })
@RelationId((attachment: Attachment) => attachment.message)
message_id: string;
@JoinColumn({ name: "message_id" })
@ManyToOne(
() => require("./Message").Message,
(message: import("./Message").Message) => message.attachments,
{
onDelete: "CASCADE",
},
)
message: import("./Message").Message;
@BeforeRemove()
onDelete() {
return deleteFile(new URL(this.url).pathname);
}
}

View file

@ -0,0 +1,198 @@
import { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm";
import { BaseClass } from "./BaseClass";
import { ChannelPermissionOverwrite } from "./Channel";
import { User } from "./User";
import { dbEngine } from "../util/Database";
export enum AuditLogEvents {
// guild level
GUILD_UPDATE = 1,
GUILD_IMPORT = 2,
GUILD_EXPORTED = 3,
GUILD_ARCHIVE = 4,
GUILD_UNARCHIVE = 5,
// join-leave
USER_JOIN = 6,
USER_LEAVE = 7,
// channels
CHANNEL_CREATE = 10,
CHANNEL_UPDATE = 11,
CHANNEL_DELETE = 12,
// permission overrides
CHANNEL_OVERWRITE_CREATE = 13,
CHANNEL_OVERWRITE_UPDATE = 14,
CHANNEL_OVERWRITE_DELETE = 15,
// kick and ban
MEMBER_KICK = 20,
MEMBER_PRUNE = 21,
MEMBER_BAN_ADD = 22,
MEMBER_BAN_REMOVE = 23,
// member updates
MEMBER_UPDATE = 24,
MEMBER_ROLE_UPDATE = 25,
MEMBER_MOVE = 26,
MEMBER_DISCONNECT = 27,
BOT_ADD = 28,
// roles
ROLE_CREATE = 30,
ROLE_UPDATE = 31,
ROLE_DELETE = 32,
ROLE_SWAP = 33,
// invites
INVITE_CREATE = 40,
INVITE_UPDATE = 41,
INVITE_DELETE = 42,
// webhooks
WEBHOOK_CREATE = 50,
WEBHOOK_UPDATE = 51,
WEBHOOK_DELETE = 52,
WEBHOOK_SWAP = 53,
// custom emojis
EMOJI_CREATE = 60,
EMOJI_UPDATE = 61,
EMOJI_DELETE = 62,
EMOJI_SWAP = 63,
// deletion
MESSAGE_CREATE = 70, // messages sent using non-primary seat of the user only
MESSAGE_EDIT = 71, // non-self edits only
MESSAGE_DELETE = 72,
MESSAGE_BULK_DELETE = 73,
// pinning
MESSAGE_PIN = 74,
MESSAGE_UNPIN = 75,
// integrations
INTEGRATION_CREATE = 80,
INTEGRATION_UPDATE = 81,
INTEGRATION_DELETE = 82,
// stage actions
STAGE_INSTANCE_CREATE = 83,
STAGE_INSTANCE_UPDATE = 84,
STAGE_INSTANCE_DELETE = 85,
// stickers
STICKER_CREATE = 90,
STICKER_UPDATE = 91,
STICKER_DELETE = 92,
STICKER_SWAP = 93,
// threads
THREAD_CREATE = 110,
THREAD_UPDATE = 111,
THREAD_DELETE = 112,
// application commands
APPLICATION_COMMAND_PERMISSION_UPDATE = 121,
// automod
POLICY_CREATE = 140,
POLICY_UPDATE = 141,
POLICY_DELETE = 142,
MESSAGE_BLOCKED_BY_POLICIES = 143, // in valkyrie, blocked messages are stealth-dropped
// instance policies affecting the guild
GUILD_AFFECTED_BY_POLICIES = 216,
// message moves
IN_GUILD_MESSAGE_MOVE = 223,
CROSS_GUILD_MESSAGE_MOVE = 224,
// message routing
ROUTE_CREATE = 225,
ROUTE_UPDATE = 226,
}
@Entity({
name: "audit_logs",
engine: dbEngine,
})
export class AuditLog extends BaseClass {
@JoinColumn({ name: "target_id" })
@ManyToOne(() => User)
target?: User;
@Column({ nullable: true })
@RelationId((auditlog: AuditLog) => auditlog.user)
user_id: string;
@JoinColumn({ name: "user_id" })
@ManyToOne(() => User, (user: User) => user.id)
user: User;
@Column({ type: "int" })
action_type: AuditLogEvents;
@Column({ type: "simple-json", nullable: true })
options?: {
delete_member_days?: string;
members_removed?: string;
channel_id?: string;
messaged_id?: string;
count?: string;
id?: string;
type?: string;
role_name?: string;
};
@Column()
@Column({ type: "simple-json" })
changes: AuditLogChange[];
@Column({ nullable: true })
reason?: string;
}
export interface AuditLogChange {
new_value?: AuditLogChangeValue;
old_value?: AuditLogChangeValue;
key: string;
}
export interface AuditLogChangeValue {
name?: string;
description?: string;
icon_hash?: string;
splash_hash?: string;
discovery_splash_hash?: string;
banner_hash?: string;
owner_id?: string;
region?: string;
preferred_locale?: string;
afk_channel_id?: string;
afk_timeout?: number;
rules_channel_id?: string;
public_updates_channel_id?: string;
mfa_level?: number;
verification_level?: number;
explicit_content_filter?: number;
default_message_notifications?: number;
vanity_url_code?: string;
$add?: object[]; // TODO: These types are bad.
$remove?: object[];
prune_delete_days?: number;
widget_enabled?: boolean;
widget_channel_id?: string;
system_channel_id?: string;
position?: number;
topic?: string;
bitrate?: number;
permission_overwrites?: ChannelPermissionOverwrite[];
nsfw?: boolean;
application_id?: string;
rate_limit_per_user?: number;
permissions?: string;
color?: number;
hoist?: boolean;
mentionable?: boolean;
allow?: string;
deny?: string;
code?: string;
channel_id?: string;
inviter_id?: string;
max_uses?: number;
uses?: number;
max_age?: number;
temporary?: boolean;
deaf?: boolean;
mute?: boolean;
nick?: string;
avatar_hash?: string;
id?: string;
type?: number;
enable_emoticons?: boolean;
expire_behavior?: number;
expire_grace_period?: number;
user_limit?: number;
}

View file

@ -0,0 +1,39 @@
import { Column, Entity, JoinColumn, ManyToOne } from "typeorm";
import { BaseClass } from "./BaseClass";
import { User } from "./User";
import crypto from "crypto";
import { dbEngine } from "../util/Database";
@Entity({
name: "backup_codes",
engine: dbEngine,
})
export class BackupCode extends BaseClass {
@JoinColumn({ name: "user_id" })
@ManyToOne(() => User, { onDelete: "CASCADE" })
user: User;
@Column()
code: string;
@Column()
consumed: boolean;
@Column()
expired: boolean;
}
export function generateMfaBackupCodes(user_id: string) {
const backup_codes: BackupCode[] = [];
for (let i = 0; i < 10; i++) {
const code = BackupCode.create({
user: { id: user_id },
code: crypto.randomBytes(4).toString("hex"), // 8 characters
consumed: false,
expired: false,
});
backup_codes.push(code);
}
return backup_codes;
}

View file

@ -0,0 +1,21 @@
import { Column, Entity } from "typeorm";
import { BaseClassWithoutId } from "./BaseClass";
import { dbEngine } from "../util/Database";
@Entity({
name: "badges",
engine: dbEngine,
})
export class Badge extends BaseClassWithoutId {
@Column({ primary: true })
id: string;
@Column()
description: string;
@Column()
icon: string;
@Column({ nullable: true })
link?: string;
}

45
src/util/entities/Ban.ts Normal file
View file

@ -0,0 +1,45 @@
import { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm";
import { BaseClass } from "./BaseClass";
import { Guild } from "./Guild";
import { User } from "./User";
import { dbEngine } from "../util/Database";
@Entity({
name: "bans",
engine: dbEngine,
})
export class Ban extends BaseClass {
@Column({ nullable: true })
@RelationId((ban: Ban) => ban.user)
user_id: string;
@JoinColumn({ name: "user_id" })
@ManyToOne(() => User, {
onDelete: "CASCADE",
})
user: User;
@Column({ nullable: true })
@RelationId((ban: Ban) => ban.guild)
guild_id: string;
@JoinColumn({ name: "guild_id" })
@ManyToOne(() => Guild, {
onDelete: "CASCADE",
})
guild: Guild;
@Column({ nullable: true })
@RelationId((ban: Ban) => ban.executor)
executor_id: string;
@JoinColumn({ name: "executor_id" })
@ManyToOne(() => User)
executor: User;
@Column()
ip: string;
@Column({ nullable: true })
reason?: string;
}

View file

@ -0,0 +1,79 @@
import {
BaseEntity,
BeforeInsert,
BeforeUpdate,
FindOptionsWhere,
ObjectIdColumn,
PrimaryColumn,
} from "typeorm";
import { Snowflake } from "../util/Snowflake";
import { getDatabase } from "../util/Database";
import { OrmUtils } from "../imports/OrmUtils";
export class BaseClassWithoutId extends BaseEntity {
private get construct() {
return this.constructor;
}
private get metadata() {
return getDatabase()?.getMetadata(this.construct);
}
assign(props: object) {
OrmUtils.mergeDeep(this, props);
return this;
}
// TODO: fix eslint
// eslint-disable-next-line @typescript-eslint/no-explicit-any
toJSON(): any {
return Object.fromEntries(
// eslint-disable-next-line @typescript-eslint/ban-ts-comment, @typescript-eslint/no-non-null-assertion
this.metadata!.columns // @ts-ignore
.map((x) => [x.propertyName, this[x.propertyName]])
.concat(
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
this.metadata.relations.map((x) => [
x.propertyName,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
this[x.propertyName],
]),
),
);
}
static increment<T extends BaseClass>(
conditions: FindOptionsWhere<T>,
propertyPath: string,
value: number | string,
) {
const repository = this.getRepository();
return repository.increment(conditions, propertyPath, value);
}
static decrement<T extends BaseClass>(
conditions: FindOptionsWhere<T>,
propertyPath: string,
value: number | string,
) {
const repository = this.getRepository();
return repository.decrement(conditions, propertyPath, value);
}
}
export const PrimaryIdColumn = process.env.DATABASE?.startsWith("mongodb")
? ObjectIdColumn
: PrimaryColumn;
export class BaseClass extends BaseClassWithoutId {
@PrimaryIdColumn()
id: string = Snowflake.generate();
@BeforeUpdate()
@BeforeInsert()
_do_validate() {
if (!this.id) this.id = Snowflake.generate();
}
}

View file

@ -0,0 +1,41 @@
import { Column, Entity } from "typeorm";
import { BaseClassWithoutId, PrimaryIdColumn } from "./BaseClass";
import { dbEngine } from "../util/Database";
// TODO: categories:
// [{
// "id": 16,
// "default": "Anime & Manga",
// "localizations": {
// "de": "Anime & Manga",
// "fr": "Anim\u00e9s et mangas",
// "ru": "\u0410\u043d\u0438\u043c\u0435 \u0438 \u043c\u0430\u043d\u0433\u0430"
// }
// },
// "is_primary": false/true
// }]
// Also populate discord default categories
@Entity({
name: "categories",
engine: dbEngine,
})
export class Categories extends BaseClassWithoutId {
// Not using snowflake
@PrimaryIdColumn()
id: number;
@Column({ nullable: true })
name: string;
@Column({ type: "simple-json" })
localizations: string;
// Whether to show the category prominently (e.g. in a sidebar) instead of only secondary (e.g. in search results)
@Column({ nullable: true })
is_primary: boolean;
@Column({ nullable: true })
icon?: string;
}

View file

@ -0,0 +1,573 @@
import { HTTPError } from "lambert-server";
import {
Column,
Entity,
JoinColumn,
ManyToOne,
OneToMany,
RelationId,
} from "typeorm";
import { DmChannelDTO } from "../dtos";
import { ChannelCreateEvent, ChannelRecipientRemoveEvent } from "../interfaces";
import {
InvisibleCharacters,
Snowflake,
containsAll,
emitEvent,
getPermission,
trimSpecial,
} from "../util";
import { BaseClass } from "./BaseClass";
import { Guild } from "./Guild";
import { Invite } from "./Invite";
import { Message } from "./Message";
import { ReadState } from "./ReadState";
import { Recipient } from "./Recipient";
import { PublicUserProjection, User } from "./User";
import { VoiceState } from "./VoiceState";
import { Webhook } from "./Webhook";
import { dbEngine } from "../util/Database";
export enum ChannelType {
GUILD_TEXT = 0, // a text channel within a guild
DM = 1, // a direct message between users
GUILD_VOICE = 2, // a voice channel within a guild
GROUP_DM = 3, // a direct message between multiple users
GUILD_CATEGORY = 4, // an organizational category that contains zero or more channels
GUILD_NEWS = 5, // a channel that users can follow and crosspost into a guild or route
GUILD_STORE = 6, // a channel in which game developers can sell their things
ENCRYPTED = 7, // end-to-end encrypted channel
ENCRYPTED_THREAD = 8, // end-to-end encrypted thread channel
TRANSACTIONAL = 9, // event chain style transactional channel
GUILD_NEWS_THREAD = 10, // a temporary sub-channel within a GUILD_NEWS channel
GUILD_PUBLIC_THREAD = 11, // a temporary sub-channel within a GUILD_TEXT channel
GUILD_PRIVATE_THREAD = 12, // a temporary sub-channel within a GUILD_TEXT channel that is only viewable by those invited and those with the MANAGE_THREADS permission
GUILD_STAGE_VOICE = 13, // a voice channel for hosting events with an audience
DIRECTORY = 14, // guild directory listing channel
GUILD_FORUM = 15, // forum composed of IM threads
TICKET_TRACKER = 33, // ticket tracker, individual ticket items shall have type 12
KANBAN = 34, // confluence like kanban board
VOICELESS_WHITEBOARD = 35, // whiteboard but without voice (whiteboard + voice is the same as stage)
CUSTOM_START = 64, // start custom channel types from here
UNHANDLED = 255, // unhandled unowned pass-through channel type
}
@Entity({
name: "channels",
engine: dbEngine,
})
export class Channel extends BaseClass {
@Column()
created_at: Date;
@Column({ nullable: true })
name?: string;
@Column({ type: "text", nullable: true })
icon?: string | null;
@Column({ type: "int" })
type: ChannelType;
@OneToMany(() => Recipient, (recipient: Recipient) => recipient.channel, {
cascade: true,
orphanedRowAction: "delete",
})
recipients?: Recipient[];
@Column({ nullable: true })
last_message_id?: string;
@Column({ nullable: true })
@RelationId((channel: Channel) => channel.guild)
guild_id?: string;
@JoinColumn({ name: "guild_id" })
@ManyToOne(() => Guild, (guild) => guild.channels, {
onDelete: "CASCADE",
nullable: true,
})
guild?: Guild;
@Column({ nullable: true })
@RelationId((channel: Channel) => channel.parent)
parent_id: string | null;
@JoinColumn({ name: "parent_id" })
@ManyToOne(() => Channel)
parent?: Channel;
// for group DMs and owned custom channel types
@Column({ nullable: true })
@RelationId((channel: Channel) => channel.owner)
owner_id?: string;
@JoinColumn({ name: "owner_id" })
@ManyToOne(() => User)
owner: User;
@Column({ nullable: true })
last_pin_timestamp?: number;
@Column({ nullable: true })
default_auto_archive_duration?: number;
@Column({ type: "simple-json", nullable: true })
permission_overwrites?: ChannelPermissionOverwrite[];
@Column({ nullable: true })
video_quality_mode?: number;
@Column({ nullable: true })
bitrate?: number;
@Column({ nullable: true })
user_limit?: number;
@Column()
nsfw: boolean = false;
@Column({ nullable: true })
rate_limit_per_user?: number;
@Column({ nullable: true })
topic?: string;
@OneToMany(() => Invite, (invite: Invite) => invite.channel, {
cascade: true,
orphanedRowAction: "delete",
})
invites?: Invite[];
@Column({ nullable: true })
retention_policy_id?: string;
@OneToMany(() => Message, (message: Message) => message.channel, {
cascade: true,
orphanedRowAction: "delete",
})
messages?: Message[];
@OneToMany(
() => VoiceState,
(voice_state: VoiceState) => voice_state.channel,
{
cascade: true,
orphanedRowAction: "delete",
},
)
voice_states?: VoiceState[];
@OneToMany(() => ReadState, (read_state: ReadState) => read_state.channel, {
cascade: true,
orphanedRowAction: "delete",
})
read_states?: ReadState[];
@OneToMany(() => Webhook, (webhook: Webhook) => webhook.channel, {
cascade: true,
orphanedRowAction: "delete",
})
webhooks?: Webhook[];
@Column()
flags: number = 0;
@Column()
default_thread_rate_limit_per_user: number = 0;
/** Must be calculated Channel.calculatePosition */
position: number;
// TODO: DM channel
static async createChannel(
channel: Partial<Channel>,
user_id: string = "0",
opts?: {
keepId?: boolean;
skipExistsCheck?: boolean;
skipPermissionCheck?: boolean;
skipEventEmit?: boolean;
skipNameChecks?: boolean;
},
) {
if (!opts?.skipPermissionCheck) {
// Always check if user has permission first
const permissions = await getPermission(user_id, channel.guild_id);
permissions.hasThrow("MANAGE_CHANNELS");
}
const guild = await Guild.findOneOrFail({
where: { id: channel.guild_id },
select: {
features: !opts?.skipNameChecks,
channel_ordering: true,
id: true,
},
});
if (!opts?.skipNameChecks) {
if (
!guild.features.includes("ALLOW_INVALID_CHANNEL_NAMES") &&
channel.name
) {
for (const character of InvisibleCharacters)
if (channel.name.includes(character))
throw new HTTPError(
"Channel name cannot include invalid characters",
403,
);
// Categories skip these checks on discord.com
if (
channel.type !== ChannelType.GUILD_CATEGORY ||
guild.features.includes("IRC_LIKE_CATEGORY_NAMES")
) {
if (channel.name.includes(" "))
throw new HTTPError(
"Channel name cannot include invalid characters",
403,
);
if (channel.name.match(/--+/g))
throw new HTTPError(
"Channel name cannot include multiple adjacent dashes.",
403,
);
if (
channel.name.charAt(0) === "-" ||
channel.name.charAt(channel.name.length - 1) === "-"
)
throw new HTTPError(
"Channel name cannot start/end with dash.",
403,
);
} else channel.name = channel.name.trim(); //category names are trimmed client side on discord.com
}
if (!guild.features.includes("ALLOW_UNNAMED_CHANNELS")) {
if (!channel.name)
throw new HTTPError("Channel name cannot be empty.", 403);
}
}
switch (channel.type) {
case ChannelType.GUILD_TEXT:
case ChannelType.GUILD_NEWS:
case ChannelType.GUILD_VOICE:
if (channel.parent_id && !opts?.skipExistsCheck) {
const exists = await Channel.findOneOrFail({
where: { id: channel.parent_id },
});
if (!exists)
throw new HTTPError(
"Parent id channel doesn't exist",
400,
);
if (exists.guild_id !== channel.guild_id)
throw new HTTPError(
"The category channel needs to be in the guild",
);
}
break;
case ChannelType.GUILD_CATEGORY:
case ChannelType.UNHANDLED:
break;
case ChannelType.DM:
case ChannelType.GROUP_DM:
throw new HTTPError("You can't create a dm channel in a guild");
case ChannelType.GUILD_STORE:
default:
throw new HTTPError("Not yet supported");
}
if (!channel.permission_overwrites) channel.permission_overwrites = [];
// TODO: eagerly auto generate position of all guild channels
const position =
(channel.type === ChannelType.UNHANDLED ? 0 : channel.position) ||
0;
channel = {
...channel,
...(!opts?.keepId && { id: Snowflake.generate() }),
created_at: new Date(),
position,
};
const ret = Channel.create(channel);
await Promise.all([
ret.save(),
!opts?.skipEventEmit
? emitEvent({
event: "CHANNEL_CREATE",
data: channel,
guild_id: channel.guild_id,
} as ChannelCreateEvent)
: Promise.resolve(),
Guild.insertChannelInOrder(guild.id, ret.id, position, guild),
]);
return ret;
}
static async createDMChannel(
recipients: string[],
creator_user_id: string,
name?: string,
) {
recipients = recipients.unique().filter((x) => x !== creator_user_id);
// TODO: check config for max number of recipients
/** if you want to disallow note to self channels, uncomment the conditional below
const otherRecipientsUsers = await User.find({ where: recipients.map((x) => ({ id: x })) });
if (otherRecipientsUsers.length !== recipients.length) {
throw new HTTPError("Recipient/s not found");
}
**/
const type =
recipients.length > 1 ? ChannelType.GROUP_DM : ChannelType.DM;
let channel = null;
const channelRecipients = [...recipients, creator_user_id];
const userRecipients = await Recipient.find({
where: { user_id: creator_user_id },
relations: ["channel", "channel.recipients"],
});
for (const ur of userRecipients) {
if (!ur.channel.recipients) continue;
const re = ur.channel.recipients.map((r) => r.user_id);
if (re.length === channelRecipients.length) {
if (containsAll(re, channelRecipients)) {
if (channel == null) {
channel = ur.channel;
await ur.assign({ closed: false }).save();
}
}
}
}
if (channel == null) {
name = trimSpecial(name);
channel = await Channel.create({
name,
type,
owner_id: undefined,
created_at: new Date(),
last_message_id: undefined,
recipients: channelRecipients.map((x) =>
Recipient.create({
user_id: x,
closed: !(
type === ChannelType.GROUP_DM ||
x === creator_user_id
),
}),
),
nsfw: false,
}).save();
}
const channel_dto = await DmChannelDTO.from(channel);
if (type === ChannelType.GROUP_DM && channel.recipients) {
for (const recipient of channel.recipients) {
await emitEvent({
event: "CHANNEL_CREATE",
data: channel_dto.excludedRecipients([recipient.user_id]),
user_id: recipient.user_id,
});
}
} else {
await emitEvent({
event: "CHANNEL_CREATE",
data: channel_dto,
user_id: creator_user_id,
});
}
if (recipients.length === 1) return channel_dto;
else return channel_dto.excludedRecipients([creator_user_id]);
}
static async removeRecipientFromChannel(channel: Channel, user_id: string) {
await Recipient.delete({ channel_id: channel.id, user_id: user_id });
channel.recipients = channel.recipients?.filter(
(r) => r.user_id !== user_id,
);
if (channel.recipients?.length === 0) {
await Channel.deleteChannel(channel);
await emitEvent({
event: "CHANNEL_DELETE",
data: await DmChannelDTO.from(channel, [user_id]),
user_id: user_id,
});
return;
}
await emitEvent({
event: "CHANNEL_DELETE",
data: await DmChannelDTO.from(channel, [user_id]),
user_id: user_id,
});
//If the owner leave the server user is the new owner
if (channel.owner_id === user_id) {
channel.owner_id = "1"; // The channel is now owned by the server user
await emitEvent({
event: "CHANNEL_UPDATE",
data: await DmChannelDTO.from(channel, [user_id]),
channel_id: channel.id,
});
}
await channel.save();
await emitEvent({
event: "CHANNEL_RECIPIENT_REMOVE",
data: {
channel_id: channel.id,
user: await User.findOneOrFail({
where: { id: user_id },
select: PublicUserProjection,
}),
},
channel_id: channel.id,
} as ChannelRecipientRemoveEvent);
}
static async deleteChannel(channel: Channel) {
// TODO Delete attachments from the CDN for messages in the channel
await Channel.delete({ id: channel.id });
const guild = await Guild.findOneOrFail({
where: { id: channel.guild_id },
select: { channel_ordering: true },
});
const updatedOrdering = guild.channel_ordering.filter(
(id) => id != channel.id,
);
await Guild.update(
{ id: channel.guild_id },
{ channel_ordering: updatedOrdering },
);
}
static async calculatePosition(
channel_id: string,
guild_id: string,
guild?: Guild,
) {
if (!guild)
guild = await Guild.findOneOrFail({
where: { id: guild_id },
select: { channel_ordering: true },
});
return guild.channel_ordering.findIndex((id) => channel_id == id);
}
static async getOrderedChannels(guild_id: string, guild?: Guild) {
if (!guild)
guild = await Guild.findOneOrFail({
where: { id: guild_id },
select: { channel_ordering: true },
});
const channels = await Promise.all(
guild.channel_ordering.map((id) =>
Channel.findOne({ where: { id } }),
),
);
return channels
.filter((channel) => channel !== null)
.reduce((r, v) => {
v = v as Channel;
v.position = (guild as Guild).channel_ordering.indexOf(v.id);
r[v.position] = v;
return r;
}, [] as Array<Channel>);
}
isDm() {
return (
this.type === ChannelType.DM || this.type === ChannelType.GROUP_DM
);
}
// Does the channel support sending messages ( eg categories do not )
isWritable() {
const disallowedChannelTypes = [
ChannelType.GUILD_CATEGORY,
ChannelType.GUILD_STAGE_VOICE,
ChannelType.VOICELESS_WHITEBOARD,
];
return disallowedChannelTypes.indexOf(this.type) == -1;
}
toJSON() {
return {
...this,
// these fields are not returned depending on the type of channel
bitrate: this.bitrate || undefined,
user_limit: this.user_limit || undefined,
rate_limit_per_user: this.rate_limit_per_user || undefined,
owner_id: this.owner_id || undefined,
};
}
}
export interface ChannelPermissionOverwrite {
allow: string;
deny: string;
id: string;
type: ChannelPermissionOverwriteType;
}
export enum ChannelPermissionOverwriteType {
role = 0,
member = 1,
group = 2,
}
export interface DMChannel extends Omit<Channel, "type" | "recipients"> {
type: ChannelType.DM | ChannelType.GROUP_DM;
recipients: Recipient[];
}
// TODO: probably more props
export function isTextChannel(type: ChannelType): boolean {
switch (type) {
case ChannelType.GUILD_STORE:
case ChannelType.GUILD_STAGE_VOICE:
case ChannelType.GUILD_CATEGORY:
case ChannelType.GUILD_FORUM:
case ChannelType.DIRECTORY:
throw new HTTPError("not a text channel", 400);
case ChannelType.DM:
case ChannelType.GROUP_DM:
case ChannelType.GUILD_NEWS:
case ChannelType.GUILD_VOICE:
case ChannelType.GUILD_NEWS_THREAD:
case ChannelType.GUILD_PUBLIC_THREAD:
case ChannelType.GUILD_PRIVATE_THREAD:
case ChannelType.GUILD_TEXT:
case ChannelType.ENCRYPTED:
case ChannelType.ENCRYPTED_THREAD:
return true;
default:
throw new HTTPError("unimplemented", 400);
}
}

View file

@ -0,0 +1,27 @@
import { Column, Entity } from "typeorm";
import { BaseClass } from "./BaseClass";
import { dbEngine } from "../util/Database";
@Entity({
name: "client_release",
engine: dbEngine,
})
export class Release extends BaseClass {
@Column()
name: string;
@Column()
pub_date: Date;
@Column()
url: string;
@Column()
platform: string;
@Column()
enabled: boolean;
@Column({ nullable: true })
notes?: string;
}

View file

@ -0,0 +1,15 @@
import { Column, Entity } from "typeorm";
import { BaseClassWithoutId, PrimaryIdColumn } from "./BaseClass";
import { dbEngine } from "../util/Database";
@Entity({
name: "config",
engine: dbEngine,
})
export class ConfigEntity extends BaseClassWithoutId {
@PrimaryIdColumn()
key: string;
@Column({ type: "simple-json", nullable: true })
value: number | boolean | null | string | undefined;
}

View file

@ -0,0 +1,72 @@
import { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm";
import { ConnectedAccountTokenData } from "../interfaces";
import { BaseClass } from "./BaseClass";
import { User } from "./User";
import { dbEngine } from "../util/Database";
export type PublicConnectedAccount = Pick<
ConnectedAccount,
"name" | "type" | "verified"
>;
@Entity({
name: "connected_accounts",
engine: dbEngine,
})
export class ConnectedAccount extends BaseClass {
@Column()
external_id: string;
@Column({ nullable: true })
@RelationId((account: ConnectedAccount) => account.user)
user_id: string;
@JoinColumn({ name: "user_id" })
@ManyToOne(() => User, {
onDelete: "CASCADE",
})
user: User;
@Column({ select: false })
friend_sync?: boolean = false;
@Column()
name: string;
@Column({ select: false })
revoked?: boolean = false;
@Column({ select: false })
show_activity?: number = 0;
@Column()
type: string;
@Column()
verified?: boolean = true;
@Column({ select: false })
visibility?: number = 0;
@Column({ type: "simple-array" })
integrations?: string[] = [];
@Column({ type: "simple-json", name: "metadata", nullable: true })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
metadata_?: any;
@Column()
metadata_visibility?: number = 0;
@Column()
two_way_link?: boolean = false;
@Column({ select: false, nullable: true, type: "simple-json" })
token_data?: ConnectedAccountTokenData | null;
async revoke() {
this.revoked = true;
this.token_data = null;
await this.save();
}
}

View file

@ -0,0 +1,15 @@
import { Column, Entity } from "typeorm";
import { BaseClassWithoutId, PrimaryIdColumn } from "./BaseClass";
import { dbEngine } from "../util/Database";
@Entity({
name: "connection_config",
engine: dbEngine,
})
export class ConnectionConfigEntity extends BaseClassWithoutId {
@PrimaryIdColumn()
key: string;
@Column({ type: "simple-json", nullable: true })
value: number | boolean | null | string | Date | undefined;
}

View file

@ -0,0 +1,16 @@
import { BaseClass } from "./BaseClass";
import { Entity, Column } from "typeorm";
import { Embed } from "./Message";
import { dbEngine } from "../util/Database";
@Entity({
name: "embed_cache",
engine: dbEngine,
})
export class EmbedCache extends BaseClass {
@Column()
url: string;
@Column({ type: "simple-json" })
embed: Embed;
}

View file

@ -0,0 +1,49 @@
import { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm";
import { User } from ".";
import { BaseClass } from "./BaseClass";
import { Guild } from "./Guild";
import { dbEngine } from "../util/Database";
@Entity({
name: "emojis",
engine: dbEngine,
})
export class Emoji extends BaseClass {
@Column()
animated: boolean;
@Column()
available: boolean; // whether this emoji can be used, may be false due to various reasons
@Column()
guild_id: string;
@JoinColumn({ name: "guild_id" })
@ManyToOne(() => Guild, (guild) => guild.emojis, {
onDelete: "CASCADE",
})
guild: Guild;
@Column({ nullable: true })
@RelationId((emoji: Emoji) => emoji.user)
user_id: string;
@JoinColumn({ name: "user_id" })
@ManyToOne(() => User)
user: User;
@Column()
managed: boolean;
@Column()
name: string;
@Column()
require_colons: boolean;
@Column({ type: "simple-array" })
roles: string[]; // roles this emoji is whitelisted to (new discord feature?)
@Column({ type: "simple-array", nullable: true })
groups: string[]; // user groups this emoji is whitelisted to (Valkyrie extension)
}

View file

@ -0,0 +1,27 @@
import { Column, Entity } from "typeorm";
import { BaseClass } from "./BaseClass";
import { dbEngine } from "../util/Database";
@Entity({
name: "security_settings",
engine: dbEngine,
})
export class SecuritySettings extends BaseClass {
@Column({ nullable: true })
guild_id: string;
@Column({ nullable: true })
channel_id: string;
@Column()
encryption_permission_mask: number;
@Column({ type: "simple-array" })
allowed_algorithms: string[];
@Column()
current_algorithm: string;
@Column({ nullable: true })
used_since_message: string;
}

444
src/util/entities/Guild.ts Normal file
View file

@ -0,0 +1,444 @@
import {
Column,
Entity,
JoinColumn,
ManyToOne,
OneToMany,
RelationId,
} from "typeorm";
import { Config, GuildWelcomeScreen, Snowflake, handleFile } from "..";
import { Ban } from "./Ban";
import { BaseClass } from "./BaseClass";
import { Channel } from "./Channel";
import { Emoji } from "./Emoji";
import { Invite } from "./Invite";
import { Member } from "./Member";
import { Role } from "./Role";
import { Sticker } from "./Sticker";
import { Template } from "./Template";
import { User } from "./User";
import { VoiceState } from "./VoiceState";
import { Webhook } from "./Webhook";
import { dbEngine } from "../util/Database";
// TODO: application_command_count, application_command_counts: {1: 0, 2: 0, 3: 0}
// TODO: guild_scheduled_events
// TODO: stage_instances
// TODO: threads
// TODO:
// "keywords": [
// "Genshin Impact",
// "Paimon",
// "Honkai Impact",
// "ARPG",
// "Open-World",
// "Waifu",
// "Anime",
// "Genshin",
// "miHoYo",
// "Gacha"
// ],
export const PublicGuildRelations = [
"channels",
"emojis",
"roles",
"stickers",
"voice_states",
// "members", // TODO: These are public, but all members should not be fetched.
// "members.user",
];
@Entity({
name: "guilds",
engine: dbEngine,
})
export class Guild extends BaseClass {
@Column({ nullable: true })
@RelationId((guild: Guild) => guild.afk_channel)
afk_channel_id?: string;
@JoinColumn({ name: "afk_channel_id" })
@ManyToOne(() => Channel)
afk_channel?: Channel;
@Column({ nullable: true })
afk_timeout?: number;
// * commented out -> use owner instead
// application id of the guild creator if it is bot-created
// @Column({ nullable: true })
// application?: string;
@JoinColumn({ name: "ban_ids" })
@OneToMany(() => Ban, (ban: Ban) => ban.guild, {
cascade: true,
orphanedRowAction: "delete",
})
bans: Ban[];
@Column({ nullable: true })
banner?: string;
@Column({ nullable: true })
default_message_notifications?: number;
@Column({ nullable: true })
description?: string;
@Column({ nullable: true })
discovery_splash?: string;
@Column({ nullable: true })
explicit_content_filter?: number;
@Column({ type: "simple-array" })
features: string[] = []; //TODO use enum
//TODO: https://discord.com/developers/docs/resources/guild#guild-object-guild-features
@Column({ nullable: true })
primary_category_id?: string; // TODO: this was number?
@Column({ nullable: true })
icon?: string;
@Column()
large?: boolean = false;
@Column({ nullable: true })
max_members?: number;
@Column({ nullable: true })
max_presences?: number;
@Column({ nullable: true })
max_video_channel_users?: number;
@Column({ nullable: true })
member_count?: number;
@Column({ nullable: true })
presence_count?: number; // users online
@OneToMany(() => Member, (member: Member) => member.guild, {
cascade: true,
orphanedRowAction: "delete",
onDelete: "CASCADE",
})
members: Member[];
@JoinColumn({ name: "role_ids" })
@OneToMany(() => Role, (role: Role) => role.guild, {
cascade: true,
orphanedRowAction: "delete",
onDelete: "CASCADE",
})
roles: Role[];
@JoinColumn({ name: "channel_ids" })
@OneToMany(() => Channel, (channel: Channel) => channel.guild, {
cascade: true,
orphanedRowAction: "delete",
})
channels: Channel[];
@Column({ nullable: true })
@RelationId((guild: Guild) => guild.template)
template_id?: string;
@JoinColumn({ name: "template_id", referencedColumnName: "id" })
@ManyToOne(() => Template)
template: Template;
@JoinColumn({ name: "emoji_ids" })
@OneToMany(() => Emoji, (emoji: Emoji) => emoji.guild, {
cascade: true,
orphanedRowAction: "delete",
onDelete: "CASCADE",
})
emojis: Emoji[];
@JoinColumn({ name: "sticker_ids" })
@OneToMany(() => Sticker, (sticker: Sticker) => sticker.guild, {
cascade: true,
orphanedRowAction: "delete",
onDelete: "CASCADE",
})
stickers: Sticker[];
@JoinColumn({ name: "invite_ids" })
@OneToMany(() => Invite, (invite: Invite) => invite.guild, {
cascade: true,
orphanedRowAction: "delete",
onDelete: "CASCADE",
})
invites: Invite[];
@JoinColumn({ name: "voice_state_ids" })
@OneToMany(() => VoiceState, (voicestate: VoiceState) => voicestate.guild, {
cascade: true,
orphanedRowAction: "delete",
onDelete: "CASCADE",
})
voice_states: VoiceState[];
@JoinColumn({ name: "webhook_ids" })
@OneToMany(() => Webhook, (webhook: Webhook) => webhook.guild, {
cascade: true,
orphanedRowAction: "delete",
onDelete: "CASCADE",
})
webhooks: Webhook[];
@Column({ nullable: true })
mfa_level?: number;
@Column()
name: string;
@Column({ nullable: true })
@RelationId((guild: Guild) => guild.owner)
owner_id?: string; // optional to allow for ownerless guilds
@JoinColumn({ name: "owner_id", referencedColumnName: "id" })
@ManyToOne(() => User)
owner?: User; // optional to allow for ownerless guilds
@Column({ nullable: true })
preferred_locale?: string;
@Column({ nullable: true })
premium_subscription_count?: number;
@Column()
premium_tier?: number; // crowd premium level
@Column({ nullable: true })
@RelationId((guild: Guild) => guild.public_updates_channel)
public_updates_channel_id: string;
@JoinColumn({ name: "public_updates_channel_id" })
@ManyToOne(() => Channel)
public_updates_channel?: Channel;
@Column({ nullable: true })
@RelationId((guild: Guild) => guild.rules_channel)
rules_channel_id?: string;
@JoinColumn({ name: "rules_channel_id" })
@ManyToOne(() => Channel)
rules_channel?: string;
@Column({ nullable: true })
region?: string;
@Column({ nullable: true })
splash?: string;
@Column({ nullable: true })
@RelationId((guild: Guild) => guild.system_channel)
system_channel_id?: string;
@JoinColumn({ name: "system_channel_id" })
@ManyToOne(() => Channel)
system_channel?: Channel;
@Column({ nullable: true })
system_channel_flags?: number;
@Column()
unavailable: boolean = false;
@Column({ nullable: true })
verification_level?: number;
@Column({ type: "simple-json" })
welcome_screen: GuildWelcomeScreen;
@Column({ nullable: true })
@RelationId((guild: Guild) => guild.widget_channel)
widget_channel_id?: string;
@JoinColumn({ name: "widget_channel_id" })
@ManyToOne(() => Channel)
widget_channel?: Channel;
@Column()
widget_enabled: boolean = true;
@Column({ nullable: true })
nsfw_level?: number;
@Column()
nsfw: boolean = false;
// TODO: nested guilds
@Column({ nullable: true })
parent?: string;
// only for developer portal
permissions?: number;
//new guild settings, 11/08/2022:
@Column({ nullable: true })
premium_progress_bar_enabled: boolean = false;
@Column({ select: false, type: "simple-array" })
channel_ordering: string[];
static async createGuild(body: {
name?: string;
icon?: string | null;
owner_id?: string;
channels?: Partial<Channel>[];
}) {
const guild_id = Snowflake.generate();
const guild = await Guild.create({
id: guild_id,
name: body.name || "Valkyrie",
icon: await handleFile(`/icons/${guild_id}`, body.icon as string),
owner_id: body.owner_id, // TODO: need to figure out a way for ownerless guilds and multiply-owned guilds
presence_count: 0,
member_count: 0, // will automatically be increased by addMember()
mfa_level: 0,
preferred_locale: "en-US",
premium_subscription_count: 0,
premium_tier: 0,
system_channel_flags: 4, // defaults effect: suppress the setup tips to save performance
nsfw_level: 0,
verification_level: 0,
welcome_screen: {
enabled: false,
description: "",
welcome_channels: [],
},
channel_ordering: [],
afk_timeout: Config.get().defaults.guild.afkTimeout,
default_message_notifications:
Config.get().defaults.guild.defaultMessageNotifications,
explicit_content_filter:
Config.get().defaults.guild.explicitContentFilter,
features: Config.get().guild.defaultFeatures,
max_members: Config.get().limits.guild.maxMembers,
max_presences: Config.get().defaults.guild.maxPresences,
max_video_channel_users:
Config.get().defaults.guild.maxVideoChannelUsers,
region: Config.get().regions.default,
}).save();
// we have to create the role _after_ the guild because else we would get a "SQLITE_CONSTRAINT: FOREIGN KEY constraint failed" error
// TODO: make the @everyone a pseudorole that is dynamically generated at runtime so we can save storage
await Role.create({
id: guild_id,
guild_id: guild_id,
color: 0,
hoist: false,
managed: false,
// NB: in Valkyrie, every role will be non-managed, as we use user-groups instead of roles for managed groups
mentionable: false,
name: "@everyone",
permissions: String("2251804225"),
position: 0,
icon: undefined,
unicode_emoji: undefined,
flags: 0, // TODO?
}).save();
if (!body.channels || !body.channels.length)
body.channels = [
{ id: "01", type: 0, name: "general", nsfw: false },
];
const ids = new Map();
body.channels.forEach((x) => {
if (x.id) {
ids.set(x.id, Snowflake.generate());
}
});
for (const channel of body.channels.sort((a) =>
a.parent_id ? 1 : -1,
)) {
const id = ids.get(channel.id) || Snowflake.generate();
const parent_id = ids.get(channel.parent_id);
const saved = await Channel.createChannel(
{ ...channel, guild_id, id, parent_id },
body.owner_id,
{
keepId: true,
skipExistsCheck: true,
skipPermissionCheck: true,
skipEventEmit: true,
},
);
await Guild.insertChannelInOrder(
guild.id,
saved.id,
parent_id ?? channel.position ?? 0,
guild,
);
}
return guild;
}
/** Insert a channel into the guild ordering by parent channel id or position */
static async insertChannelInOrder(
guild_id: string,
channel_id: string,
position: number,
guild?: Guild,
): Promise<number>;
static async insertChannelInOrder(
guild_id: string,
channel_id: string,
parent_id: string,
guild?: Guild,
): Promise<number>;
static async insertChannelInOrder(
guild_id: string,
channel_id: string,
insertPoint: string | number,
guild?: Guild,
): Promise<number>;
static async insertChannelInOrder(
guild_id: string,
channel_id: string,
insertPoint: string | number,
guild?: Guild,
): Promise<number> {
if (!guild)
guild = await Guild.findOneOrFail({
where: { id: guild_id },
select: { channel_ordering: true },
});
let position;
if (typeof insertPoint == "string")
position = guild.channel_ordering.indexOf(insertPoint) + 1;
else position = insertPoint;
guild.channel_ordering.remove(channel_id);
guild.channel_ordering.splice(position, 0, channel_id);
await Guild.update(
{ id: guild_id },
{ channel_ordering: guild.channel_ordering },
);
return position;
}
toJSON(): Guild {
return {
...this,
unavailable: this.unavailable == false ? undefined : true,
channel_ordering: undefined,
};
}
}

View file

@ -0,0 +1,95 @@
import { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm";
import { BaseClassWithoutId, PrimaryIdColumn } from "./BaseClass";
import { Channel } from "./Channel";
import { Guild } from "./Guild";
import { Member } from "./Member";
import { User } from "./User";
import { dbEngine } from "../util/Database";
export const PublicInviteRelation = ["inviter", "guild", "channel"];
@Entity({
name: "invites",
engine: dbEngine,
})
export class Invite extends BaseClassWithoutId {
@PrimaryIdColumn()
code: string;
@Column()
temporary: boolean;
@Column()
uses: number;
@Column()
max_uses: number;
@Column()
max_age: number;
@Column()
created_at: Date;
@Column({ nullable: true })
expires_at?: Date;
@Column({ nullable: true })
@RelationId((invite: Invite) => invite.guild)
guild_id: string;
@JoinColumn({ name: "guild_id" })
@ManyToOne(() => Guild, (guild) => guild.invites, {
onDelete: "CASCADE",
})
guild: Guild;
@Column({ nullable: true })
@RelationId((invite: Invite) => invite.channel)
channel_id: string;
@JoinColumn({ name: "channel_id" })
@ManyToOne(() => Channel, {
onDelete: "CASCADE",
})
channel: Channel;
@Column({ nullable: true })
@RelationId((invite: Invite) => invite.inviter)
inviter_id?: string;
@JoinColumn({ name: "inviter_id" })
@ManyToOne(() => User, {
onDelete: "CASCADE",
})
inviter: User;
@Column({ nullable: true })
@RelationId((invite: Invite) => invite.target_user)
target_user_id: string;
@JoinColumn({ name: "target_user_id" })
@ManyToOne(() => User, {
onDelete: "CASCADE",
})
target_user?: string; // could be used for "User specific invites" https://toastielab.dev/ValkyrieChat/Server/issues/326
@Column({ nullable: true })
target_user_type?: number;
@Column({ nullable: true })
vanity_url?: boolean;
@Column()
flags: number;
static async joinGuild(user_id: string, code: string) {
const invite = await Invite.findOneOrFail({ where: { code } });
if (invite.uses++ >= invite.max_uses && invite.max_uses !== 0)
await Invite.delete({ code });
else await invite.save();
await Member.addToGuild(user_id, invite.guild_id);
return invite;
}
}

539
src/util/entities/Member.ts Normal file
View file

@ -0,0 +1,539 @@
import { HTTPError } from "lambert-server";
import {
BeforeInsert,
BeforeUpdate,
Column,
Entity,
Index,
JoinColumn,
JoinTable,
ManyToMany,
ManyToOne,
Not,
PrimaryGeneratedColumn,
RelationId,
} from "typeorm";
import { Ban, Channel, PublicGuildRelations } from ".";
import { ReadyGuildDTO } from "../dtos";
import {
GuildCreateEvent,
GuildDeleteEvent,
GuildMemberAddEvent,
GuildMemberRemoveEvent,
GuildMemberUpdateEvent,
MessageCreateEvent,
} from "../interfaces";
import { Config, emitEvent } from "../util";
import { DiscordApiErrors } from "../util/Constants";
import { BaseClassWithoutId } from "./BaseClass";
import { Guild } from "./Guild";
import { Message } from "./Message";
import { Role } from "./Role";
import { PublicUser, User } from "./User";
import { dbEngine } from "../util/Database";
export const MemberPrivateProjection: (keyof Member)[] = [
"id",
"guild",
"guild_id",
"deaf",
"joined_at",
"last_message_id",
"mute",
"nick",
"pending",
"premium_since",
"roles",
"settings",
"user",
];
@Entity({
name: "members",
engine: dbEngine,
})
@Index(["id", "guild_id"], { unique: true })
export class Member extends BaseClassWithoutId {
@PrimaryGeneratedColumn()
index: string;
@Column()
@RelationId((member: Member) => member.user)
id: string;
@JoinColumn({ name: "id" })
@ManyToOne(() => User, {
onDelete: "CASCADE",
})
user: User;
@Column()
@RelationId((member: Member) => member.guild)
guild_id: string;
@JoinColumn({ name: "guild_id" })
@ManyToOne(() => Guild, {
onDelete: "CASCADE",
})
guild: Guild;
@Column({ nullable: true })
nick?: string;
@JoinTable({
name: "member_roles",
joinColumn: { name: "index", referencedColumnName: "index" },
inverseJoinColumn: {
name: "role_id",
referencedColumnName: "id",
},
})
@ManyToMany(() => Role, { cascade: true })
roles: Role[];
@Column()
joined_at: Date;
@Column({ type: "bigint", nullable: true })
premium_since?: number;
@Column()
deaf: boolean;
@Column()
mute: boolean;
@Column()
pending: boolean;
@Column({ type: "simple-json", select: false })
settings: UserGuildSettings;
@Column({ nullable: true })
last_message_id?: string;
/**
@JoinColumn({ name: "id" })
@ManyToOne(() => User, {
onDelete: "DO NOTHING",
// do not auto-kick force-joined members just because their joiners left the server
}) **/
@Column({ nullable: true })
joined_by: string;
@Column({ nullable: true })
avatar?: string;
@Column({ nullable: true })
banner: string;
@Column()
bio: string;
@Column({ nullable: true, type: "simple-array" })
theme_colors?: number[]; // TODO: Separate `User` and `UserProfile` models
@Column({ nullable: true })
pronouns?: string;
@Column({ nullable: true })
communication_disabled_until: Date;
// TODO: add this when we have proper read receipts
// @Column({ type: "simple-json" })
// read_state: ReadState;
@BeforeUpdate()
@BeforeInsert()
validate() {
if (this.nick) {
this.nick = this.nick.split("\n").join("");
this.nick = this.nick.split("\t").join("");
}
}
static async IsInGuildOrFail(user_id: string, guild_id: string) {
if (
await Member.count({
where: { id: user_id, guild: { id: guild_id } },
})
)
return true;
throw new HTTPError("You are not member of this guild", 403);
}
static async removeFromGuild(user_id: string, guild_id: string) {
const guild = await Guild.findOneOrFail({
select: ["owner_id"],
where: { id: guild_id },
});
if (guild.owner_id === user_id)
throw new Error("The owner cannot be removed of the guild");
const member = await Member.findOneOrFail({
where: { id: user_id, guild_id },
relations: ["user"],
});
// use promise all to execute all promises at the same time -> save time
return Promise.all([
Member.delete({
id: user_id,
guild_id,
}),
Guild.decrement({ id: guild_id }, "member_count", -1),
emitEvent({
event: "GUILD_DELETE",
data: {
id: guild_id,
},
user_id: user_id,
} as GuildDeleteEvent),
emitEvent({
event: "GUILD_MEMBER_REMOVE",
data: { guild_id, user: member.user },
guild_id,
} as GuildMemberRemoveEvent),
]);
}
static async addRole(user_id: string, guild_id: string, role_id: string) {
const [member] = await Promise.all([
Member.findOneOrFail({
where: { id: user_id, guild_id },
relations: ["user", "roles"], // we don't want to load the role objects just the ids
select: {
index: true,
roles: {
id: true,
},
},
}),
Role.findOneOrFail({
where: { id: role_id, guild_id },
select: ["id"],
}),
]);
member.roles.push(Role.create({ id: role_id }));
await Promise.all([
member.save(),
emitEvent({
event: "GUILD_MEMBER_UPDATE",
data: {
guild_id,
user: member.user,
roles: member.roles.map((x) => x.id),
},
guild_id,
} as GuildMemberUpdateEvent),
]);
}
static async removeRole(
user_id: string,
guild_id: string,
role_id: string,
) {
const [member] = await Promise.all([
Member.findOneOrFail({
where: { id: user_id, guild_id },
relations: ["user", "roles"], // we don't want to load the role objects just the ids
select: {
index: true,
roles: {
id: true,
},
},
}),
Role.findOneOrFail({ where: { id: role_id, guild_id } }),
]);
member.roles = member.roles.filter((x) => x.id !== role_id);
await Promise.all([
member.save(),
emitEvent({
event: "GUILD_MEMBER_UPDATE",
data: {
guild_id,
user: member.user,
roles: member.roles.map((x) => x.id),
},
guild_id,
} as GuildMemberUpdateEvent),
]);
}
static async changeNickname(
user_id: string,
guild_id: string,
nickname: string,
) {
const member = await Member.findOneOrFail({
where: {
id: user_id,
guild_id,
},
relations: ["user"],
});
// @ts-expect-error Member nickname is nullable
member.nick = nickname || null;
await Promise.all([
member.save(),
emitEvent({
event: "GUILD_MEMBER_UPDATE",
data: {
guild_id,
user: member.user,
nick: nickname || null,
},
guild_id,
} as GuildMemberUpdateEvent),
]);
}
static async addToGuild(user_id: string, guild_id: string) {
const user = await User.getPublicUser(user_id);
const isBanned = await Ban.count({ where: { guild_id, user_id } });
if (isBanned) {
throw DiscordApiErrors.USER_BANNED;
}
const { maxGuilds } = Config.get().limits.user;
const guild_count = await Member.count({ where: { id: user_id } });
if (guild_count >= maxGuilds) {
throw new HTTPError(
`You are at the ${maxGuilds} server limit.`,
403,
);
}
const guild = await Guild.findOneOrFail({
where: {
id: guild_id,
},
relations: PublicGuildRelations,
relationLoadStrategy: "query",
});
for await (const channel of guild.channels) {
channel.position = await Channel.calculatePosition(
channel.id,
guild_id,
);
}
const memberCount = await Member.count({ where: { guild_id } });
const memberPreview = (
await Member.find({
where: {
guild_id,
user: {
sessions: {
status: Not("invisible" as const), // lol typescript?
},
},
},
relations: ["user", "roles"],
take: 10,
})
).map((member) => member.toPublicMember());
if (
await Member.count({
where: { id: user.id, guild: { id: guild_id } },
})
)
throw new HTTPError("You are already a member of this guild", 400);
const member = {
id: user_id,
guild_id,
nick: undefined,
roles: [guild_id], // @everyone role
joined_at: new Date(),
deaf: false,
mute: false,
pending: false,
bio: "",
};
await Promise.all([
Member.create({
...member,
roles: [Role.create({ id: guild_id })],
// read_state: {},
settings: {
guild_id: null,
mute_config: null,
mute_scheduled_events: false,
flags: 0,
hide_muted_channels: false,
notify_highlights: 0,
channel_overrides: {},
message_notifications: 0,
mobile_push: true,
muted: false,
suppress_everyone: false,
suppress_roles: false,
version: 0,
},
// Member.save is needed because else the roles relations wouldn't be updated
}).save(),
Guild.increment({ id: guild_id }, "member_count", 1),
emitEvent({
event: "GUILD_MEMBER_ADD",
data: {
...member,
user,
guild_id,
},
guild_id,
} as GuildMemberAddEvent),
emitEvent({
event: "GUILD_CREATE",
data: {
...new ReadyGuildDTO(guild).toJSON(),
members: [...memberPreview, { ...member, user }],
member_count: memberCount + 1,
guild_hashes: {},
guild_scheduled_events: [],
joined_at: member.joined_at,
presences: [],
stage_instances: [],
threads: [],
embedded_activities: [],
voice_states: guild.voice_states,
},
user_id,
} as GuildCreateEvent),
]);
if (guild.system_channel_id) {
// Send a welcome message
const message = Message.create({
type: 7,
guild_id: guild.id,
channel_id: guild.system_channel_id,
author: user,
timestamp: new Date(),
reactions: [],
attachments: [],
embeds: [],
sticker_items: [],
edited_timestamp: undefined,
mentions: [],
mention_channels: [],
mention_roles: [],
mention_everyone: false,
});
await Promise.all([
message.save(),
emitEvent({
event: "MESSAGE_CREATE",
channel_id: message.channel_id,
data: message,
} as MessageCreateEvent),
]);
}
}
toPublicMember() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const member: any = {};
PublicMemberProjection.forEach((x) => {
member[x] = this[x];
});
if (member.roles) member.roles = member.roles.map((x: Role) => x.id);
if (member.user) member.user = member.user.toPublicUser();
return member as PublicMember;
}
}
export interface ChannelOverride {
message_notifications: number;
mute_config: MuteConfig;
muted: boolean;
channel_id: string | null;
}
export interface UserGuildSettings {
// channel_overrides: {
// channel_id: string;
// message_notifications: number;
// mute_config: MuteConfig;
// muted: boolean;
// }[];
channel_overrides: {
[channel_id: string]: ChannelOverride;
} | null;
message_notifications: number;
mobile_push: boolean;
mute_config: MuteConfig | null;
muted: boolean;
suppress_everyone: boolean;
suppress_roles: boolean;
version: number;
guild_id: string | null;
flags: number;
mute_scheduled_events: boolean;
hide_muted_channels: boolean;
notify_highlights: 0;
}
export const DefaultUserGuildSettings: UserGuildSettings = {
channel_overrides: null,
message_notifications: 1,
flags: 0,
hide_muted_channels: false,
mobile_push: true,
mute_config: null,
mute_scheduled_events: false,
muted: false,
notify_highlights: 0,
suppress_everyone: false,
suppress_roles: false,
version: 453, // ?
guild_id: null,
};
export interface MuteConfig {
end_time: number;
selected_time_window: number;
}
export type PublicMemberKeys =
| "id"
| "guild_id"
| "nick"
| "roles"
| "joined_at"
| "pending"
| "deaf"
| "mute"
| "premium_since"
| "avatar";
export const PublicMemberProjection: PublicMemberKeys[] = [
"id",
"guild_id",
"nick",
"roles",
"joined_at",
"pending",
"deaf",
"mute",
"premium_since",
"avatar",
];
export type PublicMember = Omit<Pick<Member, PublicMemberKeys>, "roles"> & {
user: PublicUser;
roles: string[]; // only role ids not objects
};

View file

@ -0,0 +1,439 @@
import { User } from "./User";
import { Member } from "./Member";
import { Role } from "./Role";
import { Channel } from "./Channel";
import { InteractionType } from "../interfaces/Interaction";
import { Application } from "./Application";
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
JoinTable,
ManyToMany,
ManyToOne,
OneToMany,
RelationId,
} from "typeorm";
import { BaseClass } from "./BaseClass";
import { Guild } from "./Guild";
import { Webhook } from "./Webhook";
import { Sticker } from "./Sticker";
import { Attachment } from "./Attachment";
import { dbEngine } from "../util/Database";
export enum MessageType {
DEFAULT = 0,
RECIPIENT_ADD = 1,
RECIPIENT_REMOVE = 2,
CALL = 3,
CHANNEL_NAME_CHANGE = 4,
CHANNEL_ICON_CHANGE = 5,
CHANNEL_PINNED_MESSAGE = 6,
GUILD_MEMBER_JOIN = 7,
USER_PREMIUM_GUILD_SUBSCRIPTION = 8,
USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_1 = 9,
USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_2 = 10,
USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_3 = 11,
CHANNEL_FOLLOW_ADD = 12,
ACTION = 13, // /me messages
GUILD_DISCOVERY_DISQUALIFIED = 14,
GUILD_DISCOVERY_REQUALIFIED = 15,
ENCRYPTED = 16,
REPLY = 19,
APPLICATION_COMMAND = 20, // application command or self command invocation
ROUTE_ADDED = 41, // custom message routing: new route affecting that channel
ROUTE_DISABLED = 42, // custom message routing: given route no longer affecting that channel
SELF_COMMAND_SCRIPT = 43, // self command scripts
ENCRYPTION = 50,
CUSTOM_START = 63,
UNHANDLED = 255,
}
@Entity({
name: "messages",
engine: dbEngine,
})
@Index(["channel_id", "id"], { unique: true })
export class Message extends BaseClass {
@Column({ nullable: true })
@RelationId((message: Message) => message.channel)
@Index()
channel_id?: string;
@JoinColumn({ name: "channel_id" })
@ManyToOne(() => Channel, {
onDelete: "CASCADE",
})
channel: Channel;
@Column({ nullable: true })
@RelationId((message: Message) => message.guild)
guild_id?: string;
@JoinColumn({ name: "guild_id" })
@ManyToOne(() => Guild, {
onDelete: "CASCADE",
})
guild?: Guild;
@Column({ nullable: true })
@RelationId((message: Message) => message.author)
@Index()
author_id?: string;
@JoinColumn({ name: "author_id", referencedColumnName: "id" })
@ManyToOne(() => User, {
onDelete: "CASCADE",
})
author?: User;
@Column({ nullable: true })
@RelationId((message: Message) => message.member)
member_id?: string;
@JoinColumn({ name: "member_id", referencedColumnName: "id" })
@ManyToOne(() => User, {
onDelete: "CASCADE",
})
member?: Member;
@Column({ nullable: true })
@RelationId((message: Message) => message.webhook)
webhook_id?: string;
@JoinColumn({ name: "webhook_id" })
@ManyToOne(() => Webhook)
webhook?: Webhook;
@Column({ nullable: true })
@RelationId((message: Message) => message.application)
application_id?: string;
@JoinColumn({ name: "application_id" })
@ManyToOne(() => Application)
application?: Application;
@Column({ nullable: true })
content?: string;
@Column()
@CreateDateColumn()
timestamp: Date;
@Column({ nullable: true })
edited_timestamp?: Date;
@Column({ nullable: true })
tts?: boolean;
@Column({ nullable: true })
mention_everyone?: boolean;
@JoinTable({ name: "message_user_mentions" })
@ManyToMany(() => User)
mentions: User[];
@JoinTable({ name: "message_role_mentions" })
@ManyToMany(() => Role)
mention_roles: Role[];
@JoinTable({ name: "message_channel_mentions" })
@ManyToMany(() => Channel)
mention_channels: Channel[];
@JoinTable({ name: "message_stickers" })
@ManyToMany(() => Sticker, { cascade: true, onDelete: "CASCADE" })
sticker_items?: Sticker[];
@OneToMany(
() => Attachment,
(attachment: Attachment) => attachment.message,
{
cascade: true,
orphanedRowAction: "delete",
},
)
attachments?: Attachment[];
@Column({ type: "simple-json" })
embeds: Embed[];
@Column({ type: "simple-json" })
reactions: Reaction[];
@Column({ type: "text", nullable: true })
nonce?: string;
@Column({ nullable: true })
pinned?: boolean;
@Column({ type: "int" })
type: MessageType;
@Column({ type: "simple-json", nullable: true })
activity?: {
type: number;
party_id: string;
};
@Column({ default: 0 })
flags: number;
@Column({ type: "simple-json", nullable: true })
message_reference?: {
message_id: string;
channel_id?: string;
guild_id?: string;
};
@JoinColumn({ name: "message_reference_id" })
@ManyToOne(() => Message)
referenced_message?: Message;
@Column({ type: "simple-json", nullable: true })
interaction?: {
id: string;
type: InteractionType;
name: string;
user_id: string; // the user who invoked the interaction
// user: User; // TODO: autopopulate user
};
@Column({ type: "simple-json", nullable: true })
components?: ActionRowComponent[];
@Column({ type: "simple-json", nullable: true })
poll?: Poll;
@Column({ nullable: true })
username?: string;
@Column({ nullable: true })
avatar?: string;
toJSON(): Message {
return {
...this,
author_id: undefined,
member_id: undefined,
webhook_id: this.webhook_id ?? undefined,
application_id: undefined,
nonce: this.nonce ?? undefined,
tts: this.tts ?? false,
guild: this.guild ?? undefined,
webhook: this.webhook ?? undefined,
interaction: this.interaction ?? undefined,
reactions: this.reactions ?? undefined,
sticker_items: this.sticker_items ?? undefined,
message_reference: this.message_reference ?? undefined,
author: {
...(this.author?.toPublicUser() ?? undefined),
// Webhooks
username: this.username ?? this.author?.username,
avatar: this.avatar ?? this.author?.avatar,
},
activity: this.activity ?? undefined,
application: this.application ?? undefined,
components: this.components ?? undefined,
poll: this.poll ?? undefined,
content: this.content ?? "",
};
}
}
export interface MessageComponent {
type: MessageComponentType;
}
export interface ActionRowComponent extends MessageComponent {
type: MessageComponentType.ActionRow;
components: (
| ButtonComponent
| StringSelectMenuComponent
| SelectMenuComponent
| TextInputComponent
)[];
}
export interface ButtonComponent extends MessageComponent {
type: MessageComponentType.Button;
style: ButtonStyle;
label?: string;
emoji?: PartialEmoji;
custom_id?: string;
sku_id?: string;
url?: string;
disabled?: boolean;
}
export enum ButtonStyle {
Primary = 1,
Secondary = 2,
Success = 3,
Danger = 4,
Link = 5,
Premium = 6,
}
export interface SelectMenuComponent extends MessageComponent {
type:
| MessageComponentType.StringSelect
| MessageComponentType.UserSelect
| MessageComponentType.RoleSelect
| MessageComponentType.MentionableSelect
| MessageComponentType.ChannelSelect;
custom_id: string;
channel_types?: number[];
placeholder?: string;
default_values?: SelectMenuDefaultOption[]; // only for non-string selects
min_values?: number;
max_values?: number;
disabled?: boolean;
}
export interface SelectMenuOption {
label: string;
value: string;
description?: string;
emoji?: PartialEmoji;
default?: boolean;
}
export interface SelectMenuDefaultOption {
id: string;
type: "user" | "role" | "channel";
}
export interface StringSelectMenuComponent extends SelectMenuComponent {
type: MessageComponentType.StringSelect;
options: SelectMenuOption[];
}
export interface TextInputComponent extends MessageComponent {
type: MessageComponentType.TextInput;
custom_id: string;
style: TextInputStyle;
label: string;
min_length?: number;
max_length?: number;
required?: boolean;
value?: string;
placeholder?: string;
}
export enum TextInputStyle {
Short = 1,
Paragraph = 2,
}
export enum MessageComponentType {
Script = 0, // self command script
ActionRow = 1,
Button = 2,
StringSelect = 3,
TextInput = 4,
UserSelect = 5,
RoleSelect = 6,
MentionableSelect = 7,
ChannelSelect = 8,
}
export interface Embed {
title?: string; //title of embed
type?: EmbedType; // type of embed (always "rich" for webhook embeds)
description?: string; // description of embed
url?: string; // url of embed
timestamp?: Date; // timestamp of embed content
color?: number; // color code of the embed
footer?: {
text: string;
icon_url?: string;
proxy_icon_url?: string;
}; // footer object footer information
image?: EmbedImage; // image object image information
thumbnail?: EmbedImage; // thumbnail object thumbnail information
video?: EmbedImage; // video object video information
provider?: {
name?: string;
url?: string;
}; // provider object provider information
author?: {
name?: string;
url?: string;
icon_url?: string;
proxy_icon_url?: string;
}; // author object author information
fields?: {
name: string;
value: string;
inline?: boolean;
}[];
}
export enum EmbedType {
rich = "rich",
image = "image",
video = "video",
gifv = "gifv",
article = "article",
link = "link",
}
export interface EmbedImage {
url?: string;
proxy_url?: string;
height?: number;
width?: number;
}
export interface Reaction {
count: number;
//// not saved in the database // me: boolean; // whether the current user reacted using this emoji
emoji: PartialEmoji;
user_ids: string[];
}
export interface PartialEmoji {
id?: string;
name: string;
animated?: boolean;
}
export interface AllowedMentions {
parse?: ("users" | "roles" | "everyone")[];
roles?: string[];
users?: string[];
replied_user?: boolean;
}
export interface Poll {
question: PollMedia;
answers: PollAnswer[];
expiry: Date;
allow_multiselect: boolean;
results?: PollResult;
}
export interface PollMedia {
text?: string;
emoji?: PartialEmoji;
}
export interface PollAnswer {
answer_id?: string;
poll_media: PollMedia;
}
export interface PollResult {
is_finalized: boolean;
answer_counts: PollAnswerCount[];
}
export interface PollAnswerCount {
id: string;
count: number;
me_voted: boolean;
}

View file

@ -0,0 +1,29 @@
import {
Column,
Entity,
ObjectIdColumn,
PrimaryGeneratedColumn,
BaseEntity,
} from "typeorm";
import { dbEngine } from "../util/Database";
export const PrimaryIdAutoGenerated = process.env.DATABASE?.startsWith(
"mongodb",
)
? ObjectIdColumn
: PrimaryGeneratedColumn;
@Entity({
name: "migrations",
engine: dbEngine,
})
export class Migration extends BaseEntity {
@PrimaryIdAutoGenerated()
id: number;
@Column({ type: "bigint" })
timestamp: number;
@Column()
name: string;
}

22
src/util/entities/Note.ts Normal file
View file

@ -0,0 +1,22 @@
import { Column, Entity, JoinColumn, ManyToOne, Unique } from "typeorm";
import { BaseClass } from "./BaseClass";
import { User } from "./User";
import { dbEngine } from "../util/Database";
@Entity({
name: "notes",
engine: dbEngine,
})
@Unique(["owner", "target"])
export class Note extends BaseClass {
@JoinColumn({ name: "owner_id" })
@ManyToOne(() => User, { onDelete: "CASCADE" })
owner: User;
@JoinColumn({ name: "target_id" })
@ManyToOne(() => User, { onDelete: "CASCADE" })
target: User;
@Column()
content: string;
}

View file

@ -0,0 +1,21 @@
import { Column, Entity } from "typeorm";
import { BaseClass } from "./BaseClass";
import { dbEngine } from "../util/Database";
@Entity({
name: "rate_limits",
engine: dbEngine,
})
export class RateLimit extends BaseClass {
@Column() // no relation as it also
executor_id: string;
@Column()
hits: number;
@Column()
blocked: boolean;
@Column()
expires_at: Date;
}

View file

@ -0,0 +1,65 @@
import {
Column,
Entity,
Index,
JoinColumn,
ManyToOne,
RelationId,
} from "typeorm";
import { BaseClass } from "./BaseClass";
import { Channel } from "./Channel";
import { User } from "./User";
import { dbEngine } from "../util/Database";
// for read receipts
// notification cursor and public read receipt need to be forwards-only (the former to prevent re-pinging when marked as unread, and the latter to be acceptable as a legal acknowledgement in criminal proceedings), and private read marker needs to be advance-rewind capable
// public read receipt ≥ notification cursor ≥ private fully read marker
@Entity({
name: "read_states",
engine: dbEngine,
})
@Index(["channel_id", "user_id"], { unique: true })
export class ReadState extends BaseClass {
@Column()
@RelationId((read_state: ReadState) => read_state.channel)
channel_id: string;
@JoinColumn({ name: "channel_id" })
@ManyToOne(() => Channel, {
onDelete: "CASCADE",
})
channel: Channel;
@Column()
@RelationId((read_state: ReadState) => read_state.user)
user_id: string;
@JoinColumn({ name: "user_id" })
@ManyToOne(() => User, {
onDelete: "CASCADE",
})
user: User;
// fully read marker
@Column({ nullable: true })
last_message_id: string;
// public read receipt
@Column({ nullable: true })
public_ack: string;
// notification cursor / private read receipt
@Column({ nullable: true })
notifications_cursor: string;
@Column({ nullable: true })
last_pin_timestamp?: Date;
@Column({ nullable: true })
mention_count: number;
// @Column({ nullable: true })
// TODO: derive this from (last_message_id=notifications_cursor=public_ack)=true
manual: boolean;
}

View file

@ -0,0 +1,34 @@
import { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm";
import { BaseClass } from "./BaseClass";
import { dbEngine } from "../util/Database";
@Entity({
name: "recipients",
engine: dbEngine,
})
export class Recipient extends BaseClass {
@Column()
@RelationId((recipient: Recipient) => recipient.channel)
channel_id: string;
@JoinColumn({ name: "channel_id" })
@ManyToOne(() => require("./Channel").Channel, {
onDelete: "CASCADE",
})
channel: import("./Channel").Channel;
@Column()
@RelationId((recipient: Recipient) => recipient.user)
user_id: string;
@JoinColumn({ name: "user_id" })
@ManyToOne(() => require("./User").User, {
onDelete: "CASCADE",
})
user: import("./User").User;
@Column({ default: false })
closed: boolean;
// TODO: settings/mute/nick/added at/encryption keys/read_state
}

View file

@ -0,0 +1,60 @@
import {
Column,
Entity,
Index,
JoinColumn,
ManyToOne,
RelationId,
} from "typeorm";
import { BaseClass } from "./BaseClass";
import { User } from "./User";
import { dbEngine } from "../util/Database";
export enum RelationshipType {
outgoing = 4,
incoming = 3,
blocked = 2,
friends = 1,
}
@Entity({
name: "relationships",
engine: dbEngine,
})
@Index(["from_id", "to_id"], { unique: true })
export class Relationship extends BaseClass {
@Column({})
@RelationId((relationship: Relationship) => relationship.from)
from_id: string;
@JoinColumn({ name: "from_id" })
@ManyToOne(() => User, {
onDelete: "CASCADE",
})
from: User;
@Column({})
@RelationId((relationship: Relationship) => relationship.to)
to_id: string;
@JoinColumn({ name: "to_id" })
@ManyToOne(() => User, {
onDelete: "CASCADE",
})
to: User;
@Column({ nullable: true })
nickname?: string;
@Column({ type: "int" })
type: RelationshipType;
toPublicRelationship() {
return {
id: this.to?.id || this.to_id,
type: this.type,
nickname: this.nickname,
user: this.to?.toPublicUser(),
};
}
}

65
src/util/entities/Role.ts Normal file
View file

@ -0,0 +1,65 @@
import { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm";
import { BaseClass } from "./BaseClass";
import { Guild } from "./Guild";
import { dbEngine } from "../util/Database";
@Entity({
name: "roles",
engine: dbEngine,
})
export class Role extends BaseClass {
@Column()
@RelationId((role: Role) => role.guild)
guild_id: string;
@JoinColumn({ name: "guild_id" })
@ManyToOne(() => Guild, (guild) => guild.roles, {
onDelete: "CASCADE",
})
guild: Guild;
@Column()
color: number;
@Column()
hoist: boolean;
@Column()
managed: boolean;
@Column()
mentionable: boolean;
@Column()
name: string;
@Column()
permissions: string;
@Column()
position: number;
@Column({ nullable: true })
icon?: string;
@Column({ nullable: true })
unicode_emoji?: string;
@Column({ type: "simple-json", nullable: true })
tags?: {
bot_id?: string;
integration_id?: string;
premium_subscriber?: boolean;
};
@Column({ default: 0 })
flags: number;
toJSON(): Role {
return {
...this,
tags: this.tags ?? undefined,
};
}
}

View file

@ -0,0 +1,32 @@
import { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm";
import { BaseClass } from "./BaseClass";
import { User } from "./User";
import { dbEngine } from "../util/Database";
@Entity({
name: "security_keys",
engine: dbEngine,
})
export class SecurityKey extends BaseClass {
@Column({ nullable: true })
@RelationId((key: SecurityKey) => key.user)
user_id: string;
@JoinColumn({ name: "user_id" })
@ManyToOne(() => User, {
onDelete: "CASCADE",
})
user: User;
@Column()
key_id: string;
@Column()
public_key: string;
@Column()
counter: number;
@Column()
name: string;
}

View file

@ -0,0 +1,52 @@
import { User } from "./User";
import { BaseClass } from "./BaseClass";
import { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm";
import { ClientStatus, Status } from "../interfaces/Status";
import { Activity } from "../interfaces/Activity";
import { dbEngine } from "../util/Database";
//TODO we need to remove all sessions on server start because if the server crashes without closing websockets it won't delete them
@Entity({
name: "sessions",
engine: dbEngine,
})
export class Session extends BaseClass {
@Column({ nullable: true })
@RelationId((session: Session) => session.user)
user_id: string;
@JoinColumn({ name: "user_id" })
@ManyToOne(() => User, {
onDelete: "CASCADE",
})
user: User;
//TODO check, should be 32 char long hex string
@Column({ nullable: false, select: false })
session_id: string;
@Column({ type: "simple-json", nullable: true })
activities: Activity[];
@Column({ type: "simple-json", select: false })
client_info: {
client: string;
os: string;
version: number;
};
@Column({ type: "simple-json" })
client_status: ClientStatus;
@Column({ nullable: false, type: "varchar" })
status: Status; //TODO enum
}
export const PrivateSessionProjection: (keyof Session)[] = [
"user_id",
"session_id",
"activities",
"client_info",
"status",
];

View file

@ -0,0 +1,70 @@
import { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm";
import { BaseClass } from "./BaseClass";
import { Guild } from "./Guild";
import { User } from "./User";
import { dbEngine } from "../util/Database";
export enum StickerType {
STANDARD = 1,
GUILD = 2,
}
export enum StickerFormatType {
GIF = 0, // gif is a custom format type and not in discord spec
PNG = 1,
APNG = 2,
LOTTIE = 3,
}
@Entity({
name: "stickers",
engine: dbEngine,
})
export class Sticker extends BaseClass {
@Column()
name: string;
@Column({ nullable: true })
description?: string;
@Column({ nullable: true })
available?: boolean;
@Column({ nullable: true })
tags?: string;
@Column({ nullable: true })
@RelationId((sticker: Sticker) => sticker.pack)
pack_id?: string;
@JoinColumn({ name: "pack_id" })
@ManyToOne(() => require("./StickerPack").StickerPack, {
onDelete: "CASCADE",
nullable: true,
})
pack: import("./StickerPack").StickerPack;
@Column({ nullable: true })
guild_id?: string;
@JoinColumn({ name: "guild_id" })
@ManyToOne(() => Guild, (guild) => guild.stickers, {
onDelete: "CASCADE",
})
guild?: Guild;
@Column({ nullable: true })
user_id?: string;
@JoinColumn({ name: "user_id" })
@ManyToOne(() => User, {
onDelete: "CASCADE",
})
user?: User;
@Column({ type: "int" })
type: StickerType;
@Column({ type: "int" })
format_type: StickerFormatType;
}

Some files were not shown because too many files have changed in this diff Show more