Skip to content

Commit 2990716

Browse files
committed
feat(reply): add button support
1 parent eaa6f49 commit 2990716

File tree

1 file changed

+158
-25
lines changed

1 file changed

+158
-25
lines changed

src/runtime/server/utils/reply.ts

Lines changed: 158 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,31 @@
1-
import type { BaseMessageOptions, CommandInteraction, InteractionCallbackResponse, InteractionEditReplyOptions, InteractionReplyOptions, InteractionResponse, Message } from 'discord.js'
1+
import type {
2+
BaseMessageOptions,
3+
ButtonComponentData,
4+
ButtonInteraction,
5+
CommandInteraction,
6+
InteractionCallbackResponse,
7+
InteractionCollector,
8+
InteractionEditReplyOptions,
9+
InteractionReplyOptions,
10+
InteractionResponse,
11+
Message,
12+
MessageCollectorOptionsParams,
13+
} from 'discord.js'
214
import type { MaybeRef } from 'vue'
315
import type { SlashCommandCustomReturnHandler } from '../../../types'
4-
import { MessageFlags } from 'discord.js'
5-
import { isRef, reactive, watch } from 'vue'
16+
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ComponentType, MessageFlags } from 'discord.js'
17+
import { computed, isRef, reactive, toValue, watch } from 'vue'
618

719
type OmitPreservingCallSignature<T, K extends keyof T = keyof T>
820
= Omit<T, K> & (T extends (...args: infer R) => infer S ? (...args: R) => S : unknown)
9-
1021
type File = NonNullable<BaseMessageOptions['files']>[number]
22+
type ButtonCollectorOptions = Partial<Omit<MessageCollectorOptionsParams<ComponentType.Button>, 'componentType'>>
23+
type MaybeRefObject<T> = T extends any
24+
? { [K in keyof T]: MaybeRef<T[K]> }
25+
: never
26+
type DistributiveOmit<T, K extends keyof T> = T extends any
27+
? Omit<T, K>
28+
: never
1129

