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(composer): match any command #733

Open
wants to merge 5 commits into
base: v2
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
9 changes: 5 additions & 4 deletions src/composer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,9 @@ export class Composer<C extends Context> implements MiddlewareObj<C> {
* bot.command('start', ctx => { ... })
* // Reacts to /help commands
* bot.command('help', ctx => { ... })
* // Reacts to any command
* bot.command(undefined, ctx => { ... })
* bot.command().use(ctx => { ... })
* ```
*
* The rest of the message (excluding the command, and trimmed) is provided
Expand All @@ -347,13 +350,11 @@ export class Composer<C extends Context> implements MiddlewareObj<C> {
* > })
* > ```
*
* Note that commands are not matched in captions or in the middle of the
* text.
* Note that commands are not matched in the middle of the text.
* ```ts
* bot.command('start', ctx => { ... })
* // ... does not match:
* // A message saying: “some text /start some more text”
* // A photo message with the caption “/start”
* ```
*
* By default, commands are detected in channel posts, too. This means that
Expand All @@ -377,7 +378,7 @@ export class Composer<C extends Context> implements MiddlewareObj<C> {
* @param middleware The middleware to register
*/
command(
command: MaybeArray<StringWithCommandSuggestions>,
command?: MaybeArray<StringWithCommandSuggestions>,
...middleware: Array<CommandMiddleware<C>>
): Composer<CommandContext<C>> {
return this.filter(Context.has.command(command), ...middleware);
Expand Down
39 changes: 18 additions & 21 deletions src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ interface StaticHas {
* @param command The command to match
*/
command(
command: MaybeArray<StringWithCommandSuggestions>,
command?: MaybeArray<StringWithCommandSuggestions>,
): <C extends Context>(ctx: C) => ctx is CommandContext<C>;
/**
* Generates a predicate function that can test context objects for
Expand Down Expand Up @@ -185,39 +185,36 @@ const checker: StaticHas = {
};
},
command(command) {
const hasEntities = checker.filterQuery(":entities:bot_command");
const atCommands = new Set<string>();
const noAtCommands = new Set<string>();
toArray(command).forEach((cmd) => {
const hasEntities = checker.filterQuery("::bot_command");
const noAtCommands = new Set<string>(toArray(command ?? []));
for (const cmd of noAtCommands) {
if (cmd.startsWith("/")) {
throw new Error(
`Do not include '/' when registering command handlers (use '${
cmd.substring(1)
}' not '${cmd}')`,
);
}
const set = cmd.includes("@") ? atCommands : noAtCommands;
set.add(cmd);
});
}
return <C extends Context>(ctx: C): ctx is CommandContext<C> => {
if (!hasEntities(ctx)) return false;
const msg = ctx.message ?? ctx.channelPost;
const txt = msg.text ?? msg.caption;
return msg.entities.some((e) => {
const txt = msg.text ?? msg.caption ?? "";
const entities = msg.entities ?? msg.caption_entities;
return entities.some((e) => {
if (e.type !== "bot_command") return false;
if (e.offset !== 0) return false;
const cmd = txt.substring(1, e.length);
if (noAtCommands.has(cmd) || atCommands.has(cmd)) {
ctx.match = txt.substring(cmd.length + 1).trimStart();
return true;
let index = cmd.indexOf("@");
if (index === -1) {
index = Infinity;
Copy link
Member

Choose a reason for hiding this comment

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

This is a big of a hack, isn't it?

Copy link
Contributor Author

@wojpawlik wojpawlik Jan 2, 2025

Choose a reason for hiding this comment

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

Not really:

Any argument value that is less than 0 or greater than str.length is treated as if it were 0 and str.length, respectively.

Suggested change
index = Infinity;
index = cmd.length;

undefined would require adding a type annotation.

} else {
const atTarget = cmd.substring(index + 1).toLowerCase();
const username = ctx.me.username.toLowerCase();
if (atTarget !== username) return false;
}
const index = cmd.indexOf("@");
if (index === -1) return false;
const atTarget = cmd.substring(index + 1).toLowerCase();
const username = ctx.me.username.toLowerCase();
if (atTarget !== username) return false;
const atCommand = cmd.substring(0, index);
if (noAtCommands.has(atCommand)) {
if (command === undefined || noAtCommands.has(atCommand)) {
ctx.match = txt.substring(cmd.length + 1).trimStart();
return true;
}
Expand Down Expand Up @@ -853,7 +850,7 @@ export class Context implements RenamedUpdate {
* @param command The command to match
*/
hasCommand(
command: MaybeArray<StringWithCommandSuggestions>,
command?: MaybeArray<StringWithCommandSuggestions>,
): this is CommandContextCore {
return Context.has.command(command)(this);
}
Expand Down Expand Up @@ -3174,7 +3171,7 @@ type CommandContextCore =
*/
export type CommandContext<C extends Context> = FilterQueryContext<
NarrowMatch<C, string>,
":entities:bot_command"
"::bot_command"
>;
type NarrowMatchCore<T extends Context["match"]> = { match: T };
type NarrowMatch<C extends Context, T extends C["match"]> = {
Expand Down
9 changes: 4 additions & 5 deletions test/composer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ describe("Composer", () => {

beforeEach(() => {
composer = new Composer();
middleware = spy((_ctx) => {});
middleware = spy((_ctx, next) => next());
});

it("should call handlers", async () => {
Expand Down Expand Up @@ -160,9 +160,10 @@ describe("Composer", () => {
0 as any,
);
it("should check for commands", async () => {
composer.command(undefined, middleware);
composer.command("start", middleware);
await exec(c);
assertEquals(middleware.calls.length, 1);
assertEquals(middleware.calls.length, 2);
assertEquals(middleware.calls[0].args[0], c);
});
it("should allow chaining commands", async () => {
Expand Down Expand Up @@ -540,9 +541,7 @@ describe("Composer", () => {
composer.use(() => {
throw err;
});
await assertRejects(async () => {
await exec();
}, "yay");
await assertRejects(async () => await exec(), Error, "yay");
assertEquals(handler.calls.length, 0);
});
it("should support passing on the control flow via next", async () => {
Expand Down
2 changes: 1 addition & 1 deletion test/composer.type.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ describe("Composer types", () => {
const channelPostCaption = ctx.channelPost?.caption;
const channelPostText = ctx.channelPost?.text;
const match = ctx.match;
assertType<IsExact<typeof msgText, string>>(true);
assertType<IsExact<typeof msgText, string | undefined>>(true);
Copy link
Member

Choose a reason for hiding this comment

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

Could you also assert that ctx.msg.text ?? ctx.msg.caption is string? If it isn't, then I'd like to fix up the filter queries first.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It's string | undefined, no idea why :(

@carafelix #686 required touching 3 files, not 1 line, and uncovered a bug.

Copy link
Member

Choose a reason for hiding this comment

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

It's odd to me that this seems to work correctly for entitiestext but fails for caption_entitiescaption. Smells like

  • it's related to bad type distribution or
  • the above assumption is wrong

Copy link
Contributor Author

Choose a reason for hiding this comment

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

if (ctx.has(":caption_entities")) ctx.msg.caption actually is string, as expected... Only msg.text ?? msg.caption breaks.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Changing

caption?: string;
to required "fixes" the issue

Copy link
Member

Choose a reason for hiding this comment

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

if (ctx.has(":caption_entities")) ctx.msg.caption actually is string, as expected... Only msg.text ?? msg.caption breaks.

Very interesting, that fits my intuition, but I'll still have to investigate it properly

assertType<IsExact<typeof messageCaption, string | undefined>>(
true,
);
Expand Down
39 changes: 25 additions & 14 deletions test/context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -381,23 +381,30 @@ describe("Context", () => {
assertFalse(ctx.hasChatType("group"));
});

it("should be able to check for commands", () => {
it("should be able to check for commands in text", () =>
testCommands("text"));
it("should be able to check for commands in captions", () =>
testCommands("caption"));

function testCommands(text: "text" | "caption") {
const entities = text === "text" ? "entities" : "caption_entities";
let up = {
message: {
text: "/start args",
entities: [{
[text]: "/start args",
[entities]: [{
type: "bot_command",
offset: 0,
length: "/start".length,
}],
},
} as Update;
} as unknown as Update;
let ctx = new Context(up, api, me);

assert(Context.has.command("start")(ctx));
assert(ctx.hasCommand("start"));
assert(Context.has.command(["help", "start"])(ctx));
assert(ctx.hasCommand(["help", "start"]));
assert(ctx.hasCommand());
assertEquals(ctx.match, "args");
assertFalse(Context.has.command("help")(ctx));
assertFalse(ctx.hasCommand("help"));
Expand All @@ -409,51 +416,55 @@ describe("Context", () => {

up = {
message: {
text: "Test with /start args",
entities: [{
[text]: "Test with /start args",
[entities]: [{
type: "bot_command",
offset: "Test with ".length,
length: "/start".length,
}],
},
} as Update;
} as unknown as Update;
ctx = new Context(up, api, me);
assertFalse(Context.has.command("start")(ctx));
assertFalse(ctx.hasCommand("start"));
assertFalse(ctx.hasCommand());

up = {
message: {
text: "/start@BoT args",
entities: [{
[text]: "/start@BoT args",
[entities]: [{
type: "bot_command",
offset: 0,
length: "/start@BoT".length,
}],
},
} as Update;
} as unknown as Update;
ctx = new Context(up, api, me);
assert(Context.has.command("start")(ctx));
assert(ctx.hasCommand("start"));
assert(ctx.hasCommand());

up = {
message: {
text: "/start@not args",
entities: [{
[text]: "/start@not args",
[entities]: [{
type: "bot_command",
offset: 0,
length: "/start@not".length,
}],
},
} as Update;
} as unknown as Update;
ctx = new Context(up, api, me);
assertFalse(Context.has.command("start")(ctx));
assertFalse(ctx.hasCommand("start"));
assertFalse(ctx.hasCommand());

up = { message: { text: "/start" } } as Update;
ctx = new Context(up, api, me);
assertFalse(Context.has.command("start")(ctx));
assertFalse(ctx.hasCommand("start"));
});
assertFalse(ctx.hasCommand());
}

it("should be able to check for game queries", () => {
const ctx = new Context(update, api, me);
Expand Down