diff --git a/common/SimpleCommand.js b/common/SimpleCommand.js deleted file mode 100644 index da7473d..0000000 --- a/common/SimpleCommand.js +++ /dev/null @@ -1,337 +0,0 @@ -// @ts-check - -const { SlashCommandBuilder } = require('discord.js'); - -/** - * @typedef {import('../util/types').Command} Command - */ - -/** - * @typedef {import('discord.js').ChatInputCommandInteraction} ChatInputCommandInteraction - */ - -/** - * @template {Function} F - * @typedef {F extends (arg: infer T) => unknown ? T : never} FirstParameter - */ - -/** - * @template {unknown} T - * @template {boolean} [Required = boolean] - * @typedef {Required extends true ? T : T | undefined} Value - */ - -/** - * @template {Option[]} O - * @typedef {({ - * [I in keyof O]: O[I] extends Option ? Value : never - * })} OptionValueMap - */ - -/** - * @template {unknown} T - * @template {boolean} [Required = boolean] - * @typedef {Object} SimpleCommandOptionData - * @property {string} name - * @property {string} description - * @property {Required} required - */ - -/** - * @template {unknown} T - * @typedef {Object} SimpleChoiceOptionData - * @property {import('discord.js').APIApplicationCommandOptionChoice[]=} choices - * @property {boolean=} autocomplete - */ - -/** - * @typedef {Object} SimpleRangeOptionData - * @property {number=} max_value - * @property {number=} min_value - */ - -/** - * @template {number} [T = number] - * @template {boolean} [Required = boolean] - * @typedef {( - * SimpleCommandOptionData & - * SimpleRangeOptionData & - * SimpleChoiceOptionData - * )} SimpleIntegerOptionData - */ - -/** - * @template {string} [T = string] - * @template {boolean} [Required = boolean] - * @typedef {( - * SimpleCommandOptionData & - * SimpleChoiceOptionData & - * { - * max_length?: number; - * min_length?: number; - * } - * )} SimpleStringOptionData - */ - -/** - * @template {unknown} [T = unknown] - * @template {boolean} [Required = boolean] - */ -class Option { - name; - - required; - - /** - * @param {string} name オプションの名前 - * @param {Required} required 必須のオプションか - */ - constructor(name, required) { - this.name = name; - this.required = required; - } - - /** - * オプションの値を取得する。 - * @abstract - * @param {ChatInputCommandInteraction} _interaction コマンドのインタラクション - * @returns {Value} - */ - get(_interaction) { - throw new Error('Not implemented'); - } -} - -/** - * @template {string | number} T - * @param {import('discord.js').ApplicationCommandOptionWithChoicesAndAutocompleteMixin} option - * @param {SimpleChoiceOptionData} input - */ -function setChoices(option, input) { - const { choices, autocomplete } = input; - if (choices != null) { - option.addChoices(...choices); - } - if (autocomplete != null) { - option.setAutocomplete(autocomplete); - } -} - -/** - * @template {number} T - * @template {boolean} [Required = boolean] - * @extends {Option} - */ -class IntegerOption extends Option { - /** - * @param {import('discord.js').SharedSlashCommandOptions} builder - * @param {SimpleIntegerOptionData} input - */ - constructor(builder, input) { - const { name, required } = input; - super(name, required); - builder.addIntegerOption((option) => { - option - .setName(name) - .setDescription(input.description) - .setRequired(required); - const { max_value, min_value } = input; - setChoices(option, input); - if (max_value != null) { - option.setMaxValue(max_value); - } - if (min_value != null) { - option.setMinValue(min_value); - } - return option; - }); - } - - /** - * @override - * @param {ChatInputCommandInteraction} interaction - */ - get(interaction) { - return this.required - ? /** @type {Value} */ ( - interaction.options.getInteger(this.name, true) - ) - : /** @type {Value} */ ( - interaction.options.getInteger(this.name, false) ?? void 0 - ); - } -} - -/** - * @template {string} [T = string] - * @template {boolean} [Required = boolean] - * @extends {Option} - */ -class StringOption extends Option { - /** - * @param {import('discord.js').SharedSlashCommandOptions} builder - * @param {SimpleStringOptionData} input - */ - constructor(builder, input) { - super(input.name, input.required); - builder.addStringOption((option) => { - option - .setName(input.name) - .setDescription(input.description) - .setRequired(input.required); - setChoices(option, input); - const { max_length, min_length } = input; - if (max_length != null) { - option.setMaxLength(max_length); - } - if (min_length != null) { - option.setMinLength(min_length); - } - return option; - }); - } - - /** - * @override - * @param {ChatInputCommandInteraction} interaction - */ - get(interaction) { - return this.required - ? /** @type {Value} */ ( - interaction.options.getString(this.name, true) - ) - : /** @type {Value} */ ( - interaction.options.getString(this.name) - ); - } -} - -/** - * シンプルな SlashCommandBuilder(?) - * @template {Option[]} [Options = []] - */ -class SimpleSlashCommandBuilder { - #name; - - #description; - - handle; - - /** - * @type {Options} - */ - options; - - /** - * @param {string} name - * @param {string} description - * @param {SlashCommandBuilder} handle - * @param {Options} options - */ - constructor(name, description, handle, options) { - handle.setName(name); - handle.setDescription(description); - this.#name = name; - this.#description = description; - this.handle = handle; - this.options = options; - } - - /** - * @param {string} name コマンドの名前 - * @param {string} description コマンドの説明文 - * @returns {SimpleSlashCommandBuilder<[]>} - */ - static create(name, description) { - return new SimpleSlashCommandBuilder( - name, - description, - new SlashCommandBuilder(), - [], - ); - } - - /** - * @template {unknown} T - * @template {boolean} [Required = false] - * @param {Option} option - */ - addOption(option) { - /** @type {[...Options, Option]} */ - const options = [...this.options, option]; - return new SimpleSlashCommandBuilder( - this.#name, - this.#description, - this.handle, - options, - ); - } - - /** - * @template {number} T - * @template {boolean} [Required = boolean] - * @param {SimpleIntegerOptionData} input - */ - addIntegerOption(input) { - return this.addOption(new IntegerOption(this.handle, input)); - } - - /** - * @template {string} T - * @template {boolean} [Required = boolean] - * @param {SimpleStringOptionData} input - * @returns - */ - addStringOption(input) { - return this.addOption(new StringOption(this.handle, input)); - } - - /** - * @param {( - * interaction: ChatInputCommandInteraction, - * ...options: OptionValueMap - * ) => Promise} action - */ - build(action) { - return new SimpleCommand(this, action); - } -} - -/** - * @template {Option[]} [Options = []] - * @implements {Command} - */ -class SimpleCommand { - action; - - builder; - - /** - * - * @param {SimpleSlashCommandBuilder} builder - * @param {( - * interaction: ChatInputCommandInteraction, - * ...options: OptionValueMap - * ) => Promise} action - */ - constructor(builder, action) { - this.builder = builder; - this.data = builder.handle; - this.action = action; - } - - /** - * @param {ChatInputCommandInteraction} interaction コマンドのインタラクション - */ - async execute(interaction) { - const optionValues = /** @type {OptionValueMap} */ ( - this.builder.options.map((option) => option.get(interaction)) - ); - await this.action(interaction, ...optionValues); - } -} - -module.exports = { - SimpleSlashCommandBuilder, - SimpleCommand, -}; diff --git a/common/SimpleCommand.ts b/common/SimpleCommand.ts new file mode 100644 index 0000000..bd7b182 --- /dev/null +++ b/common/SimpleCommand.ts @@ -0,0 +1,273 @@ +import { + APIApplicationCommandOptionChoice, + ApplicationCommandOptionWithChoicesAndAutocompleteMixin, + CacheType, + SharedSlashCommandOptions, + SlashCommandBuilder, +} from 'discord.js'; +import { ChatInputCommandInteraction } from 'discord.js'; +import { Command } from '../util/types'; + +type Value = Required extends true + ? T + : T | undefined; + +type OptionValueMap[]> = { + [I in keyof O]: O[I] extends Option + ? Value + : never; +}; + +interface SimpleCommandOptionData { + name: string; + description: string; + required: Required; +} + +interface SimpleChoiceOptionData { + choices?: APIApplicationCommandOptionChoice[]; + autocomplete?: boolean; +} + +interface SimpleRangeOptionData { + max_value?: number; + min_value?: number; +} + +interface SimpleIntegerOptionData< + T extends number = number, + Required extends boolean = boolean, +> extends SimpleCommandOptionData, + SimpleRangeOptionData, + SimpleChoiceOptionData {} + +interface SimpleStringOptionData< + T extends string = string, + Required extends boolean = boolean, +> extends SimpleCommandOptionData, + SimpleChoiceOptionData { + max_length?: number; + min_length?: number; +} + +interface Option { + /** オプションの名前 */ + name: string; + + /** 必須のオプションか */ + required: Required; + + /** + * オプションの値を取得する。 + * @param interaction コマンドのインタラクション + */ + get(interaction: ChatInputCommandInteraction): Value; +} + +function setChoices( + option: ApplicationCommandOptionWithChoicesAndAutocompleteMixin, + input: SimpleChoiceOptionData, +) { + const { choices, autocomplete } = input; + if (choices != null) { + option.addChoices(...choices); + } + if (autocomplete != null) { + option.setAutocomplete(autocomplete); + } +} + +class IntegerOption + implements Option +{ + name: string; + + required: Required; + + constructor( + builder: SharedSlashCommandOptions, + input: SimpleIntegerOptionData, + ) { + const { name, required } = input; + this.name = name; + this.required = required; + builder.addIntegerOption((option) => { + option + .setName(name) + .setDescription(input.description) + .setRequired(required); + const { max_value, min_value } = input; + setChoices(option, input); + if (max_value != null) { + option.setMaxValue(max_value); + } + if (min_value != null) { + option.setMinValue(min_value); + } + return option; + }); + } + + get(interaction: ChatInputCommandInteraction) { + return this.required + ? (interaction.options.getInteger(this.name, true) as Value) + : ((interaction.options.getInteger(this.name, false) ?? void 0) as Value< + T, + Required + >); + } +} + +class StringOption< + T extends string = string, + Required extends boolean = boolean, +> implements Option +{ + name: string; + + required: Required; + + constructor( + builder: SharedSlashCommandOptions, + input: SimpleStringOptionData, + ) { + this.name = input.name; + this.required = input.required; + builder.addStringOption((option) => { + option + .setName(input.name) + .setDescription(input.description) + .setRequired(input.required); + setChoices(option, input); + const { max_length, min_length } = input; + if (max_length != null) { + option.setMaxLength(max_length); + } + if (min_length != null) { + option.setMinLength(min_length); + } + return option; + }); + } + + get(interaction: ChatInputCommandInteraction) { + return this.required + ? (interaction.options.getString(this.name, true) as Value) + : (interaction.options.getString(this.name) as Value); + } +} + +/** + * シンプルな SlashCommandBuilder(?) + */ +export class SimpleSlashCommandBuilder< + Options extends Option[] = [], +> { + #name: string; + + #description: string; + + handle: SlashCommandBuilder; + + options: Options; + + constructor( + name: string, + description: string, + handle: SlashCommandBuilder, + options: Options, + ) { + handle.setName(name); + handle.setDescription(description); + this.#name = name; + this.#description = description; + this.handle = handle; + this.options = options; + } + + /** + * @param name コマンドの名前 + * @param description コマンドの説明文 + */ + static create( + name: string, + description: string, + ): SimpleSlashCommandBuilder<[]> { + return new SimpleSlashCommandBuilder( + name, + description, + new SlashCommandBuilder(), + [], + ); + } + + addOption(option: Option) { + /** @type {[...Options, Option]} */ + const options: [...Options, Option] = [ + ...this.options, + option, + ]; + return new SimpleSlashCommandBuilder( + this.#name, + this.#description, + this.handle, + options, + ); + } + + addIntegerOption( + input: SimpleIntegerOptionData, + ) { + return this.addOption(new IntegerOption(this.handle, input)); + } + + addStringOption( + input: SimpleStringOptionData, + ) { + return this.addOption(new StringOption(this.handle, input)); + } + + build( + action: ( + interaction: ChatInputCommandInteraction, + ...options: OptionValueMap + ) => Promise, + ) { + return new SimpleCommand(this, action); + } +} + +export class SimpleCommand[]> + implements Command +{ + action: ( + interaction: ChatInputCommandInteraction, + ...options: OptionValueMap + ) => Promise; + + builder: SimpleSlashCommandBuilder; + + data: any; + + constructor( + builder: SimpleSlashCommandBuilder, + action: ( + interaction: ChatInputCommandInteraction, + ...options: OptionValueMap + ) => Promise, + ) { + this.builder = builder; + this.data = builder.handle; + this.action = action; + } + + /** + * @param {ChatInputCommandInteraction} interaction コマンドのインタラクション + */ + async execute(interaction: ChatInputCommandInteraction) { + const optionValues = this.builder.options.map((option) => + option.get(interaction), + ) as OptionValueMap; + await this.action(interaction, ...optionValues); + } +} diff --git a/internal/commands.js b/internal/commands.ts similarity index 61% rename from internal/commands.js rename to internal/commands.ts index 1b6db4c..6109af1 100644 --- a/internal/commands.js +++ b/internal/commands.ts @@ -1,33 +1,19 @@ -// @ts-check +import { strFormat, LANG } from '../util/languages'; +import { ChatInputCommandInteraction, Client } from 'discord.js'; +import { Command } from '../util/types'; -const { strFormat, LANG } = require('../util/languages'); +export class CommandManager { + static readonly default = new CommandManager(); -/** - * @template {boolean} [Ready = boolean] - * @typedef {import('discord.js').Client} Client - */ + #client: Client | null = null; -/** - * @typedef {import('../util/types').Command} Command - */ - -class CommandManager { - /** - * @readonly - */ - static default = new CommandManager(); - - /** @type {import('discord.js').Client | null} */ - #client = null; - - /** @type {Map} */ - #commands = new Map(); + #commands: Map = new Map(); /** * クライアントにコマンドを登録する。 - * @param {Client} client ログイン済みのクライアント + * @param client ログイン済みのクライアント */ - async setClient(client) { + async setClient(client: Client) { this.#client = client; const commands = []; for (const command of this.#commands.values()) { @@ -43,9 +29,9 @@ class CommandManager { /** * コマンドを追加する。 - * @param {Command | Command[]} commands 追加するコマンド + * @param commands 追加するコマンド */ - addCommands(commands) { + addCommands(commands: Command | Command[]) { if (Array.isArray(commands)) { for (const command of commands) { this.#commands.set(command.data.name, command); @@ -55,20 +41,17 @@ class CommandManager { } } - get client() { - return this.client; - } - get size() { return this.#commands.size; } /** * コマンドの処理を行う。 - * @param {import('discord.js').ChatInputCommandInteraction} interaction - * @param {Client} client */ - async #handleInteraction(interaction, client) { + async #handleInteraction( + interaction: ChatInputCommandInteraction, + client: Client, + ) { const command = this.#commands.get(interaction.commandName); if (!command) { console.error( @@ -96,5 +79,3 @@ class CommandManager { } } } - -module.exports = { CommandManager }; diff --git a/internal/messages.test.js b/internal/messages.test.ts similarity index 90% rename from internal/messages.test.js rename to internal/messages.test.ts index 171b61a..d97334a 100644 --- a/internal/messages.test.js +++ b/internal/messages.test.ts @@ -1,6 +1,4 @@ -// @ts-check - -const { ReplyPattern } = require('./messages'); +import { ReplyPattern } from './messages'; test('ReplyPattern.match', () => { const pattern1 = new ReplyPattern('それはそう', 'https://soreha.so/'); diff --git a/internal/messages.js b/internal/messages.ts similarity index 70% rename from internal/messages.js rename to internal/messages.ts index ade15bf..c355cac 100644 --- a/internal/messages.js +++ b/internal/messages.ts @@ -1,5 +1,3 @@ -// @ts-check - /** * このファイルではクライアントが受け取ったメッセージに対して処理を行う。 * @@ -17,67 +15,53 @@ * サーバー毎の処理は {@link GuildMessageHandler#handleMessage} において行われる。 */ -const axios = require('axios').default; -const { strFormat, LANG } = require('../util/languages'); -const mongodb = require('./mongodb'); +import axios from 'axios'; +import { strFormat, LANG } from '../util/languages'; +import mongodb from './mongodb'; +import { Collection } from 'mongoose'; +import { Client, Message } from 'discord.js'; -/** - * @typedef {Object} ReplyGuildSchema replyGuilds のドキュメント。 - * @property {string} client クライアントのユーザー ID - * @property {string} guild サーバー ID - */ +interface ReplyGuildSchema { + client: string; + guild: string; +} -/** - * @typedef {Object} ReplySchema replies コレクションのドキュメント。 - * @property {string} client クライアントのユーザー ID - * @property {string} guild サーバー ID - * @property {string} message 反応するメッセージ内容 - * @property {string} reply 返信内容 - * @property {boolean} perfectMatching 完全一致する必要があるか - */ +interface ReplySchema { + client: string; + guild: string; + message: string; + reply: string; + perfectMatching: boolean; +} -/** - * @return {import("mongoose").Collection} - */ -function getReplyGuildCollection() { +function getReplyGuildCollection(): Collection { return mongodb.connection.collection('replyGuilds'); } -/** - * @return {import("mongoose").Collection} - */ -function getReplyCollection() { +function getReplyCollection(): Collection { return mongodb.connection.collection('replies'); } /** * 自動応答のパターン。 */ -class ReplyPattern { - /** - * @readonly - * @type {string} - */ - message; +export class ReplyPattern { + readonly message: string; - /** - * @readonly - * @type {string} - */ - reply; + readonly reply: string; - /** - * @readonly - * @type {boolean} - */ - perfectMatching; + readonly perfectMatching: boolean; /** - * @param {string} messagePattern 反応するメッセージ内容 - * @param {string} reply 返信内容 - * @param {boolean=} perfectMatching 完全一致する必要があるか + * @param messagePattern 反応するメッセージ内容 + * @param reply 返信内容 + * @param perfectMatching 完全一致する必要があるか */ - constructor(messagePattern, reply, perfectMatching = false) { + constructor( + messagePattern: string, + reply: string, + perfectMatching: boolean = false, + ) { this.message = messagePattern; this.reply = reply; this.perfectMatching = perfectMatching; @@ -85,10 +69,10 @@ class ReplyPattern { /** * メッセージ内容がこのパターンに一致するかを調べ、一致する場合は返信内容を返す。 - * @param {string} message メッセージ内容 + * @param message メッセージ内容 * @returns メッセージ内容がパターンに一致する場合は返信内容、一致しなければ null */ - apply(message) { + apply(message: string) { if (this.perfectMatching) { if (message == this.message) { return this.reply; @@ -103,11 +87,10 @@ class ReplyPattern { /** * replies コレクションに格納できる形式に変換する。 - * @param {string} clientUserId クライアントのユーザー ID - * @param {string} guildId サーバー ID - * @returns {ReplySchema} + * @param clientUserId クライアントのユーザー ID + * @param guildId サーバー ID */ - serialize(clientUserId, guildId) { + serialize(clientUserId: string, guildId: string): ReplySchema { const message = this.message; return { client: clientUserId, @@ -120,9 +103,9 @@ class ReplyPattern { /** * replies コレクションからのデータを ReplyPattern に変換する。 - * @param {ReplySchema} replyDocument replies コレクションのドキュメント + * @param replyDocument replies コレクションのドキュメント */ - static deserialize(replyDocument) { + static deserialize(replyDocument: ReplySchema) { const { message, reply, perfectMatching } = replyDocument; return new ReplyPattern(message, reply, perfectMatching); } @@ -141,29 +124,18 @@ class ReplyPattern { /** * サーバーのメッセージに対して処理を行うオブジェクト。 */ -class GuildMessageHandler { - /** - * @readonly - * @type {import("discord.js").Client} - */ - client; +export class GuildMessageHandler { + readonly client: Client; - /** - * @readonly - * @type {string} - */ - guildId; + readonly guildId: string; - /** - * @type {Promise>} - */ - replyPatternsPromise; + replyPatternsPromise: Promise>; /** - * @param {import("discord.js").Client} client ログイン済みのクライアント - * @param {string} guildId サーバー ID + * @param client ログイン済みのクライアント + * @param guildId サーバー ID */ - constructor(client, guildId) { + constructor(client: Client, guildId: string) { this.client = client; this.guildId = guildId; this.replyPatternsPromise = loadReplies(client.user.id, guildId); @@ -171,10 +143,10 @@ class GuildMessageHandler { /** * サーバー内でメッセージを受け取ったときの処理。 - * @param {import("discord.js").Message} message メッセージ - * @returns {Promise} メッセージに反応したかどうか + * @param message メッセージ + * @returns メッセージに反応したかどうか */ - async handleMessage(message) { + async handleMessage(message: Message): Promise { const messageContent = message.content; for (const replyPattern of (await this.replyPatternsPromise).values()) { const replyContent = replyPattern.apply(messageContent); @@ -188,10 +160,10 @@ class GuildMessageHandler { /** * 自動応答のパターンを追加する。 - * @param {ReplyPattern} replyPattern 自動応答のパターン + * @param replyPattern 自動応答のパターン * @returns 新たに追加した場合は true */ - async addReplyPattern(replyPattern) { + async addReplyPattern(replyPattern: ReplyPattern) { const replyPatterns = await this.replyPatternsPromise; const message = replyPattern.message; if (replyPatterns.has(message)) { @@ -206,10 +178,10 @@ class GuildMessageHandler { /** * 自動応答のパターンを削除する。 - * @param {string} message 反応するメッセージ内容 + * @param message 反応するメッセージ内容 * @returns 削除した ReplyPattern または、存在しなかった場合 null */ - async removeReplyPattern(message) { + async removeReplyPattern(message: string) { const replyPatterns = await this.replyPatternsPromise; const replyPattern = replyPatterns.get(message); if (replyPattern == null) { @@ -226,9 +198,9 @@ class GuildMessageHandler { /** * 自動応答のパターンを全て取得する。 - * @returns {Promise} {@link ReplyPattern} の配列 + * @returns {@link ReplyPattern} の配列 */ - async getReplyPatterns() { + async getReplyPatterns(): Promise { const replyPatterns = await this.replyPatternsPromise; return [...replyPatterns.values()]; } @@ -237,36 +209,26 @@ class GuildMessageHandler { /** * クライアントが受け取ったメッセージに対して処理を行うオブジェクト。 */ -class ClientMessageHandler { - /** - * @type {ClientMessageHandler | null} - */ - static instance = null; +export class ClientMessageHandler { + static instance: ClientMessageHandler | null = null; - /** - * @readonly - * @type {import("discord.js").Client} - */ - client; + readonly client: Client; - /** - * @type {Map} - */ - guildMessageHandlerMap = new Map(); + guildMessageHandlerMap: Map = new Map(); /** - * @param {import("discord.js").Client} client ログイン済みのクライアント + * @param client ログイン済みのクライアント */ - constructor(client) { + constructor(client: Client) { this.client = client; ClientMessageHandler.instance = this; } /** * サーバーに対応する {@link GuildMessageHandler} を取得するか、存在しない場合は新規に作成する。 - * @param {string} guildId サーバー ID + * @param guildId サーバー ID */ - getGuildMessageHandler(guildId) { + getGuildMessageHandler(guildId: string) { const guildMessageHandlerMap = this.guildMessageHandlerMap; const existing = guildMessageHandlerMap.get(guildId); if (existing != null) { @@ -279,10 +241,10 @@ class ClientMessageHandler { /** * メッセージを受け取ったときの処理。 - * @param {import("discord.js").Message} message メッセージ - * @returns {Promise} メッセージに反応したかどうか + * @param message メッセージ + * @returns メッセージに反応したかどうか */ - async handleMessage(message) { + async handleMessage(message: Message): Promise { if (message.author.bot) { return; } @@ -309,10 +271,10 @@ const defaultReplyPatterns = [ /** * サーバーの自動応答パターンを取得する。 - * @param {string} clientUserId クライアントのユーザー ID - * @param {string} guildId サーバー ID + * @param clientUserId クライアントのユーザー ID + * @param guildId サーバー ID */ -async function loadReplies(clientUserId, guildId) { +async function loadReplies(clientUserId: string, guildId: string) { const replyGuildCollection = getReplyGuildCollection(); const replyCollection = getReplyCollection(); const replyGuildDocument = await replyGuildCollection.findOne({ @@ -331,8 +293,7 @@ async function loadReplies(clientUserId, guildId) { ), ); } - /** @type {Map} */ - const result = new Map(); + const result: Map = new Map(); const replyDocuments = replyCollection.find({ client: clientUserId, guild: guildId, @@ -347,10 +308,9 @@ async function loadReplies(clientUserId, guildId) { /** * 動画の埋め込みに対応した vxtwitter.com, fxtwitter.com, vxtiktok.com の * URL を返信する可能性がある。 - * @param {import("discord.js").Message} message メッセージ - * @returns {Promise} + * @param message メッセージ */ -async function replyAlternativeUrl(message) { +async function replyAlternativeUrl(message: Message): Promise { const urls = message.content.match(/https?:\/\/[^\s]+/g); if (urls == null) { return; @@ -421,10 +381,10 @@ async function replyAlternativeUrl(message) { } /** - * @param {string} url URL + * @param url URL * @returns 動画の埋め込みに対応した代替 URL があるか */ -function isAlternativeUrlAvailable(url) { +function isAlternativeUrlAvailable(url: string) { try { const { hostname } = new URL(url); return ( @@ -440,10 +400,10 @@ function isAlternativeUrlAvailable(url) { /** * 動画の埋め込みに対応した代替 URL を取得する。 - * @param {string} url X または TikTok の URL - * @returns {Promise} 代替 URL + * @param url X または TikTok の URL + * @returns 代替 URL */ -async function getAlternativeUrl(url) { +async function getAlternativeUrl(url: string): Promise { const compiledUrl = new URL(url); const hostname = compiledUrl.hostname; if (hostname == 'twitter.com' || hostname == 'x.com') { @@ -468,10 +428,10 @@ async function getAlternativeUrl(url) { /** * リダイレクト先の URL を取得する。 * 与えられた URL からの応答がリダイレクト先を示さなければ Promise を reject する。 - * @param {string} shortUrl 短縮 URL + * @param shortUrl 短縮 URL * @returns リダイレクト先の URL */ -async function getRedirectUrl(shortUrl) { +async function getRedirectUrl(shortUrl: string) { try { const response = await axios.head(shortUrl, { maxRedirects: 0, @@ -479,11 +439,9 @@ async function getRedirectUrl(shortUrl) { }); const redirectUrl = response.headers.location; console.log(LANG.discordbot.getRedirectUrl.redirectURL, redirectUrl); - return /** @type {string} */ (redirectUrl); + return redirectUrl as string; } catch (error) { console.error(LANG.discordbot.getRedirectUrl.error, error.message); throw error; } } - -module.exports = { ReplyPattern, GuildMessageHandler, ClientMessageHandler }; diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..6231bde --- /dev/null +++ b/jest.config.js @@ -0,0 +1,5 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', +}; diff --git a/package-lock.json b/package-lock.json index 1286de2..a38292e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,7 @@ "@types/node": "^20.11.27", "jest": "^29.7.0", "prettier": "^3.2.5", + "ts-jest": "^29.1.2", "ts-node": "^10.9.2", "typescript": "^5.4.2" } @@ -1933,6 +1934,18 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/bser": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", @@ -4120,6 +4133,12 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true + }, "node_modules/lodash.snakecase": { "version": "4.1.1", "license": "MIT" @@ -5622,6 +5641,49 @@ "version": "0.0.3", "license": "MIT" }, + "node_modules/ts-jest": { + "version": "29.1.2", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.1.2.tgz", + "integrity": "sha512-br6GJoH/WUX4pu7FbZXuWGKGNDuU7b8Uj77g/Sp7puZV6EXzuByl6JrECvm0MzVzSTkSHWTihsXt+5XYER5b+g==", + "dev": true, + "dependencies": { + "bs-logger": "0.x", + "fast-json-stable-stringify": "2.x", + "jest-util": "^29.0.0", + "json5": "^2.2.3", + "lodash.memoize": "4.x", + "make-error": "1.x", + "semver": "^7.5.3", + "yargs-parser": "^21.0.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^16.10.0 || ^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/types": "^29.0.0", + "babel-jest": "^29.0.0", + "jest": "^29.0.0", + "typescript": ">=4.3 <6" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + } + } + }, "node_modules/ts-mixer": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", diff --git a/package.json b/package.json index 789e19c..ab9f8ad 100755 --- a/package.json +++ b/package.json @@ -35,7 +35,8 @@ "jest": "^29.7.0", "prettier": "^3.2.5", "ts-node": "^10.9.2", - "typescript": "^5.4.2" + "typescript": "^5.4.2", + "ts-jest": "^29.1.2" }, "scripts": { "start": "npx ts-node .", diff --git a/packages/admin/commands/globalban.js b/packages/admin/commands/globalban.js index 9a591bd..4edc467 100755 --- a/packages/admin/commands/globalban.js +++ b/packages/admin/commands/globalban.js @@ -360,15 +360,15 @@ module.exports = { const modal = new ModalBuilder() .setCustomId('gbanReport') .setTitle('レポートしたいユーザーの情報') - .setStyle(TextInputStyle.Short) - + .setStyle(TextInputStyle.Short); + const targetid = new TextInputBuilder() .setCustomId('reportuserid') - .setLabel('通報したいユーザーのID') + .setLabel('通報したいユーザーのID'); const reason = new TextInputBuilder() .setCustomId('reason') - .setTitle('通報理由') + .setTitle('通報理由'); } else { return await interaction.editReply( LANG.commands.globalban.unsupportedSubcommandError, diff --git a/packages/cdn/index.js b/packages/cdn/index.js deleted file mode 100644 index f762200..0000000 --- a/packages/cdn/index.js +++ /dev/null @@ -1,12 +0,0 @@ -// @ts-check - -const { CommandManager } = require('../../internal/commands'); -const upload = require('./upload'); - -class CdnFeature { - onLoad() { - CommandManager.default.addCommands(upload); - } -} - -module.exports = { feature: new CdnFeature() }; diff --git a/packages/cdn/index.ts b/packages/cdn/index.ts new file mode 100644 index 0000000..55f134a --- /dev/null +++ b/packages/cdn/index.ts @@ -0,0 +1,10 @@ +import { CommandManager } from '../../internal/commands'; +import upload from './upload'; + +class CdnFeature { + onLoad() { + CommandManager.default.addCommands(upload); + } +} + +export const feature = new CdnFeature(); diff --git a/packages/cdn/package.json b/packages/cdn/package.json index 972d219..c754e3f 100644 --- a/packages/cdn/package.json +++ b/packages/cdn/package.json @@ -2,7 +2,7 @@ "name": "cdn", "version": "1.0.0", "description": "", - "main": "index.js", + "main": "index.ts", "devDependencies": {}, "scripts": { "test": "echo \"Error: no test specified\" && exit 1" diff --git a/packages/misc/commands/poll.js b/packages/misc/commands/poll.ts similarity index 93% rename from packages/misc/commands/poll.js rename to packages/misc/commands/poll.ts index 2760cab..251eaee 100644 --- a/packages/misc/commands/poll.js +++ b/packages/misc/commands/poll.ts @@ -1,8 +1,6 @@ -// @ts-check - -const { EmbedBuilder } = require('discord.js'); -const { LANG, strFormat } = require('../../../util/languages'); -const { SimpleSlashCommandBuilder } = require('../../../common/SimpleCommand'); +import { EmbedBuilder } from 'discord.js'; +import { LANG, strFormat } from '../../../util/languages'; +import { SimpleSlashCommandBuilder } from '../../../common/SimpleCommand'; module.exports = SimpleSlashCommandBuilder.create( LANG.commands.poll.name, diff --git a/packages/misc/commands/randomnum.js b/packages/misc/commands/randomnum.ts similarity index 93% rename from packages/misc/commands/randomnum.js rename to packages/misc/commands/randomnum.ts index b9f58a7..b9423da 100755 --- a/packages/misc/commands/randomnum.js +++ b/packages/misc/commands/randomnum.ts @@ -1,7 +1,5 @@ -// @ts-check - -const { LANG, strFormat } = require('../../../util/languages'); -const { SimpleSlashCommandBuilder } = require('../../../common/SimpleCommand'); +import { LANG, strFormat } from '../../../util/languages'; +import { SimpleSlashCommandBuilder } from '../../../common/SimpleCommand'; const DEFAULT_MIN_VALUE = 0; const DEFAULT_MAX_VALUE = 99; diff --git a/packages/misc/commands/reply.js b/packages/misc/commands/reply.ts similarity index 88% rename from packages/misc/commands/reply.js rename to packages/misc/commands/reply.ts index a468ad1..645b372 100644 --- a/packages/misc/commands/reply.js +++ b/packages/misc/commands/reply.ts @@ -1,17 +1,12 @@ -// @ts-check +import assert from 'assert'; +import { ChatInputCommandInteraction, SlashCommandBuilder } from 'discord.js'; +import { LANG } from '../../../util/languages'; +import { ClientMessageHandler, ReplyPattern } from '../../../internal/messages'; +import Pager from '../../../util/pager'; +import config from '../../../config.json'; +import { Command } from '../../../util/types'; -const assert = require('assert'); -const { SlashCommandBuilder } = require('discord.js'); -const { LANG } = require('../../../util/languages'); -const { - ClientMessageHandler, - ReplyPattern, -} = require('../../../internal/messages'); -const Pager = require('../../../util/pager'); -const config = require('../../../config.json'); - -/** @type {import("../../../util/types").Command} */ -const commandReply = { +module.exports = { data: new SlashCommandBuilder() .setName(LANG.commands.reply.name) .setDescription(LANG.commands.reply.description) @@ -160,13 +155,12 @@ const commandReply = { assert.fail(subcommand); } }, -}; +} as Command; /** * 使う権限があるかをチェックする。 - * @param {import("discord.js").ChatInputCommandInteraction} interaction */ -async function checkPermission(interaction) { +async function checkPermission(interaction: ChatInputCommandInteraction) { if (!config.replyCustomizeAllowedUsers?.includes(interaction.user.id)) { await interaction.reply({ content: LANG.commands.reply.permissionError, @@ -176,5 +170,3 @@ async function checkPermission(interaction) { } return true; } - -module.exports = commandReply; diff --git a/packages/misc/index.js b/packages/misc/index.js index ad05a06..991ce3b 100644 --- a/packages/misc/index.js +++ b/packages/misc/index.js @@ -8,7 +8,8 @@ class MiscFeature { fs.readdirSync(path.join(__dirname, 'commands'), { withFileTypes: true, }).forEach((file) => { - if (!file.isFile() || path.extname(file.name) != '.js') return; + const ext = path.extname(file.name); + if (!file.isFile() || (ext != '.js' && ext != '.ts')) return; const cmds = require(path.join(__dirname, 'commands', file.name)); CommandManager.default.addCommands(cmds); }); diff --git a/packages/player/PlayerCommand.js b/packages/player/PlayerCommand.js deleted file mode 100644 index 6455df8..0000000 --- a/packages/player/PlayerCommand.js +++ /dev/null @@ -1,63 +0,0 @@ -// @ts-check - -const { getPlayableVoiceChannelId, getPlayingQueue } = require('./players'); -const { LANG } = require('../../util/languages'); - -/** - * @typedef {import("../../util/types").Command} Command - */ - -/** - * @typedef {import('./players').QueueMetadata} QueueMetadata - */ - -/** - * 音楽プレイヤーの操作を行うコマンド。 - * コマンドを実行したユーザーがボイスチャンネルに参加していて、 - * かつ音楽が再生されている場合に action 関数を呼び出す。 - * @implements {Command} - */ -class PlayerCommand { - data; - - action; - - /** - * - * @param {import("discord.js").SlashCommandBuilder} data - * @param {(interaction: import("discord.js").ChatInputCommandInteraction, - * queue: import("discord-player").GuildQueue, - * voiceChannelId: string) => Promise} action 音楽プレイヤーの操作。チェックを行った後に呼び出される。 - */ - constructor(data, action) { - this.data = data; - this.action = action; - } - - /** - * @param {import("discord.js").ChatInputCommandInteraction} interaction - */ - async execute(interaction) { - const voiceChannelId = getPlayableVoiceChannelId(interaction); - if (voiceChannelId == null) { - await interaction.reply({ - content: LANG.common.message.notPlayableError, - ephemeral: true, - }); - return; - } - - const queue = getPlayingQueue(interaction); - if (!queue) { - await interaction.reply({ - content: LANG.common.message.noTracksPlayed, - ephemeral: true, - }); - return; - } - - this.action(interaction, queue, voiceChannelId); - } -} - -module.exports = { PlayerCommand }; diff --git a/packages/player/PlayerCommand.ts b/packages/player/PlayerCommand.ts new file mode 100644 index 0000000..eae0aa9 --- /dev/null +++ b/packages/player/PlayerCommand.ts @@ -0,0 +1,57 @@ +import { getPlayableVoiceChannelId, getPlayingQueue } from './players'; +import { LANG } from '../../util/languages'; +import { Command } from '../../util/types'; +import { QueueMetadata } from './players'; +import { ChatInputCommandInteraction, SlashCommandBuilder } from 'discord.js'; +import { GuildQueue } from 'discord-player'; + +/** + * 音楽プレイヤーの操作を行うコマンド。 + * コマンドを実行したユーザーがボイスチャンネルに参加していて、 + * かつ音楽が再生されている場合に action 関数を呼び出す。 + */ +export class PlayerCommand implements Command { + data; + + action; + + /** + * @param action 音楽プレイヤーの操作。チェックを行った後に呼び出される。 + */ + constructor( + data: SlashCommandBuilder, + action: ( + interaction: ChatInputCommandInteraction, + queue: GuildQueue, + voiceChannelId: string, + ) => Promise, + ) { + this.data = data; + this.action = action; + } + + /** + * @param interaction + */ + async execute(interaction: ChatInputCommandInteraction) { + const voiceChannelId = getPlayableVoiceChannelId(interaction); + if (voiceChannelId == null) { + await interaction.reply({ + content: LANG.common.message.notPlayableError, + ephemeral: true, + }); + return; + } + + const queue = getPlayingQueue(interaction); + if (!queue) { + await interaction.reply({ + content: LANG.common.message.noTracksPlayed, + ephemeral: true, + }); + return; + } + + this.action(interaction, queue, voiceChannelId); + } +} diff --git a/packages/player/commands/pause.js b/packages/player/commands/pause.ts similarity index 68% rename from packages/player/commands/pause.js rename to packages/player/commands/pause.ts index fe89f6e..7bc2a00 100644 --- a/packages/player/commands/pause.js +++ b/packages/player/commands/pause.ts @@ -1,8 +1,6 @@ -// @ts-check - -const { SlashCommandBuilder } = require('discord.js'); -const { LANG } = require('../../../util/languages'); -const { PlayerCommand } = require('../PlayerCommand'); +import { SlashCommandBuilder } from 'discord.js'; +import { LANG } from '../../../util/languages'; +import { PlayerCommand } from '../PlayerCommand'; module.exports = new PlayerCommand( new SlashCommandBuilder() diff --git a/packages/player/commands/queue.js b/packages/player/commands/queue.ts similarity index 78% rename from packages/player/commands/queue.js rename to packages/player/commands/queue.ts index 36d8591..d44a5b8 100755 --- a/packages/player/commands/queue.js +++ b/packages/player/commands/queue.ts @@ -1,11 +1,9 @@ -// @ts-check - -const { SlashCommandBuilder } = require('discord.js'); -const Pager = require('../../../util/pager'); -const { getDuration } = require('../players'); -const Timespan = require('../../../util/timespan'); -const { LANG, strFormat } = require('../../../util/languages'); -const { PlayerCommand } = require('../PlayerCommand'); +import { SlashCommandBuilder } from 'discord.js'; +import Pager from '../../../util/pager'; +import { getDuration } from '../players'; +import Timespan from '../../../util/timespan'; +import { LANG, strFormat } from '../../../util/languages'; +import { PlayerCommand } from '../PlayerCommand'; module.exports = new PlayerCommand( new SlashCommandBuilder() diff --git a/packages/player/commands/resume.js b/packages/player/commands/resume.ts similarity index 73% rename from packages/player/commands/resume.js rename to packages/player/commands/resume.ts index d35eff9..54b4ee1 100644 --- a/packages/player/commands/resume.js +++ b/packages/player/commands/resume.ts @@ -1,11 +1,9 @@ -// @ts-check +import { SlashCommandBuilder } from 'discord.js'; +import { LANG } from '../../../util/languages'; +import { getPlayableVoiceChannelId, getPlayingQueue } from '../players'; +import { Command } from '../../../util/types'; -const { SlashCommandBuilder } = require('discord.js'); -const { LANG } = require('../../../util/languages'); -const { getPlayableVoiceChannelId, getPlayingQueue } = require('../players'); - -/** @type {import("../../../util/types").Command} */ -const commandResume = { +const commandResume: Command = { data: new SlashCommandBuilder() .setName(LANG.commands.resume.name) .setDescription(LANG.commands.resume.description), diff --git a/packages/player/commands/skip.js b/packages/player/commands/skip.ts similarity index 80% rename from packages/player/commands/skip.js rename to packages/player/commands/skip.ts index 62fdefd..2c04cc0 100755 --- a/packages/player/commands/skip.js +++ b/packages/player/commands/skip.ts @@ -1,9 +1,7 @@ -// @ts-check - -const assert = require('assert'); -const { SlashCommandBuilder } = require('discord.js'); -const { LANG, strFormat } = require('../../../util/languages'); -const { PlayerCommand } = require('../PlayerCommand'); +import assert from 'assert'; +import { SlashCommandBuilder } from 'discord.js'; +import { LANG, strFormat } from '../../../util/languages'; +import { PlayerCommand } from '../PlayerCommand'; module.exports = new PlayerCommand( new SlashCommandBuilder() diff --git a/packages/player/commands/stop.js b/packages/player/commands/stop.ts similarity index 61% rename from packages/player/commands/stop.js rename to packages/player/commands/stop.ts index 0146ce8..b6549dd 100755 --- a/packages/player/commands/stop.js +++ b/packages/player/commands/stop.ts @@ -1,8 +1,6 @@ -// @ts-check - -const { SlashCommandBuilder } = require('discord.js'); -const { LANG } = require('../../../util/languages'); -const { PlayerCommand } = require('../PlayerCommand'); +import { SlashCommandBuilder } from 'discord.js'; +import { LANG } from '../../../util/languages'; +import { PlayerCommand } from '../PlayerCommand'; module.exports = new PlayerCommand( new SlashCommandBuilder() diff --git a/packages/player/index.js b/packages/player/index.ts similarity index 79% rename from packages/player/index.js rename to packages/player/index.ts index 77c0227..339e614 100644 --- a/packages/player/index.js +++ b/packages/player/index.ts @@ -1,21 +1,21 @@ -// @ts-check - -const assert = require('assert'); -const { Player } = require('discord-player'); -const { LANG, strFormat } = require('../../util/languages'); -const { CommandManager } = require('../../internal/commands'); -const { +import assert from 'assert'; +import { GuildQueue, Player } from 'discord-player'; +import { LANG, strFormat } from '../../util/languages'; +import { CommandManager } from '../../internal/commands'; +import { restoreQueues, saveQueue, getDuration, deleteSavedQueues, -} = require('./players'); +} from './players'; +import { Feature } from '../../util/types'; +import { Client } from 'discord.js'; -class PlayerFeature { +class PlayerFeature implements Feature { /** @type {Player | null} */ - #player = null; + #player: Player | null = null; - onLoad(client) { + onLoad(client: Client) { console.log(LANG.discordbot.main.playerLoading); const player = new Player(client); player.extractors.loadDefault(); @@ -82,12 +82,10 @@ class PlayerFeature { assert(player != null); for (const [guildId, queue] of player.nodes.cache) { console.log(guildId); - await saveQueue( - /** @type {import('discord-player').GuildQueue} */ (queue), - ); + await saveQueue(queue as GuildQueue); } await player.destroy(); } } -module.exports = { feature: new PlayerFeature() }; +export const feature = new PlayerFeature(); diff --git a/packages/player/package.json b/packages/player/package.json index 7cb93e7..cd78ef9 100644 --- a/packages/player/package.json +++ b/packages/player/package.json @@ -2,7 +2,7 @@ "name": "player", "version": "1.0.0", "description": "", - "main": "index.js", + "main": "index.ts", "devDependencies": {}, "scripts": { "test": "echo \"Error: no test specified\" && exit 1" diff --git a/packages/player/players.js b/packages/player/players.ts similarity index 55% rename from packages/player/players.js rename to packages/player/players.ts index ab27363..0db112d 100644 --- a/packages/player/players.js +++ b/packages/player/players.ts @@ -1,49 +1,91 @@ -// @ts-check +import { + BaseInteraction, + GuildMember, + GuildVoiceChannelResolvable, + TextBasedChannel, + User, +} from 'discord.js'; +import { + useQueue, + Track, + useMainPlayer, + Player, + GuildQueue, + TrackLike, + GuildNodeCreateOptions, +} from 'discord-player'; +import Timespan from '../../util/timespan'; +import mongodb from '../../internal/mongodb'; +import { Collection } from 'mongoose'; -const { GuildMember } = require('discord.js'); -const { useQueue, Track, useMainPlayer } = require('discord-player'); -const Timespan = require('../../util/timespan'); -const mongodb = require('../../internal/mongodb'); +/** volumes コレクションのドキュメント */ +interface VolumeSchema { + /** ギルド ID */ + guild: string; -/** - * @typedef {Object} VolumeSchema volumes コレクションのドキュメント - * @property {string} guild ギルド ID - * @property {number} volume サーバーで設定されている音量 - */ + /** サーバーで設定されている音量 */ + volume: number; +} -/** - * @typedef {Object} GuildQueueSchema guild_queue コレクションのドキュメント - * @property {string} _id ギルド ID - * @property {string | null} voiceChannel キューのボイスチャンネルの ID - * @property {boolean} is_paused 一時停止中か - * @property {ReturnType | null} current_track このキューの現在の曲 - * @property {number | null} current_time 曲の現在の再生位置 (ミリ秒) - * @property {string | null} textChannel コマンドが実行されたテキストチャンネルの ID - * @property {string | null} client コマンドを受け取ったクライアントの ID - * @property {string} requested_by コマンドの実行者の ID - */ +/** guild_queue コレクションのドキュメント */ +interface GuildQueueSchema { + /** ギルド ID */ + _id: string; -/** - * @typedef {Object} GuildQueueTrackSchema guild_queue_tracks コレクションのドキュメント - * @property {string} guild ギルド ID - * @property {number} index 曲のキュー内での番号 (0始まり) - * @property {ReturnType} track キュー内の曲 - */ + /** キューのボイスチャンネルの ID */ + voiceChannel: string | null; -/** - * @typedef {Object} QueueMetadata キューに付加するメタデータ - * @property {import('discord.js').TextBasedChannel | null} channel コマンドが実行されたテキストチャンネル - * @property {GuildMember | null} client コマンドを受け取ったクライアント - * @property {import('discord.js').User} requestedBy コマンドの実行者 - */ + /** 一時停止中か */ + is_paused: boolean; + + /** このキューの現在の曲 */ + current_track: ReturnType | null; + + /** 曲の現在の再生位置 (ミリ秒) */ + current_time: number | null; + + /** コマンドが実行されたテキストチャンネルの ID */ + textChannel: string | null; + + /** コマンドを受け取ったクライアントの ID */ + client: string | null; + + /** コマンドの実行者の ID */ + requested_by: string; +} + +/** guild_queue_tracks コレクションのドキュメント */ +interface GuildQueueTrackSchema { + /** ギルド ID */ + guild: string; + + /** 曲のキュー内での番号 (0始まり) */ + index: number; + + /** キュー内の曲 */ + track: ReturnType; +} + +/** キューに付加するメタデータ */ +export interface QueueMetadata { + /** コマンドが実行されたテキストチャンネル */ + channel: TextBasedChannel | null; + + /** コマンドを受け取ったクライアント */ + client: GuildMember | null; + + /** コマンドの実行者 */ + requestedBy: User; +} /** * データベースから読み出したドキュメントを元にキューの復元する。 - * @param {import("discord-player").Player} player - * @param {GuildQueueSchema} guildQueueDocument * @returns キューが復元されたか */ -async function restoreQueue(player, guildQueueDocument) { +async function restoreQueue( + player: Player, + guildQueueDocument: GuildQueueSchema, +) { const currentTrackSerialized = guildQueueDocument.current_track; const currentTime = guildQueueDocument.current_time; const voiceChannelId = guildQueueDocument?.voiceChannel; @@ -105,10 +147,10 @@ async function restoreQueue(player, guildQueueDocument) { const functions = { /** * 対話を起こしたメンバーが接続していて、この bot が参加しているか参加できるボイスチャンネルの ID を取得する。 - * @param {import('discord.js').BaseInteraction} interaction 対話オブジェクト + * @param interaction 対話オブジェクト * @returns メンバーが接続しているボイスチャンネルの ID。この bot が接続できる状態にない場合は null */ - getPlayableVoiceChannelId(interaction) { + getPlayableVoiceChannelId(interaction: BaseInteraction) { const member = interaction.member; if (!(member instanceof GuildMember)) { return null; @@ -123,40 +165,41 @@ const functions = { /** * 対話が起こったサーバーで再生されている楽曲のキューを取得する。 - * @param {import('discord.js').BaseInteraction} interaction 対話オブジェクト + * @param interaction 対話オブジェクト * @returns 楽曲を再生している場合、楽曲のキュー。再生していない場合、null */ - getPlayingQueue(interaction) { + getPlayingQueue( + interaction: BaseInteraction, + ): GuildQueue | null { const guildId = interaction.guild; if (guildId == null) { return null; } - const queue = - /** @type {import("discord-player").GuildQueue} */ ( - useQueue(guildId) - ); - if (queue?.isPlaying()) return queue; + const queue = useQueue(guildId) as GuildQueue; + if (queue?.isPlaying()) { + return queue; + } return null; }, /** * トラックの長さを求める。 - * @param {Track} track トラック + * @param track トラック * @returns トラックの長さ */ - getDuration(track) { + getDuration(track: Track) { return new Timespan({ millis: track.durationMS }); }, /** * サーバーでの音量設定を保存する。 - * @param {string} guildId ギルド ID - * @param {number} volume 音量 + * @param guildId ギルド ID + * @param volume 音量 */ - async saveVolumeSetting(guildId, volume) { - /** @type {import("mongoose").Collection} */ - const volumeCollection = mongodb.connection.collection('volumes'); + async saveVolumeSetting(guildId: string, volume: number) { + const volumeCollection = + mongodb.connection.collection('volumes'); await volumeCollection.updateOne( { guild: guildId }, { @@ -171,12 +214,12 @@ const functions = { /** * サーバーでの音量設定を取得する。 - * @param {string} guildId ギルド ID - * @returns {Promise} 音量 + * @param guildId ギルド ID + * @returns 音量 */ - async loadVolumeSetting(guildId) { - /** @type {import("mongoose").Collection} */ - const volumeCollection = mongodb.connection.collection('volumes'); + async loadVolumeSetting(guildId: string): Promise { + const volumeCollection = + mongodb.connection.collection('volumes'); const result = await volumeCollection.findOne({ guild: guildId }); if (result != null) { return result.volume; @@ -185,13 +228,17 @@ const functions = { /** * 音楽の再生を開始する。 - * @template [T=unknown] - * @param {string} guild ギルド ID - * @param {import('discord.js').GuildVoiceChannelResolvable} channel 再生するボイスチャンネル - * @param {import('discord-player').TrackLike} query 再生する曲または音源 - * @param {T=} metadata 付加するメタデータ + * @param guild ギルド ID + * @param channel 再生するボイスチャンネル + * @param query 再生する曲または音源 + * @param metadata 付加するメタデータ */ - async play(guild, channel, query, metadata) { + async play( + guild: string, + channel: GuildVoiceChannelResolvable, + query: TrackLike, + metadata?: T, + ) { return await useMainPlayer().play(channel, query, { nodeOptions: await functions.getNodeOptions(guild, metadata), }); @@ -199,12 +246,14 @@ const functions = { /** * ノードのオプションを取得する。 - * @template [T=unknown] - * @param {string} guild ギルド - * @param {T=} metadata 付加するメタデータ - * @returns {Promise>} ノードのオプション + * @param guild ギルド + * @param metadata 付加するメタデータ + * @returns ノードのオプション */ - async getNodeOptions(guild, metadata) { + async getNodeOptions( + guild: string, + metadata?: T, + ): Promise> { const volume = await functions.loadVolumeSetting(guild); return { metadata, @@ -221,14 +270,14 @@ const functions = { /** * 現在のキューの状態をデータベースに保存する。 - * @param {import("discord-player").GuildQueue} queue キュー + * @param queue キュー */ - async saveQueue(queue) { + async saveQueue(queue: GuildQueue) { const guild = queue.guild.id; const metadata = queue.metadata; - /** @type {import("mongoose").Collection} */ - const guildQueueCollection = mongodb.connection.collection('guild_queues'); + const guildQueueCollection = + mongodb.connection.collection('guild_queues'); await guildQueueCollection.deleteOne({ _id: guild }); await guildQueueCollection.insertOne({ _id: guild, @@ -241,9 +290,10 @@ const functions = { requested_by: metadata.requestedBy.id, }); - /** @type {import("mongoose").Collection} */ const guildQueueTrackCollection = - mongodb.connection.collection('guild_queue_tracks'); + mongodb.connection.collection( + 'guild_queue_tracks', + ); const tracks = queue.tracks.toArray(); await guildQueueTrackCollection.deleteMany({ guild }); await guildQueueTrackCollection.insertMany( @@ -259,15 +309,17 @@ const functions = { * データベースに保存されたキューを削除する。 * @param {string[]} guilds ギルド ID */ - async deleteSavedQueues(...guilds) { - /** @type {import("mongoose").Collection} */ - const guildQueueCollection = mongodb.connection.collection('guild_queues'); + async deleteSavedQueues(...guilds: string[]) { + const guildQueueCollection = + mongodb.connection.collection('guild_queues'); await guildQueueCollection.deleteMany({ _id: { $in: guilds }, }); /** @type {import("mongoose").Collection} */ const guildQueueTrackCollection = - mongodb.connection.collection('guild_queue_tracks'); + mongodb.connection.collection( + 'guild_queue_tracks', + ); await guildQueueTrackCollection.deleteMany({ guild: { $in: guilds }, }); @@ -275,11 +327,11 @@ const functions = { /** * データベースに保存されたキューの状態を復元する。 - * @param {import("discord-player").Player} player プレイヤー + * @param player プレイヤー */ - async restoreQueues(player) { - /** @type {import("mongoose").Collection} */ - const guildQueueCollection = mongodb.connection.collection('guild_queues'); + async restoreQueues(player: Player) { + const guildQueueCollection = + mongodb.connection.collection('guild_queues'); const guildQueueDocuments = guildQueueCollection.find({}); const guildsToDeleteQueues = []; for await (const guildQueueDocument of guildQueueDocuments) { @@ -293,13 +345,14 @@ const functions = { /** * データベースに保存された曲を取得する。 - * @param {import("discord-player").Player} player プレイヤー - * @param {string} guild ギルド ID + * @param player プレイヤー + * @param guild ギルド ID */ - async getSavedTracks(player, guild) { - /** @type {import("mongoose").Collection} */ + async getSavedTracks(player: Player, guild: string) { const guildQueueTrackCollection = - mongodb.connection.collection('guild_queue_tracks'); + mongodb.connection.collection( + 'guild_queue_tracks', + ); const guildQueueTrackDocuments = guildQueueTrackCollection.find({ guild }); const result = []; for await (const { index, track } of guildQueueTrackDocuments) { @@ -309,4 +362,30 @@ const functions = { }, }; -module.exports = functions; +const { + getPlayableVoiceChannelId, + getPlayingQueue, + getDuration, + saveVolumeSetting, + loadVolumeSetting, + play, + getNodeOptions, + saveQueue, + deleteSavedQueues, + restoreQueues, + getSavedTracks, +} = functions; + +export { + getPlayableVoiceChannelId, + getPlayingQueue, + getDuration, + saveVolumeSetting, + loadVolumeSetting, + play, + getNodeOptions, + saveQueue, + deleteSavedQueues, + restoreQueues, + getSavedTracks, +}; diff --git a/packages/web-api/check-host.js b/packages/web-api/check-host.ts similarity index 64% rename from packages/web-api/check-host.js rename to packages/web-api/check-host.ts index 0fefc71..1d7f796 100644 --- a/packages/web-api/check-host.js +++ b/packages/web-api/check-host.ts @@ -1,36 +1,27 @@ -// @ts-check +import { setTimeout } from 'timers/promises'; +import axios from 'axios'; -const { setTimeout } = require('timers/promises'); -const axios = require('axios').default; - -/** - * @template {CheckHostResult} R - * @typedef {Object} CheckHostType - * @property {(data: any) => R} castResult - */ +interface CheckHostType { + castResult: (data: any) => R; +} -/** - * @typedef {[string, string, string]} CheckHostNodeLocation - */ +type CheckHostNodeLocation = [string, string, string]; -/** - * @typedef {Object} CheckHostNode - * @property {string} name - * @property {string} asn - * @property {string} ip - * @property {CheckHostNodeLocation} location - */ +interface CheckHostNode { + name: string; + asn: string; + ip: string; + location: CheckHostNodeLocation; +} -/** - * @typedef {Object} RequestData - * @property {1} ok - * @property {string} request_id - * @property {string} permanent_link - * @property {{[key: string]: string[]}} nodes - */ +interface RequestData { + ok: 1; + request_id: string; + permanent_link: string; + nodes: { [key: string]: string[] }; +} -/** @type {Map} */ -const nodeMap = new Map(); +const nodeMap = new Map(); const axiosCheckHost = axios.create({ baseURL: 'https://check-host.net/', @@ -41,9 +32,9 @@ const axiosCheckHost = axios.create({ /** * 名前からノードを取得する。 - * @param {string} name ノードの名前 + * @param name ノードの名前 */ -async function getCheckHostNode(name) { +async function getCheckHostNode(name: string) { const value = nodeMap.get(name); if (value != null) { return value; @@ -53,7 +44,7 @@ async function getCheckHostNode(name) { for (const [key, value] of Object.entries(nodes)) { const mapValue = nodeMap.get(key); if (mapValue == null) { - nodeMap.set(key, value); + nodeMap.set(key, value as CheckHostNode); } } const result = nodeMap.get(name); @@ -63,27 +54,16 @@ async function getCheckHostNode(name) { return result; } -/** - * @template {CheckHostResult} R - */ -class CheckHostRequest { - /** @type {CheckHostType} */ - checkType; +class CheckHostRequest { + checkType: CheckHostType; - /** @type {string} */ - requestId; + requestId: string; - /** @type {string} */ - permanentLink; + permanentLink: string; - /** @type {CheckHostNode[]} */ - nodes; + nodes: CheckHostNode[]; - /** - * @param {CheckHostType} checkType - * @param {RequestData} data - */ - constructor(checkType, data) { + constructor(checkType: CheckHostType, data: RequestData) { this.checkType = checkType; this.requestId = data.request_id; this.permanentLink = data.permanent_link; @@ -93,8 +73,7 @@ class CheckHostRequest { if (existingNode != null) { nodes.push(existingNode); } else { - /** @type {CheckHostNode} */ - const node = { + const node: CheckHostNode = { name: key, asn: value[4], ip: value[3], @@ -109,14 +88,17 @@ class CheckHostRequest { /** * 結果を問い合わせる。 - * @param {number} successRate この割合のノードが成功したら終了 - * @param {number} time 問い合わせる最大の回数 - * @param {number} period 問い合わせる周期 + * @param successRate この割合のノードが成功したら終了 + * @param time 問い合わせる最大の回数 + * @param period 問い合わせる周期 * @returns */ - async checkResult(successRate = 0, time = 1, period = 2000) { - /** @type {CheckHostResultMap} */ - const result = new CheckHostResultMap(); + async checkResult( + successRate: number = 0, + time: number = 1, + period: number = 2000, + ) { + const result = new CheckHostResultMap(); const successThreshold = this.nodes.length * successRate; for (let i = 0; i < time; i++) { await setTimeout(period); @@ -173,7 +155,11 @@ class CheckHostRequest { * @param {number} maxNodes チェックに用いる最大ノード数 * @returns {Promise>} リクエストを表すオブジェクト */ - static async get(checkType, host, maxNodes) { + static async get( + checkType: 'ping' | 'http' | 'tcp' | 'dns' | 'udp', + host: string, + maxNodes: number, + ): Promise> { const checkTypeObject = checkTypes[checkType]; const res = await axiosCheckHost.get(`/check-${checkType}`, { params: { host, maxNodes }, @@ -182,16 +168,15 @@ class CheckHostRequest { } } -/** - * @template {CheckHostResult} R - * @extends {Map} - */ -class CheckHostResultMap extends Map { +class CheckHostResultMap extends Map< + CheckHostNode, + R +> { /** * 条件に一致するノードの個数を返す。 * @param {(node: CheckHostNode, result: R) => boolean} predicate */ - count(predicate) { + count(predicate: (node: CheckHostNode, result: R) => boolean) { let count = 0; for (const [node, result] of this.entries()) { if (predicate(node, result)) { @@ -217,10 +202,7 @@ class CheckHostResultMap extends Map { class CheckHostResult { state; - /** - * @param {'ok' | 'error' | 'processing'} state - */ - constructor(state) { + constructor(state: 'ok' | 'error' | 'processing') { this.state = state; } } @@ -228,37 +210,28 @@ class CheckHostResult { // check-ping class CheckPingResult extends CheckHostResult { - /** - * @param {'ok' | 'error' | 'processing'} state - */ - constructor(state) { + constructor(state: 'ok' | 'error' | 'processing') { super(state); } } class CheckPingOk extends CheckPingResult { - /** @type {string} */ - host; + host: string; - /** @type {{reply: 'OK' | 'TIMEOUT' | 'MALFORMED', ping: number}[]} */ - values; + values: { reply: 'OK' | 'TIMEOUT' | 'MALFORMED'; ping: number }[]; - /** - * @param {any} payload - */ - constructor(payload) { + constructor(payload: any) { super('ok'); this.host = payload[0][2]; - this.values = payload.map((/** @type {any[]} */ a) => ({ + this.values = payload.map((a: any[]) => ({ reply: a[0], ping: a[1], })); } } -/** @type {CheckHostType} */ -const CHECK_PING = { - castResult(/** @type {any} */ data) { +const CHECK_PING: CheckHostType = { + castResult(data: any) { if (data == null) { return new CheckPingResult('processing'); } @@ -273,28 +246,24 @@ const CHECK_PING = { // check-http class CheckHttpResult extends CheckHostResult { - constructor(state) { + constructor(state: 'ok' | 'error' | 'processing') { super(state); } } class CheckHttpComplete extends CheckHttpResult { - /** @type {boolean} */ - success; + success: boolean; - /** @type {number} */ - time; + time: number; - /** @type {string} */ - statusMessage; + statusMessage: string; - /** - * @param {'ok' | 'error' | 'processing'} state - * @param {number} success - * @param {number} time - * @param {string} statusMessage - */ - constructor(state, success, time, statusMessage) { + constructor( + state: 'ok' | 'error' | 'processing', + success: number, + time: number, + statusMessage: string, + ) { super(state); this.success = success != 0; this.time = time; @@ -303,20 +272,17 @@ class CheckHttpComplete extends CheckHttpResult { } class CheckHttpOk extends CheckHttpComplete { - /** @type {number} */ - statusCode; + statusCode: number; - /** @type {string} */ - host; + host: string; - /** - * @param {number} success - * @param {number} time - * @param {string} statusMessage - * @param {string} statusCode - * @param {string} host - */ - constructor(success, time, statusMessage, statusCode, host) { + constructor( + success: number, + time: number, + statusMessage: string, + statusCode: string, + host: string, + ) { super('ok', success, time, statusMessage); this.statusCode = Number.parseInt(statusCode); this.host = host; @@ -324,18 +290,13 @@ class CheckHttpOk extends CheckHttpComplete { } class CheckHttpError extends CheckHttpComplete { - /** - * @param {number} success - * @param {number} time - * @param {string} statusMessage - */ - constructor(success, time, statusMessage) { + constructor(success: number, time: number, statusMessage: string) { super('error', success, time, statusMessage); } } /** @type {CheckHostType} */ -const CHECK_HTTP = { +const CHECK_HTTP: CheckHostType = { castResult(data) { if (!(data instanceof Array)) { return new CheckHttpResult('processing'); @@ -352,25 +313,17 @@ const CHECK_HTTP = { // check-tcp, check-udp class CheckTcpUdpResult extends CheckHostResult { - /** - * @param {'ok' | 'error' | 'processing'} state - */ - constructor(state) { + constructor(state: 'ok' | 'error' | 'processing') { super(state); } } class CheckTcpUdpOk extends CheckTcpUdpResult { - /** @type {number} */ - time; + time: number; - /** @type {string} */ - address; + address: string; - /** - * @param {any} payload - */ - constructor(payload) { + constructor(payload: any) { super('ok'); this.time = payload.time; this.address = payload.address; @@ -380,18 +333,14 @@ class CheckTcpUdpOk extends CheckTcpUdpResult { class CheckTcpUdpError extends CheckTcpUdpResult { description; - /** - * @param {string} description - */ - constructor(description) { + constructor(description: string) { super('error'); this.description = description; } } -/** @type {CheckHostType} */ -const CHECK_TCP_UDP = { - castResult(/** @type {any} */ data) { +const CHECK_TCP_UDP: CheckHostType = { + castResult(data: any) { if (data == null) { return new CheckTcpUdpResult('processing'); } @@ -409,10 +358,7 @@ const CHECK_TCP_UDP = { // check-dns class CheckDnsResult extends CheckHostResult { - /** - * @param {'ok' | 'error' | 'processing'} state - */ - constructor(state) { + constructor(state: 'ok' | 'error' | 'processing') { super(state); } } @@ -424,13 +370,7 @@ class CheckDnsOk extends CheckDnsResult { ttl; - /** - * - * @param {string[]} a - * @param {string[]} aaaa - * @param {number} ttl - */ - constructor(a, aaaa, ttl) { + constructor(a: string[], aaaa: string[], ttl: number) { super('ok'); this.a = a; this.aaaa = aaaa; @@ -438,8 +378,7 @@ class CheckDnsOk extends CheckDnsResult { } } -/** @type {CheckHostType} */ -const CHECK_DNS = { +const CHECK_DNS: CheckHostType = { castResult(data) { if (data == null) { return new CheckDnsResult('processing'); @@ -468,7 +407,7 @@ const ipv4Regex = const hostnameRegex = /^(?=.{1,255}$)[0-9A-Za-z](?:(?:[0-9A-Za-z]|\b-){0,61}[0-9A-Za-z])?(?:\.[0-9A-Za-z](?:(?:[0-9A-Za-z]|\b-){0,61}[0-9A-Za-z])?)*\.?$/; -function isValidHostname(str) { +function isValidHostname(str: string) { if (!ipv4Regex.test(str) && !hostnameRegex.test(str)) { try { new URL(str); @@ -480,7 +419,7 @@ function isValidHostname(str) { return true; } -module.exports = { +export { CheckHostRequest, CheckHostResult, CheckPingResult, diff --git a/packages/web-api/commands/check.js b/packages/web-api/commands/check.ts similarity index 80% rename from packages/web-api/commands/check.js rename to packages/web-api/commands/check.ts index 56aa7d6..2892f46 100755 --- a/packages/web-api/commands/check.js +++ b/packages/web-api/commands/check.ts @@ -1,7 +1,5 @@ -// @ts-check - -const { LANG, strFormat } = require('../../../util/languages'); -const { +import { LANG, strFormat } from '../../../util/languages'; +import { CheckHostRequest, CheckPingOk, isValidHostname, @@ -10,19 +8,18 @@ const { CheckHttpOk, CheckHttpComplete, CheckDnsOk, -} = require('../check-host'); -const { formatTable } = require('../../../util/strings'); -const { SimpleSlashCommandBuilder } = require('../../../common/SimpleCommand'); + CheckHostResult, +} from '../check-host'; +import { FormatTableOption, formatTable } from '../../../util/strings'; +import { SimpleSlashCommandBuilder } from '../../../common/SimpleCommand'; const MAX_NODES = 40; -/** - * @template {import('../check-host').CheckHostResult} T - * @param {CheckHostRequest} request - * @param {(result: T) => unknown[]} rowFormat - * @param {import('../../../util/strings').FormatTableOption} options - */ -async function getFormattedResult(request, rowFormat, options) { +async function getFormattedResult( + request: CheckHostRequest, + rowFormat: (result: T) => unknown[], + options: FormatTableOption, +) { const resultMap = await request.checkResult(1.0, 7); const table = [...resultMap.entries()].map(([node, result]) => { const nodeName = node.name.replace('.node.check-host.net', ''); @@ -40,10 +37,7 @@ async function getFormattedResult(request, rowFormat, options) { }); } -/** - * @param {string} hostname - */ -async function checkPing(hostname) { +async function checkPing(hostname: string) { const request = await CheckHostRequest.get('ping', hostname, MAX_NODES); return async () => await getFormattedResult( @@ -71,17 +65,13 @@ async function checkPing(hostname) { ); } -/** - * @param {string} hostname - */ -async function checkHttp(hostname) { +async function checkHttp(hostname: string) { const request = await CheckHostRequest.get('http', hostname, MAX_NODES); return async () => await getFormattedResult( request, (result) => { - /** @type {unknown[]} */ - const row = [result.state + ',']; + const row: unknown[] = [result.state + ',']; if (result instanceof CheckHttpComplete) { const { time, statusMessage } = result; row.push(time + ','); @@ -100,11 +90,7 @@ async function checkHttp(hostname) { ); } -/** - * @param {'tcp' | 'udp'} type - * @param {string} hostname - */ -async function checkTcpUdp(type, hostname) { +async function checkTcpUdp(type: 'tcp' | 'udp', hostname: string) { const request = await CheckHostRequest.get(type, hostname, MAX_NODES); return async () => await getFormattedResult( @@ -129,10 +115,7 @@ async function checkTcpUdp(type, hostname) { ); } -/** - * @param {string} hostname - */ -async function checkDns(hostname) { +async function checkDns(hostname: string) { const request = await CheckHostRequest.get('dns', hostname, MAX_NODES); return async () => await getFormattedResult( @@ -155,11 +138,10 @@ async function checkDns(hostname) { ); } -/** - * @param {'ping' | 'http' | 'tcp' | 'dns' | 'udp'} type - * @param {string} hostname - */ -function check(type, hostname) { +function check( + type: 'ping' | 'http' | 'tcp' | 'dns' | 'udp', + hostname: string, +) { switch (type) { case 'ping': return checkPing(hostname); diff --git a/packages/web-api/commands/nettool.js b/packages/web-api/commands/nettool.ts similarity index 91% rename from packages/web-api/commands/nettool.js rename to packages/web-api/commands/nettool.ts index 66735ff..8cbf014 100755 --- a/packages/web-api/commands/nettool.js +++ b/packages/web-api/commands/nettool.ts @@ -1,30 +1,20 @@ -// @ts-check - -const { SlashCommandBuilder } = require('discord.js'); -const dns = require('dns'); -const axios = require('axios').default; -const ipRangeCheck = require('ip-range-check'); -const { LANG, strFormat } = require('../../../util/languages'); -const { getIpInfo } = require('../ip-api'); -const assert = require('assert'); -let cfIps = []; +import { SlashCommandBuilder } from 'discord.js'; +import dns from 'dns'; +import axios from 'axios'; +import ipRangeCheck from 'ip-range-check'; +import { LANG, strFormat } from '../../../util/languages'; +import { getIpInfo } from '../ip-api'; +import assert from 'assert'; +let cfIps: string[] = []; axios .get('https://www.cloudflare.com/ips-v4') + .then((res) => { + cfIps = res.data.split('\n'); + }) .catch(() => { console.log(LANG.commands.nettool.ipListFetchError); - }) - .then((res) => { - cfIps = res?.data.split('\n'); }); -const dnsTypes = /** @type {const} */ ([ - 'A', - 'AAAA', - 'NS', - 'CNAME', - 'TXT', - 'MX', - 'SRV', -]); +const dnsTypes = ['A', 'AAAA', 'NS', 'CNAME', 'TXT', 'MX', 'SRV'] as const; module.exports = { data: new SlashCommandBuilder() @@ -74,7 +64,7 @@ module.exports = { ), execute: async function ( - /** @type {import("discord.js").ChatInputCommandInteraction} */ interaction, + interaction: import('discord.js').ChatInputCommandInteraction, ) { const subcommand = interaction.options.getSubcommand(); if (subcommand === LANG.commands.nettool.subcommands.isProxy.name) { @@ -238,7 +228,7 @@ module.exports = { true, ); try { - const dnsResult = {}; + const dnsResult: Record = {}; await Promise.all( dnsTypes.map(async (type) => { @@ -247,7 +237,9 @@ module.exports = { assert(res instanceof Array); if (res.length > 0) { if (type == 'MX') { - res = res.sort((a, b) => b.priority - a.priority); + res = (res as dns.MxRecord[]).sort( + (a, b) => b.priority - a.priority, + ); dnsResult[type] = '```\n' + res @@ -268,7 +260,7 @@ module.exports = { '```\n' + res .map((x) => { - const isCf = ipRangeCheck(x, cfIps); + const isCf = ipRangeCheck(String(x), cfIps); return strFormat( LANG.commands.nettool.subcommands.nsLookup.record, { @@ -297,10 +289,10 @@ module.exports = { const fields = dnsTypes .filter((x) => dnsResult[x]) .map((x) => { - return /** @type {import("discord.js").APIEmbedField} */ ({ + return /** @type {import("discord.js").APIEmbedField} */ { name: x, value: dnsResult[x], - }); + }; }); await interaction.editReply({ diff --git a/packages/web-api/commands/nyanpass.js b/packages/web-api/commands/nyanpass.ts similarity index 62% rename from packages/web-api/commands/nyanpass.js rename to packages/web-api/commands/nyanpass.ts index 7d5c144..0ce2cc5 100644 --- a/packages/web-api/commands/nyanpass.js +++ b/packages/web-api/commands/nyanpass.ts @@ -1,32 +1,28 @@ -// @ts-check - -const { +import { SlashCommandBuilder, EmbedBuilder, ButtonBuilder, ActionRowBuilder, ButtonStyle, -} = require('discord.js'); -const { LANG, strFormat } = require('../../../util/languages'); -const axios = require('axios').default; + InteractionReplyOptions, +} from 'discord.js'; +import { LANG, strFormat } from '../../../util/languages'; +import axios from 'axios'; +import { Command } from '../../../util/types'; -/** - * @typedef {Object} NyanpassData - * @property {string} time - * @property {string} count - */ +interface NyanpassData { + time: string; + count: string; +} async function getNyanpass() { - /** @type {import('axios').AxiosResponse} */ - const res = await axios.get('https://nyanpass.com/api/get_count'); + const res = await axios.get( + 'https://nyanpass.com/api/get_count', + ); return res.data; } -/** - * - * @returns {Promise} - */ -async function createReply() { +async function createReply(): Promise { const { time, count } = await getNyanpass(); const embed = new EmbedBuilder() .setTitle(LANG.commands.nyanpass.title) @@ -40,8 +36,7 @@ async function createReply() { .setEmoji('✋') .setLabel(LANG.commands.nyanpass.button) .setURL('https://nyanpass.com/'); - /** @type {ActionRowBuilder} */ - const row = new ActionRowBuilder(); + const row = new ActionRowBuilder(); row.addComponents(component); return { embeds: [embed], @@ -49,8 +44,7 @@ async function createReply() { }; } -/** @type {import("../../../util/types").Command} */ -const commandNyanpass = { +const commandNyanpass: Command = { data: new SlashCommandBuilder() .setName(LANG.commands.nyanpass.name) .setDescription(LANG.commands.nyanpass.description), diff --git a/packages/web-api/index.js b/packages/web-api/index.ts similarity index 84% rename from packages/web-api/index.js rename to packages/web-api/index.ts index 099c2b5..a302fe4 100644 --- a/packages/web-api/index.js +++ b/packages/web-api/index.ts @@ -1,6 +1,4 @@ -// @ts-check - -const { CommandManager } = require('../../internal/commands'); +import { CommandManager } from '../../internal/commands'; /** * @typedef {import("../../util/types").Feature} Feature diff --git a/packages/web-api/ip-api.js b/packages/web-api/ip-api.js deleted file mode 100644 index 74ee3d5..0000000 --- a/packages/web-api/ip-api.js +++ /dev/null @@ -1,74 +0,0 @@ -// @ts-check - -const axios = require('axios').default; -const { Ok, Err } = require('../../util/result'); - -/** - * @typedef {Object} IpApiGeolocationFullData - * @property {string} status - * @property {string} message - * @property {string} continent - * @property {string} continentCode - * @property {string} country - * @property {string} countryCode - * @property {string} region - * @property {string} regionName - * @property {string} city - * @property {string} district - * @property {string} zip - * @property {number} lat - * @property {number} lon - * @property {string} timezone - * @property {number} offset - * @property {string} isp - * @property {string} org - * @property {string} as - * @property {string} asname - * @property {string} reverse - * @property {boolean} mobile - * @property {boolean} proxy - * @property {boolean} hosting - * @property {string} query - */ - -/** - * @template {string} F - * @typedef {Object} IpApiGeolocationOption - * @property {F=} fields - * @property {( - * "en" | - * "de" | - * "es" | - * "pt-BR" | - * "fr" | - * "ja" | - * "zh-CN" | - * "ru" - * )=} lang - */ - -/** - * @template {string} T - * @typedef {Partial & ({ - * [K in import("../../util/types").Split & keyof IpApiGeolocationFullData]: IpApiGeolocationFullData[K] - * })} IpApiGeolocationData - */ - -/** - * @template {string} F - * @param {string} ip IP アドレス - * @param {IpApiGeolocationOption=} params 情報を取得する項目 - * @returns {Promise>>} 結果を Result 型でラップしたもの - */ -async function getIpInfo(ip, params) { - try { - const res = await axios.get( - `http://ip-api.com/json/${encodeURI(ip)}?${new URLSearchParams(params)}`, - ); - return new Ok(/** @type {IpApiGeolocationData} */ (res.data)); - } catch (e) { - return new Err(e); - } -} - -module.exports = { getIpInfo }; diff --git a/packages/web-api/ip-api.ts b/packages/web-api/ip-api.ts new file mode 100644 index 0000000..3ce7079 --- /dev/null +++ b/packages/web-api/ip-api.ts @@ -0,0 +1,62 @@ +import { Result } from '../../util/result'; +import { Split } from '../../util/types'; + +const axios = require('axios').default; +const { Ok, Err } = require('../../util/result'); + +interface IpApiGeolocationFullData { + status: string; + message: string; + continent: string; + continentCode: string; + country: string; + countryCode: string; + region: string; + regionName: string; + city: string; + district: string; + zip: string; + lat: number; + lon: number; + timezone: string; + offset: number; + isp: string; + org: string; + as: string; + asname: string; + reverse: string; + mobile: boolean; + proxy: boolean; + hosting: boolean; + query: string; +} + +interface IpApiGeolocationOption { + fields?: F; + lang?: 'en' | 'de' | 'es' | 'pt-BR' | 'fr' | 'ja' | 'zh-CN' | 'ru'; +} + +type IpApiGeolocationData = + Partial & { + [K in Split & + keyof IpApiGeolocationFullData]: IpApiGeolocationFullData[K]; + }; + +/** + * @param ip IP アドレス + * @param params 情報を取得する項目 + * @returns 結果を Result 型でラップしたもの + */ +export async function getIpInfo( + ip: string, + params?: IpApiGeolocationOption, +): Promise>> { + try { + const res = await axios.get( + `http://ip-api.com/json/${encodeURI(ip)}?${new URLSearchParams(params as Record)}`, + ); + return new Ok(/** @type {IpApiGeolocationData} */ res.data); + } catch (e) { + return new Err(e); + } +} diff --git a/packages/web-api/package.json b/packages/web-api/package.json index 791713f..02b93ad 100644 --- a/packages/web-api/package.json +++ b/packages/web-api/package.json @@ -2,7 +2,7 @@ "name": "web-api", "version": "1.0.0", "description": "", - "main": "index.js", + "main": "index.ts", "devDependencies": {}, "scripts": { "test": "echo \"Error: no test specified\" && exit 1" diff --git a/tsconfig.json b/tsconfig.json index 67976fc..eee70e0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,7 @@ "module": "NodeNext", "moduleResolution": "NodeNext", "outDir": "dist", - "skipLibCheck": true + "skipLibCheck": true, + "noImplicitAny": true } } diff --git a/util/languages.test.js b/util/languages.test.ts similarity index 93% rename from util/languages.test.js rename to util/languages.test.ts index 95a18cd..53019be 100644 --- a/util/languages.test.js +++ b/util/languages.test.ts @@ -1,4 +1,4 @@ -const { strFormat, FormatSyntaxError, assignDeep } = require('./languages'); +import { strFormat, FormatSyntaxError, assignDeep } from './languages'; test('assignDeep', () => { expect( diff --git a/util/languages.js b/util/languages.ts similarity index 75% rename from util/languages.js rename to util/languages.ts index 080ec35..2ec9009 100644 --- a/util/languages.js +++ b/util/languages.ts @@ -1,13 +1,8 @@ -// @ts-check +import * as path from 'path'; +import * as config from '../config.json'; +import * as LANG from '../language/default.json'; -const path = require('path'); -const config = require('../config.json'); -const LANG = require('../language/default.json'); - -/** - * @type {typeof import('../language/default.json')} - */ -const configLANG = require( +const configLANG: typeof import('../language/default.json') = require( path.join( __dirname, '..', @@ -16,10 +11,12 @@ const configLANG = require( ), ); -function assignDeep(target, source) { +function assignDeep(target: Record, source: unknown) { for (const [key, value] of Object.entries(source)) { - if (target[key] instanceof Object) assignDeep(target[key], value); - else if (value instanceof Object) target[key] = assignDeep({}, value); + const targetValue = target[key]; + if (targetValue instanceof Object) { + assignDeep(targetValue as Record, value); + } else if (value instanceof Object) target[key] = assignDeep({}, value); else target[key] = value; } return target; @@ -28,10 +25,7 @@ function assignDeep(target, source) { assignDeep(LANG, configLANG); class FormatSyntaxError extends SyntaxError { - /** - * @param {string} message - */ - constructor(message) { + constructor(message: string) { super(message); } } @@ -42,7 +36,9 @@ const State = { ESCAPED: 1, AFTER_DOLLAR: 2, IN_PLACEHOLDER: 3, -}; +} as const; + +type State = (typeof State)[keyof typeof State]; /** * 文字列中のプレースホルダを指定された値で置き換える。 @@ -67,17 +63,17 @@ const State = { * strFormat("text ${0} abc", 123); // == "text 123 abc" * ``` * - * @param {string} str 文字列のフォーマット - * @param {unknown[]} values プレースホルダを置き換える値 + * @param str 文字列のフォーマット + * @param values プレースホルダを置き換える値 */ -function strFormat(str, ...values) { +function strFormat(str: string, ...values: unknown[]) { const map = toMap(values); let result = ''; let placeholder = ''; // 入力を str, 状態を PLAIN, ESCAPED, AFTER_DOLLAR, IN_PLACEHOLDER とした有限オートマトンを構成 // 入力は常に1文字ずつ読み進められる。 - let state = State.PLAIN; + let state: State = State.PLAIN; for (const c of str) { switch (state) { case State.PLAIN: @@ -120,17 +116,14 @@ function strFormat(str, ...values) { return result; } -/** - * @param {unknown[]} values - */ -function toMap(values) { +function toMap(values: unknown[]): Record | null { if (values.length == 0) { return null; } if (values.length == 1 && values[0] instanceof Object) { - return values[0]; + return values[0] as Record; } - return values; + return values as object as Record; } -module.exports = { LANG, FormatSyntaxError, strFormat, assignDeep }; +export { LANG, FormatSyntaxError, strFormat, assignDeep }; diff --git a/util/result.js b/util/result.js deleted file mode 100644 index a3dbe6e..0000000 --- a/util/result.js +++ /dev/null @@ -1,57 +0,0 @@ -// @ts-check - -/** - * @template T - * 正常な処理結果。 - */ -class Ok { - /** - * @readonly - */ - status = 'ok'; - - /** - * @readonly - * @type {T} - */ - value; - - /** - * @param {T} value - */ - constructor(value) { - this.value = value; - } -} - -/** - * @template E - * 処理中のエラー。 - */ -class Err { - /** - * @readonly - */ - status = 'err'; - - /** - * @readonly - * @type {E} - */ - value; - - /** - * @param {E} value - */ - constructor(value) { - this.value = value; - } -} - -/** - * @template T - * @template [E=any] - * @typedef {Ok | Err} Result 処理の結果を表す型 - */ - -module.exports = { Ok, Err }; diff --git a/util/result.ts b/util/result.ts new file mode 100644 index 0000000..c30a865 --- /dev/null +++ b/util/result.ts @@ -0,0 +1,27 @@ +/** + * 正常な処理結果。 + */ +export class Ok { + readonly status = 'ok'; + + readonly value: T; + + constructor(value: T) { + this.value = value; + } +} + +/** + * 処理中のエラー。 + */ +export class Err { + readonly status = 'err'; + + readonly value: E; + + constructor(value: E) { + this.value = value; + } +} + +export type Result = Ok | Err; diff --git a/util/strings.test.js b/util/strings.test.ts similarity index 90% rename from util/strings.test.js rename to util/strings.test.ts index 387d87b..2713a44 100644 --- a/util/strings.test.js +++ b/util/strings.test.ts @@ -1,4 +1,4 @@ -const { formatTable } = require('./strings'); +import { formatTable } from './strings'; test('formatTable', () => { expect( diff --git a/util/strings.js b/util/strings.ts similarity index 62% rename from util/strings.js rename to util/strings.ts index ece1368..0d294f0 100644 --- a/util/strings.js +++ b/util/strings.ts @@ -1,20 +1,25 @@ -// @ts-check +export interface FormatTableOption { + /** 列を同じ幅にするために余白を埋める文字 */ + fillString?: string; -/** - * @typedef {Object} FormatTableOption - * @property {string=} fillString 列を同じ幅にするために余白を埋める文字 - * @property {string=} margin 列の間のスペース文字 - * @property {('left' | 'right')[]=} align 各列について文字をどちら側に寄せるか - */ + /** 列の間のスペース文字 */ + margin?: string; + + /** 各列について文字をどちら側に寄せるか */ + align?: ('left' | 'right')[]; +} /** * 二次元配列を表形式の文字列に変換する。 - * @param {unknown[][]} table 二次元配列 - * @param {FormatTableOption} options オプション + * @param table 二次元配列 + * @param options オプション */ -function formatTable(table, options = {}) { +export function formatTable( + table: unknown[][], + options: FormatTableOption = {}, +) { const stringTable = table.map((row) => row.map((cell) => String(cell))); - const /** @type {number[]} */ maxWidths = []; + const /** @type {number[]} */ maxWidths: number[] = []; for (const row of stringTable) { const length = row.length; for (let j = 0; j < length; j++) { @@ -48,5 +53,3 @@ function formatTable(table, options = {}) { }) .join('\n'); } - -module.exports = { formatTable }; diff --git a/util/types.ts b/util/types.ts index 304fbfa..8980205 100644 --- a/util/types.ts +++ b/util/types.ts @@ -1,5 +1,3 @@ -// @ts-check - import { ChatInputCommandInteraction, Client,