diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 4a0540f3..21b1cd14 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -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', 'こるく'] diff --git a/src/adaptor/proxy/command.ts b/src/adaptor/proxy/command.ts new file mode 100644 index 00000000..6fd806e0 --- /dev/null +++ b/src/adaptor/proxy/command.ts @@ -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 { + 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 = + new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setStyle(ButtonStyle.Secondary) + .setCustomId('prev') + .setLabel('戻る') + .setEmoji('⏪'), + new ButtonBuilder() + .setStyle(ButtonStyle.Secondary) + .setCustomId('next') + .setLabel('進む') + .setEmoji('⏩') + ) + .toJSON(); +const CONTROLS_DISABLED: APIActionRowComponent = + new ActionRowBuilder() + .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] }); + } + }); +}; diff --git a/src/adaptor/proxy/command/schema.test.ts b/src/adaptor/proxy/command/schema.test.ts new file mode 100644 index 00000000..a845e210 --- /dev/null +++ b/src/adaptor/proxy/command/schema.test.ts @@ -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'] + } + ]); +}); diff --git a/src/adaptor/proxy/command/schema.ts b/src/adaptor/proxy/command/schema.ts new file mode 100644 index 00000000..a27aed02 --- /dev/null +++ b/src/adaptor/proxy/command/schema.ts @@ -0,0 +1,241 @@ +import { + Param, + ParamsValues, + ParseError, + ParsedSchema, + ParsedSubCommand, + Schema, + SubCommand, + SubCommandEntries, + SubCommandGroup, + makeError +} from '../../../model/command-schema.js'; + +const DIGITS = /^\d+$/; + +const hasOwn = >( + object: O, + key: PropertyKey +): key is keyof O => Object.hasOwn(object, key); + +const parseParams =

( + args: string[], + schema: Schema, P> | SubCommand

+): ['Ok', ParamsValues

] | ['Err', ParseError] => { + if (!schema.params) { + return ['Ok', [] as ParamsValues

]; + } + const values: unknown[] = []; + for (const param of schema.params) { + const arg = args.shift(); + if (!arg) { + if (param.defaultValue === undefined) { + return ['Err', ['NEED_MORE_ARGS']]; + } + values.push(param.defaultValue); + continue; + } + switch (param.type) { + case 'BOOLEAN': { + const lowerArg = arg.toLowerCase(); + if (lowerArg === 'true' || lowerArg === 'yes') { + values.push(true); + break; + } + if (lowerArg === 'false' || lowerArg === 'no') { + values.push(false); + break; + } + return ['Err', ['INVALID_DATA', 'BOOLEAN', arg]]; + } + case 'STRING': + if ( + (param.minLength && arg.length < param.minLength) || + (param.maxLength && param.maxLength < arg.length) + ) { + return [ + 'Err', + ['OUT_OF_RANGE', param.minLength, param.maxLength, arg] + ]; + } + values.push(arg); + break; + case 'USER': + case 'CHANNEL': + case 'ROLE': + case 'MESSAGE': + if (!DIGITS.test(arg)) { + return ['Err', ['INVALID_DATA', param.type, arg]]; + } + values.push(arg); + break; + case 'INTEGER': { + if (!DIGITS.test(arg)) { + return ['Err', ['INVALID_DATA', 'INTEGER', arg]]; + } + const parsed = Number.parseInt(arg, 10); + if ( + (param.minValue && parsed < param.minValue) || + (param.maxValue && param.maxValue < parsed) + ) { + return ['Err', ['OUT_OF_RANGE', param.minValue, param.maxValue, arg]]; + } + values.push(parsed); + break; + } + case 'FLOAT': { + const parsed = Number.parseFloat(arg); + if (Number.isNaN(parsed)) { + return ['Err', ['INVALID_DATA', 'FLOAT', arg]]; + } + if ( + (param.minValue && parsed < param.minValue) || + (param.maxValue && param.maxValue < parsed) + ) { + return ['Err', ['OUT_OF_RANGE', param.minValue, param.maxValue, arg]]; + } + values.push(parsed); + break; + } + case 'CHOICES': + if (!param.choices.includes(arg)) { + return ['Err', ['UNKNOWN_CHOICE', param.choices, arg]]; + } + values.push(param.choices.indexOf(arg)); + break; + case 'VARIADIC': + values.push([arg, ...args]); + break; + } + } + return ['Ok', values as ParamsValues

]; +}; + +const parseSubCommand = ( + args: string[], + schema: Schema | SubCommandGroup +): ['Ok', ParsedSubCommand] | ['Err', ParseError] => { + const subCommandNames = Object.getOwnPropertyNames(schema.subCommands); + const arg = args.shift(); + + if (!arg) { + return ['Err', ['NEED_MORE_ARGS']]; + } + + if (subCommandNames.includes(arg)) { + if (!hasOwn(schema.subCommands, arg)) { + return ['Err', ['UNKNOWN_COMMAND', Object.keys(schema.subCommands), arg]]; + } + const subCommandKey: keyof E = arg; + + const subCommand = schema.subCommands[subCommandKey]; + if ( + !( + typeof subCommand === 'object' && + subCommand !== null && + 'type' in subCommand + ) + ) { + return ['Err', ['OTHERS', 'unreachable']]; + } + if (subCommand['type'] === 'SUB_COMMAND_GROUP') { + const subSubCommand = parseSubCommand( + args, + subCommand as SubCommandGroup + ); + if (subSubCommand[0] === 'Err') { + return subSubCommand; + } + return [ + 'Ok', + { + name: subCommandKey, + type: 'SUB_COMMAND', + subCommand: subSubCommand[1] + } as unknown as ParsedSubCommand + ]; + } + const params = parseParams(args, subCommand); + if (params[0] === 'Err') { + return params; + } + return [ + 'Ok', + { + name: subCommandKey, + type: 'PARAMS', + params: params[1] + } as ParsedSubCommand + ]; + } + return ['Err', ['UNKNOWN_COMMAND', Object.keys(schema.subCommands), arg]]; +}; + +export const parseStrings = < + E, + P extends readonly Param[], + S extends Schema +>( + args: string[], + schema: S +): ['Ok', ParsedSchema] | ['Err', ParseError] => { + const name = args.shift(); + if (typeof name !== 'string' || name === '') { + return ['Err', ['INVALID_DATA', 'STRING', name]]; + } + + const hasSubCommand = + Object.getOwnPropertyNames(schema.subCommands).length !== 0; + + if (!hasSubCommand) { + const paramsRes = parseParams(args, schema); + if (paramsRes[0] === 'Err') { + return paramsRes; + } + return [ + 'Ok', + { + name, + params: paramsRes[1] + } as ParsedSchema + ]; + } + + if (args.length === 0) { + return [ + 'Ok', + { + name, + params: [] + } as ParsedSchema + ]; + } + + const subCommandRes = parseSubCommand(args, schema); + if (subCommandRes[0] === 'Ok') { + return [ + 'Ok', + { + name, + params: [], + subCommand: subCommandRes[1] + } as ParsedSchema + ]; + } + return subCommandRes; +}; + +export const parseStringsOrThrow = < + E, + P extends readonly Param[], + S extends Schema +>( + args: string[], + schema: S +): ParsedSchema => { + const parsed = parseStrings(args, schema); + if (parsed[0] === 'Err') { + throw makeError(parsed[1]); + } + return parsed[1]; +}; diff --git a/src/adaptor/proxy/middleware.ts b/src/adaptor/proxy/middleware.ts index b783f396..4ac6acfa 100644 --- a/src/adaptor/proxy/middleware.ts +++ b/src/adaptor/proxy/middleware.ts @@ -1,10 +1,7 @@ import type { Message, PartialMessage } from 'discord.js'; -import { - observableMiddleware, - prefixMiddleware -} from './middleware/message-convert.js'; import { botFilter } from './middleware/bot-filter.js'; +import { observableMiddleware } from './middleware/message-convert.js'; export type RawMessage = Message | PartialMessage; @@ -46,8 +43,5 @@ const sameMessageFilter: Middleware< export const middlewareForMessage = () => connectMiddleware(botFilter, observableMiddleware); -export const middlewareForCommand = (prefix: string) => - connectMiddleware(botFilter, prefixMiddleware(prefix)); - export const middlewareForUpdateMessage = () => connectMiddleware(sameMessageFilter, liftTuple(middlewareForMessage())); diff --git a/src/adaptor/proxy/middleware/message-convert.ts b/src/adaptor/proxy/middleware/message-convert.ts index 9e766eba..6572f7ea 100644 --- a/src/adaptor/proxy/middleware/message-convert.ts +++ b/src/adaptor/proxy/middleware/message-convert.ts @@ -1,21 +1,11 @@ -import { - APIActionRowComponent, - APIMessageActionRowComponent, - ActionRowBuilder, - ButtonBuilder, - ButtonStyle, - MessageActionRowComponentBuilder -} from 'discord.js'; import type { Middleware, RawMessage } from '../middleware.js'; + import type { BoldItalicCop } from '../../../service/bold-italic-cop.js'; -import type { CommandMessage } from '../../../service/command/command-message.js'; import type { DeletionObservable } from '../../../service/deletion-repeater.js'; import type { EditingObservable } from '../../../service/difference-detector.js'; -import type { EmbedPage } from '../../../model/embed-message.js'; import type { EmojiSeqObservable } from '../../../service/emoji-seq-react.js'; import type { Snowflake } from '../../../model/id.js'; import type { TypoObservable } from '../../../service/command/typo-record.js'; -import { convertEmbed } from '../../embed-convert.js'; const getAuthorSnowflake = (message: RawMessage): Snowflake => (message.author?.id || 'unknown') as Snowflake; @@ -62,121 +52,3 @@ export const observableMiddleware: Middleware< } return observableMessage(raw); }; - -const SPACES = /\s+/; -const ONE_MINUTE_MS = 60_000; -const CONTROLS: APIActionRowComponent = - new ActionRowBuilder() - .addComponents( - new ButtonBuilder() - .setStyle(ButtonStyle.Secondary) - .setCustomId('prev') - .setLabel('戻る') - .setEmoji('⏪'), - new ButtonBuilder() - .setStyle(ButtonStyle.Secondary) - .setCustomId('next') - .setLabel('進む') - .setEmoji('⏩') - ) - .toJSON(); -const CONTROLS_DISABLED: APIActionRowComponent = - new ActionRowBuilder() - .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 () => { - await paginated.edit({ components: [CONTROLS_DISABLED] }); - }); -}; - -export const prefixMiddleware = - (prefix: string): Middleware => - async (message) => { - await fetchMessage(message); - if (!message.content?.trimStart().startsWith(prefix)) { - throw new Error('the message does not have the prefix'); - } - const args = message.content?.trim().slice(prefix.length).split(SPACES); - const command: CommandMessage = { - senderId: getAuthorSnowflake(message), - 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, - 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); - } - }; - return command; - }; diff --git a/src/model/command-schema.ts b/src/model/command-schema.ts new file mode 100644 index 00000000..fc626b33 --- /dev/null +++ b/src/model/command-schema.ts @@ -0,0 +1,308 @@ +export interface ParamBase { + name: string; + description: string; +} + +/** + * 真偽値の引数のスキーマ。`defaultValue` が未定義ならば必須の引数になる。 + * + * @export + * @interface BooleanParam + */ +export interface BooleanParam extends ParamBase { + type: 'BOOLEAN'; + defaultValue?: boolean; +} + +/** + * 文字列の引数のスキーマ。`defaultValue` が未定義ならば必須の引数になる。 + * + * `minLength` や `maxLength` で文字列長の最小値と最大値を指定できる。長さが指定された範囲の外ならばパースに失敗する。 + * + * @export + * @interface StringParam + */ +export interface StringParam extends ParamBase { + type: 'STRING'; + defaultValue?: string; + minLength?: number; + maxLength?: number; +} + +/** + * ユーザなどの ID の引数のスキーマ。`defaultValue` が未定義ならば必須の引数になる。 + * + * @export + * @interface SnowflakeParam + */ +export interface SnowflakeParam extends ParamBase { + type: 'USER' | 'CHANNEL' | 'ROLE' | 'MESSAGE'; + defaultValue?: string; +} + +/** + * 数値の引数のスキーマ。`defaultValue` が未定義ならば必須の引数になる。 + * + * `type` が `INTEGER` のとき、数字以外を含む文字列ならばパースに失敗する。 + * + * `minLength` や `maxLength` で数値の最小値と最大値を指定できる。長さが指定された範囲の外ならばパースに失敗する。 + * + * @export + * @interface NumberParam + */ +export interface NumberParam extends ParamBase { + type: 'INTEGER' | 'FLOAT'; + defaultValue?: number; + minValue?: number; + maxValue?: number; +} + +/** + * 選択式の引数のスキーマ。`defaultValue` が未定義ならば必須の引数になる。パース結果は、対応する `choices` 内の文字列のインデックスである。 + * + * `choices` の中に存在しない文字列ならばパースに失敗する。 + * + * @export + * @interface ChoicesParam + */ +export interface ChoicesParam extends ParamBase { + type: 'CHOICES'; + defaultValue?: number; + choices: readonly string[]; +} + +/** + * 可変長引数のスキーマ。`defaultValue` が未定義ならば必須の引数になる。引数リストの中では一番最後の位置にのみ置ける。 + * + * @export + * @interface ChoicesParam + */ +export interface VariadicParam extends ParamBase { + type: 'VARIADIC'; + defaultValue?: readonly string[]; +} + +export type Param = + | BooleanParam + | StringParam + | SnowflakeParam + | NumberParam + | ChoicesParam + | VariadicParam; +export type ParamType = Param['type']; +/** + * 引数のスキーマ `P` に対応するパース結果の型を返す。 + * + * @export + * @typedef ParamValue + * @template P 引数のスキーマの型 + */ +export type ParamValue

