-
-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* feat: Add Schema * docs: Add docs and tidy up * fix: Add paramsOrder * fix: Improve SubCommand * fix: Fix error payload * fix: Add NEED_MORE_ARGS error * fix: Add type parameters * fix: Fix arg of ParsedSubCommand * fix: Improve parsed types * fix: Support params on Schema * refactor: Update CommandMessage * feat: Impl parseStrings * fix: Fix to check range * fix: Add makeError * fix: Weaken type bound * test: Add simple test case * fix: Fix some keys and test case * fix: Remove parsed key * test: Enforce test case * test: Enforce test cases * fix: Fix DIGITS and update output * feat: Add MESSAGE type * fix: Make subCommand and params required * fix: Weaken type bound * fix: Improve ParsedSchema * fix: Fix fallback type * fix: Fix type on single arg * fix: Fix type pattern match * feat: Add ParamBase * fix: Fix SubCommand definition * fix: Support variadic arguments * fix: Make subCommand optional * feat: Add SCHEMA * fix: Improve ParamsValues * fix: Fix registration * feat: Add parseStringsOrThrow * fix: Fix type bound * fix: Improve createMockMessage args * fix: Remove empty subCommand from output * fix: Weaken type of param * fix: Fix on no args but has sub command * fix: Fix to report error * test: Update command tests * fix: Guard on other commands * fix: Fix typing with Equal * feat: Improve with CommandRunner * fix: Remove redundant test * doc: Update CONTRIBUTING for newer method * fix: Fix not to throw error * fix: Fix parsing variadic params * fix: Fix decoupling * feat: Add more hibikiness * fix: Guard with checking editable
- Loading branch information
1 parent
35fbe8d
commit 586bd15
Showing
40 changed files
with
1,512 additions
and
1,327 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,181 @@ | ||
import { | ||
type APIActionRowComponent, | ||
type APIMessageActionRowComponent, | ||
ActionRowBuilder, | ||
ButtonBuilder, | ||
ButtonStyle, | ||
Client, | ||
Message, | ||
type MessageActionRowComponentBuilder | ||
} from 'discord.js'; | ||
import type { | ||
CommandProxy, | ||
MessageCreateListener | ||
} from '../../runner/command.js'; | ||
import { Schema, makeError } from '../../model/command-schema.js'; | ||
import type { EmbedPage } from '../../model/embed-message.js'; | ||
import type { RawMessage } from './middleware.js'; | ||
import type { Snowflake } from '../../model/id.js'; | ||
import { convertEmbed } from '../embed-convert.js'; | ||
import { parseStrings } from './command/schema.js'; | ||
|
||
const SPACES = /\s+/; | ||
|
||
export class DiscordCommandProxy implements CommandProxy { | ||
constructor(client: Client, private readonly prefix: string) { | ||
client.on('messageCreate', (message) => this.onMessageCreate(message)); | ||
} | ||
|
||
private readonly listenerMap = new Map< | ||
string, | ||
[Schema, MessageCreateListener] | ||
>(); | ||
|
||
addMessageCreateListener( | ||
schema: Schema, | ||
listener: MessageCreateListener | ||
): void { | ||
for (const name of schema.names) { | ||
if (this.listenerMap.has(name)) { | ||
throw new Error(`command name conflicted: ${name}`); | ||
} | ||
this.listenerMap.set(name, [schema, listener]); | ||
} | ||
} | ||
|
||
private async onMessageCreate(message: Message): Promise<void> { | ||
if (message.author.bot || message.author.system) { | ||
return; | ||
} | ||
await message.fetch(); | ||
|
||
if (!message.content?.trimStart().startsWith(this.prefix)) { | ||
return; | ||
} | ||
const args = message.content | ||
?.trim() | ||
.slice(this.prefix.length) | ||
.split(SPACES); | ||
|
||
const entry = this.listenerMap.get(args[0]); | ||
if (!entry) { | ||
return; | ||
} | ||
const [schema, listener] = entry; | ||
const [tag, parsedArgs] = parseStrings(args, schema); | ||
if (tag === 'Err') { | ||
const error = makeError(parsedArgs); | ||
await message.reply(error.message); | ||
return; | ||
} | ||
|
||
await listener({ | ||
senderId: message.author.id as Snowflake, | ||
senderGuildId: message.guildId as Snowflake, | ||
senderChannelId: message.channelId as Snowflake, | ||
get senderVoiceChannelId(): Snowflake | null { | ||
const id = message.member?.voice.channelId ?? null; | ||
return id ? (id as Snowflake) : null; | ||
}, | ||
senderName: message.author?.username ?? '名無し', | ||
args: parsedArgs, | ||
async reply(embed) { | ||
const mes = await message.reply({ embeds: [convertEmbed(embed)] }); | ||
return { | ||
edit: async (embed) => { | ||
await mes.edit({ embeds: [convertEmbed(embed)] }); | ||
} | ||
}; | ||
}, | ||
replyPages: replyPages(message), | ||
async react(emoji) { | ||
await message.react(emoji); | ||
} | ||
}); | ||
} | ||
} | ||
|
||
const ONE_MINUTE_MS = 60_000; | ||
const CONTROLS: APIActionRowComponent<APIMessageActionRowComponent> = | ||
new ActionRowBuilder<MessageActionRowComponentBuilder>() | ||
.addComponents( | ||
new ButtonBuilder() | ||
.setStyle(ButtonStyle.Secondary) | ||
.setCustomId('prev') | ||
.setLabel('戻る') | ||
.setEmoji('⏪'), | ||
new ButtonBuilder() | ||
.setStyle(ButtonStyle.Secondary) | ||
.setCustomId('next') | ||
.setLabel('進む') | ||
.setEmoji('⏩') | ||
) | ||
.toJSON(); | ||
const CONTROLS_DISABLED: APIActionRowComponent<APIMessageActionRowComponent> = | ||
new ActionRowBuilder<MessageActionRowComponentBuilder>() | ||
.addComponents( | ||
new ButtonBuilder() | ||
.setStyle(ButtonStyle.Secondary) | ||
.setCustomId('prev') | ||
.setLabel('戻る') | ||
.setEmoji('⏪') | ||
.setDisabled(true), | ||
new ButtonBuilder() | ||
.setStyle(ButtonStyle.Secondary) | ||
.setCustomId('next') | ||
.setLabel('進む') | ||
.setEmoji('⏩') | ||
.setDisabled(true) | ||
) | ||
.toJSON(); | ||
|
||
const pagesFooter = (currentPage: number, pagesLength: number) => | ||
`ページ ${currentPage + 1}/${pagesLength}`; | ||
|
||
const replyPages = (message: RawMessage) => async (pages: EmbedPage[]) => { | ||
if (pages.length === 0) { | ||
throw new Error('pages must not be empty array'); | ||
} | ||
|
||
const generatePage = (index: number) => | ||
convertEmbed(pages[index]).setFooter({ | ||
text: pagesFooter(index, pages.length) | ||
}); | ||
|
||
const paginated = await message.reply({ | ||
embeds: [generatePage(0)], | ||
components: [CONTROLS] | ||
}); | ||
|
||
const collector = paginated.createMessageComponentCollector({ | ||
time: ONE_MINUTE_MS | ||
}); | ||
|
||
let currentPage = 0; | ||
collector.on('collect', async (interaction) => { | ||
switch (interaction.customId) { | ||
case 'prev': | ||
if (0 < currentPage) { | ||
currentPage -= 1; | ||
} else { | ||
currentPage = pages.length - 1; | ||
} | ||
break; | ||
case 'next': | ||
if (currentPage < pages.length - 1) { | ||
currentPage += 1; | ||
} else { | ||
currentPage = 0; | ||
} | ||
break; | ||
default: | ||
return; | ||
} | ||
await interaction.update({ embeds: [generatePage(currentPage)] }); | ||
}); | ||
collector.on('end', async () => { | ||
if (paginated.editable) { | ||
await paginated.edit({ components: [CONTROLS_DISABLED] }); | ||
} | ||
}); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,139 @@ | ||
import { expect, test } from 'vitest'; | ||
|
||
import { parseStrings } from './schema.js'; | ||
|
||
test('no args', () => { | ||
const SERVER_INFO_SCHEMA = { | ||
names: ['serverinfo'], | ||
subCommands: {} | ||
} as const; | ||
|
||
const noParamRes = parseStrings(['serverinfo'], SERVER_INFO_SCHEMA); | ||
|
||
expect(noParamRes).toStrictEqual([ | ||
'Ok', | ||
{ | ||
name: 'serverinfo', | ||
params: [] | ||
} | ||
]); | ||
}); | ||
|
||
test('single arg', () => { | ||
const TIME_OPTION = [ | ||
{ name: 'at', description: '', type: 'STRING' } | ||
] as const; | ||
const KAERE_SCHEMA = { | ||
names: ['kaere'], | ||
subCommands: { | ||
start: { | ||
type: 'SUB_COMMAND' | ||
}, | ||
bed: { | ||
type: 'SUB_COMMAND_GROUP', | ||
subCommands: { | ||
enable: { | ||
type: 'SUB_COMMAND' | ||
}, | ||
disable: { | ||
type: 'SUB_COMMAND' | ||
}, | ||
status: { | ||
type: 'SUB_COMMAND' | ||
} | ||
} | ||
}, | ||
reserve: { | ||
type: 'SUB_COMMAND_GROUP', | ||
subCommands: { | ||
add: { | ||
type: 'SUB_COMMAND', | ||
params: TIME_OPTION | ||
}, | ||
cancel: { | ||
type: 'SUB_COMMAND', | ||
params: TIME_OPTION | ||
}, | ||
list: { | ||
type: 'SUB_COMMAND' | ||
} | ||
} | ||
} | ||
} | ||
} as const; | ||
|
||
const noParamRes = parseStrings(['kaere'], KAERE_SCHEMA); | ||
|
||
expect(noParamRes).toStrictEqual([ | ||
'Ok', | ||
{ | ||
name: 'kaere', | ||
params: [] | ||
} | ||
]); | ||
|
||
const oneParamRes = parseStrings(['kaere', 'start'], KAERE_SCHEMA); | ||
|
||
expect(oneParamRes).toStrictEqual([ | ||
'Ok', | ||
{ | ||
name: 'kaere', | ||
params: [], | ||
subCommand: { | ||
name: 'start', | ||
type: 'PARAMS', | ||
params: [] | ||
} | ||
} | ||
]); | ||
|
||
const subCommandRes = parseStrings( | ||
['kaere', 'reserve', 'add', '01:12'], | ||
KAERE_SCHEMA | ||
); | ||
|
||
expect(subCommandRes).toStrictEqual([ | ||
'Ok', | ||
{ | ||
name: 'kaere', | ||
params: [], | ||
subCommand: { | ||
name: 'reserve', | ||
type: 'SUB_COMMAND', | ||
subCommand: { | ||
name: 'add', | ||
type: 'PARAMS', | ||
params: ['01:12'] | ||
} | ||
} | ||
} | ||
]); | ||
}); | ||
|
||
test('multi args', () => { | ||
const ROLE_CREATE_SCHEMA = { | ||
names: ['rolecreate'], | ||
subCommands: {}, | ||
params: [ | ||
{ type: 'USER', name: 'target', description: '' }, | ||
{ type: 'STRING', name: 'color', description: '', defaultValue: 'random' } | ||
] | ||
} as const; | ||
|
||
const noParamRes = parseStrings(['rolecreate'], ROLE_CREATE_SCHEMA); | ||
|
||
expect(noParamRes).toStrictEqual(['Err', ['NEED_MORE_ARGS']]); | ||
|
||
const oneParamRes = parseStrings( | ||
['rolecreate', '0123456789'], | ||
ROLE_CREATE_SCHEMA | ||
); | ||
|
||
expect(oneParamRes).toStrictEqual([ | ||
'Ok', | ||
{ | ||
name: 'rolecreate', | ||
params: ['0123456789', 'random'] | ||
} | ||
]); | ||
}); |
Oops, something went wrong.