1230
type ReplyFunction = SlashCommandCustomReturnHandler & {
1331
(text?: MaybeRef<string>, options?: InteractionReplyOptions):
@@ -31,21 +49,75 @@ type ReplyFunction = SlashCommandCustomReturnHandler & {
3149
flags: (flags: MaybeRef<InteractionReplyOptions['flags']>) => ReplyFunction
3250
file: (file: MaybeRef<File>) => ReplyFunction
3351
files: (files: MaybeRef<File[]>) => ReplyFunction
52+
button: {
53+
(
54+
label: MaybeRef<string>,
55+
handler: (interaction: ButtonInteraction) => void | Promise<void>,
56+
options?:
57+
& Partial<MaybeRefObject<DistributiveOmit<ButtonComponentData, 'label' | 'type'>>>
58+
& {
59+
defer?: boolean
60+
hide?: MaybeRef<boolean>
61+
collector?: ButtonCollectorOptions
62+
},
63+
): ReplyFunction
64+
// TODO: support reply.button.link/primary like interface
65+
}
3466
send: ReplyFunction
3567
}
3668

3769
function createReplyFunction(
38-
{ ...defaultOptions }: (InteractionReplyOptions) = {},
70+
{
71+
buttons,
72+
...defaultOptions
73+
}: (InteractionReplyOptions & {
74+
buttons?: Parameters<ReplyFunction['button']>[]
75+
}) = {},
3976
): ReplyFunction {
4077
type Msg = Message | InteractionCallbackResponse | InteractionResponse
4178

4279
const reply = ((...args) => {
43-
if (typeof args[0] === 'string' || typeof args[0] === 'undefined' || isRef(args[0]) || typeof args[0] === 'function') {
80+
const buttonComponents = computed(() => getButtonComponent(buttons ?? []))
81+
let buttonInteractionCollectors: InteractionCollector<ButtonInteraction>[] = []
82+
function registerButtonCollectors(msg: Msg) {
83+
for (const collector of buttonInteractionCollectors) {
84+
collector.stop()
85+
}
86+
buttonInteractionCollectors = []
87+
88+
for (const [_, handler, options] of buttons ?? []) {
89+
let collectorOptions: ButtonCollectorOptions | undefined
90+
if (options && 'collector' in options && options.collector) {
91+
collectorOptions = options.collector
92+
}
93+
if ('resource' in msg) {
94+
buttonInteractionCollectors.push(msg.resource!.message!.createMessageComponentCollector({
95+
componentType: ComponentType.Button,
96+
...collectorOptions,
97+
}))
98+
}
99+
else {
100+
buttonInteractionCollectors.push(msg.createMessageComponentCollector({
101+
componentType: ComponentType.Button,
102+
...collectorOptions,
103+
}))
104+
}
105+
buttonInteractionCollectors[buttonInteractionCollectors.length - 1]
106+
.on('collect', async (buttonInteraction) => {
107+
if (options?.defer ?? true)
108+
buttonInteraction.deferUpdate()
109+
await handler(buttonInteraction)
110+
})
111+
}
112+
}
113+
114+
if (typeof args[0] === 'string' || typeof args[0] === 'undefined' || isRef(args[0])) {
44115
const [text, options] = args
45116
let message: Promise<Msg>
46117

47118
const replyOptions = reactive({
48119
content: text,
120+
...buttonComponents.value.length > 0 ? { components: buttonComponents } : {},
49121
...defaultOptions,
50122
...options,
51123
}) as InteractionReplyOptions
@@ -56,7 +128,12 @@ function createReplyFunction(
56128
: interaction.deferred ? 'editReply' : 'reply'
57129
message = interaction[method](
58130
replyOptions as InteractionReplyOptions & InteractionEditReplyOptions,
59-
).catch(err => err)
131+
)
132+
.then((msg: Msg) => {
133+
registerButtonCollectors(msg)
134+
return msg
135+
})
136+
.catch(err => err)
60137
return message
61138
}
62139

@@ -82,6 +159,7 @@ function createReplyFunction(
82159
msg.edit(options as InteractionEditReplyOptions)
83160
else
84161
msg.resource?.message?.edit(options as InteractionEditReplyOptions)
162+
registerButtonCollectors(msg)
85163
}, { deep: true, flush: 'sync' })
86164

87165
return handler
@@ -103,6 +181,7 @@ function createReplyFunction(
103181
message.edit(opts as InteractionEditReplyOptions)
104182
else
105183
message.resource?.message?.edit(opts as InteractionEditReplyOptions)
184+
registerButtonCollectors(message)
106185
}, { deep: true, flush: 'sync' })
107186

108187
return {
@@ -137,35 +216,89 @@ function createReplyFunction(
137216
},
138217
},
139218
flags: {
140-
get() {
141-
return (flags: InteractionReplyOptions['flags'] & InteractionEditReplyOptions['flags']) => createReplyFunction({
142-
...defaultOptions,
143-
flags,
144-
})
145-
},
219+
value: (flags: InteractionReplyOptions['flags'] & InteractionEditReplyOptions['flags']) => createReplyFunction({
220+
...defaultOptions,
221+
flags,
222+
}),
146223
},
147224
file: {
148-
get() {
149-
return (file: File) => createReplyFunction({
150-
...defaultOptions,
151-
files: defaultOptions.files != null ? [...defaultOptions.files, file] : [file],
152-
})
153-
},
225+
value: (file: File) => createReplyFunction({
226+
...defaultOptions,
227+
files: defaultOptions.files != null ? [...defaultOptions.files, file] : [file],
228+
}),
154229
},
155230
files: {
156-
get() {
157-
return (files: File[]) => createReplyFunction({
158-
...defaultOptions,
159-
files: defaultOptions.files != null ? [...defaultOptions.files, ...files] : files,
160-
})
161-
},
231+
value: (files: File[]) => createReplyFunction({
232+
...defaultOptions,
233+
files: defaultOptions.files != null ? [...defaultOptions.files, ...files] : files,
234+
}),
162235
},
163236
send: {
164237
value: reply,
165238
},
239+
button: {
240+
value: (...args: Parameters<ReplyFunction['button']>) => {
241+
return createReplyFunction({
242+
...defaultOptions,
243+
buttons: [
244+
...(buttons ?? []),
245+
args,
246+
],
247+
})
248+
},
249+
},
166250
})
167251

168252
return reply
169253
}
170254

171255
export const reply: ReplyFunction = createReplyFunction()
256+
257+
function getButtonComponent(buttons: Parameters<ReplyFunction['button']>[]): NonNullable<BaseMessageOptions['components']> {
258+
if (buttons.length === 0)
259+
return []
260+
261+
let row = new ActionRowBuilder<ButtonBuilder>()
262+
const rows = [row]
263+
264+
const it = Iterator.from(buttons)
265+
.filter(([, , options]) => !(toValue(options?.hide) ?? false))
266+
.map(([label, , options]) => {
267+
if (options == null) {
268+
options = {} as NonNullable<typeof options>
269+
}
270+
271+
if (toValue(options.style) == null) {
272+
options.style = ButtonStyle.Primary
273+
}
274+
if (options.style === ButtonStyle.Primary) {
275+
if (!('customId' in options) || options.customId == null) {
276+
(options as any).customId = `${Date.now()}`
277+
}
278+
}
279+
280+
return new ButtonBuilder(
281+
Object.fromEntries(
282+
Object.entries({
283+
label,
284+
...options,
285+
type: ComponentType.Button,
286+
}).map(([key, value]) => [key, toValue(value)]),
287+
),
288+
)
289+
})
290+
291+
for (let i = 0; i < 5; i++) {
292+
row.addComponents(...Array.from(it.take(5)))
293+
294+
if (row.components.length >= 5) {
295+
row = new ActionRowBuilder()
296+
rows.push(row)
297+
}
298+
}
299+
300+
if (row.components.length === 0)
301+
rows.pop()
302+
303+
return rows
304+
}

0 commit comments

Comments
 (0)