Closed IlyaSemenov closed 2 years ago
No need. You can already do the following.
const privateChatComposer = bot.filter((ctx) => ctx.chat?.type === "private")
privateChatComposer.on("message:text", ...)
privateChatComposer.on("message:sticker", ...)
const groupChatComposer = bot.filter((ctx) => ctx.chat?.type === "group")
groupChatComposer.on("message:text", ...)
groupChatComposer.on("message:sticker", ...)
Does this work for you?
Right, that is also possible, but it stills requires a separate variable per branch which is not super elegant. With 3 nesting levels, that will become barely readable. Arguably that is a non-issue though, as most of the time 3 nesting levels deserve to be decomposed anyway.
What would you say about this?
bot.filter((ctx) => ctx.chat?.type === "private").compose(privateChat => {
// compose() or setup() or something else
privateChat.on("message:text", ...)
privateChat.on("message:sticker", ...)
})
Do you like this better than the current solution? If so, why? I'm not quite sure what the problem is that you're solving with this.
Either way, I wouldn't be declaring a second variable which is also called bot
. I must admit I was pretty confused when reading your first snippet with Composer.create
until I looked far to the right and saw that bot !== bot
.
If so, why?
Because it leads to simpler code and doesn't need a temporary module-level variable just for the sake of it.
I wouldn't be declaring a second variable
Fair enough. I updated the last example with proper naming.
Do I understand you correctly that this effectively is the same this as lazy
just without access to ctx
? Means, I couldn't do anything conditionally at runtime?
Another difference to lazy
is that this lazy
let's you declare and return arbitrary middleware, while this gives you a composer instance to modify via side-effects. Correct? Does this mean this function is returning void
?
What happens if a user passes an asynchronous function?
Do I understand you correctly that this effectively is the same this as lazy just without access to ctx? Means, I couldn't do anything conditionally at runtime?
No, unlike lazy, this is not run-time but setup-time.
What happens if a user passes an asynchronous function?
What if user adds a middleware to the stack with an asynchronous function? That's not what users are supposed to do in the first place.
Effectively, it will update the future bot (outer middleware) behaviour, but that's kind of misuse.
Do I understand you correctly that this effectively is the same this as lazy just without access to ctx? Means, I couldn't do anything conditionally at runtime?
No, unlike lazy, this is not run-time but setup-time.
Understood.
What happens if a user passes an asynchronous function?
What if user adds a middleware to the stack with an asynchronous function? That's not what users are supposed to do in the first place.
Effectively, it will update the future bot (outer middleware) behaviour, but that's kind of misuse.
We do support async ops during setup (using top-level await). There's also a protection in place (#14) which prevents misuse of this.
I understand you think the current solution is too cumbersome to work with. However, the suggested solution
lazy
Generally speaking, allowing users to provide callbacks comes with a lot of internal challenges. This shows here.
These are fair points, I'm not arguing. Can we still have it 'for advanced users' without advertising it extensively? After all it's simply about one more 3-lines method for a composer, which already has some other advanced methods. I'm sure most people don't use fork
, for instance. (Not trying to say it's the same thing - I understand that fork adds unique functionality and the proposed compose/setup doesn't).
I personally use this often. I even added it to utils here: https://github.com/IlyaSemenov/grammy-scenes/blob/ec4612a8e4e6d5c2baf35dd913dda4458ffc1e1f/src/utils.ts but having a native composer instance method (rather than a global function) leads to even simpler code.
I'm okay if you're against it - I'll just come up with a monkey patching module, no big deal.
Personally, I'm against adding this, but this is mostly because I don't find a nested setup function any more readable. However, this is of course subjective, and since grammY should do what the community thinks is best, I will consult other people about it to hear their opinion. I'll get back to you :)
Personally I have to agree @KnorpelSenf here, multiple ways of doing the same thing should have good reasons or they will be a pain in the long run. Its not significantly simpler and, I understand this is something I personally do, when I have a bit of stuff for one composer, it will get its own file, where export const bot = new Composer<MyContext>()
is basically the first line.
Having many Composers with this approach is a nobrainer for me.
(I probably loose some typing assumptions this way?)
With your simple example I can see that it might be slightly easier to write but it introduces interesting corner cases (which @KnorpelSenf already highlighted) which might end up causing additional pain. This might be extra fun when trying to understand projects of others because historically these corner cases are often seen as features. (There even is an xkcd for that). Also these benefits quickly vanish and are completely gone once multiple files are used (which happens rather quickly for me).
But it is awesome to think about different approaches so its great bringing up ideas how things could be improved. That will lead to discussions and growth so thanks for bringing up the idea @IlyaSemenov!
A so far unnamed advantage of this suggestion is the local scoping. This doesn't necessarily affect readability (although it can), but it can help to avoid unintentional reuse of the composer instance at a later point in the source file.
Based on feedback by @Loskir
https://ramdajs.com/docs/#applyTo (or a equivalent from a better library) already allows for
R.applyTo(bot.filter((ctx) => ctx.chat?.type === "private"), privateChat => {
privateChat.on("message:text", ...)
privateChat.on("message:sticker", ...)
})
Let me add a bit of context to this. I am playing with my own implementation of scenes, and in the current API I ended up with the following. Consider a (reusable) captcha scene/conversation:
const captchaScene = new Scene<BotContext, { secret: string }>("captcha")
captchaScene.do(async (ctx) => {
const { secret, image } = await generateCaptcha()
ctx.scene.session = { secret }
await ctx.reply(`Enter the letters you see below:`)
await ctx.replyWithPhoto(image)
})
captchaScene.wait().on("message:text", async (ctx) => {
if (ctx.message.text === ctx.scene.session.secret) {
ctx.scene.resume()
} else {
await ctx.reply(`Try again!`)
}
})
captchaScene.do((ctx) => ctx.reply("Captcha solved!"))
It looks (arguably) concise, but let's say user decides that they want to add a reaction to a sticker... So all of a sudden, they are forced to add a new module-level variable (or a weird functional wrapper):
captchaScene.do(async (ctx) => {
const { secret, image } = await generateCaptcha()
ctx.scene.session = { secret }
await ctx.reply(`Enter the letters you see below:`)
await ctx.replyWithPhoto(image)
})
const input = captchaScene.wait()
input.on("message:text", async (ctx) => {
if (ctx.message.text === ctx.scene.session.secret) {
ctx.scene.resume()
} else {
await ctx.reply(`Try again!`)
}
})
input.on("message:sticker", (ctx) => ctx.reply("No stickers please."))
captchaScene.do((ctx) => ctx.reply("Captcha solved!"))
with the proposed improve, this could become:
captchaScene.do(async (ctx) => {
const { secret, image } = await generateCaptcha()
ctx.scene.session = { secret }
await ctx.reply(`Enter the letters you see below:`)
await ctx.replyWithPhoto(image)
})
captchaScene.wait().compose(input =>
input.on("message:text", async (ctx) => {
if (ctx.message.text === ctx.scene.session.secret) {
ctx.scene.resume()
} else {
await ctx.reply(`Try again!`)
}
})
input.on("message:sticker", (ctx) => ctx.reply("No stickers please."))
})
captchaScene.do((ctx) => ctx.reply("Captcha solved!"))
Now that I'm reading the actual code I suppose the local variable is not that big deal indeed. Even when user has more waits/inputs, I guess they can always name them accordingly (inputName, inputAge, etc.), and multiple scenes will live in multiple modules anyway.
The option of not having extra module-level variables still looks reasonable to me for certain cases, but I agree it's something I could live without.
Thanks for the good ideas everyone. Even if this issue didn't lead to a PR, I'm looking forward to your next ones!
Let's say I want to build a middleware tree:
If I understand correctly, that's how one achieves that:
I propose to create a helper such as
Composer.create
(or perhaps a globalcompose
function) which allows to make the code above more concise:implemented similarly to:
UPDATE: based on the discussion below and prototyping, my current suggestion is:
Implemented like this:
See monkey patch: https://github.com/IlyaSemenov/grammy-scenes/blob/87b5fd0b9153436bcf592bd47ff0e4e9bc8c208d/src/monkey_patches/composer_setup.ts