Skip to content

Commit

Permalink
refactor: スキーマを利用した新コマンドシステム (approvers#479)
Browse files Browse the repository at this point in the history
* 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
MikuroXina authored and raiga0310 committed Sep 18, 2022
1 parent eb01cbb commit 6f4298a
Show file tree
Hide file tree
Showing 40 changed files with 1,512 additions and 1,327 deletions.
3 changes: 1 addition & 2 deletions .github/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,9 @@ import { Meme } from './meme.js';
import { createMockMessage } from './command-message.js';

it('use case of hukueki', async () => {
const fn = vi.fn<[EmbedMessage]>(() => Promise.resolve());
const fn = vi.fn();
const responder = new Meme();
await responder.on(
'CREATE',
createMockMessage(
{
args: ['hukueki', 'こるく']
Expand Down
181 changes: 181 additions & 0 deletions src/adaptor/proxy/command.ts
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] });
}
});
};
139 changes: 139 additions & 0 deletions src/adaptor/proxy/command/schema.test.ts
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']
}
]);
});

0 comments on commit 6f4298a

Please sign in to comment.