From 97f4597bd368b3a7aec425f1606c3b4db3081271 Mon Sep 17 00:00:00 2001 From: Roz Date: Sat, 7 Dec 2024 13:04:47 -0300 Subject: [PATCH 1/3] feat: fill `ctx.match` and add `ctx.commandMatch` --- src/command.ts | 120 +++++++++++++++++++++++++++++++++++++------------ src/context.ts | 9 ++++ 2 files changed, 100 insertions(+), 29 deletions(-) diff --git a/src/command.ts b/src/command.ts index 23a741a..71ccb8b 100644 --- a/src/command.ts +++ b/src/command.ts @@ -21,11 +21,32 @@ import { isMiddleware, matchesPattern, } from "./utils/checks.ts"; +import { CommandsFlavor } from "./context.ts"; type BotCommandGroupsScope = | BotCommandScopeAllGroupChats | BotCommandScopeAllChatAdministrators; +/** + * Represents a matched command, the result of the RegExp match, and the rest of the input. + */ +export interface CommandMatch { + /** + * The matched command. + */ + command: string | RegExp; + /** + * The rest of the input after the command. + */ + rest: string; + /** + * The result of the RegExp match. + * + * Only defined if the command is a RegExp. + */ + match?: RegExpExecArray | null; +} + const NOCASE_COMMAND_NAME_REGEX = /^[0-9a-z_]+$/i; /** @@ -326,6 +347,65 @@ export class Command implements MiddlewareObj { return this; } + /** + * Finds the matching command in the given context + * + * @example + * ```ts + * // ctx.msg.text = "/delete_123 something" + * const match = Command.findMatchingCommand(/delete_(.*)/, { prefix: "/", ignoreCase: true }, ctx) + * // match is { command: /delete_(.*)/, rest: ["something"], match: ["delete_123"] } + * ``` + */ + public static findMatchingCommand( + command: MaybeArray, + options: CommandOptions, + ctx: Context, + ): CommandMatch | null { + const { matchOnlyAtStart, prefix, targetedCommands } = options; + + if (!ctx.has(":text")) return null; + + if (matchOnlyAtStart && !ctx.msg.text.startsWith(prefix)) { + return null; + } + + const commandNames = ensureArray(command); + const commands = ctx.msg.text.split(prefix).map((text) => ({ text })); + + for (const { text } of commands) { + const [command, username] = text.split("@"); + if (targetedCommands === "ignored" && username) continue; + if (targetedCommands === "required" && !username) continue; + if (username && username !== ctx.me.username) continue; + const [issuedCommand, ...rest] = command.replace(prefix, "").split(" "); + const matchingCommand = commandNames.find((name) => + matchesPattern( + issuedCommand, + name, + options.ignoreCase, + ) + ); + + if (matchingCommand instanceof RegExp) { + return { + command: matchingCommand, + rest: rest.join(" "), + match: matchingCommand.exec(ctx.msg.text), + }; + } + + if (matchingCommand) { + return { + command: matchingCommand, + rest: rest.join(" "), + }; + } + } + + return null; + } + /** * Creates a matcher for the given command that can be used in filtering operations * @@ -346,38 +426,20 @@ export class Command implements MiddlewareObj { command: MaybeArray, options: CommandOptions, ) { - const { matchOnlyAtStart, prefix, targetedCommands } = options; - return (ctx: Context) => { - if (!ctx.has(":text")) return false; - if (matchOnlyAtStart && !ctx.msg.text.startsWith(prefix)) { - return false; - } + const matchingCommand = Command.findMatchingCommand( + command, + options, + ctx, + ); - const commandNames = ensureArray(command); - const commands = prefix === "/" - ? ctx.entities("bot_command") - : ctx.msg.text.split(prefix).map((text) => ({ text })); - - for (const { text } of commands) { - const [command, username] = text.split("@"); - if (targetedCommands === "ignored" && username) continue; - if (targetedCommands === "required" && !username) continue; - if (username && username !== ctx.me.username) continue; - if ( - commandNames.some((name) => - matchesPattern( - command.replace(prefix, "").split(" ")[0], - name, - options.ignoreCase, - ) - ) - ) { - return true; - } - } + if (!matchingCommand) return false; + + ctx.match = matchingCommand.rest; + // TODO: Clean this up. But how to do it without requiring the user to install the commands flavor? + (ctx as Context & CommandsFlavor).commandMatch = matchingCommand; - return false; + return true; }; } diff --git a/src/context.ts b/src/context.ts index e88018a..b15e72e 100644 --- a/src/context.ts +++ b/src/context.ts @@ -1,4 +1,5 @@ import { CommandGroup } from "./command-group.ts"; +import { CommandMatch } from "./command.ts"; import { BotCommandScopeChat, Context, NextFunction } from "./deps.deno.ts"; import { SetMyCommandsParams } from "./mod.ts"; import { BotCommandEntity } from "./types.ts"; @@ -36,6 +37,7 @@ export interface CommandsFlavor extends Context { commands: CommandGroup | CommandGroup[], options?: SetBotCommandsOptions, ) => Promise; + /** * Returns the nearest command to the user input. * If no command is found, returns `null`. @@ -56,6 +58,13 @@ export interface CommandsFlavor extends Context { getCommandEntities: ( commands: CommandGroup | CommandGroup[], ) => BotCommandEntity[]; + + /** + * The matched command and the rest of the input. + * + * When matched command is a RegExp, a `match` property exposes the result of the RegExp match. + */ + commandMatch: CommandMatch; } /** From 5004b4b0c02cb1acc6f05c4d3d3a6bcdf77e28c6 Mon Sep 17 00:00:00 2001 From: Roz Date: Tue, 10 Dec 2024 06:09:31 -0300 Subject: [PATCH 2/3] chore: remove deno-bin and update prepare script --- deno.jsonc | 2 +- package-lock.json | 126 +++++++++++++++++++++++++++++++++++++++------- package.json | 6 +-- 3 files changed, 113 insertions(+), 21 deletions(-) diff --git a/deno.jsonc b/deno.jsonc index c9ec414..67bb8da 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -18,7 +18,7 @@ }, "lock": false, "tasks": { - "backport": "rm -rf out && deno run --no-prompt --allow-read=. --allow-write=. https://deno.land/x/deno2node@v1.9.0/src/cli.ts", + "backport": "deno run --no-prompt --allow-read=. --allow-write=. https://deno.land/x/deno2node@v1.14.0/src/cli.ts", "check": "deno lint && deno fmt --check && deno check --allow-import src/mod.ts", "fix": "deno lint --fix && deno fmt", "test": "deno test --allow-import --seed=123456 --parallel ./test/", diff --git a/package-lock.json b/package-lock.json index 7f5cea1..30d55bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.3", "license": "MIT", "devDependencies": { - "deno-bin": "^2.0.6", + "deno2node": "^1.14.0", "typescript": "^5.6.3" }, "peerDependencies": { @@ -23,6 +23,17 @@ "license": "MIT", "peer": true }, + "node_modules/@ts-morph/common": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.25.0.tgz", + "integrity": "sha512-kMnZz+vGGHi4GoHnLmMhGNjm44kGtKUXGnOvrKmMwAuvNjM/PgKVGfUnL7IDvK7Jb2QQ82jq3Zmp04Gy+r3Dkg==", + "dev": true, + "dependencies": { + "minimatch": "^9.0.4", + "path-browserify": "^1.0.1", + "tinyglobby": "^0.2.9" + } + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -36,16 +47,27 @@ "node": ">=6.5" } }, - "node_modules/adm-zip": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz", - "integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==", + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0" + "dependencies": { + "balanced-match": "^1.0.0" } }, + "node_modules/code-block-writer": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", + "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==", + "dev": true + }, "node_modules/debug": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", @@ -64,19 +86,19 @@ } } }, - "node_modules/deno-bin": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/deno-bin/-/deno-bin-2.0.6.tgz", - "integrity": "sha512-r+stiyToBRQIAzYmzuNHH+RpfgCfhtnScR6PG0EpN61WtJgWteND5cKOaK+ODYCObZoAlGZaMLv67mhfCnaUCQ==", + "node_modules/deno2node": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/deno2node/-/deno2node-1.14.0.tgz", + "integrity": "sha512-F1WpKb2OuIcMmev6tGFMzoQCJcsPTmZFymzP5mcq4Ac8xQwT4brWJqDL+LyKP9uqOPSAKU5QJi6ZIJPs4IKtSA==", "dev": true, - "hasInstallScript": true, - "license": "MIT", "dependencies": { - "adm-zip": "^0.5.4" + "ts-morph": "^24.0.0" }, "bin": { - "deno": "bin/deno.js", - "deno-bin": "bin/deno.js" + "deno2node": "lib/cli.js" + }, + "engines": { + "node": ">=14.13.1" } }, "node_modules/event-target-shim": { @@ -89,6 +111,20 @@ "node": ">=6" } }, + "node_modules/fdir": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.2.tgz", + "integrity": "sha512-KnhMXsKSPZlAhp7+IjUkRZKPb4fUyccpDrdFXbi4QL1qkmFh9kVY09Yox+n4MaOb3lHZ1Tv829C3oaaXoMYPDQ==", + "dev": true, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/grammy": { "version": "1.31.3", "resolved": "https://registry.npmjs.org/grammy/-/grammy-1.31.3.tgz", @@ -105,6 +141,21 @@ "node": "^12.20.0 || >=14.13.1" } }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -133,6 +184,37 @@ } } }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true + }, + "node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.10.tgz", + "integrity": "sha512-Zc+8eJlFMvgatPZTl6A9L/yht8QqdmUNtURHaKZLmKBE12hNPSrqNkUp2cs3M/UKmNVVAMFQYSjYIVHDjW5zew==", + "dev": true, + "dependencies": { + "fdir": "^6.4.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -140,6 +222,16 @@ "license": "MIT", "peer": true }, + "node_modules/ts-morph": { + "version": "24.0.0", + "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-24.0.0.tgz", + "integrity": "sha512-2OAOg/Ob5yx9Et7ZX4CvTCc0UFoZHwLEJ+dpDPSUi5TgwwlTlX47w+iFRrEwzUZwYACjq83cgjS/Da50Ga37uw==", + "dev": true, + "dependencies": { + "@ts-morph/common": "~0.25.0", + "code-block-writer": "^13.0.3" + } + }, "node_modules/typescript": { "version": "5.6.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", diff --git a/package.json b/package.json index f1809c1..91e851e 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,8 @@ "description": "grammY Commands Plugin", "main": "out/mod.js", "scripts": { - "backport": "deno task backport", - "prepare": "deno task backport" + "backport": "deno2node tsconfig.json", + "prepare": "npm run backport" }, "keywords": [ "grammY", @@ -27,7 +27,7 @@ "grammy": "^1.17.1" }, "devDependencies": { - "deno-bin": "^2.0.6", + "deno2node": "^1.14.0", "typescript": "^5.6.3" }, "files": [ From 7bc7c3a7da45639ac22bbb617fdd7cacc406c80f Mon Sep 17 00:00:00 2001 From: Roz Date: Wed, 11 Dec 2024 20:28:05 -0300 Subject: [PATCH 3/3] chore: rework command matchin logic --- src/command.ts | 68 +++++++++++--------- test/command.test.ts | 146 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 183 insertions(+), 31 deletions(-) diff --git a/src/command.ts b/src/command.ts index 71ccb8b..19dee50 100644 --- a/src/command.ts +++ b/src/command.ts @@ -1,3 +1,4 @@ +import { CommandsFlavor } from "./context.ts"; import { type BotCommand, type BotCommandScope, @@ -12,7 +13,6 @@ import { type Middleware, type MiddlewareObj, } from "./deps.deno.ts"; -import { InvalidScopeError } from "./utils/errors.ts"; import type { CommandOptions } from "./types.ts"; import { ensureArray, type MaybeArray } from "./utils/array.ts"; import { @@ -21,7 +21,7 @@ import { isMiddleware, matchesPattern, } from "./utils/checks.ts"; -import { CommandsFlavor } from "./context.ts"; +import { InvalidScopeError } from "./utils/errors.ts"; type BotCommandGroupsScope = | BotCommandScopeAllGroupChats @@ -371,36 +371,46 @@ export class Command implements MiddlewareObj { } const commandNames = ensureArray(command); - const commands = ctx.msg.text.split(prefix).map((text) => ({ text })); - - for (const { text } of commands) { - const [command, username] = text.split("@"); - if (targetedCommands === "ignored" && username) continue; - if (targetedCommands === "required" && !username) continue; - if (username && username !== ctx.me.username) continue; - const [issuedCommand, ...rest] = command.replace(prefix, "").split(" "); - const matchingCommand = commandNames.find((name) => - matchesPattern( - issuedCommand, - name, - options.ignoreCase, - ) + const escapedPrefix = prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const commandRegex = new RegExp( + `${escapedPrefix}(?[^@ ]+)(?:@(?[^\\s]*))?(?.*)`, + "g", + ); + + const firstCommand = commandRegex.exec(ctx.msg.text)?.groups; + + if (!firstCommand) return null; + + if (!firstCommand.username && targetedCommands === "required") return null; + if (firstCommand.username && firstCommand.username !== ctx.me.username) { + return null; + } + if (firstCommand.username && targetedCommands === "ignored") return null; + + const matchingCommand = commandNames.find((name) => { + const matches = matchesPattern( + name instanceof RegExp + ? firstCommand.command + firstCommand.rest + : firstCommand.command, + name, + options.ignoreCase, ); + return matches; + }); - if (matchingCommand instanceof RegExp) { - return { - command: matchingCommand, - rest: rest.join(" "), - match: matchingCommand.exec(ctx.msg.text), - }; - } + if (matchingCommand instanceof RegExp) { + return { + command: matchingCommand, + rest: firstCommand.rest.trim(), + match: matchingCommand.exec(ctx.msg.text), + }; + } - if (matchingCommand) { - return { - command: matchingCommand, - rest: rest.join(" "), - }; - } + if (matchingCommand) { + return { + command: matchingCommand, + rest: firstCommand.rest.trim(), + }; } return null; diff --git a/test/command.test.ts b/test/command.test.ts index c5b2abb..1f7f23f 100644 --- a/test/command.test.ts +++ b/test/command.test.ts @@ -1,7 +1,8 @@ import { assertEquals } from "https://deno.land/std@0.203.0/assert/assert_equals.ts"; +import { assertExists } from "https://deno.land/std@0.203.0/assert/assert_exists.ts"; import { Command } from "../src/command.ts"; import { CommandOptions } from "../src/types.ts"; -import { matchesPattern } from "../src/utils/checks.ts"; +import { isCommandOptions, matchesPattern } from "../src/utils/checks.ts"; import { Api, assert, @@ -15,7 +16,19 @@ import { type User, type UserFromGetMe, } from "./deps.test.ts"; -import { isCommandOptions } from "../src/utils/checks.ts"; + +function createRegexpMatchArray( + match: string[], + groups?: Record, + index?: number, + input?: string, +) { + const result = [...match]; + (result as any).groups = groups; + (result as any).index = index; + (result as any).input = input; + return result as unknown as RegExpExecArray; +} describe("Command", () => { const u = { id: 42, first_name: "bot", is_bot: true } as User; @@ -75,13 +88,19 @@ describe("Command", () => { m.entities = [{ type: "bot_command", offset: 0, length: 10 }]; const ctx = new Context(update, api, me); assert(Command.hasCommand("start", options)(ctx)); + }); + it("should not match a regular targeted command in the middle of the message", () => { m.text = "blabla /start@bot"; m.entities = [{ type: "bot_command", offset: 7, length: 10 }]; + const ctx = new Context(update, api, me); assertFalse(Command.hasCommand("start", options)(ctx)); + }); + it("should not match a regular targeted command with a different username", () => { m.text = "/start@otherbot"; m.entities = [{ type: "bot_command", offset: 0, length: 13 }]; + const ctx = new Context(update, api, me); assertFalse(Command.hasCommand("start", options)(ctx)); }); @@ -634,4 +653,127 @@ describe("Command", () => { assertFalse(isCommandOptions(partialOpts)); }); }); + + describe("findMatchingCommand", () => { + it("should return null if the message does not contain a text", () => { + m.text = undefined; + const ctx = new Context(update, api, me); + assert(Command.findMatchingCommand("start", options, ctx) === null); + }); + + it("should return null if the message does not start with the prefix and matchOnlyAtStart is true", () => { + m.text = "/start"; + const ctx = new Context(update, api, me); + assertEquals( + Command.findMatchingCommand("start", { + ...options, + prefix: "NOPE", + matchOnlyAtStart: true, + }, ctx), + null, + ); + }); + + it("should correctly handle a targeted command", () => { + m.text = "/start@bot"; + const ctx = new Context(update, api, me); + assertEquals( + Command.findMatchingCommand("start", options, ctx), + { + command: "start", + rest: "", + }, + ); + }); + + it("should correctly handle a non-targeted command", () => { + m.text = "/start"; + const ctx = new Context(update, api, me); + assertEquals( + Command.findMatchingCommand("start", options, ctx), + { + command: "start", + rest: "", + }, + ); + }); + + it("should correctly handle a regex command with no args", () => { + m.text = "/start_123"; + const ctx = new Context(update, api, me); + assertEquals( + Command.findMatchingCommand(/start_(\d{3})/, options, ctx), + { + command: /start_(\d{3})/, + rest: "", + match: createRegexpMatchArray( + ["start_123", "123"], + undefined, + 1, + "/start_123", + ), + }, + ); + }); + + it("should correctly handle a regex command with args", () => { + m.text = "/start blabla"; + const ctx = new Context(update, api, me); + const result = Command.findMatchingCommand(/start (.*)/, { + ...options, + targetedCommands: "optional", + }, ctx); + + assertExists(result); + assertEquals( + result, + { + command: /start (.*)/, + rest: "blabla", + match: createRegexpMatchArray( + ["start blabla", "blabla"], + undefined, + 1, + "/start blabla", + ), + }, + ); + }); + + it("should handle a targeted command with a param that contains an @", () => { + m.text = "/start@bot john@doe.com"; + const ctx = new Context(update, api, me); + assertEquals( + Command.findMatchingCommand("start", options, ctx), + { + command: "start", + rest: "john@doe.com", + }, + ); + }); + + it("should handle a non-targeted command with a param that contains an @", () => { + m.text = "/start john@doe.com"; + const ctx = new Context(update, api, me); + assertEquals(Command.findMatchingCommand("start", options, ctx), { + command: "start", + rest: "john@doe.com", + }); + }); + + it("should handle a command after an occurence of @", () => { + m.text = "john@doe.com /start@bot test"; + const ctx = new Context(update, api, me); + assertEquals( + Command.findMatchingCommand("start", { + ...options, + matchOnlyAtStart: false, + }, ctx), + { + command: "start", + rest: "test", + }, + ); + }); + }); });