Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: fill ctx.match and add ctx.commandMatch #57

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion deno.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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/",
Expand Down
126 changes: 109 additions & 17 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -27,7 +27,7 @@
"grammy": "^1.17.1"
},
"devDependencies": {
"deno-bin": "^2.0.6",
"deno2node": "^1.14.0",
"typescript": "^5.6.3"
},
"files": [
Expand Down
132 changes: 102 additions & 30 deletions src/command.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { CommandsFlavor } from "./context.ts";
import {
type BotCommand,
type BotCommandScope,
Expand All @@ -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 {
Expand All @@ -21,11 +21,32 @@ import {
isMiddleware,
matchesPattern,
} from "./utils/checks.ts";
import { InvalidScopeError } from "./utils/errors.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;

/**
Expand Down Expand Up @@ -326,6 +347,75 @@ export class Command<C extends Context = Context> implements MiddlewareObj<C> {
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<string | RegExp>,
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 escapedPrefix = prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const commandRegex = new RegExp(
`${escapedPrefix}(?<command>[^@ ]+)(?:@(?<username>[^\\s]*))?(?<rest>.*)`,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would like to add super-expressive as a dep, since we are using quite a few complex regexs across the plugin

"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: firstCommand.rest.trim(),
match: matchingCommand.exec(ctx.msg.text),
};
}

if (matchingCommand) {
return {
command: matchingCommand,
rest: firstCommand.rest.trim(),
};
}

return null;
}

/**
* Creates a matcher for the given command that can be used in filtering operations
*
Expand All @@ -346,38 +436,20 @@ export class Command<C extends Context = Context> implements MiddlewareObj<C> {
command: MaybeArray<string | RegExp>,
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;
};
}

Expand Down
Loading
Loading