cordo 2.5.2 → 2.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,97 +1,23 @@
1
1
  # Cordo
2
2
 
3
- Cordo is a custom api wrapper built for interactions first. It functions as an addon on top of discord.js. Full documentation is yet to be written
3
+ Cordo is a developer-experience-first Discord App and UI library for TypeScript.
4
4
 
5
- Originally built for the FreeStuff Bot, this codebase could find various usecases so we decided to outsource it. Here's an [Example bot](https://github.com/Maanex/cordo-example-bot) written with it.
5
+ Cordo is built for and used in production with over 700k discord servers at https://freestuffbot.xyz/.
6
6
 
7
- Or if you prefer real-life examples check out [FreeStuff Bot](https://github.com/FreeStuffBot/discord/tree/master/src/bot) or [Tudebot](https://github.com/Maanex/tudebot4/tree/master/src/cordo).
8
7
 
9
- Use `npm i cordo` or `yarn add cordo` to install. Types are included.
8
+ ## Concepts
10
9
 
10
+ Cordo is taking inspiration from modern web-frameworks with concepts like **File Based Routing**, **Composables**, and **Error Boundaries**.
11
11
 
12
- ## How it works (aka quick docs / tutorial)
12
+ Cordo is also completely stateless allowing for incredibly easy load balancing, super light ram usage, and more. (This doesn't mean your bot cannot have state. It very well can.)
13
13
 
14
- ### Commands
14
+ Cordo also abstracts away a lot of constraints/burdens on Discord's interactions api like completely hiding and managing *custom_id*s and automatically laying out components into rows or labels.
15
15
 
16
- Go in bot/commands. They export a handler function as default which gets called when the command gets run.
16
+ Cordo can be run as a standalone web-server (recommended) or integrated into existing Discord api libraries like discord.js.
17
17
 
18
- ### Components
19
18
 
20
- Go in bot/components. They too export a handler function as default which gets called when the component gets interacted with (button press or dropdown selection).
21
- You can create folders to build hierarchy. Each folder prefixes the resulting component id with the foldername and a _
22
- For instance the handler in `components/foo/bar/test.ts` gets triggered for a component with the custom_id of `foo_bar_test`
19
+ ## Quick Start
23
20
 
24
- ### States
21
+ That's to do lol.
25
22
 
26
- States are an extra layer to the Commands and Components that let you define command pages. Instead of editing or sending a response to a Command or Component Interaction, you can just ask it to take form of a state and Cordo will do the rest.
27
-
28
- ## Interaction flow
29
-
30
- User presses button -> Check if this interaction has any overrides on timeout -> Check if there are global Component handlers -> Check if there is a state with the same name to take -> Error
31
-
32
- User runs command -> Check if command handler exists -> Check if there is a state with the command name and _main -> Error
33
-
34
-
35
- ## API Coverage
36
-
37
- ✔️ Slash Commands
38
-
39
- ✔️ Command Groups
40
-
41
- ✔️ Command Autocomplete
42
-
43
- ✔️ Button Components
44
-
45
- ✔️ Dropdown Components
46
-
47
- ❌ Modals
48
-
49
-
50
- ## Naming Convensions
51
-
52
- Commands may only have one word -> no CamelCase or snake_case needed
53
-
54
- Components and States must be prefixed with the command they originated from -> A button on the settings command must start with settings_ (=> be placed inside a folder called settings)
55
-
56
- Components must be named by their desired state and not how to get there. Example: Settings command has a page with general settings (settings_main), which has a button for advanced settings (settings_advanced) which has a button to destroy the world.
57
- Incorrect naming: settings_advanced_destroy_world
58
- Correct naming: settings_destroy_world
59
-
60
- States also follow this principle to not inherit the path into the name, only the destination as shown above.
61
-
62
- Components that just change state (like "open another page") or have state changing behaviour must be named like states. Example: config_page2, config_name
63
- Components that have side effects (like changing settings or alike) must be named with a verb. Example: config_name_change, friends_request_send
64
- For those verbs preferably pick:
65
- * _change for Select/Dropdowns
66
- * _toggle for Toggle Buttons
67
- * _enable/_disable for Single Use Buttons
68
-
69
- ### Examples
70
-
71
- Component and State names correct and incorrect examples:
72
-
73
- ❌ back
74
-
75
- ❌ back_button
76
-
77
- ❌ state_back
78
-
79
- ❌ settings_back
80
-
81
- ❌ settings_advanced_more
82
-
83
- ❌ command_free_button_one
84
-
85
- ✔️ settings_main
86
-
87
- ✔️ settings_description
88
-
89
- ✔️ free_show_details
90
-
91
- ✔️ settings_more
92
-
93
- ## Best practices
94
-
95
- While theoretically the entire system could be built on soly using states, it is recomended to use interaction.reply and interaction.edit over states in non-interactive environments to save resources. States have a larger overhead than a simply interaction reply.
96
-
97
- Cordo is best when it's used stateless. Try to reduce interactive responses to a minimum and always put as much information as possible in the custom ids. This allows for easy scaling as well as a great user experience. It might take some time to get used to but once you understand how to build on this, cordo naturally organizes your code.
23
+ If you wanna see how it looks like using cordo, check out the `./test` folder for some almost up to date demo project.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cordo",
3
- "version": "2.5.2",
3
+ "version": "2.6.1",
4
4
  "description": "A framework for handling complex discord api interactions",
5
5
  "exports": {
6
6
  ".": "./src/index.ts",
@@ -4,6 +4,7 @@ import { LibEmoji } from "../../lib/emoji"
4
4
  import { Hooks } from "../../core/hooks"
5
5
  import type { CordoFunct, CordoFunctRun } from "../../functions"
6
6
  import { FunctCompiler } from "../../functions/compiler"
7
+ import { MaxLengthConstants } from "../../lib/constants"
7
8
 
8
9
 
9
10
  export function button() {
@@ -21,7 +22,7 @@ export function button() {
21
22
  'transformUserFacingText',
22
23
  labelVal,
23
24
  { component: 'Button', position: 'label' }
24
- )
25
+ )?.slice(0, MaxLengthConstants.BUTTON_LABEL)
25
26
  }
26
27
 
27
28
  const out = {
@@ -1,6 +1,7 @@
1
1
  import { ButtonStyle } from "discord-api-types/v10"
2
2
  import { ComponentType, createComponent } from "../component"
3
3
  import { Hooks } from "../../core/hooks"
4
+ import { MaxLengthConstants } from "../../lib/constants"
4
5
 
5
6
 
6
7
  export function linkButton(url: string) {
@@ -10,13 +11,13 @@ export function linkButton(url: string) {
10
11
 
11
12
  function getLabel() {
12
13
  if (!labelVal)
13
- return emojiVal ? '' : new URL(url).hostname
14
+ return emojiVal ? '' : new URL(url).hostname.slice(0, MaxLengthConstants.BUTTON_LABEL)
14
15
 
15
16
  return Hooks.callHook(
16
17
  'transformUserFacingText',
17
18
  labelVal,
18
19
  { component: 'Button', position: 'label' }
19
- )
20
+ )?.slice(0, MaxLengthConstants.BUTTON_LABEL)
20
21
  }
21
22
 
22
23
  const out = {
@@ -25,7 +26,7 @@ export function linkButton(url: string) {
25
26
  label: getLabel(),
26
27
  emoji: emojiVal,
27
28
  style: ButtonStyle.Link,
28
- url
29
+ url: url?.slice(0, MaxLengthConstants.BUTTON_URL),
29
30
  })),
30
31
 
31
32
  label: (text: string) => {
@@ -3,6 +3,7 @@ import { ComponentType, createComponent } from "../component"
3
3
  import { Hooks } from "../../core/hooks"
4
4
  import { value, type CordoFunct, type CordoFunctRun } from "../../functions"
5
5
  import { FunctCompiler } from "../../functions/compiler"
6
+ import { MaxLengthConstants } from "../../lib/constants"
6
7
 
7
8
 
8
9
  type SelectMenuOption<Value extends string = string> = Omit<APISelectMenuOption, 'value'> & ({
@@ -31,9 +32,9 @@ export function selectString<Values extends string = string>() {
31
32
  function getOptions(): SelectMenuOption[] {
32
33
  return optionsVal.slice(0, 25).map(o => ({
33
34
  ...o,
34
- label: Hooks.callHook('transformUserFacingText', o.label, { component: 'StringSelect', position: 'option.label' }),
35
+ label: Hooks.callHook('transformUserFacingText', o.label, { component: 'StringSelect', position: 'option.label' })?.slice(0, MaxLengthConstants.SELECT_OPTION_LABEL),
35
36
  description: o.description
36
- ? Hooks.callHook('transformUserFacingText', o.description, { component: 'StringSelect', position: 'option.description' })
37
+ ? Hooks.callHook('transformUserFacingText', o.description, { component: 'StringSelect', position: 'option.description' })?.slice(0, MaxLengthConstants.SELECT_OPTION_DESCRIPTION)
37
38
  : undefined,
38
39
  value: FunctCompiler.toCustomId([
39
40
  ...(o.onClick
@@ -44,7 +44,7 @@ export type CordoConfig = {
44
44
  onNetworkError: HookFor<any>,
45
45
 
46
46
  /** transforms the name of the invoked command to a file or route name */
47
- transformCommandName: TransformHookFor<string>,
47
+ transformCommandName: TransformHookFor<string, { type: 'slash' | 'message' | 'user' }>,
48
48
  /** gets called by all cordo builtin components that render user facing text. e.g. buttons, text components, selects, etc */
49
49
  transformUserFacingText: TransformHookFor<string, { component: StringComponentType, position: null | string, interaction?: CordoInteraction }>,
50
50
  }
@@ -89,6 +89,13 @@ type RouteRequestFromModal = {
89
89
  selected: Map<string, string>
90
90
  }
91
91
 
92
+ type InstallContext = {
93
+ /** true if the bot is installed in the current guild, non-exclusive */
94
+ isGuildInstalled: boolean
95
+ /** true if the bot is installed for the current user, non-exclusive */
96
+ isUserInstalled: boolean
97
+ }
98
+
92
99
  export type RouteRequest = {
93
100
  params: Record<string, string>
94
101
  /** route path is the path to this route you are currently in */
@@ -97,6 +104,8 @@ export type RouteRequest = {
97
104
  currentPath: string
98
105
  rawInteraction: CordoInteraction
99
106
 
107
+ installContext: InstallContext
108
+
100
109
  locals: {
101
110
  get<T = any>(key: string): T | undefined
102
111
  set(key: string, value: any): void
@@ -1,4 +1,4 @@
1
- import { ApplicationCommandType, ComponentType, InteractionResponseType, InteractionType, type APIInteraction } from "discord-api-types/v10"
1
+ import { ApplicationCommandOptionType, ApplicationCommandType, ComponentType, InteractionResponseType, InteractionType, type APIInteraction } from "discord-api-types/v10"
2
2
  import type { Method } from "axios"
3
3
  import axios from "axios"
4
4
  import { FunctCompiler } from "../functions/compiler"
@@ -133,9 +133,26 @@ export namespace CordoGateway {
133
133
  }
134
134
 
135
135
  function handleCommandInteraction(i: CordoInteraction & { type: InteractionType.ApplicationCommand }) {
136
+ // slash commands: need to check for subcommands
136
137
  if (i.data.type === ApplicationCommandType.ChatInput) {
137
- const name = i.data.name
138
- const { route, path } = RoutingResolve.getRouteForCommand(name)
138
+ let name = i.data.name
139
+ let option = i.data.options?.[0]
140
+ while (option?.type === ApplicationCommandOptionType.Subcommand || option?.type === ApplicationCommandOptionType.SubcommandGroup) {
141
+ name += ` ${option.name}`
142
+ option = option.options?.[0]
143
+ }
144
+
145
+ const { route, path } = RoutingResolve.getRouteForCommand(name, 'slash')
146
+ CordoMagic.setCwd(path)
147
+ return RoutingRespond.callRoute(route.routeId, route.args, i)
148
+ }
149
+
150
+ // message/user commands: go ahead
151
+ if (i.data.type === ApplicationCommandType.Message || i.data.type === ApplicationCommandType.User) {
152
+ const { route, path } = RoutingResolve.getRouteForCommand(
153
+ i.data.name,
154
+ i.data.type === ApplicationCommandType.Message ? 'message' : 'user'
155
+ )
139
156
  CordoMagic.setCwd(path)
140
157
  return RoutingRespond.callRoute(route.routeId, route.args, i)
141
158
  }
@@ -133,10 +133,12 @@ export namespace RoutingResolve {
133
133
  }
134
134
  }
135
135
 
136
- export function getRouteForCommand(command: string) {
136
+ export function getRouteForCommand(command: string, type: 'slash' | 'message' | 'user') {
137
137
  const fileName = Hooks.isDefined('transformCommandName')
138
- ? Hooks.callHook('transformCommandName', command)
139
- : command.replaceAll(' ', '-').replace(/[^\w-]/g, '').toLowerCase()
138
+ ? Hooks.callHook('transformCommandName', command, { type })
139
+ : type === 'slash'
140
+ ? command.replaceAll(' ', '/').toLowerCase() // slash commands: subcommands become subfolders
141
+ : command.replaceAll(' ', '-').replace(/[^\w-]/g, '').toLowerCase() // message/user commands: just sanitize
140
142
  const routePath = `command/${fileName}`
141
143
  return {
142
144
  route: getRouteFromPath(routePath, false),
@@ -138,6 +138,11 @@ export namespace RoutingRespond {
138
138
  rawInteraction: interaction,
139
139
  rawEntitlements: interaction.entitlements,
140
140
 
141
+ installContext: {
142
+ isGuildInstalled: Boolean(interaction.authorizing_integration_owners[0]),
143
+ isUserInstalled: Boolean(interaction.authorizing_integration_owners[1]),
144
+ },
145
+
141
146
  locals: {
142
147
  get: <T = any>(key: string) => interaction.locals[key] as T,
143
148
  set: (key: string, value: any) => void (interaction.locals[key] = value),
@@ -0,0 +1,10 @@
1
+
2
+
3
+ export namespace MaxLengthConstants {
4
+
5
+ export const BUTTON_LABEL = 80
6
+ export const BUTTON_URL = 512
7
+ export const SELECT_OPTION_LABEL = 100
8
+ export const SELECT_OPTION_DESCRIPTION = 100
9
+
10
+ }