= P extends BooleanParam + ? boolean + : P extends NumberParam | ChoicesParam + ? number + : P extends VariadicParam + ? string[] + : string; + +// From https://github.com/type-challenges/type-challenges/blob/48346888871d9fdbbd7b315ad73a529987dd59a1/utils/index.d.ts#L7-L9 +type Equal = (() => T extends X ? 1 : 2) extends () => T extends Y + ? 1 + : 2 + ? true + : false; + +export type ParamsValues = Equal extends true + ? unknown[] + : S extends readonly [infer H, ...infer R] + ? H extends Param + ? [ParamValue, ...ParamsValues] + : [] + : []; + +/** + * コマンドの中で分岐する細かいサブコマンド。 + * + * `params` は引数を受け取る順番で並べたスキーマの配列で指定する。必須の引数は他のどの任意の引数よりも前に登場しなければならない。可変長引数は最後にのみ登場しなければならない。 + * + * @export + * @interface SubCommand + */ +export interface SubCommand

{ + type: 'SUB_COMMAND'; + params?: P; +} + +export const isValidSubCommand =

( + sc: SubCommand

+): boolean => { + if (!sc.params) { + return true; + } + const variadicIndex = sc.params.findIndex( + (param) => param.type === 'VARIADIC' + ); + if (variadicIndex !== -1 && variadicIndex !== sc.params.length - 1) { + return false; + } + const lastRequiredParam = sc.params.reduce( + (prev, curr, idx) => ('defaultValue' in curr ? prev : idx), + 0 + ); + const firstOptionalParam = sc.params.reduceRight( + (prev, curr, idx) => ('defaultValue' in curr ? idx : prev), + sc.params.length + ); + return lastRequiredParam < firstOptionalParam; +}; + +export const assertValidSubCommand =

