Telegraf.js Wizard
This add-on provides grouping of BaseScenes into wizards
Get Started
Don't forget to include your BOT_ID and replace the included package name from ./
to telegraf-wizard
Just use this demo file
Features
- Multiple number of wizards
- Creating linear wizards (when moving through the scenes relative to the order in which they were added to the wizard)
- Creation of non-linear wizards (You can register any number of scenes and move through them in a chaotic manner)
- Multilingual support (added custom simple plugin i18n and 4 default languages: russian, english, hebrew, uzbek), you can create your own translator and pass it to the wizard options
- Moving not only through the scenes of the wizard, but also from the wizard to another wizard
- Navigation (step back, exit, pinned message with active step status)
- Additional plugins and tools (Media group, grouping messages by type and more)
Usage
Include the wizard and pass your bot to it
It can be a main telegraf bot, or a bot created by a composer
const { Telegraf, session: Session } = require('telegraf');
const Bot = new Telegraf(BOT_TOKEN);
let {wizard: Wizard, subscribe, close, plugins: {i18n, mediaGroup, group}, helpers: {sendAnswerMessage, replyToAnswerMessage, createAction, prepareActions}} = require('./wizard')(Bot, {
ttl: 12000,
});
Bot.use(Session());
Bot.use(i18n());
Create wizards
You can create wizards and override global settings
let wizard = new Wizard('virtual_shop', {
display_name: 'Virtual shop',
completed: (context, data) => {
console.log('Wizard completed: ', data);
}
});
new Wizard('equipment_store', {
display_name: 'Equipment shop',
displaySceneNamesOnStepper: true,
});
Add Scenes to Wizards
Every step is a scene that should end with a callback
let step = function(callback) {
let scene = this;
let request = (ctx) => {
sendAnswerMessage(ctx, () => {
return ctx.reply('What your message?')
});
}
scene.enter(request);
scene.on('message', ctx => {
replyToAnswerMessage(ctx, `Your message: ${ctx.message.text}`).then(callback(ctx, {message: ctx.message.text}));
});
}
wizard.addScene({
id: 'message'
}, step, function() {
this.nextTo('vegetables');
});
wizard.addScene({
id: 'fruits'
}, step2, function() {
this.next();
});
wizard.addScene({
id: 'vegetables'
}, step3);
let equipmentStoreWizard = Bot.Wizards.get('equipment_store');
equipmentStoreWizard.addScene({
id: 'cars',
display_name: 'Car selection'
}, stepCars);
....
subscribe our wizards to the bot
- After adding all scenes wizards *
subscribe()
Use global commands to call wizards after subscribing
Bot.command('shop', wizard.begin.bind(wizard));
Bot.command('shop2', (ctx) => {
Bot.Wizards.get('equipment_store').begin(ctx, {
sceneId: 'washing_machine',
payload: {
car_slug: 'bmw'
}
});
});
end your bot application with the close method
close();
Documentation
Wizard options
Wizard options can be passed in the following ways:
- Global for all wizards for one bot
let {wizard: Wizard, subscribe, close} = require('./wizard')(Bot, {
...
});
let wizard = new Wizard('virtual_shop', {
..
});
wizard.begin(context, {
options: {
...
},
})
Options
{
translator: (context, {module})
localeName: 'wizard',
began: (context) => {},
completed: (context, data) => {},
controls: true,
displaySceneNamesOnStepper: null,
actionUnknownMessage: 'action_unknown',
exitMessage: null,
skipStartMessageAndControls: false,
timeOutMessage: 'timeout_message',
pauseHandler: () => {},
stepNotifications: true,
finishStickerId: 'CAACAgIAAxkBAAEDy9Vh-5AL-L6ToYP4m-8BQ27NOQ4YzgACTQADWbv8JSiBoG3dG4L3IwQ'
}
Wizard
Groups scenes into a single wizard
let {wizard: Wizard, subscribe, close} = require('./wizard')(Bot);
let wizard = new Wizard('wizardId', {
...
});
wizard.addScene(...)
...
subscribe();
close();
Methods
addScene
-
addScene(options={}, step, next), the method adds a scene to the wizard
{
"id": "sceneId",
"display_name": "sceneName",
"replace_stepper_name",
}
-
step(callback): // required
A function that asks a question and returns a response from the user via callback
-
next(context) <this = wizard>
Optional method to manually control the next step of the wizard, called after the data from the step callback has been saved, the following methods are available:
- toWizard(wizardId, data)
- next()
- nextTo(sceneId)
- getState()
- finish()
- leave()
- getCurrentStepNumber()
- prev()
these are wizard methods, but they have already accepted the current context, so there is no need to pass the context
Example
let step = function(options={}) {
let name = options.name || 'items_slug';
return function(done) {
let scene = this;
let items = options.items || [];
let buttons = [], actions = [];
for(let item of items) {
let action = createAction('item', item.replace(/[^\w\d]+/g, '_'));
buttons.push(Markup.button.callback(item, action.action));
actions.push(action);
}
let {action: skipAction, use: useSkipAction} = createAction('skip');
buttons.push(Markup.button.callback('Skip', skipAction));
let request = (ctx) => {
ctx.reply(options.question ? options.question : 'What your choose?', Markup.inlineKeyboard(buttons)) }
let {use: useActions, get: getAction} = prepareActions(actions);
scene.enter(request);
scene.action(useActions(), async ctx => {
try {
await ctx.answerCbQuery('👍 Nice choose!');
} catch(e) {
console.log(e);
}
let [action, slug] = getAction(ctx);
done(ctx, {
[name]: slug
});
});
scene.action(useSkipAction(), async ctx => {
await ctx.answerCbQuery('👍 Nice choose!');
let state = ctx.getState();
if(options.default || state[name] == undefined) {
state[name] = options.default || null
}
done(ctx, state)
});
scene.command('list', ctx => {
ctx.reply(items.join(', '));
});
scene.on('message', ctx => {
ctx.reply('I do not understand', {
reply_to_message_id: ctx.message.message_id
}).then(() => {
request(ctx);
});
});
}
}
let wizard2 = Bot.Wizards.get('equipment_store');
wizard2.addScene({
id: 'cars',
display_name: 'Car selection'
}, step({
items: ['BMW', 'Audi', 'Feat'],
question: 'Choose a car',
name: 'car_slug'
}));
wizard2.addScene({
id: 'washing_machine',
display_name: 'Washing machine selection'
}, step({
items: ['LG', 'Samsung', 'Coco'],
question: 'Choose a washing machine',
name: 'washing_machine_slug'
}), function() {
this.nextTo('cars');
});
begin(context, options={})
Run wizard, options:
{
payload: {},
sceneId: 'sceneId'
options: {
...
}
}
getState(context)
Returns the current payload of the wizard. Each wizard consists of steps, when the step ends callback is called and data is passed to it, this data is combined with the payload of wizard
next(context)
Go to the next step in the sequence in which the scenes were added to the wizard. If the nextTo method was used after any wizard step, then this step will be skipped
nextTo(context, sceneId)
Jump to a specific wizard step
prev(context)
go to previous step
getCurrentStepNumber(context)
get current scene number (not suitable for non-linear wizard)
finish(context)
will finish the wizard, if there is data it will be passed to the complete callback of the wizard
leave(context)
will delete all wizard data and exit
subscribe(customSubscribe)
subscribing the middleware to the bot. You can pass a callback for your own subscription
Subscribe((bot, middleware) => {
bot.use(Composer.acl(['userId'], middleware));
});
close(options={})
This method handles edge cases and cleans up "non-live" wizards whose session has expired
Options will be inherited from global options
Options:
{
localeName: 'wizard',
actionUnknownMessage: 'action_unknown',
messageUnknownMessage: 'message_unknown',
timeOutMessage: 'timeout_message',
removeOldActions: false
}
Plugins
i18n
Translation Plugin, include after session plugin, works with the handlebars templating engine
Bot.use(i18n({
useUserLanguage : true,
defaultLanguage: 'en',
dirLanguagesPath: './languages',
slug: 'i18n',
updateClientLanguage: async (ctx, lng) => {
return lng;
}
}));
Usage:
Create a module file (/languages/wizard.yaml) and put there lines regarding keys and languages:
your_message:
en: Your message {{message}}
ru: Ваше сообщение {{message}}
and use:
Bot.on('message', context => {
let t = context[context.i18nPluginSlug]({
module: 'wizard'
});
context.replyWithHTML(t('your_message', {
message: context.message.text
}));
});
mediaGroup
Group media messages into a single message
Bot.use(mediaGroup({
timeout: 200,
types: ['video', 'photo'],
as: 'gallery'
}));
Usage:
Bot.on('photo', ctx => {
console.log(ctx.gallery) = <[photoMessages]>
});
group
group any messages by type
Bot.use(group({
timeout: 200,
types: ['text'],
as: 'textMessages'
}));
Helpers
createAction(key, slug)
- key - action name
- slug - action slug payload
Each scene must have unique actions! So that after the scene is dismantled, the buttons become inactive (if the events for some scenes are the same, then clicking on the old (previous) buttons will work)
- returns {action, use, get} = createAction('category', 'fruits')
- action - string of action
- use - a function that returns the action string
- get(context) - will return the value (key and slug) of the selected event
let categories = ['fruits', 'vegetables', 'cars'];
let buttons = [], actions = [];
for(let category of categories) {
let action = createAction('category', category.replace(/[^\w\d]+/g, '_'));
buttons.push(Markup.button.callback(item, action.action));
actions.push(action);
}
scene.enter(ctx => ctx.reply('What your choose?', Markup.inlineKeyboard(buttons)))
let {use: useActions, get: getAction} = mergeActions(actions);
scene.action(useActions(), async ctx => {
await ctx.answerCbQuery('👍 Nice choose!');
let [action, slug] = getAction(ctx);
done(ctx, {
[action]: slug
});
});
createActions(items)
takes an array of objects {key, slug} or strings, and returns results of mergeActions method
let categories = ['fruits', 'vegetables', 'cars'];
let {actions, use: useActions, get: getAction} = createActions(categories);
let buttons = [];
for(let i=0; i<actions.length; i++) {
buttons.push(Markup.button.callback(categories[i], actions[i].action));
}
scene.enter(ctx => ctx.reply('What your choose?', Markup.inlineKeyboard(buttons)))
scene.action(useActions(), async ctx => {
await ctx.answerCbQuery('👍 Nice choose!');
let [action, slug] = getAction(ctx);
done(ctx, {
[action]: slug
});
});
mergeActions(actions)
combines actions, returns object {actions, use, get}
- actions - array of source actions
- use() - function to create an action key
- get(context) - get key and slug from current context
let {actions, use, get} = createActions(categories);
scene.action(use(), async ctx => {
await ctx.answerCbQuery('👍 Nice choose!');
let [action, slug] = get(ctx);
});
sendModifiedMessage, replyToModifiedMessage
sendModifiedMessage accepts a method to send a question, returns a wait function to reply to that message by editing the question
sendModifiedMessage(ctx, () => {
return ctx.replyWithHTML('Choose a action', Markup.inlineKeyboard([
[Markup.button.callback('Offer', 'offer')],
[Markup.button.callback('Looking for', 'looking_for')]
]))
});
replyToModifiedMessage - reply to a message sent using the sendModifiedMessage method (Editing the question)
replyModifiedMessage(ctx, `You answer accepted as: ${action}`);
sendAnswerMessage, replyToAnswerMessage
sendAnswerMessage - accepts a method to send a question, returns a wait function to reply to that question
sendAnswerMessage(ctx, () => {
return ctx.replyWithHTML('Choose a action')
});
replyToAnswerMessage - Reply to a message sent using the sendAnswerMessage method
replyToAnswerMessage(ctx, () => {
return ctx.reply('Ok')
});
confirm, confirmed, confirmReset, hasConfirm
confirm(ctx, scene, message, options={})
confirm - create a message that requires confirmation
- message - message will be sent as HTML
- options - {extra, confirmText, cancelText, localeName}
confirmed(ctx)
Checks if the request has been confirmed; if not, a new request will be sent.
confirmReset(ctx)
hasConfirm(ctx)
removeEmojis
remove emoji from text
let str = removeEmojis("Hello 😁!!!");