diff --git a/.eslintrc.js b/.eslintrc.js index e0246c7..23c7b25 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -15,6 +15,8 @@ module.exports = { '@typescript-eslint/no-unused-vars': 'error', 'no-console': 'off', 'no-continue': 'off', + 'class-methods-use-this': 'warn', + 'import/no-cycle': 'warn', }, parserOptions: { ecmaVersion: 2022, diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..963354f --- /dev/null +++ b/.prettierrc @@ -0,0 +1,3 @@ +{ + "printWidth": 120 +} diff --git a/package-lock.json b/package-lock.json index 9b5d3f9..db3cb35 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "electron-debug": "^3.2.0", "electron-log": "^4.4.8", "electron-updater": ">=6.3.0-alpha.6", + "moment": "^2.30.1", "picocolors": "^1.0.1", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -14711,6 +14712,15 @@ "node": ">=10" } }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/mrmime": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.0.tgz", diff --git a/package.json b/package.json index 1d1ad75..12e9610 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,7 @@ "electron-debug": "^3.2.0", "electron-log": "^4.4.8", "electron-updater": ">=6.3.0-alpha.6", + "moment": "^2.30.1", "picocolors": "^1.0.1", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/src/common/discord/channel.ts b/src/common/discord/channel.ts deleted file mode 100644 index ee00acb..0000000 --- a/src/common/discord/channel.ts +++ /dev/null @@ -1,164 +0,0 @@ -import BaseDiscordApi from './client/basediscordapi'; -import { IUser, User } from './user'; - -export interface IconEmoji { - name: string; - id: any; -} - -export enum ChannelType { - GuildText = 0, - DirectMessage = 1, - GuildVoice = 2, - GroupDirectMessage = 3, - GuildCategory = 4, - GuildAnnouncement = 5, - WelcomeMessage = 7, - AnnouncementThread = 10, - PublicThread = 11, - PrivateThread = 12, - GuildStageVoice = 13, - GuildDirectory = 14, - GuildForum = 15, - GuildMedia = 16, -} - -export interface IChannel { - version: number; - type: number; - topic: any; - rate_limit_per_user: number; - position: number; - permission_overwrites: string[][]; - parent_id?: string; - nsfw: boolean; - name: string; - last_pin_timestamp: string; - last_message_id: string; - id: string; - icon_emoji: IconEmoji; - flags: number; -} - -export interface Attachment { - id: string; - filename: string; - size: number; - url: string; - proxy_url: string; - width: number; - height: number; - content_type: string; - content_scan_version: number; - placeholder: string; - placeholder_version: number; -} - -export interface ChannelMessage { - type: number; - content: string; - mentions: User[]; - mention_roles: any[]; - attachments: Attachment[]; - embeds: string[][]; - timestamp: string; - edited_timestamp: any; - flags: number; - components: any[]; - id: string; - channel_id: string; - author: IUser; - pinned: boolean; - mention_everyone: boolean; - tts: boolean; -} - -export default class Channel { - public readonly id: string; - - public name: string; - - public readonly guildId: string; - - public readonly type: ChannelType; - - public readonly parentId: string | null; - - public position: number; - - public lastCheckedMessages: number = 0; - - public cachedMessages: ChannelMessage[] = []; - - public constructor(data: IChannel, guildId: string) { - this.id = data.id; - this.name = data.name; - this.type = data.type; - this.parentId = data.parent_id ?? null; - this.position = data.position; - this.guildId = guildId; - } - - public async getMessages(discord: BaseDiscordApi): Promise { - if (this.type !== ChannelType.GuildText) return []; - - // Retrieve and cache messages if we were not in the channel for the last 15 minutes - if (Date.now() - this.lastCheckedMessages >= 1000 * 60 * 15) { - console.log( - `[Discord/Channel] Updating messages from channel '${this.id}'`, - ); - this.cachedMessages = await this.retrieveMessages(discord); - } - - this.lastCheckedMessages = Date.now(); - return this.cachedMessages; - } - - private async retrieveMessages( - discord: BaseDiscordApi, - ): Promise { - if (this.type !== ChannelType.GuildText) return []; - - try { - const response = await fetch( - `https://discord.com/api/v10/channels/${this.id}/messages?limit=50`, - { - headers: { - Authorization: discord.token, - }, - }, - ); - const json = await response.json(); - if (json.message) return []; - return json as ChannelMessage[]; - } catch (err) { - console.error( - `[Discord/Channel] Failed to retrieve messages from channel '${this.id}': ${err}`, - ); - return []; - } - } - - public async sendMessage(content: string, discord: BaseDiscordApi) { - if (this.type !== ChannelType.GuildText) return; - - const response = await fetch( - `https://discord.com/api/v10/channels/${this.id}/messages`, - { - method: 'POST', - body: JSON.stringify({ - content, - flags: 0, - mobile_network_type: 'unknown', - tts: false, - }), - headers: { - 'Content-Type': 'application/json', - Authorization: discord.token, - }, - }, - ); - const json = await response.json(); - console.log('Response: ', json); - } -} diff --git a/src/common/discord/client/basediscordapi.ts b/src/common/discord/client/basediscordapi.ts deleted file mode 100644 index 58403f8..0000000 --- a/src/common/discord/client/basediscordapi.ts +++ /dev/null @@ -1,10 +0,0 @@ -import events from 'events'; - -export default class BaseDiscordApi extends events.EventEmitter { - public readonly token: string; - - protected constructor(token: string) { - super(); - this.token = token; - } -} diff --git a/src/common/discord/client/basediscordclient.ts b/src/common/discord/client/basediscordclient.ts deleted file mode 100644 index f2cf41d..0000000 --- a/src/common/discord/client/basediscordclient.ts +++ /dev/null @@ -1,41 +0,0 @@ -import VoiceService from '../service/voiceservice'; -import GuildService from '../service/guildservice'; -import { PrivateChannel, Relationship, User } from '../user'; -import Channel from '../channel'; -import UserService from '../service/userservice'; -import BaseDiscordApi from './basediscordapi'; - -export default abstract class BaseDiscordClient extends BaseDiscordApi { - public readonly voice: VoiceService; - - public readonly guilds: GuildService; - - public readonly users: UserService; - - public selfUser: User | null = null; - - public relationships: Relationship[] = []; - - public privateChannels: PrivateChannel[] = []; - - public ready: boolean = false; - - protected constructor(token: string) { - super(token); - this.voice = new VoiceService(); - this.guilds = new GuildService(); - this.users = new UserService(); - } - - public getChannel(id: string): Channel | null { - const guilds = this.guilds.getGuilds(); - for (let i = 0; i < guilds.length; i += 1) { - const guild = guilds[i]; - for (let j = 0; j < guild.channels.length; j += 1) { - const channel = guild.channels[j]; - if (channel.id === id) return channel; - } - } - return null; - } -} diff --git a/src/common/discord/client/client.ts b/src/common/discord/client/client.ts deleted file mode 100644 index 2e2dc58..0000000 --- a/src/common/discord/client/client.ts +++ /dev/null @@ -1,29 +0,0 @@ -import BaseDiscordClient from './basediscordclient'; -import { GatewayClient } from '../gateway/gatewayclient'; -import GatewayEvent from '../gateway/event'; - -export declare interface DiscordClient { - on(event: string, listener: Function): this; - - on(event: 'connect', listener: () => void): this; - - on(event: 'event', listener: (event: GatewayEvent) => void): this; -} - -// eslint-disable-next-line no-redeclare -export class DiscordClient extends BaseDiscordClient { - public readonly gateway: GatewayClient; - - public constructor(token: string) { - super(token); - this.gateway = new GatewayClient(this); - this.gateway.on('connect', () => this.emit('connect')); - this.gateway.on('event', (event: GatewayEvent) => { - this.emit('event', event); - }); - } - - public disconnect() { - this.gateway.disconnect(); - } -} diff --git a/src/common/discord/gateway/data/ReadyEventData.ts b/src/common/discord/gateway/data/ReadyEventData.ts deleted file mode 100644 index f5e06ce..0000000 --- a/src/common/discord/gateway/data/ReadyEventData.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { IGuild } from '../../guild'; -import { - ConnectedAccount, - IUser, - Member, - PrivateChannel, - Relationship, -} from '../../user'; -import { VoiceState } from '../../voice'; - -export interface Consents { - personalization: string[]; -} - -export interface NotificationSettings { - flags: number; -} - -export interface UserSettings { - detect_platform_accounts: boolean; - animate_stickers: number; - inline_attachment_media: boolean; - status: string; - message_display_compact: boolean; - view_nsfw_guilds: boolean; - timezone_offset: number; - enable_tts_command: boolean; - disable_games_tab: boolean; - stream_notifications_enabled: boolean; - animate_emoji: boolean; - guild_folders: string[]; - activity_joining_restricted_guild_ids: any[]; - friend_source_flags: string[]; - broadcast_allowed_user_ids: any[]; - convert_emoticons: boolean; - afk_timeout: number; - passwordless: boolean; - contact_sync_enabled: boolean; - broadcast_allow_friends: boolean; - gif_auto_play: boolean; - custom_status: any; - native_phone_integration_enabled: boolean; - allow_accessibility_detection: boolean; - broadcast_allowed_guild_ids: any[]; - friend_discovery_flags: number; - show_current_game: boolean; - restricted_guilds: any[]; - developer_mode: boolean; - view_nsfw_commands: boolean; - render_reactions: boolean; - locale: string; - render_embeds: boolean; - inline_embed_media: boolean; - default_guilds_restricted: boolean; - explicit_content_filter: number; - activity_restricted_guild_ids: any[]; - theme: string; -} - -export interface ReadyEventData { - v: number; - user_settings_proto: string; - user_settings: UserSettings; - user_guild_settings: string[][]; - user: IUser; - users: IUser[]; - relationships: Relationship[]; - read_state: string[][]; - private_channels: PrivateChannel[]; - presences: string[][]; - notification_settings: NotificationSettings; - guilds: IGuild[]; - merged_members: Member[][]; - guild_join_requests: any[]; - guild_experiments: string[][]; - friend_suggestion_count: number; - explicit_content_scan_version: number; - experiments: string[][]; - country_code: string; - consents: Consents; - connected_accounts: ConnectedAccount[]; - auth_session_id_hash: string; - api_code_version: number; - analytics_token: string; -} - -export interface MergedPresences { - guilds: any[]; - friends: any[]; -} - -export interface SupplementalGuild { - voice_states: VoiceState[]; - id: string; - embedded_activities: any[]; - activity_instances: any[]; -} - -export interface ReadySupplementalData { - merged_presences: MergedPresences; - merged_members: any[]; - lazy_private_channels: any[]; - guilds: SupplementalGuild[]; - game_invites: any[]; - disclose: string[]; -} diff --git a/src/common/discord/gateway/event.ts b/src/common/discord/gateway/event.ts deleted file mode 100644 index bbfd746..0000000 --- a/src/common/discord/gateway/event.ts +++ /dev/null @@ -1,6 +0,0 @@ -export default interface GatewayEvent { - op: number; - data: any; - sequence?: number; - event?: string; -} diff --git a/src/common/discord/gateway/gatewayclient.ts b/src/common/discord/gateway/gatewayclient.ts deleted file mode 100644 index 2c6f8d9..0000000 --- a/src/common/discord/gateway/gatewayclient.ts +++ /dev/null @@ -1,186 +0,0 @@ -import WebSocket from 'ws'; -import events from 'events'; -import BaseDiscordClient from '../client/basediscordclient'; -import GatewayEvent from './event'; -import { VoiceState } from '../voice'; -import { ReadyEventData } from './data/ReadyEventData'; -import { Guild } from '../guild'; -import Channel, { ChannelMessage } from '../channel'; -import { User } from '../user'; - -export declare interface GatewayClient { - on(event: string, listener: Function): this; - - on(event: 'connect', listener: () => void): this; - - on(event: 'event', listener: (event: GatewayEvent) => void): this; -} - -// eslint-disable-next-line no-redeclare -export class GatewayClient extends events.EventEmitter { - private readonly discord: BaseDiscordClient; - - private websocket: WebSocket | null = null; - - private heartbeat: number = 30000; - - public constructor(discord: BaseDiscordClient) { - super(); - this.discord = discord; - this.connect(); - } - - public sendEvent(event: GatewayEvent) { - if (this.websocket?.readyState !== WebSocket.OPEN) return; - - const data = { - op: event.op, - d: event.data, - s: event.sequence, - t: event.event, - }; - this.websocket.send(JSON.stringify(data)); - } - - public connect(): boolean { - if (this.websocket !== null) return false; - - this.websocket = new WebSocket( - 'wss://gateway.discord.gg/?v=10&encoding=json', - ); - this.websocket.on('open', () => { - this.sendEvent({ - op: 2, - data: { - token: this.discord.token, - capabilities: 4605, - properties: { - os: 'windows', - browser: 'chrome', - device: 'windows', - }, - }, - }); - }); - this.websocket.on('message', (message) => { - const raw = message.toString(); - const json = JSON.parse(raw); - - const event: GatewayEvent = { - op: json.op, - data: json.d, - sequence: json.s, - event: json.t, - }; - - this.handleEvent(event); - }); - this.websocket.on('close', (code, reason) => { - console.log( - `[WaveCord/Gateway] Disconnected (${code}, ${reason}). Reconnecting...`, - ); - this.websocket = null; - this.connect(); - }); - return true; - } - - public disconnect(): boolean { - if (this.websocket === null) return false; - - this.websocket.close(); - this.websocket = null; - return true; - } - - private handleEvent(event: GatewayEvent) { - if (event.op === 0) { - switch (event.event) { - case 'READY': - this.handleReadyEvent(event); - break; - case 'READY_SUPPLEMENTAL': - this.emit('connect'); - break; - case 'VOICE_STATE_UPDATE': - this.handleVoiceStateUpdateEvent(event); - break; - case 'MESSAGE_CREATE': - this.handleMessageCreateEvent(event); - break; - default: - break; - } - } - - if (event.op === 10) { - this.heartbeat = event.data.heartbeat_interval as number; - setInterval(() => { - this.sendEvent({ - op: 1, - data: null, - }); - }, this.heartbeat); - } - this.emit('event', event); - } - - private handleReadyEvent(event: GatewayEvent) { - const data = event.data as ReadyEventData; - - for (let i = 0; i < data.users.length; i += 1) { - const iuser = data.users[i]; - this.discord.users.setUser(new User(iuser)); - } - - // Adding members to guild - for (let index = 0; index < data.merged_members.length; index += 1) { - const members = data.merged_members[index]; - const guild = data.guilds[index]; - - if (guild !== undefined) { - if (guild.members === undefined) guild.members = [...members]; - else guild.members.push(...members); - } - } - - // Adding guilds to our guild service - for (let i = 0; i < data.guilds.length; i += 1) { - const iguild = data.guilds[i]; - const guild = new Guild(iguild); - - if (iguild.channels !== undefined) { - for (let j = 0; j < iguild.channels.length; j += 1) { - const ichannel = iguild.channels[j]; - guild.channels.push(new Channel(ichannel, guild.id)); - } - } - - this.discord.guilds.addGuild(guild); - } - - this.discord.privateChannels = data.private_channels; - this.discord.relationships = data.relationships; - this.discord.selfUser = new User(data.user); - } - - private handleVoiceStateUpdateEvent(event: GatewayEvent) { - const voiceState = event.data as VoiceState; - this.discord.voice.setVoiceState(voiceState.user_id, voiceState); - } - - private handleMessageCreateEvent(event: GatewayEvent) { - const message = event.data as ChannelMessage; - const channel = this.discord.getChannel(message.channel_id); - - if (channel === null) return; - - // Check if message is not cached - if (channel.cachedMessages.find((v) => v.id === message.id) === undefined) - channel.cachedMessages.push(message); - } - - public getWebSocket(): WebSocket | null { - return this.websocket; - } -} diff --git a/src/common/discord/guild.ts b/src/common/discord/guild.ts deleted file mode 100644 index fbc26d2..0000000 --- a/src/common/discord/guild.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { Member } from './user'; -import Channel, { IChannel } from './channel'; - -export interface Tags { - bot_id: string; -} - -export interface Role { - version: number; - unicode_emoji: any; - tags: Tags; - position: number; - permissions: string; - name: string; - mentionable: boolean; - managed: boolean; - id: string; - icon: any; - hoist: boolean; - flags: number; - color: number; -} - -export interface SoundboardSound { - volume: number; - user_id: string; - sound_id: string; - name: string; - guild_id: string; - emoji_name: string; - emoji_id: any; - available: boolean; -} - -export interface ApplicationCommandCounts {} - -export interface IGuild { - members: Member[]; - member_count: number; - incidents_data: any; - explicit_content_filter: number; - stage_instances: any[]; - rules_channel_id: any; - nsfw_level: number; - owner_id: string; - discovery_splash: any; - embedded_activities: any[]; - preferred_locale: string; - verification_level: number; - nsfw: boolean; - icon: string; - default_message_notifications: number; - vanity_url_code: any; - id: string; - afk_channel_id: any; - name: string; - splash: any; - features: string[]; - latest_onboarding_question_id: any; - emojis: any[]; - max_stage_video_channel_users: number; - lazy: boolean; - system_channel_id: string; - threads: any[]; - max_video_channel_users: number; - application_id: any; - guild_scheduled_events: string[][]; - description: any; - activity_instances: any[]; - large: boolean; - inventory_settings: any; - application_command_counts: ApplicationCommandCounts; - premium_subscription_count: number; - clan: any; - hub_type: any; - roles: Role[]; - home_header: any; - mfa_level: number; - public_updates_channel_id: any; - region: string; - voice_states: any[]; - max_members: number; - safety_alerts_channel_id: any; - channels: IChannel[]; - soundboard_sounds: SoundboardSound[]; - premium_tier: number; - stickers: any[]; - afk_timeout: number; - version: number; - presences: any[]; - joined_at: string; - system_channel_flags: number; - premium_progress_bar_enabled: boolean; - banner?: string; -} - -export class Guild { - public readonly id: string; - - public name: string; - - public channels: Channel[]; - - public members: Member[]; - - public banner: string | null; - - public icon: string | null; - - public lastVisitedChannel: string | null = null; - - public constructor(data: IGuild) { - this.id = data.id; - this.name = data.name; - this.banner = data.banner ?? null; - this.icon = data.icon ?? null; - this.channels = []; - this.members = []; - } -} diff --git a/src/common/discord/service/guildservice.ts b/src/common/discord/service/guildservice.ts deleted file mode 100644 index c8019c5..0000000 --- a/src/common/discord/service/guildservice.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Guild } from '../guild'; - -export default class GuildService { - private guilds: Guild[]; - - public constructor() { - this.guilds = []; - } - - public addGuild(guild: Guild) { - if (this.guilds.find((value) => value.id === guild.id) !== undefined) - return; - this.guilds.push(guild); - } - - public removeGuild(guildId: string) { - this.guilds = this.guilds.filter((guild: Guild) => guild.id !== guildId); - } - - public getGuilds(): Guild[] { - return this.guilds; - } -} diff --git a/src/common/discord/service/userservice.ts b/src/common/discord/service/userservice.ts deleted file mode 100644 index 04180a6..0000000 --- a/src/common/discord/service/userservice.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { User } from '../user'; - -export default class UserService { - private users: Map; - - public constructor() { - this.users = new Map(); - } - - public setUser(user: User) { - this.users.set(user.id, user); - } - - public getUsers(): Map { - return this.users; - } -} diff --git a/src/common/discord/service/voiceservice.ts b/src/common/discord/service/voiceservice.ts deleted file mode 100644 index 1ab61a8..0000000 --- a/src/common/discord/service/voiceservice.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { VoiceState } from '../voice'; -import { User } from '../user'; - -export default class VoiceService { - private readonly voiceStates: Map; - - public constructor() { - this.voiceStates = new Map(); - } - - public setVoiceState(user: User | string, state: VoiceState | null) { - const userId = typeof user === 'string' ? user : user.id; - if (state === null) { - this.voiceStates.delete(userId); - return; - } - this.voiceStates.set(userId, state); - } - - public getVoiceStates(): Map { - return this.voiceStates; - } - - public getGuildVoiceStates(guildId: string): VoiceState[] { - const states: VoiceState[] = []; - - const array = Array.from(this.voiceStates.entries()); - for (let i = 0; i < array.length; i += 1) { - const element = array[i]; - const voiceState = element['1']; - if (voiceState.guild_id === guildId) states.push(voiceState); - } - - return states; - } - - public getChannelVoiceStates(channelId: string): VoiceState[] { - const states: VoiceState[] = []; - - const array = Array.from(this.voiceStates.entries()); - for (let i = 0; i < array.length; i += 1) { - const element = array[i]; - const voiceState = element['1']; - if (voiceState.channel_id === channelId) states.push(voiceState); - } - - return states; - } -} diff --git a/src/common/discord/user.ts b/src/common/discord/user.ts deleted file mode 100644 index 6d4cd1d..0000000 --- a/src/common/discord/user.ts +++ /dev/null @@ -1,149 +0,0 @@ -/* eslint-disable no-bitwise */ -export interface IUser { - verified: boolean; - username: string; - purchased_flags: number; - public_flags: number; - pronouns: string; - premium_usage_flags: number; - premium_type: number; - premium: boolean; - phone: string; - nsfw_allowed: boolean; - mobile: boolean; - mfa_enabled: boolean; - id: string; - global_name: string; - flags: number; - email: string; - discriminator: string; - desktop: boolean; - clan: any; - bio: string; - banner_color: string; - banner: string; - avatar_decoration_data: string[]; - avatar: string | null; - accent_color: number; -} - -export class User { - public readonly username: string; - - public nitro: boolean; - - public id: string; - - public globalName: string; - - public email: string; - - public discriminator: string; - - public bio: string; - - public banner: string; - - public avatar: string | null; - - public constructor(data: IUser | User) { - if ((data as any).public_flags !== undefined) { - // eslint-disable-next-line no-param-reassign - data = data as IUser; - this.username = data.username; - this.nitro = data.premium; - this.id = data.id; - this.globalName = data.global_name ?? data.username; - this.email = data.email; - this.discriminator = data.discriminator; - this.bio = data.bio; - this.banner = data.banner; - this.avatar = data.avatar; - } else { - // eslint-disable-next-line no-param-reassign - data = data as User; - this.username = data.username; - this.nitro = data.nitro; - this.id = data.id; - this.globalName = data.globalName; - this.email = data.email; - this.discriminator = data.discriminator; - this.bio = data.bio; - this.banner = data.banner; - this.avatar = data.avatar; - } - } - - public getAvatar(animated?: boolean): string { - let defaultAvatarIndex = (Number(this.id) >> 22) % 6; - - if (defaultAvatarIndex < 0) - defaultAvatarIndex = Number(this.discriminator) % 5; - - const url = - this.avatar !== null - ? `https://cdn.discordapp.com/avatars/${this.id}/${this.avatar}.png` - : `https://cdn.discordapp.com/embed/avatars/${defaultAvatarIndex}.png`; - - // Change png to gif if we want an animated version and if our user has an animated avatar available - if ( - animated && - !url.includes('/embed/avatars/') && - this.avatar?.includes('a_') - ) { - return url.replace('.png', '.gif'); - } - - return url; - } -} - -export interface Member { - user_id: string; - user?: User; - roles: string[]; - premium_since: any; - pending: boolean; - nick: string | null; - mute: boolean; - joined_at: string; - flags: number; - deaf: boolean; - communication_disabled_until: string | null; - banner: string | null; - avatar: string | null; -} - -export interface PrivateChannel { - type: number; - recipient_ids: string[]; - owner_id: string; - name: string; - last_message_id: string; - id: string; - icon: string; - flags: number; - blocked_user_warning_dismissed: boolean; -} - -export interface Relationship { - user_id: string; - type: number; - since: string; - nickname: string | null; - is_spam_request: boolean; - id: string; -} - -export interface ConnectedAccount { - visibility: number; - verified: boolean; - type: string; - two_way_link: boolean; - show_activity: boolean; - revoked: boolean; - name: string; - metadata_visibility: number; - id: string; - friend_sync: boolean; -} diff --git a/src/common/discord/voice.ts b/src/common/discord/voice.ts deleted file mode 100644 index ea71e68..0000000 --- a/src/common/discord/voice.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Member } from './user'; - -export interface VoiceState { - member: Member; - user_id: string; - suppress: boolean; - session_id: string; - self_video: boolean; - self_mute: boolean; - self_deaf: boolean; - request_to_speak_timestamp: any; - mute: boolean; - guild_id: string; - deaf: boolean; - channel_id: string | null; -} diff --git a/src/common/log/logger.ts b/src/common/log/logger.ts new file mode 100644 index 0000000..78543da --- /dev/null +++ b/src/common/log/logger.ts @@ -0,0 +1,79 @@ +import picocolors from 'picocolors'; +import moment from 'moment'; + +enum LogType { + Info, + Warn, + Error, + Crit, + Debug, +} + +export class Logger { + public name: string; + + constructor(name: string) { + this.name = name; + } + + private handleArgs(...args: any[]): string { + const messages: string[] = []; + + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (typeof arg !== 'object') { + messages.push(arg); + continue; + } + + messages.push(JSON.stringify(arg)); + } + return messages.join(' '); + } + + private logConsole(type: LogType, ...args: any[]) { + const now = moment(); + + let message = ''; + message += now.toString().slice(0, 3); + message += ', '; + message += picocolors.red(now.day()); + message += ' '; + message += now.toString().slice(4, 7); + message += ' '; + message += picocolors.red(now.year()); + message += ' '; + message += picocolors.gray(now.format('hh:mm:ss')); + message += ' | '; + message += LogType[type]; + message += ' | '; + message += picocolors.red(this.name.padEnd(16, ' ')); + message += ' | '; + message += this.handleArgs(...args); + + console.log(message); + } + + public info(...args: any[]): void { + this.logConsole(LogType.Info, ...args); + } + + public warn(...args: any[]): void { + this.logConsole(LogType.Warn, ...args); + } + + public error(...args: any[]): void { + this.logConsole(LogType.Error, ...args); + } + + public crit(...args: any[]): void { + this.logConsole(LogType.Crit, ...args); + } + + public debug(...args: any[]): void { + this.logConsole(LogType.Debug, ...args); + } +} + +const logger = new Logger('main'); +export default logger; diff --git a/src/common/logger.ts b/src/common/logger.ts deleted file mode 100644 index 93b0f5b..0000000 --- a/src/common/logger.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { ipcMain, ipcRenderer } from 'electron'; -import pc from 'picocolors'; - -const isRenderer = process && process.type === 'renderer'; - -function handleLog(fromRenderer: boolean, ...args: any[]) { - const level = args.shift(); - const prefix = fromRenderer ? pc.red('renderer') : ` ${pc.green('main')}`; - const messages: string[] = []; - - for (let i = 0; i < args.length; i += 1) { - const arg = args[i]; - if (typeof arg !== 'object') { - messages.push(arg); - continue; - } - - messages.push(JSON.stringify(arg)); - } - - console.log( - ` ${prefix} ${pc.gray('/')} ${level} ${pc.gray('>')} ${messages.join(' ')}`, - ); -} - -function init() { - ipcMain.on('LOGGER__LOG', (_, ...args: any[]) => { - handleLog(true, ...args); - }); -} - -function internalLog(level: string, ...args: any[]) { - if (isRenderer) ipcRenderer.send('LOGGER__LOG', level, ...args); - else handleLog(false, level, ...args); -} - -function log(...args: any[]) { - internalLog('info', ...args); -} - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function info(...args: any[]) { - internalLog('info', ...args); -} - -function warn(...args: any[]) { - internalLog('warn', ...args); -} - -function error(...args: any[]) { - internalLog('error', ...args); -} - -export default { - init, - log, - warn, - error, -}; diff --git a/src/discord/core/cache.ts b/src/discord/core/cache.ts new file mode 100644 index 0000000..2851b26 --- /dev/null +++ b/src/discord/core/cache.ts @@ -0,0 +1,87 @@ +interface CachePair { + key: K; + value: V; + timeAdded: number; +} + +export interface CacheOptions { + timeLimit?: number; +} + +export class CacheHolder { + private keysArray: K[] = []; + + private valuesArray: CachePair[] = []; + + private readonly options: CacheOptions; + + constructor(options?: CacheOptions) { + this.options = options ?? {}; + } + + public has(key: K): boolean { + return this.keysArray.includes(key); + } + + public set(key: K, value: V | undefined) { + if (this.has(key)) { + this.removeKey(key); + this.removeValue(key); + } + + if (value === undefined) return; + + this.keysArray.push(key); + this.valuesArray.push({ + key, + value, + timeAdded: Date.now(), + }); + } + + public get(key: K): V | undefined { + return this.has(key) + ? this.valuesArray[this.valuesArray.findIndex((v) => v.key === key)].value + : undefined; + } + + public expired(key: K): boolean { + if (!this.has(key) || this.options.timeLimit === undefined) return false; + + const pair = this.getPair(key); + if (pair === undefined) return false; + + const now = Date.now(); + + if (now - (pair.timeAdded + this.options.timeLimit) >= 0) { + return true; + } + return false; + } + + public values(): V[] { + return this.valuesArray.map((v) => v.value); + } + + public keys(): K[] { + return this.keysArray; + } + + private getPair(key: K): CachePair | undefined { + return this.has(key) + ? this.valuesArray[this.valuesArray.findIndex((v) => v.key === key)] + : undefined; + } + + private removeKey(key: K) { + if (!this.has(key)) return; + + delete this.keysArray[this.keysArray.indexOf(key)]; + } + + private removeValue(key: K) { + if (this.valuesArray.find((v) => v.key === key) === undefined) return; + + delete this.valuesArray[this.valuesArray.findIndex((v) => v.key === key)]; + } +} diff --git a/src/discord/core/client.ts b/src/discord/core/client.ts new file mode 100644 index 0000000..d29d3f7 --- /dev/null +++ b/src/discord/core/client.ts @@ -0,0 +1,131 @@ +import { TypedEmitter } from 'tiny-typed-emitter'; +import { WebSocket } from 'ws'; +import { Gateway } from '../ws/gateway'; +import { GatewayReadyDispatchData, GatewaySocketEvent } from '../ws/types'; +import { debug, setDebugging } from './logger'; +import { GuildManager } from '../managers/GuildManager'; +import { ChannelManager } from '../managers/ChannelManager'; +import MainGuild from '../structures/guild/MainGuild'; +import MainChannel from '../structures/channel/MainChannel'; +import MainUser from '../structures/user/MainUser'; + +export interface ClientEvents { + ready: () => void; + // eslint-disable-next-line no-unused-vars + dispatch: (event: GatewaySocketEvent) => void; +} + +export interface ClientOptions { + debug?: boolean; +} + +export class Client extends TypedEmitter { + public readonly options: ClientOptions; + + public readonly gateway: Gateway; + + public readonly guilds: GuildManager; + + public readonly channels: ChannelManager; + + public user: MainUser | null; + + private token: string; + + constructor(options?: ClientOptions) { + super(); + this.options = options ?? {}; + + if (this.options.debug) setDebugging(this.options.debug); + + this.gateway = new Gateway({ apiVersion: 10, encoding: 'json' }); + this.gateway.on('dispatch', async (event) => { + if (event.event === 'READY') { + const data = event.data as GatewayReadyDispatchData; + + // Cache all guilds + data.guilds.forEach((guildData) => { + this.guilds.cache.set(guildData.id, new MainGuild(guildData)); + + // Check if the guild data has channels in it + if (guildData.channels) { + // Cache all channels + guildData.channels.forEach((channelData) => { + // Somehow this data doesn't include a guild id + channelData.guild_id = guildData.id; + + // Cache this son of a bitch + this.channels.cache.set( + channelData.id, + new MainChannel(this, channelData), + ); + }); + } + }); + this.user = new MainUser(data.user); + + debug( + 'Client', + "Client received 'Ready' dispatch event and is now ready to use!", + ); + this.emit('ready'); + } + this.emit('dispatch', event); + }); + this.guilds = new GuildManager(this); + this.channels = new ChannelManager(this); + this.user = null; + this.token = ''; + } + + /** + * Logins and connects to the discord api and gateway. + * @param token A valid discord authentication token + */ + public async login(token: string): Promise { + if (!this.validateToken(token)) + throw new Error('Failed to login: Invalid discord token.'); + + this.token = token; + this.gateway.setToken(token); + + // Connect to discord's gateway + await this.gateway.connect(); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public async restGet(path: string): Promise { + const url = `https://discord.com/api/v10${path}`; + const response = await fetch(url, { + headers: { + Authorization: this.token, + }, + }); + const json = await response.json(); + return json; + } + + /** + * Validates if a authentication token is good for discord. + * @param token A authentication token + */ + public validateToken(token: string): boolean { + if (token.length !== 70) return false; + + const parts = token.split('.'); + if (parts.length !== 3) return false; + + const userId = Buffer.from(parts[0], 'base64').toString('utf8'); + return ( + (userId.length === 17 || userId.length === 18) && + !Number.isNaN(Number(userId)) + ); + } + + public isConnected(): boolean { + return ( + this.gateway.socket !== null && + this.gateway.socket.readyState === WebSocket.OPEN + ); + } +} diff --git a/src/discord/core/logger.ts b/src/discord/core/logger.ts new file mode 100644 index 0000000..5e49f1b --- /dev/null +++ b/src/discord/core/logger.ts @@ -0,0 +1,87 @@ +/* eslint-disable no-unused-vars, @typescript-eslint/no-explicit-any */ +import pico from 'picocolors'; + +export enum LogType { + Info = 'info', + Warn = 'warn', + Error = 'error', + Critical = 'crit', + Debug = 'debug', +} + +let sequence = 1; + +function handleArgs(...args: any[]): string { + const messages: string[] = []; + + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (typeof arg !== 'object') { + messages.push(arg); + continue; + } + + messages.push(JSON.stringify(arg, null, 4)); + } + return messages.join(' '); +} + +function handleMessage(logType: LogType, ...args: any[]): string { + const message = handleArgs(...args); + + switch (logType) { + case LogType.Info: + return pico.blue(message); + case LogType.Warn: + return pico.yellow(message); + case LogType.Error: + return pico.red(message); + case LogType.Critical: + return pico.bgRed(message); + case LogType.Debug: + return pico.green(message); + default: + return message; + } +} + +function log(logger: string, logType: LogType, ...args: any[]) { + const now = new Date(); + + const date = `${now.getUTCFullYear()}-${now.getUTCMonth().toString().padStart(2, '0')}-${now.getUTCDate().toString().padStart(2, '0')}`; + const time = `${now.getUTCHours().toString().padStart(2, '0')}:${now.getUTCMinutes().toString().padStart(2, '0')}:${now.getUTCSeconds().toString().padStart(2, '0')}`; + + const message = handleMessage(logType, ...args); + + console.log( + `${pico.cyan(logger.padEnd(16, ' '))} ${pico.gray(`${date} ${time}`)} ${pico.blue(sequence.toString().padStart(5, ' '))} ${message}`, + ); + + sequence += 1; +} + +let debugging = false; + +export function setDebugging(state: boolean) { + debugging = state; +} + +export function info(logger: string, ...args: any[]) { + log(logger, LogType.Info, ...args); +} + +export function warn(logger: string, ...args: any[]) { + log(logger, LogType.Warn, ...args); +} + +export function error(logger: string, ...args: any[]) { + log(logger, LogType.Error, ...args); +} + +export function crit(logger: string, ...args: any[]) { + log(logger, LogType.Critical, ...args); +} + +export function debug(logger: string, ...args: any[]) { + if (debugging) log(logger, LogType.Debug, ...args); +} diff --git a/src/discord/managers/ChannelManager.ts b/src/discord/managers/ChannelManager.ts new file mode 100644 index 0000000..40e83ec --- /dev/null +++ b/src/discord/managers/ChannelManager.ts @@ -0,0 +1,58 @@ +import { CacheHolder } from '../core/cache'; +import { Client } from '../core/client'; +import { debug, error } from '../core/logger'; +import { IChannelData } from '../structures/channel/BaseChannel'; +import MainChannel from '../structures/channel/MainChannel'; +import { Snowflake } from '../structures/Snowflake'; + +export interface GuildChannelFetchOptions { + guildId: Snowflake; +} + +export interface SingleChannelFetchOptions { + channelId: Snowflake; +} + +export class ChannelManager { + public readonly client: Client; + + public readonly cache: CacheHolder; + + constructor(client: Client) { + this.client = client; + this.cache = new CacheHolder(); + } + + public list(guildId: Snowflake): MainChannel[] { + return this.cache.values().filter((v) => v.guildId === guildId); + } + + public async fetch( + options: SingleChannelFetchOptions | GuildChannelFetchOptions, + ): Promise { + if ('channelId' in options) { + // const opts = options as SingleChannelFetchOptions; + + return null; + } + + const opts = options as GuildChannelFetchOptions; + // Send REST request to discord and cache guilds that are returned + try { + const json = await this.client.restGet( + `/guilds/${opts.guildId}/channels`, + ); + + const array = json as IChannelData[]; + const channelArray = array.map((v) => new MainChannel(this.client, v)); + // channelArray.forEach((channel) => this.cache.set(channel.id, channel)); + + debug('ChannelManager', 'Fetched and cached', array.length, 'channels.'); + + return channelArray; + } catch (e) { + error('ChannelManager', 'Failed to fetch channels:', e); + throw new Error(`Failed to fetch channels: ${e}`); + } + } +} diff --git a/src/discord/managers/GuildManager.ts b/src/discord/managers/GuildManager.ts new file mode 100644 index 0000000..c88e1f2 --- /dev/null +++ b/src/discord/managers/GuildManager.ts @@ -0,0 +1,76 @@ +import { CacheHolder } from '../core/cache'; +import { Client } from '../core/client'; +import { debug, error } from '../core/logger'; +import { Snowflake } from '../structures/Snowflake'; +import { IGuildData } from '../structures/guild/BaseGuild'; +import MainGuild from '../structures/guild/MainGuild'; + +export interface BaseGuildFetchOptions { + skipCache?: boolean; +} + +export interface SingleGuildFetchOptions extends BaseGuildFetchOptions { + id: string; +} + +export interface MultiGuildFetchOptions extends BaseGuildFetchOptions { + lowest: string; + highest: string; +} + +export class GuildManager { + private readonly client: Client; + + public readonly cache: CacheHolder; + + constructor(client: Client) { + this.client = client; + this.cache = new CacheHolder(); + } + + /** + * Fetches one or multiple guilds either from cache or directly from discord + * @param {SingleGuildFetchOptions | MultiGuildFetchOptions} options Options for the fetching, either single or multi + */ + public async fetch( + options: SingleGuildFetchOptions | MultiGuildFetchOptions, + ): Promise { + // Single Guild Fetch + if ('id' in options) { + const opts = options as SingleGuildFetchOptions; + + debug('GuildManager', 'Fetching single guild', opts.id); + + return null; + } + + // Multi Guild Fetch + const opts = options as MultiGuildFetchOptions; + + debug( + 'GuildManager', + 'Fetching multiple guilds from', + opts.lowest, + 'to', + opts.highest, + ); + + // Send REST request to discord and cache guilds that are returned + try { + const json = await this.client.restGet( + `/users/@me/guilds?after=${opts.lowest}&before=${opts.highest}&with_counts=true`, + ); + + const array = json as IGuildData[]; + const guildArray = array.map((v) => new MainGuild(v)); + guildArray.forEach((guild) => this.cache.set(guild.id, guild)); + + debug('GuildManager', 'Fetched and cached', array.length, 'guilds.'); + + return guildArray; + } catch (e) { + error('Failed to fetch guilds:', e); + throw new Error(`Failed to fetch guilds: ${e}`); + } + } +} diff --git a/src/discord/structures/Message.ts b/src/discord/structures/Message.ts new file mode 100644 index 0000000..5fd14a9 --- /dev/null +++ b/src/discord/structures/Message.ts @@ -0,0 +1,9 @@ +import { Snowflake } from './Snowflake'; +import { IUserData } from './user/BaseUser'; + +export interface Message { + content: string; + timestamp: string; + id: Snowflake; + author: IUserData; +} diff --git a/src/discord/structures/Snowflake.ts b/src/discord/structures/Snowflake.ts new file mode 100644 index 0000000..e2626b0 --- /dev/null +++ b/src/discord/structures/Snowflake.ts @@ -0,0 +1,4 @@ +/** + * https://discord.com/developers/docs/reference#snowflakes + */ +export type Snowflake = string; diff --git a/src/discord/structures/channel/BaseChannel.ts b/src/discord/structures/channel/BaseChannel.ts new file mode 100644 index 0000000..583d2ac --- /dev/null +++ b/src/discord/structures/channel/BaseChannel.ts @@ -0,0 +1,116 @@ +import { Message } from '../Message'; +import { Snowflake } from '../Snowflake'; + +/** + * https://discord.com/developers/docs/resources/channel#channel-object-channel-types + */ +export enum ChannelType { + GuildText, + DirectMessage, + GuildVoice, + GroupDM, + GuildCategory, + GuildAnnouncement, + AnnouncementThread, + PublicThread, + PrivateThread, + GuildStageVoice, + GuildDirectory, + GuildForum, + GuildMedia, +} + +/** + * https://discord.com/developers/docs/resources/channel#channel-object-channel-structure + */ +export interface IChannelData { + id: Snowflake; + type: ChannelType; + guild_id?: Snowflake; + position?: number; + name?: string | null; + /** + * 0-4096 characters for GuildForum and GuildMedia, 0-1024 characters for all others + */ + topic?: string | null; + nsfw?: boolean; + last_message_id?: Snowflake | null; + bitrate?: number; + user_limit?: number; + rate_limit_per_user?: number; + icon?: string | null; + owner_id?: Snowflake; + application_id?: Snowflake; + managed?: boolean; + parent_id?: Snowflake | null; + last_pin_timestamp?: string | null; + rtc_region?: string | null; + video_quality_mode?: number; + message_count?: number; + member_count?: number; + default_auto_archive_duration?: number; + permissions?: string; + flags?: number; + total_message_sent?: number; +} + +/* eslint-disable class-methods-use-this */ +export default abstract class BaseChannel { + public readonly id: Snowflake; + + public readonly type: ChannelType; + + public readonly guildId: Snowflake | null; + + public position: number; + + public name: string; + + public parentId: Snowflake | null; + + public messages: Message[]; + + constructor(data: IChannelData, messages?: Message[]) { + this.id = data.id; + this.type = data.type; + this.guildId = data.guild_id ?? null; + this.position = data.position ?? 0; + this.name = data.name ?? ''; + this.parentId = data.parent_id ?? null; + this.messages = messages ?? []; + + this.patch(data); + } + + /** + * Updating the channel by raw data or directly from discord's rest api + * @param data The raw channel data (mostly received by gateway events or rest api responses) + */ + public async patch(data?: IChannelData) { + if (data) { + this.position = data.position ?? 0; + this.name = data.name ?? ''; + this.parentId = data.parent_id ?? null; + } + } + + /** + * Fetches messages from the channel + * @returns Messages from the channel + */ + public abstract fetchMessages(): Promise; + + /** + * Converts the class back into a raw data object + */ + public toRaw(): IChannelData { + return { + id: this.id, + type: this.type, + guild_id: this.guildId ? this.guildId : undefined, + position: this.position, + name: this.name, + parent_id: this.parentId, + }; + } +} diff --git a/src/discord/structures/channel/MainChannel.ts b/src/discord/structures/channel/MainChannel.ts new file mode 100644 index 0000000..6051bc3 --- /dev/null +++ b/src/discord/structures/channel/MainChannel.ts @@ -0,0 +1,47 @@ +import { Client } from '../../core/client'; +import { Message } from '../Message'; +import BaseChannel, { ChannelType, IChannelData } from './BaseChannel'; + +export default class MainChannel extends BaseChannel { + private readonly client: Client; + + private lastFetched: number; + + constructor(client: Client, data: IChannelData, messages?: Message[]) { + super(data, messages); + this.client = client; + this.lastFetched = 0; + } + + public async fetchMessages(): Promise { + if (this.type !== ChannelType.GuildText) return []; + + // Retrieve and cache messages if we were not in the channel for the last 15 minutes + if (Date.now() - this.lastFetched >= 1000 * 60 * 15) { + console.log( + `[Discord/Channel] Updating messages from channel '${this.id}'`, + ); + this.messages = await this.fetchMessagesApi(); + } + + this.lastFetched = Date.now(); + return this.messages; + } + + private async fetchMessagesApi(): Promise { + if (this.type !== ChannelType.GuildText || this.client === null) return []; + + try { + const json = await this.client.restGet( + `/channels/${this.id}/messages?limit=50`, + ); + if (json.message) return []; + return json as Message[]; + } catch (err) { + console.error( + `[Discord/Channel] Failed to retrieve messages from channel '${this.id}': ${err}`, + ); + return []; + } + } +} diff --git a/src/discord/structures/channel/RendererChannel.ts b/src/discord/structures/channel/RendererChannel.ts new file mode 100644 index 0000000..ddb3596 --- /dev/null +++ b/src/discord/structures/channel/RendererChannel.ts @@ -0,0 +1,11 @@ +import { Message } from '../Message'; +import BaseChannel from './BaseChannel'; + +export default class RendererChannel extends BaseChannel { + public async fetchMessages(): Promise { + return window.electron.ipcRenderer.invoke( + 'discord:fetch-messages', + this.id, + ); + } +} diff --git a/src/discord/structures/guild/BaseGuild.ts b/src/discord/structures/guild/BaseGuild.ts new file mode 100644 index 0000000..a700093 --- /dev/null +++ b/src/discord/structures/guild/BaseGuild.ts @@ -0,0 +1,326 @@ +/* eslint-disable no-unused-vars */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { IChannelData } from '../channel/BaseChannel'; +import { Snowflake } from '../Snowflake'; + +/** + * https://discord.com/developers/docs/resources/guild#guild-object-verification-level + */ +export enum DefaultMessageNotificationLevel { + AllMessages, + OnlyMentions, +} + +/** + * https://discord.com/developers/docs/resources/guild#guild-object-verification-level + */ +export enum ExplicitContentFilterLevel { + Disabled, + MembersWithoutRoles, + AllMembers, +} + +/** + * https://discord.com/developers/docs/resources/guild#guild-object-verification-level + */ +export enum VerificationLevel { + /** + * Unrestricted + */ + None, + + /** + * Verified email on account + */ + Low, + + /** + * Member of the server for longer than 5 minutes + */ + Medium, + + /** + * Member of the server for longer than 10 minutes + */ + High, + + /** + * Verified phone number required + */ + VeryHigh, +} + +/** + * https://discord.com/developers/docs/resources/guild#welcome-screen-object + */ +export enum GuildMFALevel { + /** + * No MFA/2FA required for moderation actions + */ + None, + + /** + * 2FA required for moderation actions + */ + Elevated, +} + +/** + * https://discord.com/developers/docs/resources/guild#welcome-screen-object + */ +export enum GuildPremiumTier { + None, + Tier1, + Tier2, + Tier3, +} + +/** + * https://discord.com/developers/docs/resources/guild#unavailable-guild-object + */ +export interface IUnavailableGuild { + id: string; + unavailable: boolean; +} + +/** + * https://discord.com/developers/docs/resources/guild#welcome-screen-object + */ +export interface IWelcomeScreenChannel { + channel_id: Snowflake; + description: string; + emoji_id: Snowflake | null; + emoji_name: string | null; +} + +/** + * https://discord.com/developers/docs/resources/guild#welcome-screen-object + */ +export interface IWelcomeScreen { + description: string | null; + welcome_channels: IWelcomeScreenChannel[]; +} + +/** + * https://discord.com/developers/docs/resources/guild#guild-object-verification-level + */ +export interface IGuildData { + id: string; + name: string; + icon: string | null; + icon_hash?: string | null; + splash: string | null; + discovery_splash: string | null; + owner?: boolean; + owner_id: Snowflake; + permissions?: string; + region?: string | null; + afk_channel_id: Snowflake | null; + afk_timeout: number; + widget_enabled?: boolean; + widget_channel_id?: Snowflake | null; + verification_level: number; + default_message_notifications: DefaultMessageNotificationLevel; + explicit_content_filter: ExplicitContentFilterLevel; + roles: any[]; + emojis: any[]; + /** + * https://discord.com/developers/docs/resources/guild#guild-object-guild-features + */ + features: string[]; + mfa_level: number; + application_id: Snowflake | null; + system_channel_id: Snowflake | null; + system_channel_flags: number; + rules_channel_id: Snowflake | null; + max_presences?: number | null; + max_members?: number; + vanity_url_code: string | null; + description: string | null; + banner: string | null; + premium_tier: GuildPremiumTier; + premium_subscription_count?: number | null; + preferred_locale: string; + public_updates_channel_id: Snowflake | null; + max_video_channel_users?: number; + max_stage_video_channel_users?: number; + approximate_member_count?: number; + approximate_presence_count?: number; + welcome_screen?: IWelcomeScreen; + nsfw_level: number; + stickers?: any[]; + premium_progress_bar_enabled: boolean; + safety_alerts_channel_id: Snowflake | null; + channels?: IChannelData[]; +} + +export abstract class BaseGuild { + public readonly id: Snowflake; + + public name: string; + + public icon: string | null; + + public splash: string | null; + + public discovery_splash: string | null; + + public ownerId: Snowflake; + + public afkChannelId: Snowflake | null; + + public afkTimeout: number; + + public verificationLevel: number; + + public defaultMessageNotifications: DefaultMessageNotificationLevel; + + public explicitContentFilter: ExplicitContentFilterLevel; + + public roles: any[]; + + public emojis: any[]; + + public features: string[]; + + public mfaLevel: number; + + public applicationId: Snowflake | null; + + public systemChannelId: Snowflake | null; + + public systemChannelFlags: number; + + public rulesChannelId: Snowflake | null; + + public vanityUrlCode: string | null; + + public description: string | null; + + public banner: string | null; + + public premiumTier: GuildPremiumTier; + + public preferredLocale: string; + + public publicUpdatesChannelId: Snowflake | null; + + public nsfwLevel: number; + + public premiumProgressBarEnabled: boolean; + + public safetyAlertsChannelId: Snowflake | null; + + constructor(data: IGuildData) { + this.id = data.id; + this.name = data.name; + this.icon = data.icon; + this.splash = data.splash ?? null; + this.discovery_splash = data.discovery_splash ?? null; + this.ownerId = data.owner_id ?? null; + this.afkChannelId = data.afk_channel_id; + this.afkTimeout = data.afk_timeout; + this.verificationLevel = data.verification_level; + this.defaultMessageNotifications = data.default_message_notifications; + this.explicitContentFilter = data.explicit_content_filter; + this.roles = data.roles; + this.emojis = data.emojis; + this.features = data.features; + this.mfaLevel = data.mfa_level; + this.applicationId = data.application_id; + this.systemChannelId = data.system_channel_id; + this.systemChannelFlags = data.system_channel_flags; + this.rulesChannelId = data.rules_channel_id; + this.vanityUrlCode = data.vanity_url_code; + this.description = data.description; + this.banner = data.banner; + this.premiumTier = data.premium_tier; + this.preferredLocale = data.preferred_locale; + this.publicUpdatesChannelId = data.public_updates_channel_id; + this.nsfwLevel = data.nsfw_level; + this.premiumProgressBarEnabled = data.premium_progress_bar_enabled; + this.safetyAlertsChannelId = data.safety_alerts_channel_id; + + this.patch(data); + } + + /** + * Updating the guild by raw data or directly from discord's rest api + * @param data The raw guild data (mostly received by gateway events or rest api responses) + */ + public async patch(data?: IGuildData) { + if (data) { + this.name = data.name; + this.icon = data.icon; + this.splash = data.splash ?? null; + this.discovery_splash = data.discovery_splash ?? null; + this.ownerId = data.owner_id ?? null; + this.afkChannelId = data.afk_channel_id; + this.afkTimeout = data.afk_timeout; + this.verificationLevel = data.verification_level; + this.defaultMessageNotifications = data.default_message_notifications; + this.explicitContentFilter = data.explicit_content_filter; + this.roles = data.roles; + this.emojis = data.emojis; + this.features = data.features; + this.mfaLevel = data.mfa_level; + this.applicationId = data.application_id; + this.systemChannelId = data.system_channel_id; + this.systemChannelFlags = data.system_channel_flags; + this.rulesChannelId = data.rules_channel_id; + this.vanityUrlCode = data.vanity_url_code; + this.description = data.description; + this.banner = data.banner; + this.premiumTier = data.premium_tier; + this.preferredLocale = data.preferred_locale; + this.publicUpdatesChannelId = data.public_updates_channel_id; + this.nsfwLevel = data.nsfw_level; + this.premiumProgressBarEnabled = data.premium_progress_bar_enabled; + this.safetyAlertsChannelId = data.safety_alerts_channel_id; + } + + /* + const json = (await this.client.restGet( + `/guilds/${this.id}?with_counts=true`, + )) as IGuildData; + this.patch(json); + */ + } + + public getIconUrl(): string { + return ''; + } + + public toRaw(): IGuildData { + return { + id: this.id, + name: this.name, + icon: this.icon, + splash: this.splash, + discovery_splash: this.discovery_splash, + owner_id: this.ownerId, + afk_channel_id: this.afkChannelId, + afk_timeout: this.afkTimeout, + verification_level: this.verificationLevel, + default_message_notifications: this.defaultMessageNotifications, + explicit_content_filter: this.explicitContentFilter, + roles: this.roles, + emojis: this.emojis, + features: this.features, + mfa_level: this.mfaLevel, + application_id: this.applicationId, + system_channel_id: this.systemChannelId, + system_channel_flags: this.systemChannelFlags, + rules_channel_id: this.rulesChannelId, + vanity_url_code: this.vanityUrlCode, + description: this.description, + banner: this.banner, + premium_tier: this.premiumTier, + preferred_locale: this.preferredLocale, + public_updates_channel_id: this.publicUpdatesChannelId, + nsfw_level: this.nsfwLevel, + premium_progress_bar_enabled: this.premiumProgressBarEnabled, + safety_alerts_channel_id: this.safetyAlertsChannelId, + }; + } +} diff --git a/src/discord/structures/guild/MainGuild.ts b/src/discord/structures/guild/MainGuild.ts new file mode 100644 index 0000000..a4b38af --- /dev/null +++ b/src/discord/structures/guild/MainGuild.ts @@ -0,0 +1,3 @@ +import { BaseGuild } from './BaseGuild'; + +export default class MainGuild extends BaseGuild {} diff --git a/src/discord/structures/guild/RendererGuild.ts b/src/discord/structures/guild/RendererGuild.ts new file mode 100644 index 0000000..2f265f7 --- /dev/null +++ b/src/discord/structures/guild/RendererGuild.ts @@ -0,0 +1,3 @@ +import { BaseGuild } from './BaseGuild'; + +export default class RendererGuild extends BaseGuild {} diff --git a/src/discord/structures/user/BaseUser.ts b/src/discord/structures/user/BaseUser.ts new file mode 100644 index 0000000..389c3ee --- /dev/null +++ b/src/discord/structures/user/BaseUser.ts @@ -0,0 +1,149 @@ +/* eslint-disable no-unused-vars */ +import { Snowflake } from '../Snowflake'; + +export enum UserType { + User, + Bot, + System, +} + +export enum NitroType { + None, + NitroClassic, + Nitro, + NitroBasic, +} + +export interface IAvatarDecorationData { + asset: string; + expires_at: string | null; + sku_id: Snowflake; +} + +export interface IUserData { + id: Snowflake; + username: string; + discriminator: string; + global_name: string | null; + avatar: string | null; + bot?: boolean; + system?: boolean; + mfa_enabled?: boolean; + banner?: string | null; + accent_color?: number | null; + locale?: string; + verified?: boolean; + email?: string | null; + flags?: number; + premium_type?: NitroType; + public_flags?: number; + avatar_decoration_data?: IAvatarDecorationData | null; +} + +export default class BaseUser { + public readonly id: Snowflake; + + public readonly type: UserType; + + public username: string; + + public globalName: string | null; + + public discriminator: string; + + public avatar: string | null; + + public banner: string | null; + + public accentColor: number | null; + + public mfaEnabled: boolean | null; + + public locale: string | null; + + public verified: boolean | null; + + public email: string | null; + + public flags: number | null; + + public nitroType: NitroType; + + public publicFlags: number | null; + + public avatarDecorationData: IAvatarDecorationData | null; + + constructor(data: IUserData) { + this.id = data.id; + this.username = data.username; + this.discriminator = data.discriminator; + + if (data.system) this.type = UserType.System; + else if (data.bot) this.type = UserType.Bot; + else this.type = UserType.User; + + // Initializing data to default values + this.globalName = null; + this.avatar = null; + this.banner = null; + this.accentColor = null; + this.mfaEnabled = null; + this.locale = null; + this.verified = null; + this.email = null; + this.flags = null; + this.nitroType = NitroType.None; + this.publicFlags = null; + this.avatarDecorationData = null; + + this.patch(data); + } + + /** + * Updating the user by raw data + * @param data The raw user data (mostly received by gateway events or rest api responses) + */ + public patch(data: IUserData) { + this.username = data.username; + this.discriminator = data.discriminator; + this.globalName = data.global_name; + this.avatar = data.avatar; + this.banner = data.banner ?? this.banner; + this.accentColor = data.accent_color ?? this.accentColor; + this.mfaEnabled = data.mfa_enabled ?? this.mfaEnabled; + this.locale = data.locale ?? this.locale; + this.verified = data.verified ?? this.verified; + this.email = data.email ?? this.email; + this.flags = data.flags ?? this.flags; + this.nitroType = data.premium_type ?? this.nitroType; + this.publicFlags = data.public_flags ?? this.publicFlags; + this.avatarDecorationData = + data.avatar_decoration_data ?? this.avatarDecorationData; + } + + public getAvatarUrl(): string { + return `https://cdn.discordapp.com/avatars/${this.id}/${this.avatar}.png`; + } + + public toRaw(): IUserData { + return { + id: this.id, + username: this.username, + discriminator: this.discriminator, + system: this.type === UserType.System, + bot: this.type === UserType.Bot, + global_name: this.globalName, + avatar: this.avatar, + banner: this.banner, + accent_color: this.accentColor, + mfa_enabled: this.mfaEnabled ?? undefined, + locale: this.locale ?? undefined, + verified: this.verified ?? undefined, + email: this.email, + flags: this.flags ?? undefined, + premium_type: this.nitroType, + public_flags: this.publicFlags ?? undefined, + avatar_decoration_data: this.avatarDecorationData, + }; + } +} diff --git a/src/discord/structures/user/MainUser.ts b/src/discord/structures/user/MainUser.ts new file mode 100644 index 0000000..b1f9176 --- /dev/null +++ b/src/discord/structures/user/MainUser.ts @@ -0,0 +1,3 @@ +import BaseUser from './BaseUser'; + +export default class MainUser extends BaseUser {} diff --git a/src/discord/structures/user/RendererUser.ts b/src/discord/structures/user/RendererUser.ts new file mode 100644 index 0000000..32cd9b2 --- /dev/null +++ b/src/discord/structures/user/RendererUser.ts @@ -0,0 +1,3 @@ +import BaseUser from './BaseUser'; + +export default class RendererUser extends BaseUser {} diff --git a/src/discord/ws/gateway.ts b/src/discord/ws/gateway.ts new file mode 100644 index 0000000..535a708 --- /dev/null +++ b/src/discord/ws/gateway.ts @@ -0,0 +1,170 @@ +import { TypedEmitter } from 'tiny-typed-emitter'; +import WebSocket from 'ws'; +import { + GatewayEvents, + GatewayHelloEventData, + GatewayOpcodes, + GatewaySocketEvent, +} from './types'; +import { debug } from '../core/logger'; + +export interface GatewayOptions { + apiVersion: number; + encoding: 'json' | 'etf'; +} + +export class Gateway extends TypedEmitter { + public readonly options: GatewayOptions; + + public socket: WebSocket | null; + + private token: string; + + private sequence: number; + + private heartbeatInterval: number; + + private heartbeat: ReturnType | undefined; + + constructor(options: GatewayOptions) { + super(); + this.options = options; + this.socket = null; + this.token = ''; + this.sequence = 0; + this.heartbeatInterval = 0; + this.heartbeat = undefined; + } + + /** + * Connects to the discord gateway. + */ + public async connect(): Promise { + // Throw error if we are already connected to the gateway + if (this.socket !== null) + throw new Error( + 'Failed to connect to gateway: A connection is already established.', + ); + + // Reset variables from gateway + this.sequence = 0; + this.heartbeatInterval = 0; + + clearTimeout(this.heartbeat); + this.heartbeat = undefined; + + // Create new websocket connection to the discord gateway + this.socket = new WebSocket( + `wss://gateway.discord.gg/?v=${this.options.apiVersion}&encoding=${this.options.encoding}`, + ); + debug('Gateway', 'Connecting to gateway:', this.socket.url); + + this.socket.on('open', () => { + debug('Gateway', 'Successfully opened connection. Identifying now...'); + // We identify ourselfes to discord + this.send({ + op: GatewayOpcodes.Identify, + data: { + token: this.token, + capabilities: 4605, + properties: { + os: 'windows', + browser: 'chrome', + device: 'windows', + }, + }, + }); + }); + + this.socket.on('close', (code, reason) => { + clearTimeout(this.heartbeat); + this.heartbeat = undefined; + throw new Error( + `Failed to connect to gateway: ${code}, ${reason.toString('utf8')}`, + ); + }); + + this.socket.on('message', (data) => { + // Todo: Add support for etf + if (this.options.encoding === 'etf') return; + + // Convert raw data into a readable utf8 string + const message = data.toString('utf8'); + + try { + // Parse message into a json object + const json = JSON.parse(message); + + const event: GatewaySocketEvent = { + op: json.op, + data: json.d, + sequence: json.s, + event: json.t, + }; + + this.sequence = event.sequence ?? this.sequence; + this.handleEvent(event); + } catch (e) { + console.error('Failed to parse gateway message:', e); + } + }); + } + + /** + * Sends a event to the discord gateway. + * @param event The event with data + */ + public send(event: GatewaySocketEvent) { + if (this.socket === null) + throw new Error( + 'Failed to send event to gateway: A connection is non existent.', + ); + + if (this.socket.readyState !== WebSocket.OPEN) + throw new Error('Failed to send event to gateway: Socket is not ready.'); + + debug('Gateway', 'Sending event with opcode', GatewayOpcodes[event.op]); + this.socket.send( + JSON.stringify({ + op: event.op, + d: event.data, + s: event.sequence, + t: event.event, + }), + ); + } + + /** + * Sets the token for identifying. + */ + public setToken(token: string) { + this.token = token; + } + + /** + * Handles a gateway socket event. (includes dispatch events) + */ + private handleEvent(event: GatewaySocketEvent) { + switch (event.op) { + case GatewayOpcodes.Dispatch: + this.emit('dispatch', event); + break; + + case GatewayOpcodes.Hello: { + const data = event.data as GatewayHelloEventData; + this.heartbeatInterval = data.heartbeat_interval; + this.heartbeat = setInterval( + () => + this.send({ + op: GatewayOpcodes.Heartbeat, + data: this.sequence, + }), + this.heartbeatInterval, + ); + break; + } + default: + break; + } + } +} diff --git a/src/discord/ws/types.ts b/src/discord/ws/types.ts new file mode 100644 index 0000000..9cd6dbb --- /dev/null +++ b/src/discord/ws/types.ts @@ -0,0 +1,131 @@ +/* eslint-disable no-unused-vars */ + +import { IGuildData } from '../structures/guild/BaseGuild'; +import { IUserData } from '../structures/user/BaseUser'; + +export enum GatewayOpcodes { + Dispatch, + Heartbeat, + Identify, + Hello = 10, +} + +/** + * https://discord.com/developers/docs/topics/gateway-events#receive-events + */ +export enum GatewayDispatchEvents { + ApplicationCommandPermissionsUpdate = 'APPLICATION_COMMAND_PERMISSIONS_UPDATE', + ChannelCreate = 'CHANNEL_CREATE', + ChannelDelete = 'CHANNEL_DELETE', + ChannelPinsUpdate = 'CHANNEL_PINS_UPDATE', + ChannelUpdate = 'CHANNEL_UPDATE', + GuildBanAdd = 'GUILD_BAN_ADD', + GuildBanRemove = 'GUILD_BAN_REMOVE', + GuildCreate = 'GUILD_CREATE', + GuildDelete = 'GUILD_DELETE', + GuildEmojisUpdate = 'GUILD_EMOJIS_UPDATE', + GuildIntegrationsUpdate = 'GUILD_INTEGRATIONS_UPDATE', + GuildMemberAdd = 'GUILD_MEMBER_ADD', + GuildMemberRemove = 'GUILD_MEMBER_REMOVE', + GuildMembersChunk = 'GUILD_MEMBERS_CHUNK', + GuildMemberUpdate = 'GUILD_MEMBER_UPDATE', + GuildRoleCreate = 'GUILD_ROLE_CREATE', + GuildRoleDelete = 'GUILD_ROLE_DELETE', + GuildRoleUpdate = 'GUILD_ROLE_UPDATE', + GuildStickersUpdate = 'GUILD_STICKERS_UPDATE', + GuildUpdate = 'GUILD_UPDATE', + IntegrationCreate = 'INTEGRATION_CREATE', + IntegrationDelete = 'INTEGRATION_DELETE', + IntegrationUpdate = 'INTEGRATION_UPDATE', + InteractionCreate = 'INTERACTION_CREATE', + InviteCreate = 'INVITE_CREATE', + InviteDelete = 'INVITE_DELETE', + MessageCreate = 'MESSAGE_CREATE', + MessageDelete = 'MESSAGE_DELETE', + MessageDeleteBulk = 'MESSAGE_DELETE_BULK', + MessageReactionAdd = 'MESSAGE_REACTION_ADD', + MessageReactionRemove = 'MESSAGE_REACTION_REMOVE', + MessageReactionRemoveAll = 'MESSAGE_REACTION_REMOVE_ALL', + MessageReactionRemoveEmoji = 'MESSAGE_REACTION_REMOVE_EMOJI', + MessageUpdate = 'MESSAGE_UPDATE', + PresenceUpdate = 'PRESENCE_UPDATE', + StageInstanceCreate = 'STAGE_INSTANCE_CREATE', + StageInstanceDelete = 'STAGE_INSTANCE_DELETE', + StageInstanceUpdate = 'STAGE_INSTANCE_UPDATE', + Ready = 'READY', + Resumed = 'RESUMED', + ThreadCreate = 'THREAD_CREATE', + ThreadDelete = 'THREAD_DELETE', + ThreadListSync = 'THREAD_LIST_SYNC', + ThreadMembersUpdate = 'THREAD_MEMBERS_UPDATE', + ThreadMemberUpdate = 'THREAD_MEMBER_UPDATE', + ThreadUpdate = 'THREAD_UPDATE', + TypingStart = 'TYPING_START', + UserUpdate = 'USER_UPDATE', + VoiceServerUpdate = 'VOICE_SERVER_UPDATE', + VoiceStateUpdate = 'VOICE_STATE_UPDATE', + WebhooksUpdate = 'WEBHOOKS_UPDATE', + MessagePollVoteAdd = 'MESSAGE_POLL_VOTE_ADD', + MessagePollVoteRemove = 'MESSAGE_POLL_VOTE_REMOVE', + GuildScheduledEventCreate = 'GUILD_SCHEDULED_EVENT_CREATE', + GuildScheduledEventUpdate = 'GUILD_SCHEDULED_EVENT_UPDATE', + GuildScheduledEventDelete = 'GUILD_SCHEDULED_EVENT_DELETE', + GuildScheduledEventUserAdd = 'GUILD_SCHEDULED_EVENT_USER_ADD', + GuildScheduledEventUserRemove = 'GUILD_SCHEDULED_EVENT_USER_REMOVE', + AutoModerationRuleCreate = 'AUTO_MODERATION_RULE_CREATE', + AutoModerationRuleUpdate = 'AUTO_MODERATION_RULE_UPDATE', + AutoModerationRuleDelete = 'AUTO_MODERATION_RULE_DELETE', + AutoModerationActionExecution = 'AUTO_MODERATION_ACTION_EXECUTION', + GuildAuditLogEntryCreate = 'GUILD_AUDIT_LOG_ENTRY_CREATE', + EntitlementCreate = 'ENTITLEMENT_CREATE', + EntitlementUpdate = 'ENTITLEMENT_UPDATE', + EntitlementDelete = 'ENTITLEMENT_DELETE', +} + +export interface GatewaySocketEvent { + op: GatewayOpcodes; + data: unknown; + sequence?: number; + event?: GatewayDispatchEvents; +} + +export interface GatewayEvents { + dispatch: (event: GatewaySocketEvent) => void; +} + +/** + * https://discord.com/developers/docs/topics/gateway#sending-heartbeats + */ +export interface GatewayHelloEventData { + heartbeat_interval: number; +} + +/** + * https://discord.com/developers/docs/topics/gateway-events#ready + */ +export interface GatewayReadyDispatchData { + /** + * Api Version + */ + v: number; + + /** + * Yourself as raw data + */ + user: IUserData; + + /** + * Guilds you are in + */ + guilds: IGuildData[]; + + /** + * The session id + */ + session_id: string; + + /** + * The url you'll use when reconnecting + */ + resume_gateway_url: string; +} diff --git a/src/main/app.ts b/src/main/app.ts index 5a592bf..835e706 100644 --- a/src/main/app.ts +++ b/src/main/app.ts @@ -1,10 +1,11 @@ /* eslint-disable global-require */ -import { app, BrowserWindow, ipcMain, Menu, shell, Tray } from 'electron'; +import { app, BrowserWindow, Menu, shell, Tray } from 'electron'; import fs from 'fs'; import path from 'path'; -import { DiscordClient } from '../common/discord/client/client'; -import logger from '../common/logger'; -import GatewayEvent from '../common/discord/gateway/event'; +import logger, { Logger } from '../common/log/logger'; +import { Client } from '../discord/core/client'; +import { GatewaySocketEvent } from '../discord/ws/types'; +import { registerHandler, registerListener } from './ipc'; export default class WaveCordApp { public readonly resourcesPath: string; @@ -20,11 +21,17 @@ export default class WaveCordApp { public token: string = ''; - public discord: DiscordClient | null = null; + public discord: Client; + + public ready: boolean = false; public quitting: boolean = false; + private rendererLogger = new Logger('renderer'); + public constructor() { + this.discord = new Client({ debug: true }); + app.setPath('userData', path.join(app.getPath('appData'), 'WaveCord')); this.resourcesPath = app.isPackaged @@ -32,8 +39,7 @@ export default class WaveCordApp { : path.join(__dirname, '../../assets'); this.instanceLock = app.requestSingleInstanceLock(); - logger.init(); - logger.log('Starting app...'); + logger.info('Starting app...'); if (!this.instanceLock) { this.quit(); @@ -42,18 +48,17 @@ export default class WaveCordApp { this.loadUser(); - logger.log('Connecting to discord...'); - this.discord = new DiscordClient(this.token); - - this.discord.on('connect', () => { - logger.log('Discord is ready.'); - this.discord!.ready = true; + logger.info('Connecting to discord...'); + this.discord.on('ready', () => { + logger.info('Discord is ready'); + this.ready = true; }); - - this.discord.on('event', (event: GatewayEvent) => { - logger.log('Received discord event: ', event.event); + this.discord.on('dispatch', (event: GatewaySocketEvent) => { + logger.info('Received event: ', event.event); }); + this.discord.login(this.token); + app.on('ready', async () => { await this.init(); }); @@ -89,7 +94,7 @@ export default class WaveCordApp { await WaveCordApp.installExtensions(); } - logger.log('Creating new window.'); + logger.info('Creating new window.'); this.window = new BrowserWindow({ show: false, width: 1250, @@ -113,7 +118,7 @@ export default class WaveCordApp { if (!this.window) { throw new Error('"window" is not defined'); } - logger.log('Window ready to be shown.'); + logger.info('Window ready to be shown.'); this.window.show(); }); @@ -142,96 +147,83 @@ export default class WaveCordApp { return { action: 'deny' }; }); - /* Window Ipc */ - ipcMain.on('WINDOW_MINIMIZE', () => { + this.registerIpcs(); + } + + private registerIpcs() { + registerListener('window:minimize', () => { this.window?.minimize(); }); - ipcMain.on('WINDOW_MAXIMIZE', () => { + registerListener('window:maximize', () => { if (this.window === null) return; if (this.window.isMaximized()) this.window.unmaximize(); else this.window.maximize(); }); - /* App Ipc */ - ipcMain.on('APP_EXIT', () => { - this.window?.close(); + registerListener('logger:info', (...args: any[]) => { + this.rendererLogger.info(...args); }); - /* Discord Ipc */ - ipcMain.handle('DISCORD_READY', () => { - return this.discord ? this.discord.ready : false; + registerListener('logger:warn', (...args: any[]) => { + this.rendererLogger.warn(...args); }); - ipcMain.handle('DISCORD_GET_USER', (_, userId: string) => { - if (this.discord === null) return null; + registerListener('logger:error', (...args: any[]) => { + this.rendererLogger.error(...args); + }); - if (userId === '@me') { - return this.discord.selfUser; - } - return null; + registerListener('logger:crit', (...args: any[]) => { + this.rendererLogger.crit(...args); }); - ipcMain.handle('DISCORD_GET_GUILDS', () => { - return this.discord ? this.discord.guilds.getGuilds() : []; + registerListener('logger:debug', (...args: any[]) => { + this.rendererLogger.debug(...args); }); - ipcMain.handle('DISCORD_LOAD_GUILD', (_, id: string) => { - return this.discord - ? this.discord.guilds.getGuilds().find((v) => v.id === id) - : []; + registerListener('app:exit', () => { + this.window?.close(); }); - ipcMain.handle('DISCORD_LOAD_CHANNEL', (_, channelId: string) => { - return this.discord?.getChannel(channelId); + registerHandler('discord:ready', () => { + return this.ready; }); - ipcMain.handle('DISCORD_GET_MESSAGES', (_, channelId: string) => { - const channel = this.discord?.getChannel(channelId); - if (channel === null || channel === undefined) return []; + registerHandler('discord:user', (userId: string | undefined) => { + if (userId === undefined) return this.discord.user?.toRaw(); - return channel.getMessages(this.discord!); + return null; }); - ipcMain.handle( - 'DISCORD_GET_LAST_VISITED_GUILD_CHANNEL', - (_, guildId: string) => { - const guild = this.discord - ? (this.discord.guilds.getGuilds().find((v) => v.id === guildId) ?? - null) - : null; + registerHandler('discord:guilds', () => { + return this.discord.guilds.cache.values().map((v) => v.toRaw()); + }); - if (guild === null) return null; + registerHandler('discord:channels', (guildId: string) => { + return this.discord.channels.list(guildId).map((v) => v.toRaw()); + }); - return this.discord?.getChannel(guild.lastVisitedChannel ?? ''); - }, - ); - - ipcMain.on( - 'DISCORD_SET_LAST_VISITED_GUILD_CHANNEL', - (_, channelId: string) => { - const channel = this.discord - ? this.discord.getChannel(channelId) - : null; - if (channel === null) return; - - const guild = this.discord - ? (this.discord.guilds - .getGuilds() - .find((v) => v.id === channel.guildId) ?? null) - : null; - - if (guild === null) return; - - logger.log('Set last visited channel for guild', guild.id, channel.id); - guild.lastVisitedChannel = channelId; - }, - ); + registerHandler('discord:fetch-guild', (id: string) => { + const guild = this.discord.guilds.cache.get(id); + return guild ? guild.toRaw() : null; + }); + + registerHandler('discord:load-channel', (channelId: string) => { + const channel = this.discord.channels.cache.get(channelId); + return channel ? channel.toRaw() : null; + }); + + registerHandler('discord:fetch-messages', (channelId: string) => { + const channel = this.discord.channels.cache.get(channelId); + if (channel === undefined) return []; + + return channel.fetchMessages(); + }); } private initTray() { - logger.log('Creating new tray.'); + logger.info('Creating new tray.'); this.tray = new Tray(path.join(this.resourcesPath, 'icon.png')); const contextMenu = Menu.buildFromTemplate([ { type: 'separator' }, @@ -259,16 +251,16 @@ export default class WaveCordApp { } private loadUser() { - logger.log('Loading user token...'); + logger.info('Loading user token...'); const filePath = `${app.getPath('userData')}/user`; if (!fs.existsSync(filePath)) { fs.writeFileSync(filePath, '', 'utf8'); logger.warn(`'${filePath}'`, 'file not found! Creating empty one...'); } else { - logger.log(`'${filePath}'`, 'exists! Reading file...'); + logger.info(`'${filePath}'`, 'exists! Reading file...'); this.token = fs.readFileSync(filePath, 'utf8').replaceAll('\n', ''); - logger.log('Successfully loaded user token!'); + logger.info('Successfully loaded user token!'); } } @@ -292,6 +284,6 @@ export default class WaveCordApp { extensions.map((name) => installer[name]), forceDownload, ) - .catch(logger.log); + .catch(logger.info); } } diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 3dc9eac..66216b8 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -1,12 +1,38 @@ +import { ipcMain } from 'electron'; + export type IpcChannels = - | 'WINDOW_MINIMIZE' - | 'WINDOW_MAXIMIZE' - | 'APP_EXIT' - | 'DISCORD_READY' - | 'DISCORD_GET_GUILDS' - | 'DISCORD_LOAD_CHANNEL' - | 'DISCORD_GET_MESSAGES' - | 'DISCORD_LOAD_GUILD' - | 'DISCORD_GET_USER' - | 'DISCORD_GET_LAST_VISITED_GUILD_CHANNEL' - | 'DISCORD_SET_LAST_VISITED_GUILD_CHANNEL'; + | 'logger:info' + | 'logger:warn' + | 'logger:error' + | 'logger:crit' + | 'logger:debug' + | 'window:minimize' + | 'window:maximize' + | 'app:exit' + | 'discord:ready' + | 'discord:guilds' + | 'discord:channels' + | 'discord:load-channel' + | 'discord:fetch-messages' + | 'discord:fetch-guild' + | 'discord:user' + | 'discord:get-last-visited-channel' + | 'discord:set-last-visited-channel'; + +export function registerHandler( + channel: IpcChannels, + func: (...args: any[]) => any, +) { + ipcMain.handle(channel, (_, ...args: any[]) => { + return func(...args); + }); +} + +export function registerListener( + channel: IpcChannels, + func: (...args: any[]) => void, +) { + ipcMain.on(channel, (_, ...args: any[]) => { + func(...args); + }); +} diff --git a/src/main/preload.ts b/src/main/preload.ts index 440878d..583d5b7 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -1,7 +1,8 @@ /* eslint no-unused-vars: off */ import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron'; +import moment from 'moment'; +import picocolors from 'picocolors'; import { IpcChannels } from './ipc'; -import logger from '../common/logger'; const electronHandler = { ipcRenderer: { @@ -26,8 +27,51 @@ const electronHandler = { }, }; +const loggerHandler = { + info(...args: any[]) { + console.log( + picocolors.gray(moment().format('hh:mm:ss')), + '| Info |', + ...args, + ); + ipcRenderer.send('logger:info', ...args); + }, + warn(...args: any[]) { + console.log( + picocolors.gray(moment().format('hh:mm:ss')), + '| Warn |', + ...args, + ); + ipcRenderer.send('logger:warn', ...args); + }, + error(...args: any[]) { + console.log( + picocolors.gray(moment().format('hh:mm:ss')), + '| Error |', + ...args, + ); + ipcRenderer.send('logger:error', ...args); + }, + crit(...args: any[]) { + console.log( + picocolors.gray(moment().format('hh:mm:ss')), + '| Crit |', + ...args, + ); + ipcRenderer.send('logger:crit', ...args); + }, + debug(...args: any[]) { + console.log( + picocolors.gray(moment().format('hh:mm:ss')), + '| Debug |', + ...args, + ); + ipcRenderer.send('logger:debug', ...args); + }, +}; + contextBridge.exposeInMainWorld('electron', electronHandler); -contextBridge.exposeInMainWorld('logger', logger); +contextBridge.exposeInMainWorld('logger', loggerHandler); -export type Logger = typeof logger; +export type LoggerHandler = typeof loggerHandler; export type ElectronHandler = typeof electronHandler; diff --git a/src/renderer/components/Loading/index.tsx b/src/renderer/components/Loading/index.tsx index 98b3a8e..67f33b0 100644 --- a/src/renderer/components/Loading/index.tsx +++ b/src/renderer/components/Loading/index.tsx @@ -9,7 +9,7 @@ export default function Loading() { useEffect(() => { const interval = setInterval(() => { window.electron.ipcRenderer - .invoke('DISCORD_READY') + .invoke('discord:ready') .then((value: boolean) => { const container = document.getElementById('loading__container'); if (container === null) return false; diff --git a/src/renderer/components/Serverbar/index.tsx b/src/renderer/components/Serverbar/index.tsx index 613f01c..eeae060 100644 --- a/src/renderer/components/Serverbar/index.tsx +++ b/src/renderer/components/Serverbar/index.tsx @@ -1,31 +1,32 @@ import { useEffect, useState } from 'react'; import './Serverbar.css'; import { Link, useLocation } from 'react-router-dom'; -import { Guild } from '../../../common/discord/guild'; +import RendererGuild from '../../../discord/structures/guild/RendererGuild'; +import { IGuildData } from '../../../discord/structures/guild/BaseGuild'; export default function Serverbar() { // States - const [guilds, setGuilds] = useState([]); + const [guilds, setGuilds] = useState([]); const location = useLocation(); useEffect(() => { const interval = setInterval(async () => { const ready = await window.electron.ipcRenderer - .invoke('DISCORD_READY') + .invoke('discord:ready') .catch((err) => window.logger.error(err)); if (!ready) return; - const data: Guild[] = await window.electron.ipcRenderer - .invoke('DISCORD_GET_GUILDS') + const data: IGuildData[] = await window.electron.ipcRenderer + .invoke('discord:guilds') .catch((err) => window.logger.error(err)); - window.logger.log( + window.logger.info( 'Received guilds for server list:', data.map((v) => `${v.id}`).join(','), ); - setGuilds(data); + setGuilds(data.map((v) => new RendererGuild(v))); clearInterval(interval); }, 1000); return () => { diff --git a/src/renderer/components/Titlebar/index.tsx b/src/renderer/components/Titlebar/index.tsx index 002239f..47d61b2 100644 --- a/src/renderer/components/Titlebar/index.tsx +++ b/src/renderer/components/Titlebar/index.tsx @@ -5,15 +5,15 @@ import './Titlebar.css'; export default function Titlebar() { const handleMinimize = () => { - window.electron.ipcRenderer.sendMessage('WINDOW_MINIMIZE'); + window.electron.ipcRenderer.sendMessage('window:minimize'); }; const handleMaximize = () => { - window.electron.ipcRenderer.sendMessage('WINDOW_MAXIMIZE'); + window.electron.ipcRenderer.sendMessage('window:maximize'); }; const handleClose = () => { - window.electron.ipcRenderer.sendMessage('APP_EXIT'); + window.electron.ipcRenderer.sendMessage('app:exit'); }; return ( diff --git a/src/renderer/components/Topbar/index.tsx b/src/renderer/components/Topbar/index.tsx index 880968b..56db955 100644 --- a/src/renderer/components/Topbar/index.tsx +++ b/src/renderer/components/Topbar/index.tsx @@ -1,5 +1,5 @@ import './Topbar.css'; export default function Topbar() { - return
+ return
; } diff --git a/src/renderer/components/UserPanel/index.tsx b/src/renderer/components/UserPanel/index.tsx index 1021a19..cc75f09 100644 --- a/src/renderer/components/UserPanel/index.tsx +++ b/src/renderer/components/UserPanel/index.tsx @@ -1,15 +1,16 @@ import { useEffect, useState } from 'react'; import './UserPanel.css'; -import { User } from '../../../common/discord/user'; +import RendererUser from '../../../discord/structures/user/RendererUser'; +import { IUserData } from '../../../discord/structures/user/BaseUser'; export default function UserPanel() { - const [user, setUser] = useState(null); + const [user, setUser] = useState(null); useEffect(() => { window.electron.ipcRenderer - .invoke('DISCORD_GET_USER', '@me') - .then((data) => { - setUser(data); + .invoke('discord:user') + .then((data: IUserData) => { + setUser(new RendererUser(data)); return true; }) .catch((err) => window.logger.error(err)); @@ -27,7 +28,7 @@ export default function UserPanel() {

{user.globalName}

diff --git a/src/renderer/pages/Guild/Channel/index.tsx b/src/renderer/pages/Guild/Channel/index.tsx index 823cec7..d860ebb 100644 --- a/src/renderer/pages/Guild/Channel/index.tsx +++ b/src/renderer/pages/Guild/Channel/index.tsx @@ -1,43 +1,36 @@ import { useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; -import Channel, { ChannelMessage } from '../../../../common/discord/channel'; import './Channel.css'; +import RendererChannel from '../../../../discord/structures/channel/RendererChannel'; +import { IChannelData } from '../../../../discord/structures/channel/BaseChannel'; +import { Message } from '../../../../discord/structures/Message'; export default function ChannelPage() { const params = useParams(); const channelId = params.channelId ?? ''; - const [channel, setChannel] = useState(null); - const [messages, setMessages] = useState([]); + const [channel, setChannel] = useState(null); + const [messages, setMessages] = useState([]); useEffect(() => { - if (channelId.length === 0) return; - const messageList = document.getElementById('channel_page_message_list'); if (messageList) { messageList.scrollTop = messageList.scrollHeight; } window.electron.ipcRenderer - .invoke('DISCORD_LOAD_CHANNEL', channelId) - .then(async (data: Channel | null) => { - setChannel(data); - - if (data) - setMessages( - await window.electron.ipcRenderer.invoke( - 'DISCORD_GET_MESSAGES', - channelId, - ), - ); - + .invoke('discord:load-channel', channelId) + .then(async (data: IChannelData) => { + const chn = new RendererChannel(data); + const msgs = await chn.fetchMessages(); + setChannel(chn); + setMessages(msgs); return true; }) .catch((err) => console.error(err)); - window.electron.ipcRenderer.sendMessage( - 'DISCORD_SET_LAST_VISITED_GUILD_CHANNEL', - channelId, - ); + return () => { + setMessages([]); + }; }, [channelId]); if (channelId.length === 0 || channel === null) return null; diff --git a/src/renderer/pages/Guild/Guild.css b/src/renderer/pages/Guild/Guild.css index 8279700..a515e04 100644 --- a/src/renderer/pages/Guild/Guild.css +++ b/src/renderer/pages/Guild/Guild.css @@ -26,7 +26,6 @@ } .guild_page__content { - width: calc(100% - 270px - var(--guild-page--sidebar-width)); background: var(--background2); height: 100%; } diff --git a/src/renderer/pages/Guild/index.tsx b/src/renderer/pages/Guild/index.tsx index 333e5a2..fbecc30 100644 --- a/src/renderer/pages/Guild/index.tsx +++ b/src/renderer/pages/Guild/index.tsx @@ -15,11 +15,16 @@ import StageChannelIcon from '../../../../assets/app/icons/channel/radio.svg'; import CategoryIcon from '../../../../assets/app/icons/channel/arrow-down.svg'; import './Guild.css'; -import { Guild } from '../../../common/discord/guild'; -import Channel, { ChannelType } from '../../../common/discord/channel'; import Topbar from '../../components/Topbar'; import { sortChannels } from '../../utils/channelUtils'; import UserPanel from '../../components/UserPanel'; +import RendererChannel from '../../../discord/structures/channel/RendererChannel'; +import { + ChannelType, + IChannelData, +} from '../../../discord/structures/channel/BaseChannel'; +import RendererGuild from '../../../discord/structures/guild/RendererGuild'; +import { IGuildData } from '../../../discord/structures/guild/BaseGuild'; function ChannelWrapper({ children, @@ -28,7 +33,7 @@ function ChannelWrapper({ }: { children: ReactNode[]; isCurrent: boolean; - channel: Channel; + channel: RendererChannel; }) { const classes = `guild_page__channel ${isCurrent ? 'guild_page__current_channel' : ''}`; @@ -61,26 +66,28 @@ export default function GuildPage() { const guildId = params.id ?? ''; // States - const [guild, setGuild] = useState(undefined); + const [guild, setGuild] = useState( + undefined, + ); + const [channels, setChannels] = useState([]); + const memberListEnabled = false; useEffect(() => { if (guild === undefined || (guild && guild.id !== guildId)) { // Load guild if guild id is not invalid if (guildId.length !== 0) { window.electron.ipcRenderer - .invoke('DISCORD_LOAD_GUILD', guildId) - .then((data) => { - setGuild(data); + .invoke('discord:fetch-guild', guildId) + .then((data: IGuildData) => { + setGuild(new RendererGuild(data)); return true; }) .catch((err) => window.logger.error(err)); window.electron.ipcRenderer - .invoke('DISCORD_GET_LAST_VISITED_GUILD_CHANNEL', guildId) - .then((channel: Channel | null) => { - if (channel !== null) { - navigate(`/guild/${guildId}/channel/${channel.id}`); - } + .invoke('discord:channels', guildId) + .then((data: IChannelData[]) => { + setChannels(data.map((v) => new RendererChannel(v))); return true; }) .catch((err) => window.logger.error(err)); @@ -98,7 +105,7 @@ export default function GuildPage() { if (guild === null) return

Server does not exist or you are not in this server!

; - const sorted = sortChannels(guild ? guild.channels : []); + const sorted = sortChannels(channels); return (
@@ -165,18 +172,15 @@ export default function GuildPage() {
-
+
-
- {guild?.members.map((member) => { - return ( -
- {member.user_id} -
- ); - })} -
+ {memberListEnabled &&
}
); diff --git a/src/renderer/preload.d.ts b/src/renderer/preload.d.ts index c2bc6cc..6943f33 100644 --- a/src/renderer/preload.d.ts +++ b/src/renderer/preload.d.ts @@ -1,10 +1,10 @@ -import { ElectronHandler, Logger } from '../main/preload'; +import { ElectronHandler, LoggerHandler } from '../main/preload'; declare global { // eslint-disable-next-line no-unused-vars interface Window { electron: ElectronHandler; - logger: Logger; + logger: LoggerHandler; } } diff --git a/src/renderer/utils/channelUtils.ts b/src/renderer/utils/channelUtils.ts index eb51f9f..4befa39 100644 --- a/src/renderer/utils/channelUtils.ts +++ b/src/renderer/utils/channelUtils.ts @@ -1,17 +1,18 @@ /* eslint-disable import/prefer-default-export */ -import Channel from '../../common/discord/channel'; -export function sortChannels(channels: Channel[]): Channel[] { +import RendererChannel from '../../discord/structures/channel/RendererChannel'; + +export function sortChannels(channels: RendererChannel[]): RendererChannel[] { let pairs: { - category: Channel | null; - channels: Channel[]; + category: RendererChannel | null; + channels: RendererChannel[]; }[] = [ { category: null, channels: [], }, ]; - const waitLine: Channel[] = []; + const waitLine: RendererChannel[] = []; // Loop through all channels for (let i = 0; i < channels.length; i += 1) {