( + sc: SubCommand

+): void => { + if (!isValidSubCommand(sc)) { + console.dir(sc); + throw new Error('assertion failure'); + } +}; + +export type SubCommandEntries = Record< + string, + SubCommand | SubCommandGroup> +>; + +/** + * サブコマンドが属するグループ。これ単体ではコマンドとして実行できない。 + * + * @export + * @interface SubCommandGroup + */ +export interface SubCommandGroup { + type: 'SUB_COMMAND_GROUP'; + subCommands: Readonly; +} + +/** + * コマンドの引数のスキーマ。 + * + * `names` はこのコマンド実行に利用可能な全てのコマンド名、`subCommands` はこの配下にあるサブコマンドである。 + * + * @export + * @interface Schema + */ +export interface Schema, P = readonly Param[]> { + names: readonly string[]; + subCommands: Readonly; + params?: P; +} + +/** + * コマンドのスキーマ `S` に対応するパース結果の型を返す。 + * + * @export + * @typedef ParsedSchema + * @template S コマンドスキーマの型 + */ +export type ParsedSchema = { + name: S['names'][number]; + subCommand?: ParsedSubCommand; + params: ParamsValues; +}; + +/** + * コマンドのスキーマ `S` の引数のみに対応するパース結果の型を返す。 + * + * @export + * @typedef ParsedParameter + * @template S コマンドスキーマの型 + */ +export type ParsedParameter = S extends SubCommand + ? { + type: 'PARAMS'; + params: ParamsValues

; + } + : S extends SubCommandGroup + ? R extends SubCommandEntries + ? { + type: 'SUB_COMMAND'; + subCommand: ParsedSubCommand; + } + : never + : never; + +export type HasSubCommand = + | Schema> + | SubCommandGroup>; + +/** + * コマンドのスキーマ `S` のサブコマンドのみに対応するパース結果の型を返す。 + * + * @export + * @typedef ParsedParameter + * @template S コマンドスキーマの型 + */ +export type ParsedSubCommand = { + [K in keyof E]: { + name: K; + } & ParsedParameter; +}[keyof E]; + +export type SubCommands = S extends Schema + ? C + : S extends SubCommandGroup + ? C + : never; + +/** + * パース結果のエラーを表す型。 + * + * @export + * @typedef ParseError + */ +export type ParseError = + | [type: 'INVALID_DATA', expected: ParamType, but: unknown] + | [type: 'NEED_MORE_ARGS'] + | [ + type: 'OUT_OF_RANGE', + min: number | undefined, + max: number | undefined, + but: unknown + ] + | [type: 'UNKNOWN_CHOICE', choices: readonly string[], but: unknown] + | [type: 'UNKNOWN_COMMAND', subCommands: readonly string[], but: unknown] + | [type: 'OTHERS', message: string]; + +export const makeError = (error: ParseError): Error => { + let message: string; + switch (error[0]) { + case 'INVALID_DATA': + message = `\`${error[1]}\` 型の値を期待したけど、\`${String( + error[2] + )}\` がやって来たよ`; + break; + case 'NEED_MORE_ARGS': + message = `このコマンドの実行にはもっと引数が必要みたい`; + break; + case 'OUT_OF_RANGE': + message = `値 \`${String(error[3])}\` が範囲 \`${ + error[1] ?? '-∞' + }\` ~ \`${error[2] ?? '∞'}\` の外だったよ`; + break; + case 'UNKNOWN_CHOICE': + message = `\`${String(error[2])}\` が選択肢 ${error[1] + .map((command) => `\`${command}\``) + .join(', ')} の中に無いよ`; + break; + case 'UNKNOWN_COMMAND': + message = `コマンド \`${String( + error[2] + )}\` は利用可能なコマンド ${error[1] + .map((command) => `\`${command}\``) + .join(', ')} の中に無いよ`; + break; + case 'OTHERS': + message = + '不明なエラーが発生しちゃった。多分バグだよ。バグ報告はこちらから https://github.com/approvers/OreOreBot2/issues/new/choose'; + break; + } + return new Error(message); +}; diff --git a/src/runner/command.ts b/src/runner/command.ts new file mode 100644 index 00000000..dca8354c --- /dev/null +++ b/src/runner/command.ts @@ -0,0 +1,34 @@ +import type { + CommandMessage, + CommandResponder +} from '../service/command/command-message.js'; + +import type { Schema } from '../model/command-schema.js'; + +export type MessageCreateListener = ( + message: CommandMessage +) => Promise; + +export interface CommandProxy { + addMessageCreateListener( + schema: Schema, + listener: MessageCreateListener + ): void; +} + +export class CommandRunner { + private readonly responders: CommandResponder[] = []; + + constructor(private readonly proxy: CommandProxy) {} + + addResponder(responder: CommandResponder) { + this.responders.push(responder); + this.proxy.addMessageCreateListener(responder.schema, (message) => + responder.on(message) + ); + } + + getResponders(): readonly CommandResponder[] { + return this.responders; + } +} diff --git a/src/server/index.ts b/src/server/index.ts index 2972e3da..f3fcbce3 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -11,16 +11,11 @@ import { MessageProxy, MessageUpdateProxy, VoiceRoomProxy, - middlewareForCommand, middlewareForMessage, middlewareForUpdateMessage, roleProxy } from '../adaptor/index.js'; import { Client, GatewayIntentBits, version } from 'discord.js'; -import type { - CommandMessage, - CommandResponder -} from '../service/command/command-message.js'; import { EmojiResponseRunner, MessageResponseRunner, @@ -40,7 +35,10 @@ import { allRoleResponder, registerAllCommandResponder } from '../service/index.js'; + import type { AssetKey } from '../service/command/party.js'; +import { CommandRunner } from '../runner/command.js'; +import { DiscordCommandProxy } from '../adaptor/proxy/command.js'; import { DiscordMemberStats } from '../adaptor/discord/member-stats.js'; import { DiscordMessageRepository } from '../adaptor/discord/message-repo.js'; import { DiscordRoleManager } from '../adaptor/discord/role.js'; @@ -126,10 +124,8 @@ if (features.includes('MESSAGE_UPDATE')) { const scheduleRunner = new ScheduleRunner(clock); -const commandRunner: MessageResponseRunner = - new MessageResponseRunner( - new MessageProxy(client, middlewareForCommand(PREFIX)) - ); +const commandProxy = new DiscordCommandProxy(client, PREFIX); +const commandRunner = new CommandRunner(commandProxy); const stats = new DiscordMemberStats(client, GUILD_ID as Snowflake); // ほとんど変わらないことが予想され環境変数で管理する必要性が薄いので、ハードコードした。 diff --git a/src/service/command.ts b/src/service/command.ts index 426aeb76..91f86abc 100644 --- a/src/service/command.ts +++ b/src/service/command.ts @@ -4,10 +4,6 @@ import { RandomGenerator as PartyRng } from './command/party.js'; import type { Clock, ScheduleRunner } from '../runner/schedule.js'; -import type { - CommandMessage, - CommandResponder -} from './command/command-message.js'; import { DebugCommand, MessageRepository } from './command/debug.js'; import { GetVersionCommand, VersionFetcher } from './command/version.js'; import { GuildInfo, GuildStatsRepository } from './command/guild-info.js'; @@ -20,6 +16,7 @@ import { } from './command/kaere.js'; import { KokuseiChousa, MemberStats } from './command/kokusei-chousa.js'; import { MembersWithRoleRepository, RoleRank } from './command/role-rank.js'; +import type { Param, Schema } from '../model/command-schema.js'; import { Ping, PingCommand } from './command/ping.js'; import { RoleCreate, RoleCreateManager } from './command/role-create.js'; import { RoleInfo, RoleStatsRepository } from './command/role-info.js'; @@ -27,9 +24,10 @@ import { Sheriff, SheriffCommand } from './command/stfu.js'; import { TypoReporter, TypoRepository } from './command/typo-record.js'; import { UserInfo, UserStatsRepository } from './command/user-info.js'; +import type { CommandResponder } from './command/command-message.js'; +import type { CommandRunner } from '../runner/command.js'; import { HelpCommand } from './command/help.js'; import { Meme } from './command/meme.js'; -import type { MessageResponseRunner } from '../runner/message.js'; import type { VoiceConnectionFactory } from './voice-connection.js'; export const registerAllCommandResponder = ({ @@ -59,7 +57,7 @@ export const registerAllCommandResponder = ({ scheduleRunner: ScheduleRunner; random: PartyRng & RandomGenerator; roomController: VoiceRoomController; - commandRunner: MessageResponseRunner; + commandRunner: CommandRunner; stats: MemberStats; sheriff: Sheriff; ping: Ping; @@ -96,6 +94,10 @@ export const registerAllCommandResponder = ({ new RoleCreate(roleCreateRepo) ]; for (const responder of allResponders) { - commandRunner.addResponder(responder); + commandRunner.addResponder( + responder as unknown as CommandResponder< + Schema, readonly Param[]> + > + ); } }; diff --git a/src/service/command/command-message.ts b/src/service/command/command-message.ts index 62d1d7dd..25d05992 100644 --- a/src/service/command/command-message.ts +++ b/src/service/command/command-message.ts @@ -1,5 +1,6 @@ import type { EmbedMessage, EmbedPage } from '../../model/embed-message.js'; -import type { MessageEventResponder } from '../../runner/index.js'; +import type { ParsedSchema, Schema } from '../../model/command-schema.js'; + import type { Snowflake } from '../../model/id.js'; /** @@ -7,8 +8,9 @@ import type { Snowflake } from '../../model/id.js'; * * @export * @interface CommandMessage + * @template S スキーマの型 */ -export interface CommandMessage { +export interface CommandMessage>> { /** * コマンドの送信者の ID。 * @@ -50,12 +52,12 @@ export interface CommandMessage { senderName: string; /** - * コマンドの引数リスト。 + * パースされたコマンドの引数。 * - * @type {readonly string[]} + * @type {Readonly>} * @memberof CommandMessage */ - args: readonly string[]; + args: Readonly>; /** * このメッセージに `message` の内容で返信する。 @@ -92,28 +94,25 @@ export interface SentMessage { export interface HelpInfo { title: string; description: string; - commandName: string[]; - argsFormat: { - name: string; - description: string; - defaultValue?: string; - }[]; } -export type CommandResponder = MessageEventResponder & { +export interface CommandResponder>> { help: Readonly; -}; + schema: Readonly; + on(message: CommandMessage): Promise; +} -export const createMockMessage = ( - partial: Readonly>, - reply?: (message: EmbedMessage) => Promise -): CommandMessage => ({ +export const createMockMessage = >>( + args: Readonly>, + reply?: (message: EmbedMessage) => void | Promise, + partial?: Readonly, 'reply'>>> +): CommandMessage => ({ senderId: '279614913129742338' as Snowflake, senderGuildId: '683939861539192860' as Snowflake, senderChannelId: '711127633810817026' as Snowflake, senderVoiceChannelId: '683939861539192865' as Snowflake, senderName: 'Mikuroさいな', - args: [], + args, reply: reply ? async (mes) => (await reply(mes)) || { diff --git a/src/service/command/debug.test.ts b/src/service/command/debug.test.ts index c174110b..c8fcc91e 100644 --- a/src/service/command/debug.test.ts +++ b/src/service/command/debug.test.ts @@ -1,7 +1,9 @@ import { DebugCommand, MessageRepository } from './debug.js'; import { afterEach, describe, expect, it, vi } from 'vitest'; + import type { Snowflake } from '../../model/id.js'; import { createMockMessage } from './command-message.js'; +import { parseStringsOrThrow } from '../../adaptor/proxy/command/schema.js'; describe('debug', () => { afterEach(() => { @@ -17,15 +19,14 @@ describe('debug', () => { const getMessageContent = vi .spyOn(repo, 'getMessageContent') .mockImplementation(() => Promise.resolve('🅰️ Hoge')); - const reply = vi.fn(() => Promise.resolve()); + const reply = vi.fn(); await responder.on( - 'CREATE', createMockMessage( + parseStringsOrThrow(['debug', '1423523'], responder.schema), + reply, { - args: ['debug', '1423523'], senderChannelId: '8623233' as Snowflake - }, - reply + } ) ); expect(getMessageContent).toHaveBeenCalledWith('8623233', '1423523'); @@ -43,15 +44,14 @@ describe('debug', () => { console.log(\`Hello, \${name}!\`); \`\`\``) ); - const reply = vi.fn(() => Promise.resolve()); + const reply = vi.fn(); await responder.on( - 'CREATE', createMockMessage( + parseStringsOrThrow(['debug', '1423523'], responder.schema), + reply, { - args: ['debug', '1423523'], senderChannelId: '8623233' as Snowflake - }, - reply + } ) ); expect(getMessageContent).toHaveBeenCalledWith('8623233', '1423523'); @@ -69,15 +69,14 @@ console.log(\`Hello, \${name}!\`); it('errors on message not found', async () => { const getMessageContent = vi.spyOn(repo, 'getMessageContent'); - const reply = vi.fn(() => Promise.resolve()); + const reply = vi.fn(); await responder.on( - 'CREATE', createMockMessage( + parseStringsOrThrow(['debug', '1423523'], responder.schema), + reply, { - args: ['debug', '1423523'], senderChannelId: '8623233' as Snowflake - }, - reply + } ) ); expect(getMessageContent).toHaveBeenCalledWith('8623233', '1423523'); @@ -86,46 +85,4 @@ console.log(\`Hello, \${name}!\`); description: 'そのメッセージがこのチャンネルにあるかどうか確認してね。' }); }); - - it('does not react to another command', async () => { - const getMessageContent = vi.spyOn(repo, 'getMessageContent'); - const reply = vi.fn(() => Promise.resolve()); - await responder.on( - 'CREATE', - createMockMessage( - { - args: ['party'] - }, - reply - ) - ); - await responder.on( - 'DELETE', - createMockMessage( - { - args: ['party'] - }, - reply - ) - ); - expect(getMessageContent).not.toHaveBeenCalled(); - expect(reply).not.toHaveBeenCalled(); - }); - - it('does not react on deletion', async () => { - const getMessageContent = vi.spyOn(repo, 'getMessageContent'); - const reply = vi.fn(() => Promise.resolve()); - await responder.on( - 'DELETE', - createMockMessage( - { - args: ['debug', '1423523'], - senderChannelId: '8623233' as Snowflake - }, - reply - ) - ); - expect(getMessageContent).not.toHaveBeenCalled(); - expect(reply).not.toHaveBeenCalled(); - }); }); diff --git a/src/service/command/debug.ts b/src/service/command/debug.ts index 0294da6a..c6db7547 100644 --- a/src/service/command/debug.ts +++ b/src/service/command/debug.ts @@ -3,7 +3,7 @@ import type { CommandResponder, HelpInfo } from './command-message.js'; -import type { MessageEvent } from '../../runner/message.js'; + import type { Snowflake } from '../../model/id.js'; export interface MessageRepository { @@ -13,33 +13,35 @@ export interface MessageRepository { ): Promise; } +const SCHEMA = { + names: ['debug'], + subCommands: {}, + params: [ + { + type: 'MESSAGE', + name: 'メッセージID', + description: 'デバッグ表示したいメッセージのID' + } + ] +} as const; + const TRIPLE_BACK_QUOTES = /```/g; -export class DebugCommand implements CommandResponder { +export class DebugCommand implements CommandResponder { help: Readonly = { title: 'デバッガーはらちょ', description: - 'メッセージIDを渡すと、同じチャンネル内にあればそれをコードブロックとして表示するよ', - commandName: ['debug'], - argsFormat: [ - { - name: 'messageId', - description: 'デバッグ表示したいメッセージのID' - } - ] + 'メッセージIDを渡すと、同じチャンネル内にあればそれをコードブロックとして表示するよ' }; + readonly schema = SCHEMA; constructor(private readonly repo: MessageRepository) {} - async on(event: MessageEvent, message: CommandMessage): Promise { - if (event !== 'CREATE') { - return; - } + async on(message: CommandMessage): Promise { + const { + params: [messageId] + } = message.args; - const [commandName, messageId] = message.args; - if (!this.help.commandName.includes(commandName)) { - return; - } const content = await this.repo.getMessageContent( message.senderChannelId, messageId as Snowflake diff --git a/src/service/command/guild-info.test.ts b/src/service/command/guild-info.test.ts index b289e4c5..c17c8a84 100644 --- a/src/service/command/guild-info.test.ts +++ b/src/service/command/guild-info.test.ts @@ -1,7 +1,9 @@ import { GuildInfo, GuildStatsRepository } from './guild-info.js'; import { afterEach, describe, expect, it, vi } from 'vitest'; + import type { Snowflake } from '../../model/id.js'; import { createMockMessage } from './command-message.js'; +import { parseStringsOrThrow } from '../../adaptor/proxy/command/schema.js'; describe('GuildInfo', () => { afterEach(() => { @@ -37,11 +39,8 @@ describe('GuildInfo', () => { const fn = vi.fn(); await guildInfo.on( - 'CREATE', createMockMessage( - { - args: ['guildinfo'] - }, + parseStringsOrThrow(['guildinfo'], guildInfo.schema), fn ) ); @@ -130,11 +129,8 @@ describe('GuildInfo', () => { const fn = vi.fn(); await guildInfo.on( - 'CREATE', createMockMessage( - { - args: ['guildinfo'] - }, + parseStringsOrThrow(['guildinfo'], guildInfo.schema), fn ) ); diff --git a/src/service/command/guild-info.ts b/src/service/command/guild-info.ts index 3ef78b27..759fdf79 100644 --- a/src/service/command/guild-info.ts +++ b/src/service/command/guild-info.ts @@ -3,7 +3,7 @@ import type { CommandResponder, HelpInfo } from './command-message.js'; -import type { MessageEvent } from '../../runner/message.js'; + import type { Snowflake } from '../../model/id.js'; import { createTimestamp } from '../../model/create-timestamp.js'; @@ -69,24 +69,21 @@ export interface GuildStatsRepository { fetchGuildStats(): Promise; } -export class GuildInfo implements CommandResponder { +const SCHEMA = { + names: ['guildinfo', 'serverinfo'], + subCommands: {} +} as const; + +export class GuildInfo implements CommandResponder { help: Readonly = { title: 'ギルド秘書艦', - description: '限界開発鯖の情報を持ってくるよ', - commandName: ['guildinfo', 'serverinfo'], - argsFormat: [] + description: '限界開発鯖の情報を持ってくるよ' }; + readonly schema = SCHEMA; constructor(private readonly repo: GuildStatsRepository) {} - async on(event: MessageEvent, message: CommandMessage): Promise { - if ( - event !== 'CREATE' || - !this.help.commandName.includes(message.args[0]) - ) { - return; - } - + async on(message: CommandMessage): Promise { const stats = await this.repo.fetchGuildStats(); if (!stats) { await message.reply({ diff --git a/src/service/command/help.ts b/src/service/command/help.ts index 4dfc8def..d21206df 100644 --- a/src/service/command/help.ts +++ b/src/service/command/help.ts @@ -3,60 +3,59 @@ import type { CommandResponder, HelpInfo } from './command-message.js'; -import type { - MessageEvent, - MessageResponseRunner -} from '../../runner/index.js'; +import type { Param, Schema } from '../../model/command-schema.js'; + +import type { CommandRunner } from '../../runner/command.js'; import type { EmbedPage } from '../../model/embed-message.js'; -export class HelpCommand implements CommandResponder { +const SCHEMA = { + names: ['help', 'h'], + subCommands: {} +} as const; + +export class HelpCommand implements CommandResponder { help: Readonly = { title: 'はらちょヘルプ', - description: 'こんな機能が搭載されてるよ', - commandName: ['help', 'h'], - argsFormat: [] + description: 'こんな機能が搭載されてるよ' }; + readonly schema = SCHEMA; - constructor( - private readonly runner: MessageResponseRunner< - CommandMessage, - CommandResponder - > - ) {} + constructor(private readonly runner: CommandRunner) {} - async on(event: MessageEvent, message: CommandMessage): Promise { - const { args } = message; - if (event !== 'CREATE' || !this.help.commandName.includes(args[0])) { - return; - } - const helps = this.runner + async on(message: CommandMessage): Promise { + const helpAndSchema = this.runner .getResponders() - .map((responder) => responder.help); - const pages: EmbedPage[] = helps.map((help) => this.buildField(help)); + .map((responder) => ({ ...responder.help, ...responder.schema })); + const pages: EmbedPage[] = helpAndSchema.map((helpScheme) => + this.buildField(helpScheme) + ); await message.replyPages(pages); } private buildField({ title, description, - commandName, - argsFormat - }: Readonly): EmbedPage { - const patternsWithDesc: [string, string][] = argsFormat.map( - ({ name, description, defaultValue }) => [ - defaultValue === undefined ? `<${name}>` : `[${name}=${defaultValue}]`, + names, + params + }: Readonly< + HelpInfo & Schema, readonly Param[]> + >): EmbedPage { + const patternsWithDesc: [string, string][] = + params?.map(({ name, description, defaultValue }) => [ + defaultValue === undefined + ? `<${name}>` + : `[${name}=${String(defaultValue)}]`, description - ] - ); - const argsDecrptions = patternsWithDesc + ]) ?? []; + const argsDescriptions = patternsWithDesc .map(([argPattern, description]) => `\`${argPattern}\`: ${description}`) .join('\n'); const patterns = patternsWithDesc.map(([pattern]) => pattern); return { title, description: `${description} -\`${commandName.join('/')}${['', ...patterns].join(' ')}\` -${argsDecrptions}` +\`${names.join('/')}${['', ...patterns].join(' ')}\` +${argsDescriptions}` }; } } diff --git a/src/service/command/judging.test.ts b/src/service/command/judging.test.ts index 6e77b861..9ff3e081 100644 --- a/src/service/command/judging.test.ts +++ b/src/service/command/judging.test.ts @@ -1,22 +1,20 @@ import { emojiOf, waitingJudgingEmoji } from '../../model/judging-status.js'; import { expect, it, vi } from 'vitest'; -import type { EmbedMessage } from '../../model/embed-message.js'; + import { JudgingCommand } from './judging.js'; import { createMockMessage } from './command-message.js'; +import { parseStringsOrThrow } from '../../adaptor/proxy/command/schema.js'; it('use case of jd', async () => { const responder = new JudgingCommand({ sleep: () => Promise.resolve(), uniform: () => 2 }); - const fn = vi.fn<[EmbedMessage]>(() => Promise.resolve()); + const fn = vi.fn(); await responder.on( - 'CREATE', createMockMessage( - { - args: ['jd'] - }, + parseStringsOrThrow(['jd'], responder.schema), (embed) => { expect(embed).toStrictEqual({ title: '***†HARACHO ONLINE JUDGING SYSTEM†***', @@ -45,14 +43,11 @@ it('use case of judge', async () => { sleep: () => Promise.resolve(), uniform: () => 0 }); - const fn = vi.fn<[EmbedMessage]>(() => Promise.resolve()); + const fn = vi.fn(); await responder.on( - 'CREATE', createMockMessage( - { - args: ['judge', '1', 'WWW'] - }, + parseStringsOrThrow(['judge', '1', 'WWW'], responder.schema), (embed) => { expect(embed).toStrictEqual({ title: '***†HARACHO ONLINE JUDGING SYSTEM†***', @@ -77,61 +72,24 @@ it('max number of cases', async () => { }); await responder.on( - 'CREATE', createMockMessage( - { - args: ['jd', '1'] - }, + parseStringsOrThrow(['jd', '1'], responder.schema), (embed) => { expect(embed).toStrictEqual({ title: '***†HARACHO ONLINE JUDGING SYSTEM†***', description: `0 / 1 ${waitingJudgingEmoji}` }); - return Promise.resolve({ edit: () => Promise.resolve() }); } ) ); await responder.on( - 'CREATE', createMockMessage( - { - args: ['jd', '64'] - }, + parseStringsOrThrow(['jd', '64'], responder.schema), (embed) => { expect(embed).toStrictEqual({ title: '***†HARACHO ONLINE JUDGING SYSTEM†***', description: `0 / 64 ${waitingJudgingEmoji}` }); - return Promise.resolve({ edit: () => Promise.resolve() }); - } - ) - ); - - await responder.on( - 'CREATE', - createMockMessage( - { - args: ['jd', '0'] - }, - (embed) => { - expect(embed).toStrictEqual({ - title: '回数の指定が 1 以上 64 以下の整数じゃないよ。' - }); - return Promise.resolve({ edit: () => Promise.resolve() }); - } - ) - ); - await responder.on( - 'CREATE', - createMockMessage( - { - args: ['jd', '65'] - }, - (embed) => { - expect(embed).toStrictEqual({ - title: '回数の指定が 1 以上 64 以下の整数じゃないよ。' - }); - return Promise.resolve({ edit: () => Promise.resolve() }); } ) ); diff --git a/src/service/command/judging.ts b/src/service/command/judging.ts index 759e0a3b..4997a552 100644 --- a/src/service/command/judging.ts +++ b/src/service/command/judging.ts @@ -9,7 +9,6 @@ import { isJudgingStatus, waitingJudgingEmoji } from '../../model/judging-status.js'; -import type { MessageEvent } from '../../runner/index.js'; /** * `JudgingCommand` のための乱数生成器。 @@ -39,6 +38,33 @@ export interface RandomGenerator { const JUDGING_TITLE = '***†HARACHO ONLINE JUDGING SYSTEM†***'; +const SCHEMA = { + names: ['jd', 'judge'], + subCommands: {}, + params: [ + { + type: 'INTEGER', + name: 'テスト数', + description: '判定のアニメーションに使うテストケースの数、最大値は 64', + minValue: 1, + maxValue: 64, + defaultValue: 5 + }, + { + type: 'STRING', + name: '判定結果', + description: 'アニメーション終了後の判定', + defaultValue: 'AC' + }, + { + type: 'BOOLEAN', + name: '全失敗', + description: 'すべての判定結果を失敗にするかどうか', + defaultValue: false + } + ] +} as const; + /** * `judge` コマンドで競技プログラミングの判定をシミュレートする。 * @@ -46,47 +72,22 @@ const JUDGING_TITLE = '***†HARACHO ONLINE JUDGING SYSTEM†***'; * @class JudgingCommand * @implements {MessageEventResponder} */ -export class JudgingCommand implements CommandResponder { +export class JudgingCommand implements CommandResponder { help: Readonly = { title: JUDGING_TITLE, - description: 'プログラムが適格かどうか判定してあげるよ', - commandName: ['jd', 'judge'], - argsFormat: [ - { - name: 'テストケースの数', - description: '判定のアニメーションに使うテストケースの数、最大値は 64', - defaultValue: '5' - }, - { - name: '判定結果', - description: 'アニメーション終了後の判定', - defaultValue: 'AC' - } - ] + description: 'プログラムが適格かどうか判定してあげるよ' }; + readonly schema = SCHEMA; constructor(private readonly rng: RandomGenerator) {} - async on(event: MessageEvent, message: CommandMessage): Promise { - if (event !== 'CREATE') { - return; - } - - const [commandName, countArg = '5', result = 'AC', errorFromStartArg] = - message.args; - if (!['jd', 'judge'].includes(commandName)) { - return; - } - const count = parseInt(countArg, 10); - if (Number.isNaN(count) || count <= 0 || 64 < count) { - await message.reply({ - title: '回数の指定が 1 以上 64 以下の整数じゃないよ。' - }); - return; - } + async on(message: CommandMessage): Promise { + const { + params: [count, result, errorFromStart] + } = message.args; if (!isJudgingStatus(result)) { - await this.reject({ message, count, errorFromStartArg, result }); + await this.reject({ message, count, errorFromStart, result }); return; } if (result === 'AC') { @@ -103,12 +104,12 @@ export class JudgingCommand implements CommandResponder { await this.reject({ message, count, - errorFromStartArg, + errorFromStart, result: emojiOf(result) }); } - private async accept(message: CommandMessage, count: number) { + private async accept(message: CommandMessage, count: number) { const sent = await message.reply({ title: JUDGING_TITLE, description: `0 / ${count} ${waitingJudgingEmoji}` @@ -130,12 +131,12 @@ export class JudgingCommand implements CommandResponder { private async reject({ message, count, - errorFromStartArg, + errorFromStart, result }: { - message: CommandMessage; + message: CommandMessage; count: number; - errorFromStartArg: string; + errorFromStart: boolean; result: string; }) { const sent = await message.reply({ @@ -143,7 +144,6 @@ export class JudgingCommand implements CommandResponder { description: `0 / ${count} ${waitingJudgingEmoji}` }); - const errorFromStart = errorFromStartArg == '-all'; const errorAt = errorFromStart ? 1 : this.rng.uniform(1, count + 1); for (let i = 1; i <= count - 1; ++i) { diff --git a/src/service/command/kaere.test.ts b/src/service/command/kaere.test.ts index a0d862bf..3643d2c7 100644 --- a/src/service/command/kaere.test.ts +++ b/src/service/command/kaere.test.ts @@ -7,6 +7,7 @@ import { KaereCommand, type KaereMusicKey } from './kaere.js'; import { expect, it, vi } from 'vitest'; import { ScheduleRunner } from '../../runner/index.js'; import { createMockMessage } from './command-message.js'; +import { parseStringsOrThrow } from '../../adaptor/proxy/command/schema.js'; it('use case of kaere', async () => { const fn = vi.fn(); @@ -25,184 +26,109 @@ it('use case of kaere', async () => { }); await responder.on( - 'CREATE', - createMockMessage({ - args: ['kaere'] - }) + createMockMessage(parseStringsOrThrow(['kaere'], responder.schema)) ); await responder.on( - 'CREATE', createMockMessage( - { - args: ['kaere', 'bed', 'status'] - }, + parseStringsOrThrow(['kaere', 'bed', 'status'], responder.schema), (message) => { expect(message).toStrictEqual({ title: '強制切断モードは現在無効だよ。' }); - return Promise.resolve(); } ) ); await responder.on( - 'CREATE', createMockMessage( - { - args: ['kaere', 'bed', 'enable'] - }, + parseStringsOrThrow(['kaere', 'bed', 'enable'], responder.schema), (message) => { expect(message).toStrictEqual({ title: '強制切断モードを有効化したよ。' }); - return Promise.resolve(); } ) ); await responder.on( - 'CREATE', createMockMessage( - { - args: ['kaere', 'bed', 'status'] - }, + parseStringsOrThrow(['kaere', 'bed', 'status'], responder.schema), (message) => { expect(message).toStrictEqual({ title: '強制切断モードは現在有効だよ。' }); - return Promise.resolve(); } ) ); await responder.on( - 'CREATE', createMockMessage( - { - args: ['kaere', 'bed', 'disable'] - }, + parseStringsOrThrow(['kaere', 'bed', 'disable'], responder.schema), (message) => { expect(message).toStrictEqual({ title: '強制切断モードを無効化したよ。' }); - return Promise.resolve(); } ) ); await responder.on( - 'CREATE', createMockMessage( - { - args: ['kaere', 'bed', 'status'] - }, + parseStringsOrThrow(['kaere', 'bed', 'status'], responder.schema), (message) => { expect(message).toStrictEqual({ title: '強制切断モードは現在無効だよ。' }); - return Promise.resolve(); } ) ); await responder.on( - 'CREATE', createMockMessage( - { - args: ['kaere', 'reserve', 'add', '01:0'] - }, + parseStringsOrThrow( + ['kaere', 'reserve', 'add', '01:0'], + responder.schema + ), (message) => { expect(message).toStrictEqual({ title: '予約に成功したよ。', description: '午前1時0分に予約を入れておくね。' }); - return Promise.resolve(); } ) ); await responder.on( - 'CREATE', createMockMessage( - { - args: ['kaere', 'reserve', 'list'] - }, + parseStringsOrThrow(['kaere', 'reserve', 'list'], responder.schema), (message) => { expect(message).toStrictEqual({ title: '現在の予約状況をお知らせするね。', description: '- 午前1時0分' }); - return Promise.resolve(); } ) ); await responder.on( - 'CREATE', createMockMessage( - { - args: ['kaere', 'reserve', 'cancel', '1:00'] - }, + parseStringsOrThrow( + ['kaere', 'reserve', 'cancel', '01:0'], + responder.schema + ), (message) => { expect(message).toStrictEqual({ title: '予約キャンセルに成功したよ。', description: '午前1時0分の予約はキャンセルしておくね。' }); - return Promise.resolve(); } ) ); await responder.on( - 'CREATE', createMockMessage( - { - args: ['kaere', 'reserve', 'list'] - }, + parseStringsOrThrow(['kaere', 'reserve', 'list'], responder.schema), (message) => { expect(message).toStrictEqual({ title: '今は誰も予約してないようだね。' }); - return Promise.resolve(); } ) ); scheduleRunner.killAll(); }); - -it('must not reply', async () => { - const fn = vi.fn(); - const connectionFactory = new MockVoiceConnectionFactory(); - const clock = new MockClock(new Date(0)); - const scheduleRunner = new ScheduleRunner(clock); - const repo = new InMemoryReservationRepository(); - const responder = new KaereCommand({ - connectionFactory, - controller: { - disconnectAllUsersIn: fn - }, - clock, - scheduleRunner, - repo - }); - - await responder.on( - 'CREATE', - createMockMessage({ - args: ['typo'], - reply: fn - }) - ); - await responder.on( - 'CREATE', - createMockMessage({ - args: ['party'], - reply: fn - }) - ); - await responder.on( - 'DELETE', - createMockMessage({ - args: ['kaere'], - reply: fn - }) - ); - expect(fn).not.toHaveBeenCalled(); - - scheduleRunner.killAll(); -}); diff --git a/src/service/command/kaere.ts b/src/service/command/kaere.ts index 7a086011..8e3c6ac1 100644 --- a/src/service/command/kaere.ts +++ b/src/service/command/kaere.ts @@ -1,8 +1,4 @@ -import type { - Clock, - MessageEvent, - ScheduleRunner -} from '../../runner/index.js'; +import type { Clock, ScheduleRunner } from '../../runner/index.js'; import type { CommandMessage, CommandResponder, @@ -10,6 +6,7 @@ import type { } from './command-message.js'; import { Reservation, ReservationTime } from '../../model/reservation.js'; import { addDays, isBefore, setHours, setMinutes, setSeconds } from 'date-fns'; + import type { EmbedMessage } from '../../model/embed-message.js'; import type { Snowflake } from '../../model/id.js'; import type { VoiceConnectionFactory } from '../voice-connection.js'; @@ -80,10 +77,54 @@ export interface ReservationRepository { } const timeFormatErrorMessage: EmbedMessage = { - title: '日時の形式が読めないよ。', + title: '時刻の形式として読めないよ。', description: '`HH:MM` の形式で指定してくれないかな。' }; +const TIME_OPTIONS = [ + { + type: 'STRING', + name: '時刻', + description: '[HH]:[MM] 形式の時刻' + } +] as const; + +const SCHEMA = { + names: ['kaere'], + subCommands: { + 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_OPTIONS + }, + cancel: { + type: 'SUB_COMMAND', + params: TIME_OPTIONS + }, + list: { + type: 'SUB_COMMAND' + } + } + } + } +} as const; + /** * `kaere` コマンドでボイスチャンネルの参加者に切断を促す機能。 * @@ -91,20 +132,13 @@ const timeFormatErrorMessage: EmbedMessage = { * @class KaereCommand * @implements {MessageEventResponder} */ -export class KaereCommand implements CommandResponder { +export class KaereCommand implements CommandResponder { help: Readonly = { title: 'Kaere一葉', description: - 'VC内の人類に就寝を促すよ。引数なしで即起動。どの方式でもコマンド発行者がVCに居ないと動かないよ', - commandName: ['kaere'], - argsFormat: [ - { - name: 'モード', - description: - '`bed` または `reserve` を指定して、サブコマンドを続けてね。詳しいヘルプは `kaere help` まで' - } - ] + 'VC内の人類に就寝を促すよ。引数なしで即起動。どの方式でもコマンド発行者がVCに居ないと動かないよ' }; + readonly schema = SCHEMA; constructor( private readonly deps: { @@ -122,15 +156,9 @@ export class KaereCommand implements CommandResponder { }); } - async on(event: MessageEvent, message: CommandMessage): Promise { - if (event !== 'CREATE') { - return; - } + async on(message: CommandMessage): Promise { const { args } = message; - if (args.length < 1 || args[0] !== 'kaere') { - return; - } - if (args.length === 1) { + if (!args.subCommand) { const roomId = message.senderVoiceChannelId; if (!roomId) { await message.reply({ @@ -143,23 +171,12 @@ export class KaereCommand implements CommandResponder { await this.start(message.senderGuildId, roomId); return; } - switch (args[1]) { + switch (args.subCommand.name) { case 'bed': return this.handleBedCommand(message); case 'reserve': return this.handleReserveCommand(message); } - await message.reply({ - title: 'Kaereヘルプ', - description: ` -引数無しだと即座にKaereするよ。 -- \`bed enable\`/\`bed disable\`: 強制切断モードの有効/無効化 -- \`bed status\`: 強制切断モードの状態の確認 -- \`reserve add [HH]:[MM]\`: Kaereの開始を指定時刻で予約 -- \`reserve cancel [HH]:[MM]\`: 指定時刻の予約をキャンセル -- \`reserve list\`: 予約リストの一覧 -` - }); } private bedModeEnabled = false; @@ -191,8 +208,10 @@ export class KaereCommand implements CommandResponder { this.doingKaere = false; } - private async handleBedCommand(message: CommandMessage): Promise { - switch (message.args[2]) { + private async handleBedCommand( + message: CommandMessage + ): Promise { + switch (message.args.subCommand?.subCommand.name) { case 'enable': this.bedModeEnabled = true; await message.reply({ @@ -222,8 +241,10 @@ export class KaereCommand implements CommandResponder { }); } - private async handleReserveCommand(message: CommandMessage): Promise { - switch (message.args[2]) { + private async handleReserveCommand( + message: CommandMessage + ): Promise { + switch (message.args.subCommand?.subCommand.name) { case 'add': { const roomId = message.senderVoiceChannelId; @@ -234,7 +255,9 @@ export class KaereCommand implements CommandResponder { }); return; } - const time = ReservationTime.fromHoursMinutes(message.args[3]); + const time = ReservationTime.fromHoursMinutes( + message.args.subCommand.subCommand.params[0] + ); if (!time) { await message.reply(timeFormatErrorMessage); return; @@ -260,7 +283,9 @@ export class KaereCommand implements CommandResponder { return; case 'cancel': { - const time = ReservationTime.fromHoursMinutes(message.args[3]); + const time = ReservationTime.fromHoursMinutes( + message.args.subCommand.subCommand.params[0] + ); if (!time) { await message.reply(timeFormatErrorMessage); return; diff --git a/src/service/command/kokusei-chousa.test.ts b/src/service/command/kokusei-chousa.test.ts index 8a660db7..435979e1 100644 --- a/src/service/command/kokusei-chousa.test.ts +++ b/src/service/command/kokusei-chousa.test.ts @@ -1,6 +1,8 @@ -import { expect, it, vi } from 'vitest'; +import { expect, it } from 'vitest'; + import { KokuseiChousa } from './kokusei-chousa.js'; import { createMockMessage } from './command-message.js'; +import { parseStringsOrThrow } from '../../adaptor/proxy/command/schema.js'; it('use case of kokusei-chousa', async () => { const responder = new KokuseiChousa({ @@ -12,11 +14,8 @@ it('use case of kokusei-chousa', async () => { } }); await responder.on( - 'CREATE', createMockMessage( - { - args: ['kokusei'] - }, + parseStringsOrThrow(['kokusei'], responder.schema), (message) => { expect(message).toStrictEqual({ title: '***†只今の限界開発鯖の人口†***', @@ -35,29 +34,7 @@ it('use case of kokusei-chousa', async () => { { name: 'Bot率', value: '33.333%' } ] }); - return Promise.resolve(); } ) ); }); - -it('delete message', async () => { - const responder = new KokuseiChousa({ - allMemberCount(): Promise { - return Promise.resolve(100); - }, - botMemberCount(): Promise { - return Promise.resolve(50); - } - }); - const fn = vi.fn(); - await responder.on( - 'DELETE', - createMockMessage({ - args: ['kokusei'], - reply: fn - }) - ); - - expect(fn).not.toHaveBeenCalled(); -}); diff --git a/src/service/command/kokusei-chousa.ts b/src/service/command/kokusei-chousa.ts index 171a4f95..9339da12 100644 --- a/src/service/command/kokusei-chousa.ts +++ b/src/service/command/kokusei-chousa.ts @@ -3,38 +3,34 @@ import type { CommandResponder, HelpInfo } from './command-message.js'; -import type { MessageEvent } from '../../runner/index.js'; export interface MemberStats { allMemberCount(): Promise; botMemberCount(): Promise; } -export class KokuseiChousa implements CommandResponder { +const SCHEMA = { + names: [ + 'kokusei', + 'kokusei-chousa', + 'population', + 'number', + 'zinnkou', + 'zinkou' + ], + subCommands: {} +} as const; + +export class KokuseiChousa implements CommandResponder { help: Readonly = { title: '国勢調査', - description: '限界開発鯖の人類の数、Botの数とBot率を算出するよ。', - commandName: [ - 'kokusei', - 'kokusei-chousa', - 'population', - 'number', - 'zinnkou', - 'zinkou' - ], - argsFormat: [] + description: '限界開発鯖の人類の数、Botの数とBot率を算出するよ。' }; + readonly schema = SCHEMA; constructor(private readonly stats: MemberStats) {} - async on(event: MessageEvent, message: CommandMessage): Promise { - if ( - event !== 'CREATE' || - !this.help.commandName.includes(message.args[0]) - ) { - return; - } - + async on(message: CommandMessage): Promise { const botMemberCount = await this.stats.botMemberCount(); const allMemberCount = await this.stats.allMemberCount(); const peopleCount = allMemberCount - botMemberCount; diff --git a/src/service/command/meme.test.ts b/src/service/command/meme.test.ts index 49ca9ed7..218cc1ae 100644 --- a/src/service/command/meme.test.ts +++ b/src/service/command/meme.test.ts @@ -1,18 +1,16 @@ import { Meme, sanitizeArgs } from './meme.js'; import { describe, expect, it, vi } from 'vitest'; -import type { EmbedMessage } from '../../model/embed-message.js'; + import { createMockMessage } from './command-message.js'; +import { parseStringsOrThrow } from '../../adaptor/proxy/command/schema.js'; describe('meme', () => { const responder = new Meme(); it('use case of hukueki', async () => { await responder.on( - 'CREATE', createMockMessage( - { - args: ['hukueki', 'こるく'] - }, + parseStringsOrThrow(['hukueki', 'こるく'], responder.schema), (message) => { expect(message).toStrictEqual({ description: @@ -20,7 +18,6 @@ describe('meme', () => { 'こるくはしてないといいね\n' + '困らないでよ' }); - return Promise.resolve(); } ) ); @@ -28,17 +25,15 @@ describe('meme', () => { it('use case of lolicon', async () => { await responder.on( - 'CREATE', createMockMessage( - { - args: ['lolicon', 'こるく'], - senderName: 'める' - }, + parseStringsOrThrow(['lolicon', 'こるく'], responder.schema), (message) => { expect(message).toStrictEqual({ description: `だから僕はこるくを辞めた - める (Music Video)` }); - return Promise.resolve(); + }, + { + senderName: 'める' } ) ); @@ -46,30 +41,22 @@ describe('meme', () => { it('use case of dousurya', async () => { await responder.on( - 'CREATE', createMockMessage( - { - args: ['dousurya', 'こるく'] - }, + parseStringsOrThrow(['dousurya', 'こるく'], responder.schema), (message) => { expect(message).toStrictEqual({ description: `限界みたいな鯖に住んでるこるくはどうすりゃいいですか?` }); - return Promise.resolve(); } ) ); await responder.on( - 'CREATE', createMockMessage( - { - args: ['dousureba', 'こるく'] - }, + parseStringsOrThrow(['dousureba', 'こるく'], responder.schema), (message) => { expect(message).toStrictEqual({ description: `限界みたいな鯖に住んでるこるくはどうすりゃいいですか?` }); - return Promise.resolve(); } ) ); @@ -77,17 +64,15 @@ describe('meme', () => { it('use case of takopi', async () => { await responder.on( - 'CREATE', createMockMessage( - { - args: ['takopi', 'こるく'], - senderName: 'りにあ' - }, + parseStringsOrThrow(['takopi', 'こるく'], responder.schema), (message) => { expect(message).toStrictEqual({ description: `教員「こるく、出して」\nりにあ「わ、わかんないっピ.......」` }); - return Promise.resolve(); + }, + { + senderName: 'りにあ' } ) ); @@ -95,17 +80,15 @@ describe('meme', () => { it('use case of takopi (-f)', async () => { await responder.on( - 'CREATE', createMockMessage( - { - args: ['takopi', '-f', 'こるく'], - senderName: 'りにあ' - }, + parseStringsOrThrow(['takopi', '-f', 'こるく'], responder.schema), (message) => { expect(message).toStrictEqual({ description: `りにあ「こるく、出して」\n教員「わ、わかんないっピ.......」` }); - return Promise.resolve(); + }, + { + senderName: 'りにあ' } ) ); @@ -113,16 +96,15 @@ describe('meme', () => { it('use case of n', async () => { await responder.on( - 'CREATE', createMockMessage( - { - args: ['n', 'テスト前に課題もやらないで原神してて'] - }, + parseStringsOrThrow( + ['n', 'テスト前に課題もやらないで原神してて'], + responder.schema + ), (message) => { expect(message).toStrictEqual({ description: `テスト前に課題もやらないで原神しててNった` }); - return Promise.resolve(); } ) ); @@ -130,17 +112,13 @@ describe('meme', () => { it('use case of web3', async () => { await responder.on( - 'CREATE', createMockMessage( - { - args: ['web3', 'Rust'] - }, + parseStringsOrThrow(['web3', 'Rust'], responder.schema), (message) => { expect(message).toStrictEqual({ description: '```\n「いちばんやさしいRustの教本」 - インプレス \n```' }); - return Promise.resolve(); } ) ); @@ -148,17 +126,13 @@ describe('meme', () => { it('use case of moeta', async () => { await responder.on( - 'CREATE', createMockMessage( - { - args: ['moeta', '雪'] - }, + parseStringsOrThrow(['moeta', '雪'], responder.schema), (message) => { expect(message).toStrictEqual({ description: '「久留米の花火大会ね、寮から見れたの?」\n「うん ついでに雪が燃えた」\n「は?」' }); - return Promise.resolve(); } ) ); @@ -166,17 +140,18 @@ describe('meme', () => { it('args space', async () => { await responder.on( - 'CREATE', createMockMessage( - { - args: ['lolicon', 'こるく', 'にえっちを申し込む'], - senderName: 'める' - }, + parseStringsOrThrow( + ['lolicon', 'こるく', 'にえっちを申し込む'], + responder.schema + ), (message) => { expect(message).toStrictEqual({ description: `だから僕はこるく にえっちを申し込むを辞めた - める (Music Video)` }); - return Promise.resolve(); + }, + { + senderName: 'める' } ) ); @@ -184,17 +159,13 @@ describe('meme', () => { it('args null (hukueki)', async () => { await responder.on( - 'CREATE', createMockMessage( - { - args: ['hukueki'] - }, + parseStringsOrThrow(['hukueki'], responder.schema), (message) => { expect(message).toStrictEqual({ description: '服役できなかった。', title: '引数が不足してるみたいだ。' }); - return Promise.resolve(); } ) ); @@ -202,17 +173,13 @@ describe('meme', () => { it('args null (lolicon)', async () => { await responder.on( - 'CREATE', createMockMessage( - { - args: ['lolicon'] - }, + parseStringsOrThrow(['lolicon'], responder.schema), (message) => { expect(message).toStrictEqual({ description: 'こるくはロリコンをやめられなかった。', title: '引数が不足してるみたいだ。' }); - return Promise.resolve(); } ) ); @@ -220,17 +187,13 @@ describe('meme', () => { it('args null (dousureba)', async () => { await responder.on( - 'CREATE', createMockMessage( - { - args: ['dousureba'] - }, + parseStringsOrThrow(['dousureba'], responder.schema), (message) => { expect(message).toStrictEqual({ description: 'どうしようもない。', title: '引数が不足してるみたいだ。' }); - return Promise.resolve(); } ) ); @@ -238,17 +201,13 @@ describe('meme', () => { it('args null (takopi)', async () => { await responder.on( - 'CREATE', createMockMessage( - { - args: ['takopi'] - }, + parseStringsOrThrow(['takopi'], responder.schema), (message) => { expect(message).toStrictEqual({ description: '(引数が)わ、わかんないっピ.......', title: '引数が不足してるみたいだ。' }); - return Promise.resolve(); } ) ); @@ -256,18 +215,14 @@ describe('meme', () => { it('args null (n)', async () => { await responder.on( - 'CREATE', createMockMessage( - { - args: ['n'] - }, + parseStringsOrThrow(['n'], responder.schema), (message) => { expect(message).toStrictEqual({ title: '引数が不足してるみたいだ。', description: 'このままだと <@521958252280545280> みたいに留年しちゃう....' }); - return Promise.resolve(); } ) ); @@ -275,18 +230,14 @@ describe('meme', () => { it('args null (web3)', async () => { await responder.on( - 'CREATE', createMockMessage( - { - args: ['web3'] - }, + parseStringsOrThrow(['web3'], responder.schema), (message) => { expect(message).toStrictEqual({ title: '引数が不足してるみたいだ。', description: 'TCP/IP、SMTP、HTTPはGoogleやAmazonに独占されています。' }); - return Promise.resolve(); } ) ); @@ -294,18 +245,14 @@ describe('meme', () => { it('args null (moeta)', async () => { await responder.on( - 'CREATE', createMockMessage( - { - args: ['moeta'] - }, + parseStringsOrThrow(['moeta'], responder.schema), (message) => { expect(message).toStrictEqual({ title: '引数が不足してるみたいだ。', description: '[元ネタ](https://twitter.com/yuki_yuigishi/status/1555557259798687744)' }); - return Promise.resolve(); } ) ); @@ -314,31 +261,12 @@ describe('meme', () => { it('delete message', async () => { const fn = vi.fn(); await responder.on( - 'DELETE', - createMockMessage({ - args: ['hukueki', 'こるく'], - reply: fn - }) - ); - expect(fn).not.toHaveBeenCalled(); - }); - - it('help of meme', async () => { - const fn = vi.fn<[EmbedMessage]>(() => Promise.resolve()); - await responder.on( - 'CREATE', createMockMessage( - { - args: ['takopi', '--help'] - }, + parseStringsOrThrow(['fukueki', 'こるく'], responder.schema), fn ) ); - expect(fn).toHaveBeenCalledWith({ - title: '`takopi`', - description: - '「〜、出して」\n`-f` で教員と自分の名前の位置を反対にします。([idea: フライさん](https://github.com/approvers/OreOreBot2/issues/90))' - }); + expect(fn).not.toHaveBeenCalled(); }); }); diff --git a/src/service/command/meme.ts b/src/service/command/meme.ts index 84177471..7e44ba93 100644 --- a/src/service/command/meme.ts +++ b/src/service/command/meme.ts @@ -3,8 +3,8 @@ import type { CommandResponder, HelpInfo } from './command-message.js'; + import type { MemeTemplate } from '../../model/meme-template.js'; -import type { MessageEvent } from '../../runner/index.js'; import { memes } from './meme/index.js'; import parse from 'cli-argparse'; @@ -15,23 +15,33 @@ const memesByCommandName: Record< memes.flatMap((meme) => meme.commandNames.map((name) => [name, meme])) ); -export class Meme implements CommandResponder { +const SCHEMA = { + names: memes.flatMap((meme) => meme.commandNames), + subCommands: {}, + params: [ + { + type: 'VARIADIC', + name: '引数リスト', + description: + '各構文ごとの引数リスト。--help で各コマンドのヘルプが見られるよ', + defaultValue: [] + } + ] +} as const; + +export class Meme implements CommandResponder { help: Readonly = { title: 'ミーム構文機能', - description: '何これ……引数のテキストを構文にはめ込むみたいだよ', - commandName: memes.flatMap((meme) => meme.commandNames), - argsFormat: [ - { - name: '--help', - description: 'その構文ごとの詳細なヘルプを表示します' - } - ] + description: '何これ……引数のテキストを構文にはめ込むみたいだよ' }; + readonly schema = SCHEMA; - async on(event: MessageEvent, message: CommandMessage): Promise { - if (event !== 'CREATE') return; + async on(message: CommandMessage): Promise { const { args } = message; - const [commandName, ...commandArgs] = args; + const { + name: commandName, + params: [commandArgs] + } = args; const meme = memesByCommandName[commandName]; if (!meme) { return; diff --git a/src/service/command/party.test.ts b/src/service/command/party.test.ts index dbd18cae..13640432 100644 --- a/src/service/command/party.test.ts +++ b/src/service/command/party.test.ts @@ -4,6 +4,7 @@ import { afterAll, describe, expect, it, vi } from 'vitest'; import type { EmbedMessage } from '../../model/embed-message.js'; import { ScheduleRunner } from '../../runner/index.js'; import { createMockMessage } from './command-message.js'; +import { parseStringsOrThrow } from '../../adaptor/proxy/command/schema.js'; const random: RandomGenerator = { minutes: () => 42, @@ -25,17 +26,13 @@ describe('party ichiyo', () => { it('with no options', async () => { await responder.on( - 'CREATE', createMockMessage( - { - args: ['party'] - }, + parseStringsOrThrow(['party'], responder.schema), (message) => { expect(message).toStrictEqual({ title: `パーティー Nigth`, description: 'хорошо、宴の始まりだ。' }); - return Promise.resolve(); } ) ); @@ -43,85 +40,52 @@ describe('party ichiyo', () => { it('use case of party', async () => { await responder.on( - 'CREATE', createMockMessage( - { - args: ['party', 'status'] - }, + parseStringsOrThrow(['party', 'status'], responder.schema), (message) => { expect(message).toStrictEqual({ title: 'ゲリラは現在無効だよ。' }); - return Promise.resolve(); } ) ); await responder.on( - 'CREATE', createMockMessage( - { - args: ['party', 'enable'] - }, + parseStringsOrThrow(['party', 'enable'], responder.schema), (message) => { expect(message).toStrictEqual({ title: 'ゲリラを有効化しておいたよ。' }); - return Promise.resolve(); } ) ); await responder.on( - 'CREATE', createMockMessage( - { - args: ['party', 'status'] - }, + parseStringsOrThrow(['party', 'status'], responder.schema), (message) => { expect(message).toStrictEqual({ title: 'ゲリラは現在有効だよ。' }); - return Promise.resolve(); } ) ); await responder.on( - 'CREATE', createMockMessage( - { - args: ['party', 'disable'] - }, + parseStringsOrThrow(['party', 'disable'], responder.schema), (message) => { expect(message).toStrictEqual({ title: 'ゲリラを無効化しておいたよ。' }); - return Promise.resolve(); } ) ); await responder.on( - 'CREATE', createMockMessage( - { - args: ['party', 'status'] - }, + parseStringsOrThrow(['party', 'status'], responder.schema), (message) => { expect(message).toStrictEqual({ title: 'ゲリラは現在無効だよ。' }); - return Promise.resolve(); - } - ) - ); - - await responder.on( - 'CREATE', - createMockMessage( - { - args: ['party', 'set', '__UNKNOWN__'] - }, - (message) => { - expect(message.title).toStrictEqual('BGMを設定できなかった。'); - return Promise.resolve(); } ) ); @@ -130,8 +94,10 @@ describe('party ichiyo', () => { it('party time', async () => { const fn = vi.fn(() => Promise.resolve()); await responder.on( - 'CREATE', - createMockMessage({ args: ['party', 'time'] }, fn) + createMockMessage( + parseStringsOrThrow(['party', 'time'], responder.schema), + fn + ) ); expect(fn).toHaveBeenCalledWith({ title: '次のゲリラ参加時刻を42分にしたよ。' @@ -141,47 +107,27 @@ describe('party ichiyo', () => { it('party specified time', async () => { const fn = vi.fn(() => Promise.resolve()); await responder.on( - 'CREATE', - createMockMessage({ args: ['party', 'time', '36'] }, fn) + createMockMessage( + parseStringsOrThrow(['party', 'time', '36'], responder.schema), + fn + ) ); expect(fn).toHaveBeenCalledWith({ title: '次のゲリラ参加時刻を36分にしたよ。' }); }); - it('must not reply', async () => { - const fn = vi.fn(); - await responder.on( - 'CREATE', - createMockMessage({ - args: ['typo'] - }) - ); - await responder.on( - 'CREATE', - createMockMessage({ - args: ['partyichiyo'] - }) - ); - await responder.on( - 'DELETE', - createMockMessage({ - args: ['party'] - }) - ); - expect(fn).not.toHaveBeenCalled(); - }); - it('party enable but must cancel', async () => { const fn = vi.fn(); const reply = vi.fn<[EmbedMessage]>(() => Promise.resolve({ edit: fn })); await responder.on( - 'CREATE', - createMockMessage({ - args: ['party', 'enable'], + createMockMessage( + parseStringsOrThrow(['party', 'enable'], responder.schema), reply, - senderVoiceChannelId: null - }) + { + senderVoiceChannelId: null + } + ) ); const nextTriggerMs = (random.minutes() + 1) * 60 * 1000; clock.placeholder = new Date(nextTriggerMs); diff --git a/src/service/command/party.ts b/src/service/command/party.ts index 0521159c..9bc70748 100644 --- a/src/service/command/party.ts +++ b/src/service/command/party.ts @@ -1,8 +1,4 @@ -import type { - Clock, - MessageEvent, - ScheduleRunner -} from '../../runner/index.js'; +import type { Clock, ScheduleRunner } from '../../runner/index.js'; import type { CommandMessage, CommandResponder, @@ -13,6 +9,7 @@ import type { VoiceConnectionFactory } from '../voice-connection.js'; import { addHours, getMinutes, setMinutes, setSeconds } from 'date-fns'; + import type { EmbedMessage } from '../../model/embed-message.js'; const partyStarting: EmbedMessage = { @@ -50,6 +47,44 @@ export interface RandomGenerator { pick(array: readonly T[]): T; } +const SCHEMA = { + names: ['party'], + subCommands: { + enable: { + type: 'SUB_COMMAND' + }, + disable: { + type: 'SUB_COMMAND' + }, + status: { + type: 'SUB_COMMAND' + }, + time: { + type: 'SUB_COMMAND', + params: [ + { + type: 'INTEGER', + name: '開始する分', + description: + '次にゲリラを始める分を指定できるよ。指定しなかったり負数を指定したらランダムになるよ。', + defaultValue: -1 + } + ] + }, + set: { + type: 'SUB_COMMAND', + params: [ + { + type: 'CHOICES', + name: '曲', + description: '次の Party で再生する曲を指定できるよ。', + choices: assetKeys + } + ] + } + } +} as const; + /** * `party` コマンドで押し掛けPartyする機能。 * @@ -57,16 +92,13 @@ export interface RandomGenerator { * @class PartyCommand * @implements {MessageEventResponder} */ -export class PartyCommand implements CommandResponder { +export class PartyCommand implements CommandResponder { help: Readonly = { title: 'Party一葉', description: - 'VC内の人類に押しかけてPartyを開くよ。引数なしで即起動。どの方式でもコマンド発行者がVCに居ないと動かないよ', - commandName: ['party'], - argsFormat: [ - { name: 'サブコマンド', description: '詳しくは `party help` まで' } - ] + 'VC内の人類に押しかけてPartyを開くよ。引数なしで即起動。どの方式でもコマンド発行者がVCに居ないと動かないよ' }; + readonly schema = SCHEMA; constructor( private readonly deps: { @@ -81,19 +113,13 @@ export class PartyCommand implements CommandResponder { private connection: VoiceConnection | null = null; private randomizedEnabled = false; - async on(event: MessageEvent, message: CommandMessage): Promise { - if (event !== 'CREATE') { - return; - } + async on(message: CommandMessage): Promise { const { args } = message; - if (args.length < 1 || args[0] !== 'party') { - return; - } - if (args.length === 1) { + if (!args.subCommand) { await this.startPartyImmediately(message); return; } - switch (args[1]) { + switch (args.subCommand.name) { case 'disable': this.stopRandomized(); await message.reply({ title: 'ゲリラを無効化しておいたよ。' }); @@ -109,9 +135,9 @@ export class PartyCommand implements CommandResponder { return; case 'time': { - let minutes: number; - if (args.length === 3) { - minutes = parseInt(args[2], 10) % 60; + let [minutes] = args.subCommand.params; + if (0 <= args.subCommand.params[0]) { + minutes = minutes % 60; } else { minutes = this.deps.random.minutes(); } @@ -123,31 +149,12 @@ export class PartyCommand implements CommandResponder { return; case 'set': { - const musicKey = args[2]; - if (!(assetKeys as readonly string[]).includes(musicKey)) { - await message.reply({ - title: 'BGMを設定できなかった。', - description: `以下のいずれかを指定してね。\n${assetKeys - .map((key) => `- ${key}`) - .join('\n')}` - }); - return; - } - this.nextMusicKey = musicKey as AssetKey; + const musicKey = assetKeys[args.subCommand.params[0]]; + this.nextMusicKey = musicKey; await message.reply({ title: 'BGMを設定したよ。' }); } return; } - await message.reply({ - title: 'Party一葉ヘルプ', - description: ` -引数無しだと即座にPartyするよ。 -- \`enable\`/\`disable\`: ゲリラモードの有効/無効化 -- \`status\`: ゲリラモードの状態の確認 -- \`time\`: ゲリラモードの参加時刻を上書き指定 -- \`set\`: 次のPartyの曲を上書き指定 -` - }); } private generateNextKey(): AssetKey { @@ -160,7 +167,7 @@ export class PartyCommand implements CommandResponder { } private async startPartyImmediately( - message: CommandMessage + message: CommandMessage ): Promise<'BREAK' | 'CONTINUE'> { if (this.connection) { return 'BREAK'; @@ -199,7 +206,10 @@ export class PartyCommand implements CommandResponder { return setSeconds(nextTime, 0); } - private startPartyAt(minutes: number, message: CommandMessage) { + private startPartyAt( + minutes: number, + message: CommandMessage + ) { this.deps.scheduleRunner.runOnNextTime( 'party-once', async () => { @@ -210,7 +220,7 @@ export class PartyCommand implements CommandResponder { ); } - private activateRandomized(message: CommandMessage) { + private activateRandomized(message: CommandMessage) { this.randomizedEnabled = true; this.deps.scheduleRunner.runOnNextTime( 'party-random', diff --git a/src/service/command/ping.ts b/src/service/command/ping.ts index 220b17d9..63acfe8e 100644 --- a/src/service/command/ping.ts +++ b/src/service/command/ping.ts @@ -3,7 +3,6 @@ import type { CommandResponder, HelpInfo } from './command-message.js'; -import type { MessageEvent } from '../../runner/index.js'; export interface Ping { /** @@ -12,24 +11,21 @@ export interface Ping { avgPing: number; } -export class PingCommand implements CommandResponder { +const SCHEMA = { + names: ['ping', 'latency'], + subCommands: {} +} as const; + +export class PingCommand implements CommandResponder { help: Readonly = { title: 'Ping', - description: '現在のレイテンシを表示するよ。', - commandName: ['ping', 'latency'], - argsFormat: [] + description: '現在のレイテンシを表示するよ。' }; + readonly schema = SCHEMA; constructor(private readonly ping: Ping) {} - async on(event: MessageEvent, message: CommandMessage): Promise { - if ( - event !== 'CREATE' || - !this.help.commandName.includes(message.args[0]) - ) { - return; - } - + async on(message: CommandMessage): Promise { await message.reply({ title: 'Ping', url: 'https://discordstatus.com/', diff --git a/src/service/command/role-create.test.ts b/src/service/command/role-create.test.ts index 6086c46a..8e417efb 100644 --- a/src/service/command/role-create.test.ts +++ b/src/service/command/role-create.test.ts @@ -1,6 +1,8 @@ import { RoleCreate, RoleCreateManager } from './role-create.js'; import { afterEach, describe, expect, it, vi } from 'vitest'; + import { createMockMessage } from './command-message.js'; +import { parseStringsOrThrow } from '../../adaptor/proxy/command/schema.js'; describe('Create a role', () => { afterEach(() => { @@ -19,11 +21,11 @@ describe('Create a role', () => { const fn = vi.fn(); await createRole.on( - 'CREATE', createMockMessage( - { - args: ['rolecreate', newRoleName, newRoleColor] - }, + parseStringsOrThrow( + ['rolecreate', newRoleName, newRoleColor], + createRole.schema + ), fn ) ); @@ -46,11 +48,11 @@ describe('Create a role', () => { const fn = vi.fn(); await createRole.on( - 'CREATE', createMockMessage( - { - args: ['rolecreate', newRoleName, newRoleColor] - }, + parseStringsOrThrow( + ['rolecreate', newRoleName, newRoleColor], + createRole.schema + ), fn ) ); @@ -73,11 +75,11 @@ describe('Create a role', () => { const fn = vi.fn(); await createRole.on( - 'CREATE', createMockMessage( - { - args: ['rolecreate', newRoleName, newRoleColor] - }, + parseStringsOrThrow( + ['rolecreate', newRoleName, newRoleColor], + createRole.schema + ), fn ) ); @@ -93,61 +95,17 @@ describe('Create a role', () => { ); }); - it('Missing argument(rolename)', async () => { - const createGuildRole = vi.spyOn(manager, 'createRole'); - const fn = vi.fn(); - - await createRole.on( - 'CREATE', - createMockMessage( - { - args: ['rolecreate'] - }, - fn - ) - ); - - expect(fn).toHaveBeenCalledWith({ - title: 'コマンド形式エラー', - description: '引数にロール名の文字列を指定してね' - }); - expect(createGuildRole).not.toHaveBeenCalled(); - }); - - it('Missing argument(rolecolor)', async () => { - const newRoleName = 'かわえもんのおねえさん'; - const createGuildRole = vi.spyOn(manager, 'createRole'); - const fn = vi.fn(); - - await createRole.on( - 'CREATE', - createMockMessage( - { - args: ['rolecreate', newRoleName] - }, - fn - ) - ); - - expect(fn).toHaveBeenCalledWith({ - title: 'コマンド形式エラー', - description: - '引数にロールの色の[HEX](https://htmlcolorcodes.com/)を指定してね' - }); - expect(createGuildRole).not.toHaveBeenCalled(); - }); - it('HEX Error (rolecolor)', async () => { const newRoleName = 'かわえもんのおねえさん'; const createGuildRole = vi.spyOn(manager, 'createRole'); const fn = vi.fn(); await createRole.on( - 'CREATE', createMockMessage( - { - args: ['rolecreate', newRoleName, 'fffffff'] - }, + parseStringsOrThrow( + ['rolecreate', newRoleName, 'fffffff'], + createRole.schema + ), fn ) ); @@ -166,11 +124,11 @@ describe('Create a role', () => { const fn = vi.fn(); await createRole.on( - 'CREATE', createMockMessage( - { - args: ['rolecreate', newRoleName, '#ffffff'] - }, + parseStringsOrThrow( + ['rolecreate', newRoleName, '#ffffff'], + createRole.schema + ), fn ) ); diff --git a/src/service/command/role-create.ts b/src/service/command/role-create.ts index 277b846a..b76a8e3f 100644 --- a/src/service/command/role-create.ts +++ b/src/service/command/role-create.ts @@ -3,7 +3,6 @@ import type { CommandResponder, HelpInfo } from './command-message.js'; -import type { MessageEvent } from '../../runner/message.js'; export interface RoleCreateManager { createRole( @@ -13,52 +12,38 @@ export interface RoleCreateManager { ): Promise; } -export class RoleCreate implements CommandResponder { +const HEX_FORMAT = /^#?[0-9a-fA-F]{6}$/m; + +const SCHEMA = { + names: ['rolecreate'], + subCommands: {}, + params: [ + { + type: 'STRING', + name: 'ロール名', + description: '作成するロールの名前を指定してね' + }, + { + type: 'STRING', + name: 'ロールの色', + description: + '作成するロールの色を[HEX](https://htmlcolorcodes.com/)で指定してね' + } + ] +} as const; + +export class RoleCreate implements CommandResponder { help: Readonly = { title: 'ロール作成', - description: 'ロールを作成するよ', - commandName: ['rolecreate'], - argsFormat: [ - { - name: 'ロール名', - description: '作成するロールの名前を指定してね' - }, - { - name: 'ロールの色', - description: - '作成するロールの色を[HEX](https://htmlcolorcodes.com/)で指定してね' - } - ] + description: 'ロールを作成するよ' }; + readonly schema = SCHEMA; constructor(private readonly manager: RoleCreateManager) {} - async on(event: MessageEvent, message: CommandMessage): Promise { - if (event !== 'CREATE') { - return; - } - const [command, roleName, roleColor] = message.args; - if (!this.help.commandName.includes(command)) { - return; - } - - if (typeof roleName !== 'string') { - await message.reply({ - title: 'コマンド形式エラー', - description: '引数にロール名の文字列を指定してね' - }); - return; - } - if (typeof roleColor !== 'string') { - await message.reply({ - title: 'コマンド形式エラー', - description: - '引数にロールの色の[HEX](https://htmlcolorcodes.com/)を指定してね' - }); - return; - } - - if (!roleColor.match(/^#?[0-9a-fA-F]{6}$/m)) { + async on(message: CommandMessage): Promise { + const [roleName, roleColor] = message.args.params; + if (!roleColor.match(HEX_FORMAT)) { await message.reply({ title: 'コマンド形式エラー', description: diff --git a/src/service/command/role-info.test.ts b/src/service/command/role-info.test.ts index 1a288e6a..1430db60 100644 --- a/src/service/command/role-info.test.ts +++ b/src/service/command/role-info.test.ts @@ -1,10 +1,12 @@ import { RoleInfo, RoleStatsRepository } from './role-info.js'; import { afterEach, describe, expect, it, vi } from 'vitest'; + import { createMockMessage } from './command-message.js'; +import { parseStringsOrThrow } from '../../adaptor/proxy/command/schema.js'; describe('RoleRank', () => { afterEach(() => { - vi.resetAllMocks(); + vi.restoreAllMocks(); }); const repo: RoleStatsRepository = { @@ -27,11 +29,8 @@ describe('RoleRank', () => { const fn = vi.fn(); await roleInfo.on( - 'CREATE', createMockMessage( - { - args: ['roleinfo', '101'] - }, + parseStringsOrThrow(['roleinfo', '101'], roleInfo.schema), fn ) ); @@ -71,37 +70,13 @@ describe('RoleRank', () => { expect(fetchStats).toHaveBeenCalledOnce(); }); - it('errors with no arg', async () => { - const fetchStats = vi.spyOn(repo, 'fetchStats'); - const fn = vi.fn(); - - await roleInfo.on( - 'CREATE', - createMockMessage( - { - args: ['roleinfo'] - }, - fn - ) - ); - - expect(fn).toHaveBeenCalledWith({ - title: 'コマンド形式エラー', - description: '引数にロールIDの文字列を指定してね' - }); - expect(fetchStats).not.toHaveBeenCalled(); - }); - it('errors with invalid id', async () => { const fetchStats = vi.spyOn(repo, 'fetchStats'); const fn = vi.fn(); await roleInfo.on( - 'CREATE', createMockMessage( - { - args: ['roleinfo', '100'] - }, + parseStringsOrThrow(['roleinfo', '100'], roleInfo.schema), fn ) ); @@ -112,22 +87,4 @@ describe('RoleRank', () => { }); expect(fetchStats).toHaveBeenCalledOnce(); }); - - it('does not react on deletion', async () => { - const fetchStats = vi.spyOn(repo, 'fetchStats'); - const fn = vi.fn(); - - await roleInfo.on( - 'DELETE', - createMockMessage( - { - args: ['roleinfo', '101'] - }, - fn - ) - ); - - expect(fn).not.toBeCalled(); - expect(fetchStats).not.toHaveBeenCalled(); - }); }); diff --git a/src/service/command/role-info.ts b/src/service/command/role-info.ts index e3a987c7..5197b7a9 100644 --- a/src/service/command/role-info.ts +++ b/src/service/command/role-info.ts @@ -3,7 +3,6 @@ import type { CommandResponder, HelpInfo } from './command-message.js'; -import type { MessageEvent } from '../../runner/message.js'; export type RoleIcon = | { @@ -27,36 +26,29 @@ export interface RoleStatsRepository { fetchStats(roleId: string): Promise; } -export class RoleInfo implements CommandResponder { +const SCHEMA = { + names: ['roleinfo'], + subCommands: {}, + params: [ + { + type: 'ROLE', + name: 'ロールID', + description: 'このIDのロールを調べるよ' + } + ] +} as const; + +export class RoleInfo implements CommandResponder { help: Readonly = { title: 'ロール秘書艦', - description: '指定したロールの情報を調べてくるよ', - commandName: ['roleinfo'], - argsFormat: [ - { - name: 'ロールID', - description: 'このIDのロールを調べるよ' - } - ] + description: '指定したロールの情報を調べてくるよ' }; + readonly schema = SCHEMA; constructor(private readonly repo: RoleStatsRepository) {} - async on(event: MessageEvent, message: CommandMessage): Promise { - if (event !== 'CREATE') { - return; - } - const [command, roleId] = message.args; - if (!this.help.commandName.includes(command)) { - return; - } - if (typeof roleId !== 'string') { - await message.reply({ - title: 'コマンド形式エラー', - description: '引数にロールIDの文字列を指定してね' - }); - return; - } + async on(message: CommandMessage): Promise { + const [roleId] = message.args.params; const stats = await this.repo.fetchStats(roleId); if (!stats) { diff --git a/src/service/command/role-rank.test.ts b/src/service/command/role-rank.test.ts index 2203d628..a09c5b81 100644 --- a/src/service/command/role-rank.test.ts +++ b/src/service/command/role-rank.test.ts @@ -1,10 +1,12 @@ import { MembersWithRoleRepository, RoleRank } from './role-rank.js'; import { afterEach, describe, expect, it, vi } from 'vitest'; + import { createMockMessage } from './command-message.js'; +import { parseStringsOrThrow } from '../../adaptor/proxy/command/schema.js'; describe('RoleRank', () => { afterEach(() => { - vi.resetAllMocks(); + vi.restoreAllMocks(); }); const repo: MembersWithRoleRepository = { @@ -43,13 +45,7 @@ describe('RoleRank', () => { const fn = vi.fn(); await roleRank.on( - 'CREATE', - createMockMessage( - { - args: ['rolerank'] - }, - fn - ) + createMockMessage(parseStringsOrThrow(['rolerank'], roleRank.schema), fn) ); expect(fn).toHaveBeenCalledWith({ @@ -79,22 +75,4 @@ describe('RoleRank', () => { }); expect(fetchMembersWithRole).toHaveBeenCalledOnce(); }); - - it('does not react on deletion', async () => { - const fetchMembersWithRole = vi.spyOn(repo, 'fetchMembersWithRole'); - const fn = vi.fn(); - - await roleRank.on( - 'DELETE', - createMockMessage( - { - args: ['rolerank'] - }, - fn - ) - ); - - expect(fn).not.toBeCalled(); - expect(fetchMembersWithRole).not.toHaveBeenCalled(); - }); }); diff --git a/src/service/command/role-rank.ts b/src/service/command/role-rank.ts index 8d7211ed..9a19b30f 100644 --- a/src/service/command/role-rank.ts +++ b/src/service/command/role-rank.ts @@ -4,8 +4,6 @@ import type { HelpInfo } from './command-message.js'; -import type { MessageEvent } from '../../runner/message.js'; - export interface MemberWithRole { displayName: string; roles: number; @@ -15,23 +13,21 @@ export interface MembersWithRoleRepository { fetchMembersWithRole(): Promise; } -export class RoleRank implements CommandResponder { +const SCHEMA = { + names: ['rolerank'], + subCommands: {} +} as const; + +export class RoleRank implements CommandResponder { help: Readonly = { title: 'ロール数ランキング', - commandName: ['rolerank'], - argsFormat: [], description: '各メンバーごとのロール数をランキング形式で表示するよ' }; + readonly schema = SCHEMA; constructor(private readonly repo: MembersWithRoleRepository) {} - async on(event: MessageEvent, message: CommandMessage): Promise { - if (event !== 'CREATE') { - return; - } - if (!this.help.commandName.includes(message.args[0])) { - return; - } + async on(message: CommandMessage): Promise { const members = await this.repo.fetchMembersWithRole(); members.sort((a, b) => b.roles - a.roles); members.splice(5); diff --git a/src/service/command/stfu.test.ts b/src/service/command/stfu.test.ts index 4a9e569b..4aaf1646 100644 --- a/src/service/command/stfu.test.ts +++ b/src/service/command/stfu.test.ts @@ -1,7 +1,9 @@ import { type Sheriff, SheriffCommand } from './stfu.js'; import { afterEach, describe, expect, it, vi } from 'vitest'; + import type { Snowflake } from '../../model/id.js'; import { createMockMessage } from './command-message.js'; +import { parseStringsOrThrow } from '../../adaptor/proxy/command/schema.js'; describe('stfu', () => { afterEach(() => { @@ -16,10 +18,7 @@ describe('stfu', () => { const fn = vi.fn(); const react = vi.fn<[string]>(() => Promise.resolve()); await responder.on( - 'CREATE', - createMockMessage({ - args: ['stfu'], - reply: fn, + createMockMessage(parseStringsOrThrow(['stfu'], responder.schema), fn, { react }) ); @@ -37,12 +36,13 @@ describe('stfu', () => { const fn = vi.fn(); const react = vi.fn<[string]>(() => Promise.resolve()); await responder.on( - 'CREATE', - createMockMessage({ - args: ['stfu', '25'], - reply: fn, - react - }) + createMockMessage( + parseStringsOrThrow(['stfu', '25'], responder.schema), + fn, + { + react + } + ) ); expect(fn).not.toHaveBeenCalled(); @@ -53,55 +53,4 @@ describe('stfu', () => { ); expect(react).toHaveBeenCalledWith('👌'); }); - - it('errors invalid specification', async () => { - const executeMessage = vi.spyOn(sheriff, 'executeMessage'); - const fn = vi.fn(); - const react = vi.fn<[string]>(() => Promise.resolve()); - await responder.on( - 'CREATE', - createMockMessage({ - args: ['stfu', '51'], - reply: fn, - react - }) - ); - - expect(fn).toHaveBeenCalledWith({ - title: '引数の範囲エラー', - description: '1 以上 50 以下の整数を指定してね。' - }); - expect(executeMessage).not.toHaveBeenCalled(); - expect(react).not.toHaveBeenCalled(); - }); - - it('delete message', async () => { - const executeMessage = vi.spyOn(sheriff, 'executeMessage'); - const fn = vi.fn(); - await responder.on( - 'DELETE', - createMockMessage({ - args: ['stfu'], - reply: fn - }) - ); - - expect(fn).not.toHaveBeenCalled(); - expect(executeMessage).not.toHaveBeenCalled(); - }); - - it('other command', async () => { - const executeMessage = vi.spyOn(sheriff, 'executeMessage'); - const fn = vi.fn(); - await responder.on( - 'CREATE', - createMockMessage({ - args: ['sft'], - reply: fn - }) - ); - - expect(fn).not.toHaveBeenCalled(); - expect(executeMessage).not.toHaveBeenCalled(); - }); }); diff --git a/src/service/command/stfu.ts b/src/service/command/stfu.ts index d15ff609..4d9bb8a1 100644 --- a/src/service/command/stfu.ts +++ b/src/service/command/stfu.ts @@ -3,7 +3,7 @@ import type { CommandResponder, HelpInfo } from './command-message.js'; -import type { MessageEvent } from '../../runner/index.js'; + import type { Snowflake } from '../../model/id.js'; /** @@ -21,6 +21,23 @@ export interface Sheriff { */ executeMessage(channel: Snowflake, historyRange: number): Promise; } + +const SCHEMA = { + names: ['stfu'], + subCommands: {}, + params: [ + { + type: 'INTEGER', + name: '削除個数', + description: + 'はらちょのメッセージを削除する個数だよ。1 以上 50 以下の整数で指定してね。', + defaultValue: 1, + minValue: 1, + maxValue: 50 + } + ] +} as const; + /** * 'sftu' コマンドではらちょの直近のメッセージを削除する・ * @@ -28,41 +45,18 @@ export interface Sheriff { * @class Sheriff * */ -export class SheriffCommand implements CommandResponder { +export class SheriffCommand implements CommandResponder { help: Readonly = { title: '治安統率機構', description: - 'はらちょがうるさいときに治安維持するためのコマンドだよ。最新メッセージから 50 件以内のはらちょのメッセージを指定の個数だけ削除するよ。', - commandName: ['stfu'], - argsFormat: [ - { - name: 'numbersToRemove', - description: - 'はらちょのメッセージを削除する個数だよ。1 以上 50 以下の整数で指定してね。', - defaultValue: '1' - } - ] + 'はらちょがうるさいときに治安維持するためのコマンドだよ。最新メッセージから 50 件以内のはらちょのメッセージを指定の個数だけ削除するよ。' }; + readonly schema = SCHEMA; constructor(private readonly sheriff: Sheriff) {} - async on(event: MessageEvent, message: CommandMessage): Promise { - if (event !== 'CREATE') { - return; - } - - const [commandName, numbersToRemove = '1'] = message.args; - if (!this.help.commandName.includes(commandName)) { - return; - } - const toRemove = parseInt(numbersToRemove, 10); - if (Number.isNaN(toRemove) || !(1 <= toRemove && toRemove <= 50)) { - await message.reply({ - title: '引数の範囲エラー', - description: '1 以上 50 以下の整数を指定してね。' - }); - return; - } + async on(message: CommandMessage): Promise { + const [toRemove] = message.args.params; for (let i = 0; i < toRemove; ++i) { const channel = message.senderChannelId; await this.sheriff.executeMessage(channel, 50); diff --git a/src/service/command/typo-record.test.ts b/src/service/command/typo-record.test.ts index 331f79cd..61e0c9bc 100644 --- a/src/service/command/typo-record.test.ts +++ b/src/service/command/typo-record.test.ts @@ -1,5 +1,4 @@ import { InMemoryTypoRepository, MockClock } from '../../adaptor/index.js'; -import { SentMessage, createMockMessage } from './command-message.js'; import { TypoRecorder, TypoReporter, @@ -7,10 +6,12 @@ import { } from './typo-record.js'; import { addDays, setHours, setMinutes } from 'date-fns'; import { afterAll, describe, expect, it, vi } from 'vitest'; -import type { EmbedMessage } from '../../model/embed-message.js'; + import EventEmitter from 'node:events'; import { ScheduleRunner } from '../../runner/index.js'; import type { Snowflake } from '../../model/id.js'; +import { createMockMessage } from './command-message.js'; +import { parseStringsOrThrow } from '../../adaptor/proxy/command/schema.js'; class MockRepository extends EventEmitter implements TypoRepository { private db = new InMemoryTypoRepository(); @@ -71,9 +72,7 @@ it('must not react', async () => { describe('typo record command', () => { const clock = new MockClock(new Date(0)); - const db = new InMemoryTypoRepository(); const runner = new ScheduleRunner(clock); - const responder = new TypoReporter(db, clock, runner); afterAll(() => runner.killAll()); @@ -86,107 +85,44 @@ describe('typo record command', () => { const responder = new TypoReporter(db, clock, runner); await responder.on( - 'CREATE', createMockMessage( - { - args: ['typo'] - }, + parseStringsOrThrow(['typo'], responder.schema), (message) => { expect(message).toStrictEqual({ description: '***† 今日のMikuroさいなのtypo †***\n- foo\n- hoge\n- fuga' }); - return Promise.resolve(); } ) ); await responder.on( - 'CREATE', createMockMessage( - { - args: ['typo', 'by', '279614913129742338'] - }, + parseStringsOrThrow( + ['typo', 'by', '279614913129742338'], + responder.schema + ), (message) => { expect(message).toStrictEqual({ description: '***† 今日の<@279614913129742338>のtypo †***\n- foo\n- hoge\n- fuga' }); - return Promise.resolve(); } ) ); }); - it('invalid user id', async () => { - await responder.on( - 'CREATE', - createMockMessage( - { - args: ['typo', 'by', 'hoge'] - }, - (message) => { - expect(message).toStrictEqual({ - title: '入力形式エラー', - description: 'ユーザ ID は整数を入力してね' - }); - return Promise.resolve(); - } - ) - ); - }); - - it('must not reply', async () => { - const fn = vi.fn(); - await responder.on( - 'DELETE', - createMockMessage({ - senderId: '279614913129742338' as Snowflake, - senderGuildId: '683939861539192860' as Snowflake, - senderName: 'Mikuroさいな', - args: ['typo'], - reply: fn - }) - ); - expect(fn).not.toHaveBeenCalled(); - }); - - it('help', async () => { - const fn = vi.fn<[EmbedMessage]>(() => Promise.resolve({} as SentMessage)); - await responder.on( - 'CREATE', - createMockMessage({ - senderId: '279614913129742338' as Snowflake, - senderGuildId: '683939861539192860' as Snowflake, - senderName: 'Mikuroさいな', - args: ['typo', 'hoge'], - reply: fn - }) - ); - expect(fn).toHaveBeenCalledWith({ - title: 'Typoヘルプ', - description: ` -- 引数なし: あなたの今日のTypoを表示するよ -- \`by <ユーザID>\`: そのIDの人の今日のTypoを表示するよ -` - }); - }); - it('clear typos on next day', async () => { const db = new InMemoryTypoRepository(); await db.addTypo('279614913129742338' as Snowflake, 'foo'); await db.addTypo('279614913129742338' as Snowflake, 'hoge'); const responder = new TypoReporter(db, clock, runner); await responder.on( - 'CREATE', createMockMessage( - { - args: ['typo'] - }, + parseStringsOrThrow(['typo'], responder.schema), (message) => { expect(message).toStrictEqual({ description: '***† 今日のMikuroさいなのtypo †***\n- foo\n- hoge' }); - return Promise.resolve(); } ) ); @@ -198,16 +134,12 @@ describe('typo record command', () => { runner.consume(); await responder.on( - 'CREATE', createMockMessage( - { - args: ['typo'] - }, + parseStringsOrThrow(['typo'], responder.schema), (message) => { expect(message).toStrictEqual({ description: '***† 今日のMikuroさいなのtypo †***\n' }); - return Promise.resolve(); } ) ); diff --git a/src/service/command/typo-record.ts b/src/service/command/typo-record.ts index 864a6acd..7e66bd8a 100644 --- a/src/service/command/typo-record.ts +++ b/src/service/command/typo-record.ts @@ -11,6 +11,7 @@ import type { HelpInfo } from './command-message.js'; import { addDays, setHours, setMinutes } from 'date-fns'; + import type { Snowflake } from '../../model/id.js'; /** @@ -109,7 +110,23 @@ const typoRecordResetTask = return next6OClock(clock); }; -const DIGITS = /^[0-9]+$/; +const SCHEMA = { + names: ['typo'], + subCommands: { + by: { + type: 'SUB_COMMAND', + params: [ + { + type: 'USER', + name: '表示するユーザID', + description: + 'この後にユーザ ID を入れると, そのユーザ ID の今日の Typo を表示するよ', + defaultValue: 'me' + } + ] + } + } +} as const; /** * `typo` コマンドで今日の Typo 一覧を返信する。 @@ -118,19 +135,12 @@ const DIGITS = /^[0-9]+$/; * @class TypoReporter * @implements {MessageEventResponder} */ -export class TypoReporter implements CommandResponder { +export class TypoReporter implements CommandResponder { help: Readonly = { title: '今日のTypo', - description: '「〜だカス」をTypoとして一日間記録するよ', - commandName: ['typo'], - argsFormat: [ - { - name: 'by', - description: - 'この後にユーザ ID を入れると, そのユーザ ID の今日の Typo を表示するよ' - } - ] + description: '「〜だカス」をTypoとして一日間記録するよ' }; + readonly schema = SCHEMA; constructor( private readonly repo: TypoRepository, @@ -144,49 +154,19 @@ export class TypoReporter implements CommandResponder { ); } - async on(event: MessageEvent, message: CommandMessage): Promise { - if (event !== 'CREATE') { - return; - } + async on(message: CommandMessage): Promise { const { senderId, senderName, args } = message; - if (args.length < 1 || args[0] !== 'typo') { - return; - } - if (args.length === 1) { + + if (!args.subCommand) { await this.replyTypos(message, senderId, senderName); return; } - if (2 <= args.length && args[1] === 'by') { - const userId: string | undefined = args[2]; - if (!userId) { - await message.reply({ - title: '入力形式エラー', - description: '`typo by ` の形式で入力してね' - }); - return; - } - if (!DIGITS.test(userId)) { - await message.reply({ - title: '入力形式エラー', - description: 'ユーザ ID は整数を入力してね' - }); - return; - } - await this.replyTypos(message, userId as Snowflake, `<@${userId}>`); - return; - } - await message.reply({ - title: 'Typoヘルプ', - description: ` -- 引数なし: あなたの今日のTypoを表示するよ -- \`by <ユーザID>\`: そのIDの人の今日のTypoを表示するよ -` - }); - return; + const [userId] = args.subCommand.params; + await this.replyTypos(message, userId as Snowflake, `<@${userId}>`); } private async replyTypos( - message: CommandMessage, + message: CommandMessage, senderId: Snowflake, senderName: string ) { diff --git a/src/service/command/user-info.test.ts b/src/service/command/user-info.test.ts index 72952894..1ed3991a 100644 --- a/src/service/command/user-info.test.ts +++ b/src/service/command/user-info.test.ts @@ -1,7 +1,9 @@ import { UserInfo, UserStatsRepository } from './user-info.js'; import { afterEach, describe, expect, it, vi } from 'vitest'; + import type { Snowflake } from '../../model/id.js'; import { createMockMessage } from './command-message.js'; +import { parseStringsOrThrow } from '../../adaptor/proxy/command/schema.js'; describe('UserInfo', () => { afterEach(() => { @@ -31,11 +33,11 @@ describe('UserInfo', () => { const fn = vi.fn(); await userInfo.on( - 'CREATE', createMockMessage( - { - args: ['userinfo', '586824421470109716'] - }, + parseStringsOrThrow( + ['userinfo', '586824421470109716'], + userInfo.schema + ), fn ) ); @@ -94,13 +96,12 @@ describe('UserInfo', () => { const fn = vi.fn(); await userInfo.on( - 'CREATE', createMockMessage( + parseStringsOrThrow(['userinfo'], userInfo.schema), + fn, { - args: ['userinfo', 'me'], senderId: '586824421470109716' as Snowflake - }, - fn + } ) ); @@ -153,37 +154,16 @@ describe('UserInfo', () => { expect(fetchStats).toHaveBeenCalledOnce(); }); - it('error with no arg', async () => { - const fetchStats = vi.spyOn(repo, 'fetchUserStats'); - const fn = vi.fn(); - - await userInfo.on( - 'CREATE', - createMockMessage( - { - args: ['userinfo'] - }, - fn - ) - ); - - expect(fn).toHaveBeenCalledWith({ - title: 'コマンド形式エラー', - description: '引数にユーザーIDの文字列を指定してね' - }); - expect(fetchStats).not.toHaveBeenCalled(); - }); - it('error with invalid arg', async () => { const fetchStats = vi.spyOn(repo, 'fetchUserStats'); const fn = vi.fn(); await userInfo.on( - 'CREATE', createMockMessage( - { - args: ['userinfo', '354996809447505920'] - }, + parseStringsOrThrow( + ['userinfo', '354996809447505920'], + userInfo.schema + ), fn ) ); diff --git a/src/service/command/user-info.ts b/src/service/command/user-info.ts index 33e3ddfd..517bd48a 100644 --- a/src/service/command/user-info.ts +++ b/src/service/command/user-info.ts @@ -3,7 +3,7 @@ import type { CommandResponder, HelpInfo } from './command-message.js'; -import type { MessageEvent } from '../../runner/message.js'; + import type { Snowflake } from '../../model/id.js'; import { createTimestamp } from '../../model/create-timestamp.js'; @@ -21,35 +21,34 @@ export interface UserStatsRepository { fetchUserStats(userId: string): Promise; } -export class UserInfo implements CommandResponder { +const SCHEMA = { + names: ['userinfo'], + subCommands: {}, + params: [ + { + type: 'USER', + name: 'ユーザーID', + description: + 'このIDのロールを調べるよ。何も入力しないと自分が対象になるよ。', + defaultValue: 'me' + } + ] +} as const; + +export class UserInfo implements CommandResponder { help: Readonly = { title: 'ユーザー秘書艦', description: - '指定したユーザーの情報を調べてくるよ。限界開発鯖のメンバーしか検索できないから注意してね。', - commandName: ['userinfo'], - argsFormat: [ - { - name: 'ユーザーID', - description: - 'このIDのロールを調べるよ。`me`と入力すると自分が対象になるよ。' - } - ] + '指定したユーザーの情報を調べてくるよ。限界開発鯖のメンバーしか検索できないから注意してね。' }; + readonly schema = SCHEMA; constructor(private readonly repo: UserStatsRepository) {} - async on(event: MessageEvent, message: CommandMessage): Promise { - if (event !== 'CREATE') { - return; - } - - const [command, arg] = message.args; + async on(message: CommandMessage): Promise { + const [arg] = message.args.params; const userId = fetchUserId(arg, message.senderId); - if (!this.help.commandName.includes(command)) { - return; - } - if (typeof userId !== 'string') { await message.reply({ title: 'コマンド形式エラー', diff --git a/src/service/command/version.ts b/src/service/command/version.ts index 52b7054c..00dade30 100644 --- a/src/service/command/version.ts +++ b/src/service/command/version.ts @@ -3,29 +3,26 @@ import type { CommandResponder, HelpInfo } from './command-message.js'; -import type { MessageEvent } from '../../runner/index.js'; export interface VersionFetcher { version: string; } -export class GetVersionCommand implements CommandResponder { +const SCHEMA = { + names: ['version'], + subCommands: {} +} as const; + +export class GetVersionCommand implements CommandResponder { help: Readonly = { - commandName: ['version'], title: 'はらちょバージョン', - description: '現在の私のバージョンを出力するよ。', - argsFormat: [] + description: '現在の私のバージョンを出力するよ。' }; + readonly schema = SCHEMA; constructor(private readonly fetcher: VersionFetcher) {} - async on(event: MessageEvent, message: CommandMessage): Promise { - if (event !== 'CREATE') { - return; - } - if (!this.help.commandName.includes(message.args[0])) { - return; - } + async on(message: CommandMessage): Promise { const { version } = this.fetcher; await message.reply({ title: 'はらちょバージョン',