From b93e8c414a10ce7c68c9fdc336bf6a2dce191513 Mon Sep 17 00:00:00 2001 From: Wojciech Pawlik Date: Tue, 31 Dec 2024 19:17:06 +0100 Subject: [PATCH 1/5] feat(`Composer`): match any command --- src/composer.ts | 5 ++++- src/context.ts | 45 ++++++++++++++++++++------------------------ test/context.test.ts | 5 +++++ 3 files changed, 29 insertions(+), 26 deletions(-) diff --git a/src/composer.ts b/src/composer.ts index c383aa96..54008d28 100644 --- a/src/composer.ts +++ b/src/composer.ts @@ -329,6 +329,9 @@ export class Composer implements MiddlewareObj { * 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 @@ -377,7 +380,7 @@ export class Composer implements MiddlewareObj { * @param middleware The middleware to register */ command( - command: MaybeArray, + command?: MaybeArray, ...middleware: Array> ): Composer> { return this.filter(Context.has.command(command), ...middleware); diff --git a/src/context.ts b/src/context.ts index e67ada4b..2c0941d7 100644 --- a/src/context.ts +++ b/src/context.ts @@ -82,7 +82,7 @@ interface StaticHas { * @param command The command to match */ command( - command: MaybeArray, + command?: MaybeArray, ): (ctx: C) => ctx is CommandContext; /** * Generates a predicate function that can test context objects for @@ -186,9 +186,8 @@ const checker: StaticHas = { }, command(command) { const hasEntities = checker.filterQuery(":entities:bot_command"); - const atCommands = new Set(); - const noAtCommands = new Set(); - toArray(command).forEach((cmd) => { + const noAtCommands = new Set(toArray(command ?? [])); + for (const cmd of noAtCommands) { if (cmd.startsWith("/")) { throw new Error( `Do not include '/' when registering command handlers (use '${ @@ -196,33 +195,29 @@ const checker: StaticHas = { }' not '${cmd}')`, ); } - const set = cmd.includes("@") ? atCommands : noAtCommands; - set.add(cmd); - }); + } return (ctx: C): ctx is CommandContext => { if (!hasEntities(ctx)) return false; const msg = ctx.message ?? ctx.channelPost; const txt = msg.text ?? msg.caption; - return msg.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; - } - const index = cmd.indexOf("@"); - if (index === -1) return false; + const e: MessageEntity = msg.entities[0]; + if (e.type !== "bot_command") return false; + if (e.offset !== 0) return false; + const cmd = txt.substring(1, e.length); + let index = cmd.indexOf("@"); + if (index === -1) { + index = Infinity; + } else { 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)) { - ctx.match = txt.substring(cmd.length + 1).trimStart(); - return true; - } - return false; - }); + } + const atCommand = cmd.substring(0, index); + if (command === undefined || noAtCommands.has(atCommand)) { + ctx.match = txt.substring(cmd.length + 1).trimStart(); + return true; + } + return false; }; }, reaction(reaction) { @@ -853,7 +848,7 @@ export class Context implements RenamedUpdate { * @param command The command to match */ hasCommand( - command: MaybeArray, + command?: MaybeArray, ): this is CommandContextCore { return Context.has.command(command)(this); } diff --git a/test/context.test.ts b/test/context.test.ts index 69e9e508..8865bb0e 100644 --- a/test/context.test.ts +++ b/test/context.test.ts @@ -398,6 +398,7 @@ describe("Context", () => { 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")); @@ -420,6 +421,7 @@ describe("Context", () => { ctx = new Context(up, api, me); assertFalse(Context.has.command("start")(ctx)); assertFalse(ctx.hasCommand("start")); + assertFalse(ctx.hasCommand()); up = { message: { @@ -434,6 +436,7 @@ describe("Context", () => { ctx = new Context(up, api, me); assert(Context.has.command("start")(ctx)); assert(ctx.hasCommand("start")); + assert(ctx.hasCommand()); up = { message: { @@ -448,11 +451,13 @@ describe("Context", () => { 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", () => { From e14ffa2646a814261af3c4aa737351437ac74800 Mon Sep 17 00:00:00 2001 From: Wojciech Pawlik Date: Wed, 1 Jan 2025 14:26:42 +0100 Subject: [PATCH 2/5] fix: avoid undocumented behavior --- src/context.ts | 37 +++++++++++++++++++------------------ 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/src/context.ts b/src/context.ts index 2c0941d7..84940ee3 100644 --- a/src/context.ts +++ b/src/context.ts @@ -200,24 +200,25 @@ const checker: StaticHas = { if (!hasEntities(ctx)) return false; const msg = ctx.message ?? ctx.channelPost; const txt = msg.text ?? msg.caption; - const e: MessageEntity = msg.entities[0]; - if (e.type !== "bot_command") return false; - if (e.offset !== 0) return false; - const cmd = txt.substring(1, e.length); - let index = cmd.indexOf("@"); - if (index === -1) { - index = Infinity; - } else { - 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 (command === undefined || noAtCommands.has(atCommand)) { - ctx.match = txt.substring(cmd.length + 1).trimStart(); - return true; - } - return false; + return msg.entities.some((e) => { + if (e.type !== "bot_command") return false; + if (e.offset !== 0) return false; + const cmd = txt.substring(1, e.length); + let index = cmd.indexOf("@"); + if (index === -1) { + index = Infinity; + } else { + 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 (command === undefined || noAtCommands.has(atCommand)) { + ctx.match = txt.substring(cmd.length + 1).trimStart(); + return true; + } + return false; + }); }; }, reaction(reaction) { From b65fd23559c16b26908b14963d3c5ac955601603 Mon Sep 17 00:00:00 2001 From: Wojciech Pawlik Date: Wed, 1 Jan 2025 19:15:28 +0100 Subject: [PATCH 3/5] Add and fix a test --- test/composer.test.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/test/composer.test.ts b/test/composer.test.ts index af102cc7..3bd38eb3 100644 --- a/test/composer.test.ts +++ b/test/composer.test.ts @@ -49,7 +49,7 @@ describe("Composer", () => { beforeEach(() => { composer = new Composer(); - middleware = spy((_ctx) => {}); + middleware = spy((_ctx, next) => next()); }); it("should call handlers", async () => { @@ -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 () => { @@ -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 () => { From 0ed7b79d769c07e1ed426c84e72a5891bc8b4025 Mon Sep 17 00:00:00 2001 From: Wojciech Pawlik Date: Thu, 2 Jan 2025 15:45:15 +0100 Subject: [PATCH 4/5] feat(composer)!: match commands in captions Closes #627 Closes #686 --- src/composer.ts | 4 +--- src/context.ts | 7 ++++--- test/context.test.ts | 34 ++++++++++++++++++++-------------- 3 files changed, 25 insertions(+), 20 deletions(-) diff --git a/src/composer.ts b/src/composer.ts index 54008d28..af653b33 100644 --- a/src/composer.ts +++ b/src/composer.ts @@ -350,13 +350,11 @@ export class Composer implements MiddlewareObj { * > }) * > ``` * - * 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 diff --git a/src/context.ts b/src/context.ts index 84940ee3..0e8aee68 100644 --- a/src/context.ts +++ b/src/context.ts @@ -185,7 +185,7 @@ const checker: StaticHas = { }; }, command(command) { - const hasEntities = checker.filterQuery(":entities:bot_command"); + const hasEntities = checker.filterQuery("::bot_command"); const noAtCommands = new Set(toArray(command ?? [])); for (const cmd of noAtCommands) { if (cmd.startsWith("/")) { @@ -199,8 +199,9 @@ const checker: StaticHas = { return (ctx: C): ctx is CommandContext => { 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); diff --git a/test/context.test.ts b/test/context.test.ts index 8865bb0e..5948c75c 100644 --- a/test/context.test.ts +++ b/test/context.test.ts @@ -381,17 +381,23 @@ 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)); @@ -410,14 +416,14 @@ 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")); @@ -425,14 +431,14 @@ describe("Context", () => { 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")); @@ -440,14 +446,14 @@ describe("Context", () => { 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")); @@ -458,7 +464,7 @@ describe("Context", () => { 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); From a55168c7532be0761aa0ce54496ad7e0d25d68a5 Mon Sep 17 00:00:00 2001 From: Wojciech Pawlik Date: Thu, 2 Jan 2025 16:27:29 +0100 Subject: [PATCH 5/5] Fix a type --- src/context.ts | 2 +- test/composer.type.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/context.ts b/src/context.ts index 0e8aee68..fadbf9d1 100644 --- a/src/context.ts +++ b/src/context.ts @@ -3171,7 +3171,7 @@ type CommandContextCore = */ export type CommandContext = FilterQueryContext< NarrowMatch, - ":entities:bot_command" + "::bot_command" >; type NarrowMatchCore = { match: T }; type NarrowMatch = { diff --git a/test/composer.type.test.ts b/test/composer.type.test.ts index 52b3d334..73fa34c8 100644 --- a/test/composer.type.test.ts +++ b/test/composer.type.test.ts @@ -110,7 +110,7 @@ describe("Composer types", () => { const channelPostCaption = ctx.channelPost?.caption; const channelPostText = ctx.channelPost?.text; const match = ctx.match; - assertType>(true); + assertType>(true); assertType>( true, );