This commit is contained in:
Skillz4Killz
2023-06-06 16:28:56 +00:00
15 changed files with 120 additions and 85 deletions
+38 -22
View File
@@ -3,16 +3,16 @@ name: Rest proxy
on:
push:
branches:
- "main"
- 'main'
paths:
- ".github/workflows/rest-proxy.yml"
- "proxies/rest/**"
- '.github/workflows/rest-proxy.yml'
- 'proxies/rest/**'
pull_request:
paths:
- ".github/workflows/rest-proxy.yml"
- "proxies/rest/**"
- '.github/workflows/rest-proxy.yml'
- 'proxies/rest/**'
schedule:
- cron: "0 0 * * *"
- cron: '0 0 * * *'
jobs:
build:
@@ -59,6 +59,7 @@ jobs:
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Download artifact
@@ -71,27 +72,42 @@ jobs:
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
image-ref: "discordeno/rest-proxy:latest"
format: "table"
exit-code: "0"
image-ref: 'discordeno/rest-proxy:latest'
format: 'table'
exit-code: '0'
ignore-unfixed: true
vuln-type: "os,library"
severity: "UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL"
vuln-type: 'os,library'
severity: 'UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL'
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
if: ${{ github.event_name == 'schedule' }}
if: ${{ github.event_name == 'schedule' || github.event_name == 'push' }}
with:
image-ref: "discordeno/rest-proxy:latest"
exit-code: "0"
vuln-type: "os,library"
severity: "UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL"
format: "sarif"
output: "trivy-results.sarif"
image-ref: 'discordeno/rest-proxy:latest'
exit-code: '0'
vuln-type: 'os,library'
severity: 'UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL'
format: 'sarif'
output: 'trivy-results.sarif'
- name: Upload Trivy scan results to GitHub Security tab
uses: github/codeql-action/upload-sarif@v2
if: ${{ github.event_name == 'schedule' }}
if: ${{ github.event_name == 'schedule' || github.event_name == 'push' }}
with:
sarif_file: "trivy-results.sarif"
sarif_file: 'trivy-results.sarif'
- name: Run Snyk to check Docker image for vulnerabilities
if: ${{ github.event_name == 'schedule' || github.event_name == 'push' }}
continue-on-error: true
uses: snyk/actions/docker@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
with:
image: 'discordeno/rest-proxy:latest'
args: --file=proxies/rest/Dockerfile
- name: Upload result to GitHub Code Scanning
if: ${{ github.event_name == 'schedule' || github.event_name == 'push' }}
uses: github/codeql-action/upload-sarif@v2
with:
sarif_file: snyk.sarif
build-all-arch:
name: Build image for all architectures
@@ -117,7 +133,7 @@ jobs:
with:
context: proxies/rest
push: false
tags: "discordeno/rest-proxy:latest"
tags: 'discordeno/rest-proxy:latest'
# linux/s390x stuck at yarn install, remove it for now
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8,linux/ppc64le
target: runner
@@ -157,7 +173,7 @@ jobs:
with:
context: proxies/rest
push: true
tags: "ghcr.io/discordeno/rest-proxy:latest"
tags: 'ghcr.io/discordeno/rest-proxy:latest'
# linux/s390x stuck at yarn install, remove it for now
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8,linux/ppc64le
target: runner
+4 -1
View File
@@ -1,4 +1,4 @@
import type { ButtonStyles, MessageComponentTypes, SelectOption, TextStyles } from '@discordeno/types'
import type { ButtonStyles, ChannelTypes, MessageComponentTypes, SelectOption, TextStyles } from '@discordeno/types'
import type { Bot } from '../index.js'
import type { DiscordComponent } from '../typings.js'
@@ -17,6 +17,7 @@ export function transformComponent(bot: Bot, payload: DiscordComponent): Compone
}
: undefined,
url: payload.url,
channelTypes: payload.channel_types,
options: payload.options?.map((option) => ({
label: option.label,
value: option.value,
@@ -68,6 +69,8 @@ export interface Component {
}
/** optional url for link-style buttons that can navigate a user to the web. Only type 5 Link buttons can have a url */
url?: string
/** List of channel types to include in a channel select menu options list */
channelTypes?: ChannelTypes[]
/** The choices! Maximum of 25 items. */
options?: SelectOption[]
/** A custom placeholder text if nothing is selected. Maximum 150 characters. */
+1 -2
View File
@@ -60,7 +60,7 @@ export function transformGuild(bot: Bot, payload: { guild: DiscordGuild } & { sh
banner: payload.guild.banner ? iconHashToBigInt(payload.guild.banner) : undefined,
splash: payload.guild.splash ? iconHashToBigInt(payload.guild.splash) : undefined,
channels: new Collection(
payload.guild.channels?.map((channel) => {
[...(payload.guild.channels ?? []), ...(payload.guild.threads ?? [])].map((channel) => {
const result = bot.transformers.channel(bot, { channel, guildId })
return [result.id, result]
}),
@@ -86,7 +86,6 @@ export function transformGuild(bot: Bot, payload: { guild: DiscordGuild } & { sh
voiceStates: new Collection(
(payload.guild.voice_states ?? []).map((vs) => bot.transformers.voiceState(bot, { voiceState: vs, guildId })).map((vs) => [vs.userId, vs]),
),
id: guildId,
// WEIRD EDGE CASE WITH BOT CREATED SERVERS
ownerId: payload.guild.owner_id ? bot.transformers.snowflake(payload.guild.owner_id) : 0n,
+21 -26
View File
@@ -23,10 +23,8 @@ import type { User } from './user.js'
export interface Interaction extends BaseInteraction {
/** The bot object */
bot: Bot
/** Whether or not this interaction has been replied to. */
/** Whether or not this interaction has been responded to. */
acknowledged: boolean
/** Whether or not a modal has been shown for this interaction. */
shownModal: boolean
/** Id of the interaction */
id: bigint
/** Id of the application this interaction is for */
@@ -84,41 +82,36 @@ const baseInteraction: Partial<Interaction> & BaseInteraction = {
async respond(response, options) {
let type = InteractionResponseTypes.ChannelMessageWithSource
// If user provides a string, change it to response object
if (typeof response === 'string') {
response = {
content: response,
}
}
// If user provides a string, change it to a response object
if (typeof response === 'string') response = { content: response }
// If user provides an object, determine if it should be an autocomplete or a modal response
else {
if (response.title) type = InteractionResponseTypes.Modal
else if (this.type === InteractionTypes.ApplicationCommandAutocomplete) type = InteractionResponseTypes.ApplicationCommandAutocompleteResult
}
else if (response.title) type = InteractionResponseTypes.Modal
else if (this.type === InteractionTypes.ApplicationCommandAutocomplete) type = InteractionResponseTypes.ApplicationCommandAutocompleteResult
// If user wants to send a private message
if (type === InteractionResponseTypes.ChannelMessageWithSource && options?.isPrivate) response.flags = 64
// Since this has already been given a response, any further responses must be followups.
if (this.acknowledged) return await this.bot?.rest.sendFollowupMessage(this.token!, response)
if (this.shownModal && type === InteractionResponseTypes.Modal) throw new Error('Cannot respond to a modal interaction with another modal.')
// Modals cannot be chained
if (this.type === InteractionTypes.ModalSubmit && type === InteractionResponseTypes.Modal)
throw new Error('Cannot respond to a modal interaction with another modal.')
// Autocomplete response can only be used for autocomplete interactions
if (this.type === InteractionTypes.ApplicationCommandAutocomplete && type !== InteractionResponseTypes.ApplicationCommandAutocompleteResult)
throw new Error('Cannot respond to an autocomplete interaction with a modal or message.')
// If user has not already responded to this interaction we need to send an original response
if (type === InteractionResponseTypes.Modal) this.shownModal = true
if (type === InteractionResponseTypes.ChannelMessageWithSource) this.acknowledged = true
this.acknowledged = true
return await this.bot?.rest.sendInteractionResponse(this.id!, this.token!, { type, data: response })
},
async edit(response) {
if (this.type === InteractionTypes.ApplicationCommandAutocomplete) throw new Error('Cannot edit an autocomplete interaction')
// If user provides a string, change it to response object
if (typeof response === 'string') {
response = {
content: response,
}
}
// If user provides a string, change it to a response object
if (typeof response === 'string') response = { content: response }
return await this.bot!.rest.editOriginalInteractionResponse(this.token!, response)
},
@@ -128,14 +121,16 @@ const baseInteraction: Partial<Interaction> & BaseInteraction = {
if (this.acknowledged) throw new Error('Cannot defer an already responded interaction')
// Determine the type of defer response
let type: InteractionResponseTypes
if (this.type === InteractionTypes.MessageComponent) type = InteractionResponseTypes.DeferredUpdateMessage
else type = InteractionResponseTypes.DeferredChannelMessageWithSource
const type =
this.type === InteractionTypes.MessageComponent
? InteractionResponseTypes.DeferredUpdateMessage
: InteractionResponseTypes.DeferredChannelMessageWithSource
// If user wants to send a private message
const data: InteractionCallbackData = {}
if (isPrivate) data.flags = 64
this.acknowledged = true
return await this.bot?.rest.sendInteractionResponse(this.id!, this.token!, { type, data })
},
@@ -18,6 +18,7 @@ export function transformComponentToDiscordComponent(bot: Bot, payload: Componen
}
: undefined,
url: payload.url,
channel_types: payload.channelTypes,
options: payload.options?.map((option) => ({
label: option.label,
value: option.value,
+6 -3
View File
@@ -2,6 +2,7 @@ import {
ApplicationCommandTypes,
type AllowedMentions,
type ButtonStyles,
type ChannelTypes,
type CreateApplicationCommand,
type CreateContextApplicationCommand,
type DiscordAllowedMentions,
@@ -16,13 +17,13 @@ import {
type DiscordUser,
type FileContent,
type InteractionResponseTypes,
type MessageComponents,
type MessageComponentTypes,
type MessageComponents,
type TextStyles,
} from '@discordeno/types'
import type * as handlers from './handlers/index.js'
import type { Embed } from './transformers/embed.js'
import type { ApplicationCommandOptionChoice } from './transformers/applicationCommandOptionChoice.js'
import type { Embed } from './transformers/embed.js'
export function isContextApplicationCommand(command: CreateApplicationCommand): command is CreateContextApplicationCommand {
return command.type === ApplicationCommandTypes.Message || command.type === ApplicationCommandTypes.User
@@ -69,6 +70,8 @@ export interface DiscordComponent {
}
/** optional url for link-style buttons that can navigate a user to the web. Only type 5 Link buttons can have a url */
url?: string
/** List of channel types to include in a channel select menu options list */
channel_types?: ChannelTypes[]
/** The choices! Maximum of 25 items. */
options?: DiscordSelectOption[]
/** A custom placeholder text if nothing is selected. Maximum 150 characters. */
@@ -205,7 +208,7 @@ export interface BotGatewayHandlerOptions {
export enum MessageFlags {
/** Whether this message has been published to subscribed channels (via Channel Following) */
Crossposted = 1 << 0,
Crossposted = 1 << 0,
/** Whether this message originated from a message in another channel (via Channel Following) */
IsCrosspost = 1 << 1,
/** Whether do not include any embeds when serializing this message */
+12 -10
View File
@@ -12,11 +12,13 @@ import type {
import { GatewayCloseEventCodes, GatewayIntents, GatewayOpcodes } from '@discordeno/types'
import { Collection, LeakyBucket, camelize, delay, logger } from '@discordeno/utils'
import { inflateSync } from 'node:zlib'
import WebSocket from 'ws'
import NodeWebSocket from 'ws'
import type { RequestMemberRequest } from './manager.js'
import type { BotStatusUpdate, ShardEvents, ShardGatewayConfig, ShardHeart, ShardSocketRequest, StatusUpdate, UpdateVoiceState } from './types.js'
import { ShardSocketCloseCodes, ShardState } from './types.js'
declare let WebSocket: any
export class DiscordenoShard {
/** The id of the shard */
id: number
@@ -33,7 +35,7 @@ export class DiscordenoShard {
/** Current session id of the shard if present. */
sessionId?: string
/** This contains the WebSocket connection to Discord, if currently connected. */
socket?: WebSocket
socket?: NodeWebSocket
/** Current internal state of the this. */
state = ShardState.Offline
/** The url provided by discord to use when resuming a connection for this this. */
@@ -111,7 +113,7 @@ export class DiscordenoShard {
/** Close the socket connection to discord if present. */
close(code: number, reason: string): void {
if (this.socket?.readyState !== WebSocket.OPEN) return
if (this.socket?.readyState !== NodeWebSocket.OPEN) return
this.socket?.close(code, reason)
}
@@ -129,13 +131,13 @@ export class DiscordenoShard {
url.searchParams.set('v', this.gatewayConfig.version.toString())
url.searchParams.set('encoding', 'json')
const socket = new WebSocket(url.toString())
const socket: NodeWebSocket = process?.versions !== undefined ? new NodeWebSocket(url.toString()) : new WebSocket(url.toString())
this.socket = socket
// TODO: proper event handling
socket.onerror = (event) => console.log({ error: event, shardId: this.id })
socket.onclose = async (event) => await this.handleClose(event)
socket.onmessage = async (message) => await this.handleMessage(message)
socket.onerror = (event: NodeWebSocket.ErrorEvent) => console.log({ error: event, shardId: this.id })
socket.onclose = async (event: NodeWebSocket.CloseEvent) => await this.handleClose(event)
socket.onmessage = async (message: NodeWebSocket.MessageEvent) => await this.handleMessage(message)
return await new Promise((resolve) => {
socket.onopen = () => {
@@ -204,7 +206,7 @@ export class DiscordenoShard {
/** Check whether the connection to Discord is currently open. */
isOpen(): boolean {
return this.socket?.readyState === WebSocket.OPEN
return this.socket?.readyState === NodeWebSocket.OPEN
}
/** Attempt to resume the previous shards session with the gateway. */
@@ -282,7 +284,7 @@ export class DiscordenoShard {
}
/** Handle a gateway connection close. */
async handleClose(close: WebSocket.CloseEvent): Promise<void> {
async handleClose(close: NodeWebSocket.CloseEvent): Promise<void> {
// gateway.debug("GW CLOSED", { shardId, payload: event });
this.stopHeartbeating()
@@ -485,7 +487,7 @@ export class DiscordenoShard {
}
/** Handle an incoming gateway message. */
async handleMessage(message: WebSocket.MessageEvent): Promise<void> {
async handleMessage(message: NodeWebSocket.MessageEvent): Promise<void> {
let preProcessMessage = message.data
// If message compression is enabled,
+1 -1
View File
@@ -32,7 +32,7 @@ export function createGatewayManager(options: CreateGatewayManagerOptions): Gate
version: options.version ?? 10,
connection: options.connection,
totalShards: options.totalShards ?? options.connection.shards ?? 1,
lastShardId: options.lastShardId ?? 0,
lastShardId: options.lastShardId ?? (options.totalShards ? options.totalShards - 1 : (options.connection ? options.connection.shards - 1 : 0)),
firstShardId: options.firstShardId ?? 0,
totalWorkers: options.totalWorkers ?? 4,
shardsPerWorker: options.shardsPerWorker ?? 25,
+21 -10
View File
@@ -118,18 +118,29 @@ export function createRestManager(options: CreateRestManagerOptions): RestManage
const newObj: any = {}
for (const key of Object.keys(obj)) {
// Keys that dont require snake casing
if (['permissions', 'allow', 'deny'].includes(key) && obj[key] !== undefined) {
newObj[key] = calculateBits(obj[key])
continue
const value = obj[key]
// Some falsy values should be allowed like null or 0
if (value !== undefined) {
switch (key) {
case 'permissions':
case 'allow':
case 'deny':
newObj[key] = calculateBits(value)
continue
case 'defaultMemberPermissions':
newObj.default_member_permissions = calculateBits(value)
continue
case 'nameLocalizations':
newObj.name_localizations = value
continue
case 'descriptionLocalizations':
newObj.description_localizations = value
continue
}
}
if (key === 'defaultMemberPermissions' && obj[key] !== undefined) {
newObj.default_member_permissions = calculateBits(obj[key])
continue
}
newObj[camelToSnakeCase(key)] = rest.changeToDiscordFormat(obj[key])
newObj[camelToSnakeCase(key)] = rest.changeToDiscordFormat(value)
}
return newObj
+2
View File
@@ -1134,6 +1134,8 @@ export interface DiscordSelectMenuComponent {
min_values?: number
/** The maximum number of items that can be selected. Default 1. Between 1-25. */
max_values?: number
/** List of channel types to include in a channel select menu options list */
channelTypes?: ChannelTypes[]
/** The choices! Maximum of 25 items. */
options: DiscordSelectOption[]
}
+2
View File
@@ -195,6 +195,8 @@ export interface SelectMenuChannelsComponent {
minValues?: number
/** The maximum number of items that can be selected. Default 1. Between 1-25. */
maxValues?: number
/** List of channel types to include in the options list */
channelTypes?: ChannelTypes[]
/** Whether or not this select is disabled */
disabled?: boolean
}
+5 -5
View File
@@ -1,4 +1,4 @@
# we node 18 alpine 3.17 as base image
# we node 18 alpine 3.18 as base image
# we use multi stage build in this file
# deps: contain all dependencies including dev dependencies
# builder: contains all compiled files
@@ -6,7 +6,7 @@
# runner: the final image, with only the dependencies and compiled files
# build only with the platform of the host machine, since it only uses for dev purposes
FROM --platform=$BUILDPLATFORM node:18.15.0-alpine3.17 AS deps
FROM --platform=$BUILDPLATFORM node:18.16.0-alpine3.18 AS deps
WORKDIR /app
# copy necessary for install dependencies
COPY package.json yarn.lock ./
@@ -14,7 +14,7 @@ COPY package.json yarn.lock ./
RUN yarn install
# build only with the platform of the host machine, since we just need its files
FROM --platform=$BUILDPLATFORM node:18.15.0-alpine3.17 AS builder
FROM --platform=$BUILDPLATFORM node:18.16.0-alpine3.18 AS builder
# copy the dependencies (node_modules) from the deps image
COPY --from=deps /app /app
WORKDIR /app
@@ -25,7 +25,7 @@ COPY .swcrc ./
# compile the files
RUN yarn build
FROM node:18.15.0-alpine3.17 AS prod-deps
FROM node:18.16.0-alpine3.18 AS prod-deps
WORKDIR /app
# copy necessary files for install dependencies
COPY package.json yarn.lock .yarnrc.yml ./
@@ -35,7 +35,7 @@ RUN yarn plugin import workspace-tools
# install prod dependencies
RUN yarn workspaces focus --all --production
FROM node:18.15.0-alpine3.17 AS runner
FROM node:18.16.0-alpine3.18 AS runner
# copy the compiled files from the builder image
COPY --from=builder /app/dist /app/dist
# copy the prod dependencies (node_modules) from the prod-deps image
+2 -2
View File
@@ -64,8 +64,8 @@ app.all('/*', async (req, res) => {
try {
const result = await REST.makeRequest(
req.method,
`${REST.baseUrl}${req.url}`,
req.body
req.url.substring(4),
{ body: req.method !== 'DELETE' && req.method !== 'GET' ? {} : req.body }
)
if (result) {
+3 -2
View File
@@ -58,7 +58,7 @@ export const GATEWAY = createGatewayManager({
connection: await REST.getSessionInfo(),
})
// More code to be added here but first you need to understand this part.
GATEWAY.spawnShards()
```
Now let's break it down.
@@ -149,6 +149,7 @@ GATEWAY.tellWorkerToIdentify = async function (workerId, shardId, bucketId) {
method: 'POST',
headers: {
authorization: process.env.AUTHORIZATION,
"Content-type": "application/json",
},
body: JSON.stringify({ type: 'IDENTIFY_SHARD', shardId }),
})
@@ -156,7 +157,7 @@ GATEWAY.tellWorkerToIdentify = async function (workerId, shardId, bucketId) {
.catch(logger.error)
}
// More code to be added here but first you need to understand this part.
GATEWAY.spawnShards()
```
Here, we are overriding the built in method on the gateway manager called `tellWorkerToIdentify`. Internally, this function just simply starts a new shard as by default the lib supports small bots. For our case, we are going to make it get the server url from a `.env` file
+1 -1
View File
@@ -114,7 +114,7 @@ try {
// OPTIONAL: Runs the raw event handler if you need it
bot.events.raw(bot, req.body.payload, req.body.shardId);
// Runs the event handler if available
if (message.t) bot.events.[snakeToCamelCase(message.t)]?.(req.body.payload, req.body.shardId);
if (message.t) bot.events.[snakeToCamelCase(message.t.toLowerCase())]?.(req.body.payload, req.body.shardId);
res.status(200).json({ success: true })
}