diff --git a/README.md b/README.md index d5b57ef..010ed79 100644 --- a/README.md +++ b/README.md @@ -4,164 +4,258 @@ [![Dependency Status](https://david-dm.org/EdJoPaTo/telegraf-inline-menu/status.svg)](https://david-dm.org/EdJoPaTo/telegraf-inline-menu) [![Dependency Status](https://david-dm.org/EdJoPaTo/telegraf-inline-menu/dev-status.svg)](https://david-dm.org/EdJoPaTo/telegraf-inline-menu?type=dev) -In order to build inline menus with Telegraf you need to handle `Markup.inlineKeyboard` and `bot.action` way to often. -Do this simple with this inline menu library. +This menu library is made to easily create an inline menu for your Telegram bot. ## Example ```js -const menu = new TelegrafInlineMenu('main', +const menu = new TelegrafInlineMenu( ctx => `Hey ${ctx.from.first_name}!` ) -function toggle(ctx) { - ctx.session.exited = !ctx.session.exited -} -menu.toggle('excited', 'Excited!', toggle) +menu.simpleButton('I am excited!', 'a', { + doFunc: ctx => `Hey ${ctx.from.first_name}!` +}) +bot.use(menu.init()) ``` # Documentation -This menu library is made to be as stateless as possible. -Restarting the bot will result in a still working bot. - -All functions start with a actionCode: +The menu function arguments start with the text of the resulting button: ```js -menu.toggle(actionCode, text, setFunc) +menu.simpleButton(text, action, { + doFunc +}) ``` -This actionCode is a unique identifier in the current menu and should stay the same as long as possible. -This ensures a smooth user experience as he can be anywhere in a menu and continue seemlessly after bot restarts. -As it will be used as `callback_data` it should be short in order to allow more data in it. - -actionCodes will be concatinated in order to determine the exact location in the menu. -For Example `a:b:c` indicate the user is in menu `b` below menu `a` and used method `c`. +The second argument is the action. +This has to be an unique identifier in the current menu and should stay the same as long as possible. +It is used to determine the pressed button. +Keep the action the same as long as possible over updates of the bot to ensure a smooth user experience as the user can continue seemlessly after bot restarts or updates. +As it will be used as a part in the `callback_data` it should be short in order to allow more data in it. -Other arguments of functions like `text` can be simple strings or functions. +Arguments in most functions like `text` can be a simple string or a function / Promise. When used as functions it will be called when the user accesses the menu. -This was used in the first line of the Example +This was used in the first line of the example to return the user `first_name`. + +As this is based on Telegraf see their [Docs](https://telegraf.js.org/) or [GitHub Repo](https://github.com/telegraf/telegraf). +Also see the Telegram [Bot API Documentation](https://core.telegram.org/bots/api). ## Methods Methods generally support ES6 Promises as Parameters. -Optional arguments are possible in the object as the last parameter. Methods often have these parameters: -- `actionCode` - String or Function. Will be used as 'callback_data'. -- `text` +- `text` (String / Function / Promise) String or Function that returns the text. Will be set as the button text. -- `setFunc` - Will be called when the user selects an option from the menu -- `joinLastRow` (optional) - When set to true the button will try to join the row before -- `hide` (optional) +- `action` (String) + Will be used as 'callback_data'. +- `hide` (optional, Function) Hides the button in the menu when the Function returns false +- `joinLastRow` (optional, Boolean) + When set to true the button will try to join the row before. Useful in order to create buttons side by side. -### `new TelegrafInlineMenu(actionCode, text, backButtonText, mainMenuButtonText)` +### `const menu = new TelegrafInlineMenu(text)` Creates a new Menu. -`actionCode` is the root actionCode. -Every actionCode generate my other Methods will be a child of this actionCode. Example: When this is called with `a` and `toggle('c', …)` is called the resulting actionCode for the toggle button will be `a:c`. -`test` is the text in the message itself. +`text` is the text in the message itself. This can be a `string` or `function(ctx)`. -`backButtonText` and `mainMenuButtonText` will be used for the back and top buttons. -Submenus will use these attibutes of parents. +### `bot.use(menu.init({backButtonText, mainMenuButtonText, actionCode}))` -### `menu.manual(actionCode, text, {hide, joinLastRow, root})` +This is used to apply the menu to the bot. +Should be one the last things before using `bot.startPolling()` -Add a Button for a manual (or legacy) bot.action +#### Arguments -`actionCode` has to be unique in this menu. -`text` can be a `string` or a `function(ctx)` that will be set as the Button text. -`hide(ctx)` (optional) can hide the button when return is true. -`root` (optional) can be `true` or `false`. When `true` the actionCode is not relative to the menu. This is useful for links to other menus. +`backButtonText` and `mainMenuButtonText` (both optional) will be used for the back and top buttons. -### `menu.button(actionCode, text, doFunc, {hide, joinLastRow})` +`actionCode` (optional, for advanced use only) +When multiple menus that are not submenus of each other are created this is used to define the root actionCode of the current menu. As action codes have to be unique there as to be an own action code for each menu. When this is not given, 'main' is assumed. -Simple Button for triggering functions. -Updates menu when doFunc() resolved. +### `menu.setCommand(command)` +This used to entry the current menu by a bot command. +Normally you would do something like `bot.command('start', …)` in order to get a command. +This is different as it has to do some extra steps internally. -`actionCode` has to be unique in this menu. -`text` can be a `string` or a `function(ctx)` that will be set as the Button text. -`doFunc(ctx)` will be triggered when user presses the button. -`hide(ctx)` (optional) can hide the button when return is true. +Each submenu can have its own commands. For example this is useful with a main menu (/start) and a settings submenu (/settings): -### `menu.urlButton(text, url, {hide, joinLastRow})` +```js +const main = new TelegrafInlineMenu('Main') +main.setCommand('start') +const settings = new TelegrafInlineMenu('Settings') +settings.setCommand('settings') +main.submenu('Settings', 's', settings) +``` -Url button. This button is just a pass through and has no effect on the actionCode system. +### `menu.button(text, action, {doFunc, hide, joinLastRow})` -`text` and `url` can be `string` or `function(ctx)`. -`hide(ctx)` (optional) can hide the button when return is true. +Button for triggering functions. +Updates menu when `doFunc()` finished. -### `menu.switchToChatButton(text, value, {hide, joinLastRow})` -### `menu.switchToCurrentChatButton(text, value, {hide, joinLastRow})` +When your `doFunc` does not update things in the menu use `menu.simpleButton` instead. +It has the exact same arguments and will not update the menu after the `doFunc()`. -Switch buttons. These buttons are just pass throughs and don't have an effect on the actionCode system. +#### Arguments -`text` and `value` can be `string` or `function(ctx)`. +`text` can be a `string` or a `function(ctx)` that will be set as the Button text. + +`action` has to be unique in this menu. + +`doFunc(ctx)` will be triggered when user presses the button. `hide(ctx)` (optional) can hide the button when return is true. -### `menu.submenu(text, menu, {hide, joinLastRow})` +### `menu.simpleButton(text, action, {doFunc, hide, joinLastRow})` -Creates a Button in the menu to a submenu +see `menu.button` -This method is the only 'special' method as it does not start with 'actionCode'. -It uses the actionCode of the provided `menu`. -`text` can be a `string` or `function(ctx)` +### `menu.question(buttonText, action, {questionText, setFunc, hide, joinLastRow})` -`menu` is another TelegrafInlineMenu with an actionCode below the one of the current menu. -`hide(ctx)` (optional) can hide the button that opens the submenu. +When the user presses the button, he will be asked a question. +The answer he gives is available via `setFunc(ctx, answer)` +When the user answers with something that is not a text (a photo for example) `answer` will be undefined. +`ctx.message` contains the full answer. -### `menu.toggle(actionCode, text, setFunc, {isSetFunc, hide, joinLastRow})` +#### Arguments -Creates a button that toggles a setting +`buttonText` can be a `string` or a `function(ctx)` that will be set as the Button text. -`actionCode` has to be unique in this menu. -`text` can be a `string` or a `function(ctx)` that will be set as the Button text. -`setFunc(ctx, newState)` will be called when a user presses the toggle button. `newState` contains the new State (true / false) +`action` has to be unique in this menu. + +`setFunc(ctx, answer)` will be called when the user answers the question. + +`questionText` can only be a string. +This has to be globally unique! +If this is not unique it will collide with the other question with the same text and probably not work as intended. -`isSetFunc(ctx)` should return the current state of the toggle (true / false). -This will show an emoji to the user on the button as text prefix. `hide(ctx)` (optional) can hide the button when return is true. -### `menu.select(actionCode, options, setFunc, {isSetFunc, prefixFunc, hide, joinLastRow, columns, maxRows})` +### `menu.select(action, options, {setFunc, isSetFunc, prefixFunc, hide, joinLastRow, columns, maxRows})` Creates multiple buttons for each provided option. -`actionCode` has to be unique in this menu. +#### Arguments + +`action` has to be unique in this menu. + `options` can be an string array or an object. (Or a function returning one of them) The string array will use the value as Button text and as part of the `callback_data`. -When an object is proviced, key will be part of the `callback_data` while the value is used as Button Text. +The option as an object has to be in the following format: +`{key1: buttonText, key2: buttonText, …}` `setFunc(ctx, key)` will be called when the user selects an entry. + `isSetFunc(ctx, key)` (optional) will be called in order to use this as an exclusive selection. When true is returned the key will have an emoji indicating the current selection. +When `multiselect` is set all entries will have an emoji as they are true or false. +When `isSetFunc` returns something different than true or false, it will be the prefix instead. Can only be used when `prefixFunc` is not used. `prefixFunc(ctx, key)` (optional) will be called to determine an individual prefix for each option. Can only be used when `isSetFunc` is not used. -`hide(ctx, key)` (optional) can be used to hide some or all buttons in the menu when true is returned on the specific key. +`multiselect` (optional) +see `isSetFunc` + +`hide(ctx, key)` (optional) can be used to hide the button with the given key in the menu when true is returned. `columns` (Integer, optional) can be provided in order to limit the amount of buttons in one row. (default: 6) `maxRows` (Integer, optional) can be provided to limit the maximal rows of buttons. (default: 10) -### `menu.list` +### `menu.toggle(text, action, {setFunc, isSetFunc, hide, joinLastRow})` -This is an alias for `menu.select` -The wording makes more sense with list that are not exclusive selections. +Creates a button that toggles a setting. -### `menu.question(actionCode, buttonText, setFunc, {questionText, hide, joinLastRow})` +#### Arguments -When the user presses the button, he will be asked a question. -The answer he gives will be given via `setFunc(ctx, answer)` +`text` can be a `string` or a `function(ctx)` that will be set as the Button text. + +`action` has to be unique in this menu. + +`setFunc(ctx, newState)` will be called when a user presses the toggle button. +`newState` contains the new State (true / false). + +`isSetFunc(ctx)` has to return the current state of the toggle (true / false). +This will show an emoji to the user on the button as text prefix. -`actionCode` has to be unique in this menu. -`buttonText` can be a `string` or a `function(ctx)` that will be set as the Button text. -`setFunc(ctx, answer)` will be called when the user answers the question. -`questionText` (optional) can be a string. This has to be globally unique! If this is not unique it will collide with the other question with the same text and probably not work as intended. `hide(ctx)` (optional) can hide the button when return is true. + +### `menu.submenu(text, action, submenu, {hide, joinLastRow})` + +Creates a Button in the menu to a submenu + +#### Arguments + +`text` can be a `string` or `function(ctx)` + +`action` has to be a `string` / `RegExp` + +`menu` is another TelegrafInlineMenu. +`hide(ctx)` (optional) can hide the button that opens the submenu. + +#### Usage + +As methods return the current menu you can concat button methods like that: +```js +const menu = new TelegrafInlineMenu('test') +menu.manual('Test 1', 'a') + .manual('Test 2', 'b') +``` + +With submenus this is different in order to create simple submenus. +As it returns the submenu instead methods attached to the .submenu are added to the submenu instead. +In the following example the Test1 & 2 buttons are inside the submenu. Test3 button is in the upper menu. + +```js +const menu = new TelegrafInlineMenu('test') +menu.submenu('Submenu', 's', new TelegrafInlineMenu('Fancy Submenu')) + .manual('Test1', 'a') + .manual('Test2', 'b') + +menu.manual('Test3', 'z') +``` + +### `menu.manual(text, action, {hide, joinLastRow, root})` + +Add a Button for a manual (or legacy) `bot.action`. + +You have to do `bot.action` yourself with the corresponding actionCode. +`root` can be useful. + +#### Arguments + +`text` can be a `string` or a `function(ctx)` that will be set as the Button text. + +`action` has to be unique in this menu. + +`hide(ctx)` (optional) can hide the button when return is true. + +`root` (optional) can be `true` or `false`. +When `true` the action is not relative to the menu and will be 'global'. +This is useful for links to other menus. + +### `menu.urlButton(text, url, {hide, joinLastRow})` + +Url button. This button is just a pass through and has no effect on the actionCode system. + +#### Arguments + +`text` and `url` can be `string` or `function(ctx)`. + +`hide(ctx)` (optional) can hide the button when return is true. + +### `menu.switchToChatButton(text, value, {hide, joinLastRow})` + +Switch button. This button is just a pass through and doesn't have an effect on the actionCode system. + +#### Arguments + +`text` and `value` can be `string` or `function(ctx)`. + +`hide(ctx)` (optional) can hide the button when return is true. + +### `menu.switchToCurrentChatButton(text, value, {hide, joinLastRow})` + +see `menu.switchToChatButton` diff --git a/build-keyboard.button.test.js b/build-keyboard.button.test.js index b8002da..4c27385 100644 --- a/build-keyboard.button.test.js +++ b/build-keyboard.button.test.js @@ -1,39 +1,25 @@ import test from 'ava' +import ActionCode from './action-code' const {buildKeyboardButton} = require('./build-keyboard') test('hide is questioned first and does not trigger other func', async t => { const result = await buildKeyboardButton({ text: () => t.fail(), - textPrefix: () => t.fail(), - actionCode: 'a', + action: 'a', hide: () => true - }, 42) + }, new ActionCode(''), 42) t.true(result.hide) }) test('async func possible', async t => { const result = await buildKeyboardButton({ - text: () => Promise.resolve(42), - textPrefix: () => Promise.resolve(7), - actionCode: 'a', + text: () => Promise.resolve('42'), + action: 'a', hide: () => Promise.resolve(false) - }) + }, new ActionCode('')) t.deepEqual(result, { - text: '7 42', - callback_data: 'a', - hide: false - }) -}) - -test('textPrefix works', async t => { - const result = await buildKeyboardButton({ text: '42', - textPrefix: '7', - actionCode: 'a' - }) - t.deepEqual(result, { - text: '7 42', callback_data: 'a', hide: false }) @@ -42,11 +28,10 @@ test('textPrefix works', async t => { test('urlButton', async t => { const result = await buildKeyboardButton({ text: '42', - textPrefix: '7', url: () => 'https://edjopato.de' }) t.deepEqual(result, { - text: '7 42', + text: '42', url: 'https://edjopato.de', hide: false }) diff --git a/build-keyboard.js b/build-keyboard.js index f1ced96..f07395b 100644 --- a/build-keyboard.js +++ b/build-keyboard.js @@ -1,12 +1,12 @@ const {Markup} = require('telegraf') -async function buildKeyboard(buttons, ctx) { +async function buildKeyboard(buttons, actionCodePrefix, ctx) { const resultButtons = await Promise.all(buttons.map(async row => { if (typeof row === 'function') { const rows = await row(ctx) - return Promise.all(rows.map(row => buildKeyboardRow(row, ctx))) + return Promise.all(rows.map(row => buildKeyboardRow(row, actionCodePrefix, ctx))) } - return [await buildKeyboardRow(row, ctx)] + return [await buildKeyboardRow(row, actionCodePrefix, ctx)] })) const resultButtonsFlatted = [].concat(...resultButtons) return Markup.inlineKeyboard(resultButtonsFlatted) @@ -18,14 +18,14 @@ async function buildKeyboardRow(row, ...args) { } async function buildKeyboardButton({ - actionCode, + action, hide, + root, switchToChat, switchToCurrentChat, text, - textPrefix, url -}, ...args) { +}, actionCodePrefix, ...args) { if (hide) { hide = await hide(...args) if (hide) { @@ -36,13 +36,11 @@ async function buildKeyboardButton({ if (typeof text === 'function') { text = await text(...args) } - if (textPrefix) { - if (typeof textPrefix === 'function') { - textPrefix = await textPrefix(...args) - } - if (String(textPrefix).length > 0) { - text = textPrefix + ' ' + text - } + if (typeof action === 'function') { + action = await action(...args) + } + if (action && !root) { + action = actionCodePrefix.concat(action).get() } const buttonWithPromises = { @@ -50,8 +48,8 @@ async function buildKeyboardButton({ hide: false } - if (actionCode) { - buttonWithPromises.callback_data = actionCode + if (action) { + buttonWithPromises.callback_data = action } else if (url) { buttonWithPromises.url = url } else if (switchToChat) { diff --git a/build-keyboard.test.js b/build-keyboard.test.js index 145c0cd..ca88927 100644 --- a/build-keyboard.test.js +++ b/build-keyboard.test.js @@ -1,13 +1,14 @@ import test from 'ava' +import ActionCode from './action-code' const {buildKeyboard} = require('./build-keyboard') test('one row one key', async t => { const buttons = [[{ text: '42', - actionCode: 'a' + action: 'a' }]] - const result = await buildKeyboard(buttons) + const result = await buildKeyboard(buttons, new ActionCode('')) t.deepEqual(result.inline_keyboard, [ [ { @@ -23,19 +24,19 @@ test('four buttons in two rows', async t => { const buttons = [ [{ text: '42', - actionCode: 'a' + action: 'a' }, { text: '43', - actionCode: 'b' + action: 'b' }], [{ text: '666', - actionCode: 'd' + action: 'd' }, { text: '667', - actionCode: 'e' + action: 'e' }] ] - const result = await buildKeyboard(buttons) + const result = await buildKeyboard(buttons, new ActionCode('')) t.deepEqual(result.inline_keyboard, [ [ { @@ -65,10 +66,10 @@ test('row is func that creates one row with one button', async t => { const buttons = [ () => ([[{ text: '42', - actionCode: 'a' + action: 'a' }]]) ] - const result = await buildKeyboard(buttons) + const result = await buildKeyboard(buttons, new ActionCode('')) t.deepEqual(result.inline_keyboard, [ [ { diff --git a/enabled-emoji.js b/enabled-emoji.js deleted file mode 100644 index 932dd63..0000000 --- a/enabled-emoji.js +++ /dev/null @@ -1,11 +0,0 @@ -const enabledEmojiTrue = '✅' -const enabledEmojiFalse = '🚫' -function enabledEmoji(truthy) { - return truthy ? enabledEmojiTrue : enabledEmojiFalse -} - -module.exports = { - enabledEmoji, - enabledEmojiTrue, - enabledEmojiFalse -} diff --git a/example-depth-two.test.js b/example-depth-two.test.js deleted file mode 100644 index f833fa5..0000000 --- a/example-depth-two.test.js +++ /dev/null @@ -1,102 +0,0 @@ -import test from 'ava' - -const Telegraf = require('telegraf') - -const TelegrafInlineMenu = require('./telegraf-inline-menu') -const {enabledEmojiTrue} = require('./enabled-emoji') - -const {Extra} = Telegraf - -function exampleToogleMenu(backButtonText) { - const menu = new TelegrafInlineMenu('a:b', 'some text', backButtonText) - const isSetFunc = () => true - const setFunc = ({t}, newState) => t.false(newState) - - const optionalArgs = { - isSetFunc - } - menu.toggle('c', 'toggle me', setFunc, optionalArgs) - return menu -} - -function exampleMainMenuWithDepthTwo() { - const mainmenu = new TelegrafInlineMenu('', '42', 'back…', 'back to main menu…') - const submenu = new TelegrafInlineMenu('a', '43') - const subsubmenu = exampleToogleMenu() - submenu.submenu('subsubmenu', subsubmenu) - mainmenu.submenu('submenu', submenu) - return {mainmenu, submenu, subsubmenu} -} - -test('generate', async t => { - const {mainmenu, submenu, subsubmenu} = exampleMainMenuWithDepthTwo() - - t.deepEqual((await mainmenu.generate({t})).extra, new Extra({ - parse_mode: 'Markdown', - reply_markup: { - inline_keyboard: [[{ - text: 'submenu', - hide: false, - callback_data: 'a' - }]] - } - })) - - t.deepEqual((await submenu.generate({t})).extra, new Extra({ - parse_mode: 'Markdown', - reply_markup: { - inline_keyboard: [ - [{ - text: 'subsubmenu', - hide: false, - callback_data: 'a:b' - }], [{ - text: 'back to main menu…', - hide: false, - callback_data: 'main' - }] - ] - } - })) - - t.deepEqual((await subsubmenu.generate({t})).extra, new Extra({ - parse_mode: 'Markdown', - reply_markup: { - inline_keyboard: [ - [{ - text: enabledEmojiTrue + ' toggle me', - hide: false, - callback_data: 'a:b:c:false' - }], [{ - text: 'back…', - hide: false, - callback_data: 'a' - }, { - text: 'back to main menu…', - hide: false, - callback_data: 'main' - }] - ] - } - })) -}) - -test('toggles', async t => { - t.plan(5) - - const {mainmenu} = exampleMainMenuWithDepthTwo() - - const bot = new Telegraf() - bot.context.t = t - bot.context.editMessageText = () => Promise.resolve(t.pass()) - bot.context.answerCbQuery = () => Promise.resolve() - bot.use(mainmenu) - bot.use(ctx => t.fail('update not handled: ' + JSON.stringify(ctx.update))) - - await bot.handleUpdates([ - {callback_query: {data: 'main'}}, - {callback_query: {data: 'a'}}, - {callback_query: {data: 'a:b'}}, - {callback_query: {data: 'a:b:c:false'}} - ]) -}) diff --git a/example.js b/example.js index 2194443..93ed6d5 100644 --- a/example.js +++ b/example.js @@ -1,168 +1,72 @@ const fs = require('fs') + const Telegraf = require('telegraf') const session = require('telegraf/session') -const TelegrafInlineMenu = require('./telegraf-inline-menu') -const {enabledEmoji} = require('./enabled-emoji') +const TelegrafInlineMenu = require('./inline-menu') -const token = fs.readFileSync('token.txt', 'utf8').trim() -const bot = new Telegraf(token) -bot.use(session()) +const menu = new TelegrafInlineMenu('Main Menu') + +menu.urlButton('EdJoPaTo.de', 'https://edjopato.de') + +let mainMenuToggle = false +menu.toggle('toggle me', 'a', { + setFunc: (ctx, newVal) => { + mainMenuToggle = newVal + }, + isSetFunc: () => mainMenuToggle +}) + +menu.simpleButton('click me', 'c', { + doFunc: ctx => ctx.answerCbQuery('you clicked me!'), + hide: () => mainMenuToggle +}) -const mainMenu = new TelegrafInlineMenu('', ctx => `Hey ${ctx.from.first_name}!`, '🔙 zurück…', '🔝 zum Hauptmenü') - -mainMenu.urlButton('EdJoPaTo.de', 'https://edjopato.de') - -mainMenu.button('test', 'Do nothing', () => {}) - -mainMenu.switchToCurrentChatButton('Interessant', 'nope') - -const eventMenu = new TelegrafInlineMenu('e', 'Hier gibts Events') -let someValue = false -eventMenu.toggle('t', 'toggle me', (ctx, newState) => { - someValue = newState -}, {isSetFunc: () => someValue}) - -const allEvents = [ - 'AA', - 'AD', - 'AF', - 'CE', - 'DT', - 'VS' -] - -function selectEvent(ctx, selected) { - return ctx.answerCbQuery(selected + ' was added') -} - -const addMenu = new TelegrafInlineMenu('e:a', 'Welche Events möchtest du hinzufügen?') -function filterText(ctx) { - let text = '🔎 Filter' - if (ctx.session.eventfilter && ctx.session.eventfilter !== '.+') { - text += ': ' + ctx.session.eventfilter - } - return text -} -addMenu.question('filter', filterText, - (ctx, answer) => { - ctx.session.eventfilter = answer - }, { - questionText: 'Wonach möchtest du filtern?' - } -) - -addMenu.button('clearfilter', 'Filter aufheben', ctx => { - ctx.session.eventfilter = '.+' -}, { +menu.simpleButton('click me harder', 'd', { + doFunc: ctx => ctx.answerCbQuery('you can do better!'), joinLastRow: true, - hide: ctx => !ctx.session.eventfilter || ctx.session.eventfilter === '.+' + hide: () => mainMenuToggle }) -addMenu.list('add', () => allEvents, selectEvent, { - hide: (ctx, selectedEvent) => { - const filter = ctx.session.eventfilter || '.+' - const regex = new RegExp(filter, 'i') - return !regex.test(selectedEvent) +let selectedKey = 'b' +menu.select('s', ['A', 'B', 'C'], { + setFunc: (ctx, key) => { + selectedKey = key + return ctx.answerCbQuery(`you selected ${key}`) }, - columns: 3 + isSetFunc: (ctx, key) => key === selectedKey }) -eventMenu.submenu('Hinzufügen…', addMenu) - -mainMenu.submenu('Events', eventMenu) - -const settingsMenu = new TelegrafInlineMenu('s', '*Settings*') - -const mensaSettingsMenu = new TelegrafInlineMenu('s:m', '*Mensa Settings*') -let mensaToggle = false -let student = false -mensaSettingsMenu.toggle('t', 'Essen', (ctx, newState) => { - mensaToggle = newState -}, {isSetFunc: () => mensaToggle}) -mensaSettingsMenu.toggle('student', 'Studentenpreis', (ctx, newState) => { - student = newState -}, {isSetFunc: () => student, hide: () => !mensaToggle}) - -let price = 'student' -const priceOptions = { - student: 'Student', - attendent: 'Angestellt', - guest: 'Gast' -} - -const selectSet = (ctx, key) => { - price = key -} -const selectIsSet = (ctx, key) => key === price -const selectHide = () => !mensaToggle - -mensaSettingsMenu.select('p', priceOptions, selectSet, {isSetFunc: selectIsSet, hide: selectHide}) - -const mensaList = ['Berliner Tor', 'Bergedorf', 'Café Berliner Tor', 'Harburg', 'Hafencity', 'Sonstwo'] -const mainMensa = mensaList[0] -mensaList.sort() -let currentlySelectedMensen = [] -function toggleMensa(ctx, mensa) { - if (mensa === mainMensa) { - return ctx.answerCbQuery('Dies ist bereits deine Hauptmensa') - } - if (currentlySelectedMensen.indexOf(mensa) >= 0) { - currentlySelectedMensen = currentlySelectedMensen.filter(o => o !== mensa) - } else { - currentlySelectedMensen.push(mensa) - } -} -function mensaEmoji(ctx, mensa) { - if (mensa === mainMensa) { - return '🍽' - } - return enabledEmoji(currentlySelectedMensen.indexOf(mensa) >= 0) -} - -mensaSettingsMenu.list('l', mensaList, toggleMensa, {prefixFunc: mensaEmoji, hide: selectHide, columns: 2}) - -function mensaMenuText() { - return `Mensa (${currentlySelectedMensen.length})` -} - -settingsMenu.submenu(mensaMenuText, mensaSettingsMenu) - -mainMenu.submenu('Settings', settingsMenu) - -bot.use(mainMenu) -bot.start(ctx => mainMenu.replyMenuNow(ctx)) - -const {Extra, Markup} = Telegraf - -bot.command('test', ctx => ctx.reply('test', Extra.markup( - Markup.inlineKeyboard([ - Markup.callbackButton('Mensa Settings', 's:m') - ]) -))) - -bot.action(/.+/, ctx => ctx.reply('action not handled: ' + ctx.match[0])) - -bot.use(ctx => { - if (ctx.updateType === 'inline_query') { - // This bot example has no inline mode. - // The switchToCurrentChatButton example will trigger it and fail - return - } - return ctx.reply('something not handled') +menu.question('Frage', 'f', { + questionText: 'Was willst du schon immer loswerden?', + setFunc: (ctx, answer) => ctx.reply(answer), + hide: () => mainMenuToggle }) -bot.catch(error => { - if (error.description === 'Bad Request: message is not modified') { - console.error('message not modified') - return - } - console.error('telegraf error', - error.response, - error.on - ) - console.error('inline keyboard', error.on && error.on.payload && error.on.payload.reply_markup && error.on.payload.reply_markup.inline_keyboard) - console.error('full error', error) +const someMenu = new TelegrafInlineMenu('Other Menu') +someMenu.button('other hit me', 'c', { + doFunc: ctx => ctx.answerCbQuery('other hit me') }) +someMenu.submenu('Third Menu', 'y', new TelegrafInlineMenu('Third Menu')) + .setCommand('third') + .simpleButton('Just a button', 'a', { + doFunc: ctx => ctx.answerCbQuery('Just a callback query answer') + }) + +menu.submenu('Other menu', 'b', someMenu, { + hide: () => mainMenuToggle +}) + +menu.setCommand('start') + +const token = fs.readFileSync('token.txt', 'utf8').trim() +const bot = new Telegraf(token) +bot.use(session()) + +bot.use(menu.init({ + backButtonText: 'back…', + mainMenuButtonText: 'back to main menu…' +})) + bot.startPolling() diff --git a/inline-menu.js b/inline-menu.js new file mode 100644 index 0000000..722ac59 --- /dev/null +++ b/inline-menu.js @@ -0,0 +1,394 @@ +const {Composer, Extra, Markup} = require('telegraf') + +const ActionCode = require('./action-code') +const {getRowsOfButtons} = require('./align-buttons') +const {buildKeyboard} = require('./build-keyboard') +const {prefixEmoji} = require('./prefix') +const {createHandlerMiddleware} = require('./middleware-helper') + +class TelegrafInlineMenu { + constructor(text) { + this.menuText = text + this.buttons = [] + this.commands = [] + this.handlers = [] + } + + setCommand(commands) { + if (!Array.isArray(commands)) { + commands = [commands] + } + this.commands = this.commands.concat(commands) + return this + } + + addButton(button, ownRow = true) { + if (ownRow || this.buttons.length === 0) { + this.buttons.push([ + button + ]) + } else { + const lastRow = this.buttons[this.buttons.length - 1] + lastRow.push(button) + } + } + + addHandler(obj) { + if (obj.action && !(obj.action instanceof ActionCode)) { + throw new TypeError('action has to be an ActionCode') + } + this.handlers.push(obj) + } + + async generate(ctx, actionCode, options) { + if (!options) { + throw new Error('options has to be set') + } + options.log('generate…', actionCode) + const text = typeof this.menuText === 'function' ? (await this.menuText(ctx)) : this.menuText + + const buttons = [...this.buttons] + const lastButtonRow = generateBackButtonsAsNeeded(actionCode, options) + if (lastButtonRow.length > 0) { + buttons.push(lastButtonRow) + } + + const keyboardMarkup = await buildKeyboard(buttons, new ActionCode(actionCode), ctx) + options.log('buttons', keyboardMarkup.inline_keyboard) + const extra = Extra.markdown().markup(keyboardMarkup) + return {text, extra} + } + + async setMenuNow(ctx, actionCode, options) { + const {text, extra} = await this.generate(ctx, actionCode, options) + if (ctx.updateType !== 'callback_query') { + return ctx.reply(text, extra) + } + return ctx.editMessageText(text, extra) + .catch(error => { + if (error.description === 'Bad Request: message is not modified') { + // This is kind of ok. + // Not changed stuff should not be sended but sometimes it happens… + console.warn('menu is not modified. Think about preventing this. Happened while setting menu', actionCode || 'main') + } else { + throw error + } + }) + } + + init(options = {}) { + const actionCode = options.actionCode || 'main' + delete options.actionCode + options.depth = 0 + options.hasMainMenu = actionCode === 'main' + // Debug + // options.log = (...args) => console.log(new Date(), ...args) + options.log = options.log || (() => {}) + options.log('init', options) + const middleware = this.middleware(actionCode, options) + options.log('init finished') + return middleware + } + + middleware(actionCode, options) { + if (!actionCode) { + throw new Error('use this menu with .init(): but.use(menu.init(args))') + } + if (!options) { + throw new Error('options has to be set') + } + options.log('middleware triggered', actionCode, options, this) + const currentActionCode = new ActionCode(actionCode) + options.log('add action reaction', currentActionCode.get(), 'setMenu') + const setMenuFunc = (ctx, reason) => { + options.log('set menu', currentActionCode.get(), reason, this) + return this.setMenuNow(ctx, currentActionCode.get(), options) + } + const functions = [] + functions.push(Composer.action(currentActionCode.get(), ctx => setMenuFunc(ctx, 'menu action'))) + if (this.commands) { + functions.push(Composer.command(this.commands, ctx => setMenuFunc(ctx, 'command'))) + } + + const subOptions = { + ...options, + depth: options.depth + 1 + } + + const handlerFuncs = this.handlers + .map(handler => { + const middlewareOptions = {} + middlewareOptions.hide = handler.hide + middlewareOptions.only = handler.only + + let middleware + if (handler.action) { + const childActionCode = currentActionCode.concat(handler.action) + if (handler.submenu) { + middleware = handler.submenu.middleware(childActionCode.get(), subOptions) + } else { + options.log('add action reaction', childActionCode.get(), handler.middleware) + middleware = Composer.action(childActionCode.get(), async (ctx, next) => { + await handler.middleware(ctx, next) + if (handler.setMenuAfter) { + await setMenuFunc(ctx, 'after handler action' + childActionCode.get()) + } + }) + } + } else { + middleware = handler.middleware + if (handler.setMenuAfter) { + middlewareOptions.afterFunc = ctx => setMenuFunc(ctx, 'after handler else') + } + } + return createHandlerMiddleware(middleware, middlewareOptions) + }) + const handlerFuncsFlattened = [].concat(...handlerFuncs) + + return Composer.compose([ + ...functions, + ...handlerFuncsFlattened + ]) + } + + basicButton(text, { + action, + hide, + root, + switchToChat, + switchToCurrentChat, + url, + + joinLastRow + }) { + this.addButton({ + action, + hide, + root, + switchToChat, + switchToCurrentChat, + url, + + text + }, !joinLastRow) + return this + } + + urlButton(text, url, additionalArgs = {}) { + return this.basicButton(text, {...additionalArgs, url}) + } + + switchToChatButton(text, value, additionalArgs = {}) { + return this.basicButton(text, {...additionalArgs, switchToChat: value}) + } + + switchToCurrentChatButton(text, value, additionalArgs = {}) { + return this.basicButton(text, {...additionalArgs, switchToCurrentChat: value}) + } + + manual(text, action, additionalArgs = {}) { + return this.basicButton(text, {...additionalArgs, action}) + } + + // This button does not update the menu after being pressed + simpleButton(text, action, additionalArgs) { + if (!additionalArgs.doFunc) { + throw new Error('doFunc is not set. set it or use menu.manual') + } + this.addHandler({ + action: new ActionCode(action), + hide: additionalArgs.hide, + middleware: additionalArgs.doFunc, + setMenuAfter: additionalArgs.setMenuAfter + }) + return this.manual(text, action, additionalArgs) + } + + button(text, action, additionalArgs) { + additionalArgs.setMenuAfter = true + return this.simpleButton(text, action, additionalArgs) + } + + question(text, action, additionalArgs = {}) { + const {questionText, setFunc, hide} = additionalArgs + if (!questionText) { + throw new Error('questionText is not set. set it') + } + if (!setFunc) { + throw new Error('setFunc is not set. set it') + } + + const parseQuestionAnswer = async ctx => { + const answer = ctx.message.text + await setFunc(ctx, answer) + } + + this.addHandler({ + hide, + setMenuAfter: true, + only: ctx => ctx.message && ctx.message.reply_to_message && ctx.message.reply_to_message.text === questionText, + middleware: parseQuestionAnswer + }) + + const hitQuestionButton = ctx => { + const extra = Extra.markup(Markup.forceReply()) + return Promise.all([ + ctx.reply(questionText, extra), + ctx.deleteMessage() + .catch(error => { + if (/can't be deleted/.test(error)) { + // Looks like message is to old to be deleted + return + } + throw error + }) + ]) + } + return this.simpleButton(text, action, { + ...additionalArgs, + doFunc: hitQuestionButton + }) + } + + select(action, options, additionalArgs = {}) { + if (!additionalArgs.setFunc) { + throw new Error('setFunc is not set. set it') + } + const {setFunc, hide} = additionalArgs + + const actionCodeBase = new ActionCode(action) + + const hitSelectButton = async (ctx, next) => { + const key = ctx.match[1] + if (hide && (await hide(ctx, key))) { + // Normally next (setKeyboard) should not be done + // As the user had a change to press the hidden key, update the menu to hide the key + return next(ctx) + } + return setFunc(ctx, key) + } + + this.addHandler({ + action: actionCodeBase.concat(/(.+)/), + middleware: hitSelectButton, + hide: additionalArgs.hide, + setMenuAfter: true + }) + + if (typeof options === 'function') { + this.buttons.push(async ctx => { + const optionsResult = await options(ctx) + return generateSelectButtons(actionCodeBase, optionsResult, additionalArgs) + }) + } else { + const result = generateSelectButtons(actionCodeBase, options, additionalArgs) + result.forEach(o => this.buttons.push(o)) + } + + return this + } + + toggle(text, action, additionalArgs) { + if (!additionalArgs.setFunc) { + throw new Error('setFunc is not set. set it') + } + if (!additionalArgs.isSetFunc) { + throw new Error('isSetFunc is not set. set it') + } + const textFunc = ctx => + prefixEmoji(text, additionalArgs.isSetFunc, { + ...additionalArgs + }, ctx) + + const actionFunc = async ctx => { + const currentState = await additionalArgs.isSetFunc(ctx) + return currentState ? action + ':false' : action + ':true' + } + + const baseHandler = { + hide: additionalArgs.hide, + setMenuAfter: true + } + + const toggleTrue = ctx => additionalArgs.setFunc(ctx, true) + const toggleFalse = ctx => additionalArgs.setFunc(ctx, false) + + this.addHandler({...baseHandler, + action: new ActionCode(action).concat('true'), + middleware: toggleTrue + }) + + this.addHandler({...baseHandler, + action: new ActionCode(action).concat('false'), + middleware: toggleFalse + }) + + return this.manual(textFunc, actionFunc, additionalArgs) + } + + submenu(text, action, submenu, additionalArgs = {}) { + this.manual(text, action, additionalArgs) + this.addHandler({ + action: new ActionCode(action), + hide: additionalArgs.hide, + submenu + }) + return submenu + } +} + +function generateSelectButtons(actionCodeBase, options, { + columns, + hide, + isSetFunc, + maxRows, + multiselect, + prefixFunc +}) { + const isArray = Array.isArray(options) + const keys = isArray ? options : Object.keys(options) + const buttons = keys.map(key => { + const action = actionCodeBase.concat(key).get() + const text = isArray ? key : options[key] + const textFunc = ctx => + prefixEmoji(text, prefixFunc || isSetFunc, { + hideFalseEmoji: !multiselect + }, ctx, key) + const hideKey = ctx => hide && hide(ctx, key) + return { + text: textFunc, + action, + hide: hideKey + } + }) + return getRowsOfButtons(buttons, columns, maxRows) +} + +function generateBackButtonsAsNeeded(actionCode, { + depth, + hasMainMenu, + backButtonText, + mainMenuButtonText +}) { + if ((actionCode || 'main') === 'main' || depth === 0) { + return [] + } + const buttons = [] + if (depth >= (hasMainMenu ? 2 : 1) && backButtonText) { + buttons.push({ + text: backButtonText, + action: ActionCode.parent(actionCode).get(), + root: true + }) + } + if (depth > 0 && hasMainMenu && mainMenuButtonText) { + buttons.push({ + text: mainMenuButtonText, + action: 'main', + root: true + }) + } + return buttons +} + +module.exports = TelegrafInlineMenu diff --git a/middleware-helper.js b/middleware-helper.js new file mode 100644 index 0000000..d443379 --- /dev/null +++ b/middleware-helper.js @@ -0,0 +1,22 @@ +function createHandlerMiddleware(middleware, { + hide, + afterFunc, + only +} = {}) { + return async (ctx, next) => { + if (only && !(await only(ctx))) { + return next(ctx) + } + if (hide && (await hide(ctx))) { + return next(ctx) + } + await middleware(ctx, next) + if (afterFunc) { + await afterFunc(ctx) + } + } +} + +module.exports = { + createHandlerMiddleware +} diff --git a/middleware-helper.test.js b/middleware-helper.test.js new file mode 100644 index 0000000..3d06f3c --- /dev/null +++ b/middleware-helper.test.js @@ -0,0 +1,31 @@ +import test from 'ava' + +import {createHandlerMiddleware} from './middleware-helper' + +function create(t, plan, {m: middleware, n: next}, options) { + t.plan(plan) + const result = createHandlerMiddleware(middleware, options, t.log) + return result(666, next) +} + +test('just middleware runs', t => create(t, 1, {m: t.pass, n: t.fail})) + +test('hide true passes through', t => create(t, 1, {m: t.fail, n: t.pass}, { + hide: () => Promise.resolve(true) +})) + +test('hide false middleware runs', t => create(t, 1, {m: t.pass, n: t.fail}, { + hide: () => Promise.resolve(false) +})) + +test('only true middleware runs', t => create(t, 1, {m: t.pass, n: t.fail}, { + only: () => Promise.resolve(true) +})) + +test('only false passes through', t => create(t, 1, {m: t.fail, n: t.pass}, { + only: () => Promise.resolve(false) +})) + +test('afterfunc runs', t => create(t, 2, {m: t.pass, n: t.fail}, { + afterFunc: () => Promise.resolve(t.pass()) +})) diff --git a/package.json b/package.json index 5fce754..f15fad3 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "telegraf-inline-menu", "version": "1.7.0", "description": "Inline Menus for Telegraf made simple.", - "main": "telegraf-inline-menu.js", + "main": "inline-menu.js", "engines": { "node": ">=8.6" }, @@ -48,8 +48,7 @@ { "files": [ "build-keyboard*.js", - "example-depth-two.test.js", - "telegraf-inline-menu.test.js" + "test/*.js" ], "rules": { "camelcase": [ diff --git a/prefix.emoji.test.js b/prefix.emoji.test.js new file mode 100644 index 0000000..59e87d1 --- /dev/null +++ b/prefix.emoji.test.js @@ -0,0 +1,65 @@ +import test from 'ava' + +import { + prefixEmoji, + emojiFalse, + emojiTrue +} from './prefix' + +test('no prefix', async t => { + const result = await prefixEmoji('42') + t.is(result, '42') +}) + +test('value text & prefix truthy still passthrough', async t => { + const result = await prefixEmoji('42', '6') + t.is(result, '6 42') +}) + +test('value text & prefix true', async t => { + const result = await prefixEmoji('42', true) + t.is(result, emojiTrue + ' 42') +}) + +test('value text & prefix false', async t => { + const result = await prefixEmoji('42', false) + t.is(result, emojiFalse + ' 42') +}) + +test('value text & prefix true hidden', async t => { + const result = await prefixEmoji('42', true, {hideTrueEmoji: true}) + t.is(result, '42') +}) + +test('value text & prefix false hidden', async t => { + const result = await prefixEmoji('42', false, {hideFalseEmoji: true}) + t.is(result, '42') +}) + +test('async prefix', async t => { + const prefix = () => Promise.resolve(true) + const result = await prefixEmoji('42', prefix) + t.is(result, emojiTrue + ' 42') +}) + +test('async text and prefix', async t => { + const text = () => Promise.resolve('42') + const prefix = () => Promise.resolve(true) + const result = await prefixEmoji(text, prefix) + t.is(result, emojiTrue + ' 42') +}) + +test('own true prefix', async t => { + const result = await prefixEmoji('42', true, { + prefixTrue: 'foo' + }) + t.is(result, 'foo 42') +}) + +test('own false prefix', async t => { + const result = await prefixEmoji('42', false, { + prefixFalse: 'bar', + showFalse: true + }) + t.is(result, 'bar 42') +}) diff --git a/prefix.js b/prefix.js new file mode 100644 index 0000000..8020e59 --- /dev/null +++ b/prefix.js @@ -0,0 +1,59 @@ +const emojiTrue = '✅' +const emojiFalse = '🚫' + +async function prefixEmoji(text, prefix, options = {}, ...args) { + if (!options.prefixTrue) { + options.prefixTrue = emojiTrue + } + if (!options.prefixFalse) { + options.prefixFalse = emojiFalse + } + const { + prefixFalse, + prefixTrue, + hideFalseEmoji, + hideTrueEmoji + } = options + + if (typeof prefix === 'function') { + prefix = await prefix(...args) + } + + if (prefix === true) { + if (hideTrueEmoji) { + prefix = undefined + } else { + prefix = prefixTrue + } + } + if (prefix === false) { + if (hideFalseEmoji) { + prefix = undefined + } else { + prefix = prefixFalse + } + } + + return prefixText(text, prefix, ...args) +} + +async function prefixText(text, prefix, ...args) { + if (typeof text === 'function') { + text = text(...args) + } + if (typeof prefix === 'function') { + prefix = await prefix(...args) + } + + if (!prefix) { + return text + } + return `${prefix} ${await text}` +} + +module.exports = { + emojiFalse, + emojiTrue, + prefixEmoji, + prefixText +} diff --git a/prefix.text.test.js b/prefix.text.test.js new file mode 100644 index 0000000..c569303 --- /dev/null +++ b/prefix.text.test.js @@ -0,0 +1,34 @@ +import test from 'ava' + +import { + prefixText +} from './prefix' + +test('no prefix', async t => { + const result = await prefixText('42') + t.is(result, '42') +}) + +test('value text & prefix', async t => { + const result = await prefixText('42', '6') + t.is(result, '6 42') +}) + +test('async text', async t => { + const text = () => Promise.resolve('42') + const result = await prefixText(text) + t.is(result, '42') +}) + +test('async prefix', async t => { + const prefix = () => Promise.resolve('6') + const result = await prefixText('42', prefix) + t.is(result, '6 42') +}) + +test('async text and prefix', async t => { + const text = () => Promise.resolve('42') + const prefix = () => Promise.resolve('6') + const result = await prefixText(text, prefix) + t.is(result, '6 42') +}) diff --git a/telegraf-inline-menu.js b/telegraf-inline-menu.js deleted file mode 100644 index 961f87e..0000000 --- a/telegraf-inline-menu.js +++ /dev/null @@ -1,322 +0,0 @@ -const {Composer, Extra, Markup} = require('telegraf') - -const ActionCode = require('./action-code') -const {getRowsOfButtons} = require('./align-buttons') -const {buildKeyboard} = require('./build-keyboard') -const {enabledEmoji, enabledEmojiTrue} = require('./enabled-emoji') - -class TelegrafInlineMenu { - constructor(code, text, backButtonText, mainMenuButtonText) { - this.code = new ActionCode(code) - this.mainText = text - this.backButtonText = backButtonText - this.mainMenuButtonText = mainMenuButtonText - - this.bot = new Composer() - this.buttons = [] - - this.bot.action(this.code.get(), ctx => this.setMenuNow(ctx)) - } - - getNeededLastRowButtons() { - const lastButtonRow = [] - - // When there is a parent… - // When there is a main menu, display main menu button first, back with depth >= 2 - // When there is no main menu instantly display back button - if (this.parent && this.parent.code.get() !== 'main') { - const backButtonText = goUpUntilTrue(this, menu => menu.backButtonText).result - if (backButtonText) { - const actionCode = this.code.parent().get() - lastButtonRow.push({ - text: backButtonText, - actionCode - }) - } - } - - const mainmenu = goUpUntilTrue(this, menu => menu.code.get() === 'main') - if (this.parent && mainmenu) { - const mainMenuButtonText = goUpUntilTrue(this, menu => menu.mainMenuButtonText).result - if (mainMenuButtonText) { - lastButtonRow.push({ - text: mainMenuButtonText, - actionCode: 'main' - }) - } - } - - return lastButtonRow - } - - async generate(ctx) { - let text = this.mainText - if (typeof this.mainText === 'function') { - text = await this.mainText(ctx) - } - - const buttons = [...this.buttons] - const lastButtonRow = this.getNeededLastRowButtons() - if (lastButtonRow.length > 0) { - buttons.push(lastButtonRow) - } - - const keyboardMarkup = await buildKeyboard(buttons, ctx) - const extra = Extra.markdown().markup(keyboardMarkup) - return {text, extra} - } - - async replyMenuNow(ctx) { - const {text, extra} = await this.generate(ctx) - return ctx.reply(text, extra) - } - - async setMenuNow(ctx) { - const {text, extra} = await this.generate(ctx) - return ctx.editMessageText(text, extra) - .catch(error => { - if (error.description === 'Bad Request: message is not modified') { - // This is kind of ok. - // Not changed stuff should not be sended but sometimes it happens… - console.warn('menu is not modified', this.code) - } else { - throw error - } - }) - } - - middleware() { - return this.bot.middleware() - } - - hideMiddleware(hide, ...fns) { - // This is the opposite of Composer.optional - return Composer.branch(hide, Composer.safePassThru(), Composer.compose(fns)) - } - - addButton(button, ownRow = true) { - if (ownRow) { - this.buttons.push([ - button - ]) - } else { - const lastRow = this.buttons[this.buttons.length - 1] - lastRow.push(button) - } - } - - manual(action, text, {hide, joinLastRow, root} = {}) { - const actionCode = root ? new ActionCode(action) : this.code.concat(action) - this.addButton({ - text, - actionCode: actionCode.get(), - hide - }, !joinLastRow) - } - - button(action, text, doFunc, {hide, joinLastRow} = {}) { - if (!hide) { - hide = () => false - } - - const actionCode = this.code.concat(action).get() - this.addButton({ - text, - actionCode, - hide - }, !joinLastRow) - - this.bot.action(actionCode, this.hideMiddleware(hide, async ctx => { - await doFunc(ctx) - return this.setMenuNow(ctx) - })) - } - - urlButton(text, url, {hide, joinLastRow} = {}) { - this.addButton({ - text, - url, - hide - }, !joinLastRow) - } - - switchToChatButton(text, value, {hide, joinLastRow} = {}) { - this.addButton({ - text, - switchToChat: value, - hide - }, !joinLastRow) - } - - switchToCurrentChatButton(text, value, {hide, joinLastRow} = {}) { - this.addButton({ - text, - switchToCurrentChat: value, - hide - }, !joinLastRow) - } - - submenu(text, submenu, {hide, joinLastRow} = {}) { - if (!hide) { - hide = () => false - } - - if (submenu.code.parent().get() !== this.code.get()) { - throw new Error('submenu is not directly below this menu') - } - - submenu.parent = this - - const actionCode = submenu.code.get() - this.addButton({ - text, - actionCode, - hide - }, !joinLastRow) - this.bot.use(this.hideMiddleware(hide, submenu)) - } - - toggle(action, text, setFunc, {isSetFunc, hide, joinLastRow} = {}) { - if (!hide) { - hide = () => false - } - console.assert(isSetFunc, `Use menu.toggle(${action}) with isSetFunc. Not using it is depricated. If you cant provide it use menu.button instead.`, 'menu prefix:', this.code, 'toggle text:', text) - - const set = async (ctx, newVal) => { - await setFunc(ctx, newVal) - return this.setMenuNow(ctx) - } - - const actionCode = this.code.concat(action) - const actionCodeTrue = actionCode.concat('true').get() - const actionCodeFalse = actionCode.concat('false').get() - this.bot.action(actionCodeTrue, this.hideMiddleware(hide, ctx => set(ctx, true))) - this.bot.action(actionCodeFalse, this.hideMiddleware(hide, ctx => set(ctx, false))) - // This will be used when isSetFunc is not available (depricated) - this.bot.action(actionCode.get(), this.hideMiddleware(hide, ctx => set(ctx))) - - const textPrefix = isSetFunc ? async ctx => enabledEmoji(await isSetFunc(ctx)) : undefined - - const resultActionCode = isSetFunc ? async ctx => { - return (await isSetFunc(ctx)) ? actionCodeFalse : actionCodeTrue - } : actionCode.get() - - this.addButton({ - text, - textPrefix, - actionCode: resultActionCode, - hide - }, !joinLastRow) - } - - list(action, options, setFunc, optionalArgs = {}) { - return this.select(action, options, setFunc, optionalArgs) - } - - select(action, options, setFunc, optionalArgs = {}) { - if (!optionalArgs.hide) { - optionalArgs.hide = () => false - } - const {isSetFunc, hide} = optionalArgs - - const actionCodeBase = this.code.concat(action) - const actionCode = actionCodeBase.concat(/(.+)/).get() - this.bot.action(actionCode, async ctx => { - const key = ctx.match[1] - if (hide && (await hide(ctx, key))) { - return ctx.answerCbQuery() - } - if (isSetFunc && (await isSetFunc(ctx, key))) { - // Value is already set. ignore - return ctx.answerCbQuery() - } - await setFunc(ctx, key) - return this.setMenuNow(ctx) - }) - - if (typeof options === 'function') { - this.buttons.push(async ctx => { - const optionsResult = await options(ctx) - return generateSelectButtons(actionCodeBase, optionsResult, optionalArgs) - }) - } else { - const result = generateSelectButtons(actionCodeBase, options, optionalArgs) - result.forEach(o => this.buttons.push(o)) - } - } - - question(action, buttonText, setFunc, {hide, questionText, joinLastRow} = {}) { - if (!questionText) { - questionText = buttonText - } - - const actionCode = this.code.concat(action).get() - - this.bot.on('message', Composer.optional(ctx => ctx.message && ctx.message.reply_to_message && ctx.message.reply_to_message.text === questionText, async ctx => { - const answer = ctx.message.text - await setFunc(ctx, answer) - return this.replyMenuNow(ctx) - })) - - this.bot.action(actionCode, this.hideMiddleware(hide, ctx => { - const extra = Extra.markup(Markup.forceReply()) - return Promise.all([ - ctx.reply(questionText, extra), - ctx.deleteMessage() - ]) - })) - - this.addButton({ - text: buttonText, - actionCode, - hide - }, !joinLastRow) - } -} - -function generateSelectButtons(actionCodeBase, options, { - columns, - hide, - isSetFunc, - maxRows, - prefixFunc -}) { - const isArray = Array.isArray(options) - const keys = isArray ? options : Object.keys(options) - const buttons = keys.map(key => { - const actionCode = actionCodeBase.concat(key).get() - const text = isArray ? key : options[key] - let textPrefix - if (prefixFunc) { - textPrefix = ctx => { - return prefixFunc(ctx, key) - } - } else if (isSetFunc) { - textPrefix = async ctx => { - const result = await isSetFunc(ctx, key) - return result ? enabledEmojiTrue : '' - } - } - const hideKey = ctx => hide(ctx, key) - return { - text, - textPrefix, - actionCode, - hide: hideKey - } - }) - return getRowsOfButtons(buttons, columns, maxRows) -} - -function goUpUntilTrue(start, func) { - const result = func(start) - if (result) { - return {result, hit: start} - } - if (start.parent) { - return goUpUntilTrue(start.parent, func) - } - return undefined -} - -module.exports = TelegrafInlineMenu diff --git a/telegraf-inline-menu.test.js b/telegraf-inline-menu.test.js deleted file mode 100644 index 71858ae..0000000 --- a/telegraf-inline-menu.test.js +++ /dev/null @@ -1,392 +0,0 @@ -import test from 'ava' - -const Telegraf = require('telegraf') - -const TelegrafInlineMenu = require('./telegraf-inline-menu') -const {enabledEmoji, enabledEmojiTrue, enabledEmojiFalse} = require('./enabled-emoji') - -const {Extra} = Telegraf - -test('main menu with main prefix', t => { - const menu = new TelegrafInlineMenu('main', 'Main Menu') - t.is(menu.code.get(), 'main') -}) - -test('main menu without prefix', t => { - const menu = new TelegrafInlineMenu('', 'Main Menu') - t.is(menu.code.get(), 'main') -}) - -test('main menu dynamic text', async t => { - const textFunc = bla => `Hey ${bla}` - const menu = new TelegrafInlineMenu('', textFunc) - const {text} = await menu.generate('42') - t.is(text, 'Hey 42') -}) - -test('manual', async t => { - const menu = new TelegrafInlineMenu('a', '42') - menu.manual('b', 'test1') - menu.manual('b', 'test2', {root: true}) - const {extra} = await menu.generate('42') - t.deepEqual(extra, new Extra({ - parse_mode: 'Markdown', - reply_markup: { - inline_keyboard: [[{ - text: 'test1', - hide: false, - callback_data: 'a:b' - }], [{ - text: 'test2', - hide: false, - callback_data: 'b' - }]] - } - })) -}) - -function exampleToogleMenu() { - const menu = new TelegrafInlineMenu('a:b', 'some text') - const setFunc = ({t}, newState) => t.false(newState) - const isSetFunc = () => true - - const optionalArgs = { - isSetFunc - } - menu.toggle('c', 'toggle me', setFunc, optionalArgs) - return menu -} - -test('submenu generate', async t => { - const submenu = exampleToogleMenu() - const menu = new TelegrafInlineMenu('a', 'upper menu text', 'back…') - menu.submenu('open submenu here', submenu) - const ctx = {t} - - t.deepEqual((await menu.generate(ctx)).extra, new Extra({ - parse_mode: 'Markdown', - reply_markup: { - inline_keyboard: [[{ - text: 'open submenu here', - hide: false, - callback_data: 'a:b' - }]] - } - })) - - t.deepEqual((await submenu.generate(ctx)).extra, new Extra({ - parse_mode: 'Markdown', - reply_markup: { - inline_keyboard: [ - [{ - text: enabledEmojiTrue + ' toggle me', - hide: false, - callback_data: 'a:b:c:false' - }], [{ - text: 'back…', - hide: false, - callback_data: 'a' - }] - ] - } - })) -}) - -test('submenu toggles', async t => { - t.plan(4) - - const submenu = exampleToogleMenu() - const menu = new TelegrafInlineMenu('a', 'upper menu text', 'back…') - menu.submenu('open submenu here', submenu) - - const bot = new Telegraf() - bot.context.t = t - bot.context.editMessageText = () => Promise.resolve(t.pass()) - bot.context.answerCbQuery = () => Promise.resolve() - bot.use(menu) - bot.use(ctx => t.fail('update not handled: ' + JSON.stringify(ctx.update))) - - // All menus can be accessed anytime (+2) - await bot.handleUpdates([ - {callback_query: {data: 'a'}}, - {callback_query: {data: 'a:b'}} - ]) - - // Toggles toggle & update message (+2) - await bot.handleUpdate({callback_query: {data: 'a:b:c:false'}}) -}) - -test('submenu must be below', t => { - const menu = new TelegrafInlineMenu('a', 'some text') - const submenu = new TelegrafInlineMenu('b', 'different text') - t.throws(() => menu.submenu('Button Text', submenu), /below/) -}) - -test('submenu must be directly below', t => { - const menu = new TelegrafInlineMenu('a', 'some text') - const submenu = new TelegrafInlineMenu('a:b:c', 'different text') - t.throws(() => menu.submenu('Button Text', submenu), /directly below/) -}) - -test('toogle generate', async t => { - const menu = exampleToogleMenu() - const ctx = {t} - - const {text, extra} = await menu.generate(ctx) - t.is(text, 'some text') - t.deepEqual(extra, new Extra({ - parse_mode: 'Markdown', - reply_markup: { - inline_keyboard: [[{ - text: enabledEmojiTrue + ' toggle me', - hide: false, - callback_data: 'a:b:c:false' - }]] - } - })) -}) - -test('toogle toggles', async t => { - t.plan(2) - - const menu = exampleToogleMenu() - const bot = new Telegraf() - bot.context.t = t - bot.context.editMessageText = () => Promise.resolve(t.pass()) - bot.context.answerCbQuery = () => Promise.resolve() - - bot.use(menu) - bot.use(ctx => t.fail('update not handled: ' + JSON.stringify(ctx.update))) - - // Toggles: toggle & message update (+2) - await bot.handleUpdate({callback_query: {data: 'a:b:c:false'}}) -}) - -function exampleSelectMenu(options, additionalArgs) { - const menu = new TelegrafInlineMenu('a:b', 'some text') - let selected = 'peter' - const isSetFunc = ({t}, key) => { - if (key === undefined) { - t.fail('key has to be always set') - } - return key === selected - } - const hide = ({t}, key) => { - if (key === undefined) { - t.fail('key has to be always set') - } - return false - } - - const setFunc = ({t}, key) => { - selected = key - t.pass() - } - - const optionalArgs = { - isSetFunc, - hide, - ...additionalArgs - } - menu.select('c', options, setFunc, optionalArgs) - return menu -} - -const listSynchronousOptions = { - hans: 'Hans', - peter: 'Peter' -} - -const listAsyncOptions = () => ({ - hans: 'Hans', - peter: 'Peter' -}) - -test('select generate synchronous', selectGenerate, listSynchronousOptions) -test('select selects synchronous', selectSelect, listSynchronousOptions) - -test('select generate async', selectGenerate, listAsyncOptions) -test('select selects async', selectSelect, listAsyncOptions) - -async function selectGenerate(t, options) { - const menu = exampleSelectMenu(options) - const ctx = {t} - - const {text, extra} = await menu.generate(ctx) - t.is(text, 'some text') - t.deepEqual(extra, new Extra({ - parse_mode: 'Markdown', - reply_markup: { - inline_keyboard: [[{ - text: 'Hans', - hide: false, - callback_data: 'a:b:c:hans' - }, { - text: enabledEmojiTrue + ' Peter', - hide: false, - callback_data: 'a:b:c:peter' - }]] - } - })) -} - -async function selectSelect(t, options) { - t.plan(2 * 2) - - const menu = exampleSelectMenu(options) - const bot = new Telegraf() - bot.context.t = t - bot.context.editMessageText = () => Promise.resolve(t.pass()) - bot.context.answerCbQuery = () => Promise.resolve() - bot.use(menu) - - // Already selected -> will not t.pass() - await bot.handleUpdate({callback_query: {data: 'a:b:c:peter'}}) - - bot.use(ctx => t.fail('update not handled: ' + JSON.stringify(ctx.update))) - - await bot.handleUpdate({callback_query: {data: 'a:b:c:hans'}}) - await bot.handleUpdate({callback_query: {data: 'a:b:c:peter'}}) -} - -test('select with option array', async t => { - const menu = new TelegrafInlineMenu('a:b', 'some text') - const options = ['Hans', 'Peter'] - const setFunc = () => {} - const optionalArgs = { - isSetFunc: (ctx, key) => key === 'Peter' - } - menu.select('c', options, setFunc, optionalArgs) - - const {text, extra} = await menu.generate({}) - t.is(text, 'some text') - t.deepEqual(extra, new Extra({ - parse_mode: 'Markdown', - reply_markup: { - inline_keyboard: [[{ - text: 'Hans', - hide: false, - callback_data: 'a:b:c:Hans' - }, { - text: enabledEmojiTrue + ' Peter', - hide: false, - callback_data: 'a:b:c:Peter' - }]] - } - })) -}) - -test('select column 1 generate', async t => { - const menu = exampleSelectMenu(listSynchronousOptions, {columns: 1}) - const ctx = {t} - - const {text, extra} = await menu.generate(ctx) - t.is(text, 'some text') - t.deepEqual(extra, new Extra({ - parse_mode: 'Markdown', - reply_markup: { - inline_keyboard: [[{ - text: 'Hans', - hide: false, - callback_data: 'a:b:c:hans' - }], [{ - text: enabledEmojiTrue + ' Peter', - hide: false, - callback_data: 'a:b:c:peter' - }]] - } - })) -}) - -test('select with prefix', async t => { - const menu = exampleSelectMenu(listSynchronousOptions, { - prefixFunc: (ctx, key) => enabledEmoji(key === 'peter') - }) - const ctx = {t} - - const {text, extra} = await menu.generate(ctx) - t.is(text, 'some text') - t.deepEqual(extra, new Extra({ - parse_mode: 'Markdown', - reply_markup: { - inline_keyboard: [[{ - text: enabledEmojiFalse + ' Hans', - hide: false, - callback_data: 'a:b:c:hans' - }, { - text: enabledEmojiTrue + ' Peter', - hide: false, - callback_data: 'a:b:c:peter' - }]] - } - })) -}) - -function exampleQuestionMenu(questionText, expectedAnswer) { - const menu = new TelegrafInlineMenu('a:b', 'some text') - - const setFunc = ({t}, answer) => { - t.is(answer, expectedAnswer) - } - - const optionalArgs = { - questionText - } - menu.question('c', 'Question Button', setFunc, optionalArgs) - return menu -} - -test('question generate', async t => { - const menu = exampleQuestionMenu('what?') - - const ctx = {t} - - const {text, extra} = await menu.generate(ctx) - t.is(text, 'some text') - t.deepEqual(extra, new Extra({ - parse_mode: 'Markdown', - reply_markup: { - inline_keyboard: [[{ - text: 'Question Button', - hide: false, - callback_data: 'a:b:c' - }]] - } - })) -}) - -test('question answer', async t => { - t.plan(3) - - const questionText = 'wat?' - const menu = exampleQuestionMenu(questionText, 'correct') - const bot = new Telegraf() - bot.context.t = t - bot.context.editMessageText = () => Promise.resolve(t.pass()) - bot.context.reply = () => Promise.resolve(t.pass()) - bot.context.answerCbQuery = () => Promise.resolve() - bot.context.deleteMessage = () => Promise.resolve() - bot.use(menu) - bot.use(ctx => t.fail('update not handled: ' + JSON.stringify(ctx.update))) - - // Will reply question (+1 -> 1) - await bot.handleUpdate({callback_query: {data: 'a:b:c'}}) - - // Will setFunction (+1) and reply menu after that (+1 -> 3) - await bot.handleUpdate({message: {reply_to_message: {text: questionText}, text: 'correct'}}) -}) - -test('question works not only with text', async t => { - const questionText = 'wat?' - const menu = exampleQuestionMenu(questionText) - const bot = new Telegraf() - bot.context.t = t - bot.context.editMessageText = () => Promise.resolve() - bot.context.reply = () => Promise.resolve() - bot.context.answerCbQuery = () => Promise.resolve() - bot.context.deleteMessage = () => Promise.resolve() - bot.use(menu) - bot.use(ctx => t.fail('update not handled: ' + JSON.stringify(ctx.update))) - - await bot.handleUpdate({message: {reply_to_message: {text: questionText}, photo: {}, caption: '42'}}) -}) diff --git a/test/button.js b/test/button.js new file mode 100644 index 0000000..efd2123 --- /dev/null +++ b/test/button.js @@ -0,0 +1,76 @@ +import test from 'ava' +import Telegraf from 'telegraf' + +import TelegrafInlineMenu from '../inline-menu' + +const menuKeyboard = [[{ + text: 'hit me', + hide: false, + callback_data: 'a:b:c' +}]] + +test('manual menu correct', async t => { + const menu = new TelegrafInlineMenu('yaay') + menu.manual('hit me', 'c') + + const bot = new Telegraf() + bot.use(menu.init({actionCode: 'a:b'})) + + bot.context.editMessageText = (text, extra) => { + t.deepEqual(extra.reply_markup.inline_keyboard, menuKeyboard) + return Promise.resolve() + } + + await bot.handleUpdate({callback_query: {data: 'a:b'}}) +}) + +test('simpleButton works', async t => { + const menu = new TelegrafInlineMenu('yaay') + menu.simpleButton('hit me', 'c', { + doFunc: t.pass + }) + + const bot = new Telegraf() + bot.use(menu.init({actionCode: 'a:b'})) + + bot.context.editMessageText = t.fail + + await bot.handleUpdate({callback_query: {data: 'a:b:c'}}) +}) + +test('button updates menu', async t => { + t.plan(2) + const menu = new TelegrafInlineMenu('yaay') + menu.button('hit me', 'c', { + doFunc: t.pass + }) + + const bot = new Telegraf() + bot.use(menu.init({actionCode: 'a:b'})) + + bot.context.editMessageText = (text, extra) => { + t.deepEqual(extra.reply_markup.inline_keyboard, menuKeyboard) + return Promise.resolve() + } + + await bot.handleUpdate({callback_query: {data: 'a:b:c'}}) +}) + +test('hidden button can not be trigged', async t => { + t.plan(1) + const menu = new TelegrafInlineMenu('yaay') + menu.simpleButton('hit me', 'c', { + doFunc: t.fail, + hide: () => { + t.pass() + return Promise.resolve(true) + } + }) + + const bot = new Telegraf() + bot.use(menu.init({actionCode: 'a:b'})) + + bot.context.editMessageText = t.fail + + await bot.handleUpdate({callback_query: {data: 'a:b:c'}}) +}) diff --git a/test/command.js b/test/command.js new file mode 100644 index 0000000..9b352e3 --- /dev/null +++ b/test/command.js @@ -0,0 +1,32 @@ +import test from 'ava' +import Telegraf from 'telegraf' + +import TelegrafInlineMenu from '../inline-menu' + +test('one command', async t => { + t.plan(2) + const menu = new TelegrafInlineMenu('foo') + .manual('bar', 'c') + menu.setCommand('test') + + const bot = new Telegraf() + bot.context.reply = (text, extra) => { + t.is(text, 'foo') + t.deepEqual(extra.reply_markup.inline_keyboard, [[{ + text: 'bar', + hide: false, + callback_data: 'a:b:c' + }]]) + + return Promise.resolve() + } + + bot.use(menu.init({actionCode: 'a:b'})) + bot.command('test', () => t.fail('command not handled')) + bot.use(ctx => t.fail('update not handled: ' + JSON.stringify(ctx.update))) + + await bot.handleUpdate({message: { + text: '/test', + entities: [{type: 'bot_command', offset: 0, length: 5}] + }}) +}) diff --git a/test/constructor.js b/test/constructor.js new file mode 100644 index 0000000..ce26c23 --- /dev/null +++ b/test/constructor.js @@ -0,0 +1,57 @@ +import test from 'ava' +import Telegraf from 'telegraf' + +import TelegrafInlineMenu from '../inline-menu' + +test('simple text without buttons', async t => { + const menu = new TelegrafInlineMenu('yaay') + + const bot = new Telegraf() + bot.use(menu.init({actionCode: 'a:b'})) + + bot.context.editMessageText = (text, extra) => { + t.is(text, 'yaay') + t.deepEqual(extra.reply_markup, {}) + return Promise.resolve() + } + + await bot.handleUpdate({callback_query: {data: 'a:b'}}) +}) + +test('markdown text', async t => { + const menu = new TelegrafInlineMenu('yaay') + + const bot = new Telegraf() + bot.use(menu.init({actionCode: 'a:b'})) + + bot.context.editMessageText = (text, extra) => { + t.is(text, 'yaay') + t.is(extra.parse_mode, 'Markdown') + return Promise.resolve() + } + + await bot.handleUpdate({callback_query: {data: 'a:b'}}) +}) + +test('async text func', async t => { + const menu = new TelegrafInlineMenu(() => Promise.resolve('yaay')) + + const bot = new Telegraf() + bot.use(menu.init({actionCode: 'a:b'})) + + bot.context.editMessageText = text => { + t.is(text, 'yaay') + return Promise.resolve() + } + + await bot.handleUpdate({callback_query: {data: 'a:b'}}) +}) + +test('menu.middleware fails with .init() hint', t => { + const menu = new TelegrafInlineMenu('yaay') + + const bot = new Telegraf() + // Normally user would use bot.use. + // But telegraf will later use .middleware() on it. in order to check this faster, trigger this directly + t.throws(() => bot.use(menu.middleware()), /but\.use\(menu\.init/) +}) diff --git a/test/question.js b/test/question.js new file mode 100644 index 0000000..3c9ae78 --- /dev/null +++ b/test/question.js @@ -0,0 +1,200 @@ +import test from 'ava' +import Telegraf from 'telegraf' + +import TelegrafInlineMenu from '../inline-menu' + +const menuKeyboard = [[{ + text: 'Question', + hide: false, + callback_data: 'a:b:c' +}]] + +test('menu correct', async t => { + const menu = new TelegrafInlineMenu('yaay') + menu.question('Question', 'c', { + questionText: 'what do you want?', + setFunc: t.fail + }) + + const bot = new Telegraf() + bot.use(menu.init({actionCode: 'a:b'})) + + bot.context.editMessageText = (text, extra) => { + t.deepEqual(extra.reply_markup.inline_keyboard, menuKeyboard) + return Promise.resolve() + } + + await bot.handleUpdate({callback_query: {data: 'a:b'}}) +}) + +test('sends question text', async t => { + t.plan(3) + const menu = new TelegrafInlineMenu('yaay') + menu.question('Question', 'c', { + questionText: 'what do you want?', + setFunc: t.fail + }) + + const bot = new Telegraf() + bot.use(menu.init({actionCode: 'a:b'})) + + bot.context.editMessageText = t.fail + bot.context.deleteMessage = () => Promise.resolve(t.pass()) + bot.context.reply = (text, extra) => { + t.is(text, 'what do you want?') + t.deepEqual(extra.reply_markup, { + force_reply: true + }) + } + + await bot.handleUpdate({callback_query: {data: 'a:b:c'}}) +}) + +test('setFunc on answer', async t => { + t.plan(2) + const menu = new TelegrafInlineMenu('yaay') + menu.question('Question', 'c', { + questionText: 'what do you want?', + setFunc: (ctx, answer) => t.is(answer, 'more money') + }) + + const bot = new Telegraf() + bot.use(menu.init({actionCode: 'a:b'})) + + bot.context.editMessageText = t.fail + bot.context.reply = (text, extra) => { + t.deepEqual(extra.reply_markup.inline_keyboard, menuKeyboard) + return Promise.resolve() + } + bot.use(ctx => { + t.log('update not handled', ctx.update) + t.fail('something not handled') + }) + + await bot.handleUpdate({message: { + reply_to_message: { + text: 'what do you want?' + }, + text: 'more money' + }}) +}) + +test('dont setFunc on wrong input text', async t => { + t.plan(1) + const menu = new TelegrafInlineMenu('yaay') + menu.question('Question', 'c', { + questionText: 'what do you want?', + setFunc: (ctx, answer) => t.is(answer, 'more money') + }) + + const bot = new Telegraf() + bot.use(menu.init({actionCode: 'a:b'})) + + bot.context.editMessageText = t.fail + bot.context.reply = () => Promise.resolve( + t.fail('dont reply on wrong text') + ) + bot.use(t.pass) + + await bot.handleUpdate({message: { + reply_to_message: { + text: 'what do you do?' + }, + text: 'more money' + }}) +}) + +test('dont setFunc on hide', async t => { + t.plan(1) + const menu = new TelegrafInlineMenu('yaay') + menu.question('Question', 'c', { + questionText: 'what do you want?', + hide: () => true, + setFunc: (ctx, answer) => t.is(answer, 'more money') + }) + + const bot = new Telegraf() + bot.use(menu.init({actionCode: 'a:b'})) + + bot.context.editMessageText = t.fail + bot.context.reply = () => Promise.resolve( + t.fail('on hide nothing has to be replied') + ) + + bot.use(t.pass) + + await bot.handleUpdate({message: { + reply_to_message: { + text: 'what do you want?' + }, + text: 'more money' + }}) +}) + +test('accepts other stuff than text', async t => { + t.plan(2) + const menu = new TelegrafInlineMenu('yaay') + menu.question('Question', 'c', { + questionText: 'what do you want?', + setFunc: (ctx, answer) => t.is(answer, undefined) + }) + + const bot = new Telegraf() + bot.use(menu.init({actionCode: 'a:b'})) + + bot.context.editMessageText = t.fail + bot.context.reply = (text, extra) => { + t.deepEqual(extra.reply_markup.inline_keyboard, menuKeyboard) + return Promise.resolve() + } + bot.use(ctx => { + t.log('update not handled', ctx.update) + t.fail('something not handled') + }) + + await bot.handleUpdate({message: { + reply_to_message: { + text: 'what do you want?' + }, + photo: {}, + caption: '42' + }}) +}) + +test('multiple question setFuncs do not interfere', async t => { + t.plan(2) + const menu = new TelegrafInlineMenu('yaay') + menu.question('Question', 'c', { + questionText: 'what do you want to have?', + setFunc: (ctx, answer) => t.is(answer, 'more money') + }) + menu.question('Question', 'd', { + questionText: 'what do you want to eat?', + setFunc: (ctx, answer) => t.is(answer, 'less meat') + }) + + const bot = new Telegraf() + bot.use(menu.init({actionCode: 'a:b'})) + + bot.context.editMessageText = t.fail + bot.context.reply = () => Promise.resolve() + + bot.use(ctx => { + t.log('update not handled', ctx.update) + t.fail('something not handled') + }) + + await bot.handleUpdate({message: { + reply_to_message: { + text: 'what do you want to have?' + }, + text: 'more money' + }}) + + await bot.handleUpdate({message: { + reply_to_message: { + text: 'what do you want to eat?' + }, + text: 'less meat' + }}) +}) diff --git a/test/select.js b/test/select.js new file mode 100644 index 0000000..b5b297d --- /dev/null +++ b/test/select.js @@ -0,0 +1,228 @@ +import test from 'ava' +import Telegraf from 'telegraf' + +import TelegrafInlineMenu from '../inline-menu' +import {emojiTrue, emojiFalse} from '../prefix' + +test('option array menu', async t => { + const menu = new TelegrafInlineMenu('foo') + menu.select('c', ['a', 'b'], { + setFunc: t.fail + }) + + const bot = new Telegraf() + bot.use(menu.init({actionCode: 'a:b'})) + + bot.context.editMessageText = (text, extra) => { + t.deepEqual(extra.reply_markup.inline_keyboard, [[ + { + text: 'a', + hide: false, + callback_data: 'a:b:c:a' + }, { + text: 'b', + hide: false, + callback_data: 'a:b:c:b' + } + ]]) + return Promise.resolve() + } + + await bot.handleUpdate({callback_query: {data: 'a:b'}}) +}) + +test('option object menu', async t => { + const menu = new TelegrafInlineMenu('foo') + menu.select('c', {a: 'A', b: 'B'}, { + setFunc: t.fail + }) + + const bot = new Telegraf() + bot.use(menu.init({actionCode: 'a:b'})) + + bot.context.editMessageText = (text, extra) => { + t.deepEqual(extra.reply_markup.inline_keyboard, [[ + { + text: 'A', + hide: false, + callback_data: 'a:b:c:a' + }, { + text: 'B', + hide: false, + callback_data: 'a:b:c:b' + } + ]]) + return Promise.resolve() + } + + await bot.handleUpdate({callback_query: {data: 'a:b'}}) +}) + +test('option async array menu', async t => { + const menu = new TelegrafInlineMenu('foo') + menu.select('c', () => Promise.resolve(['a', 'b']), { + setFunc: t.fail + }) + + const bot = new Telegraf() + bot.use(menu.init({actionCode: 'a:b'})) + + bot.context.editMessageText = (text, extra) => { + t.deepEqual(extra.reply_markup.inline_keyboard, [[ + { + text: 'a', + hide: false, + callback_data: 'a:b:c:a' + }, { + text: 'b', + hide: false, + callback_data: 'a:b:c:b' + } + ]]) + return Promise.resolve() + } + + await bot.handleUpdate({callback_query: {data: 'a:b'}}) +}) + +test('selects', async t => { + t.plan(2) + const menu = new TelegrafInlineMenu('foo') + menu.select('c', ['a', 'b'], { + setFunc: (ctx, selected) => t.is(selected, 'b') + }) + + const bot = new Telegraf() + bot.use(menu.init({actionCode: 'a:b'})) + + bot.context.editMessageText = () => Promise.resolve(t.pass()) + + await bot.handleUpdate({callback_query: {data: 'a:b:c:b'}}) +}) + +test('selected key has emoji prefix', async t => { + const menu = new TelegrafInlineMenu('foo') + menu.select('c', ['a', 'b'], { + setFunc: t.fail, + isSetFunc: (ctx, key) => Promise.resolve(key === 'b') + }) + + const bot = new Telegraf() + bot.use(menu.init({actionCode: 'a:b'})) + + bot.context.editMessageText = (text, extra) => { + t.deepEqual(extra.reply_markup.inline_keyboard, [[ + { + text: 'a', + hide: false, + callback_data: 'a:b:c:a' + }, { + text: emojiTrue + ' b', + hide: false, + callback_data: 'a:b:c:b' + } + ]]) + return Promise.resolve() + } + + await bot.handleUpdate({callback_query: {data: 'a:b'}}) +}) + +test('multiselect has prefixes', async t => { + const menu = new TelegrafInlineMenu('foo') + menu.select('c', ['a', 'b'], { + multiselect: true, + setFunc: t.fail, + isSetFunc: (ctx, key) => Promise.resolve(key === 'b') + }) + + const bot = new Telegraf() + bot.use(menu.init({actionCode: 'a:b'})) + + bot.context.editMessageText = (text, extra) => { + t.deepEqual(extra.reply_markup.inline_keyboard, [[ + { + text: emojiFalse + ' a', + hide: false, + callback_data: 'a:b:c:a' + }, { + text: emojiTrue + ' b', + hide: false, + callback_data: 'a:b:c:b' + } + ]]) + return Promise.resolve() + } + + await bot.handleUpdate({callback_query: {data: 'a:b'}}) +}) + +test('custom prefix', async t => { + const menu = new TelegrafInlineMenu('foo') + menu.select('c', ['a', 'b'], { + setFunc: t.fail, + prefixFunc: () => Promise.resolve('bar') + }) + + const bot = new Telegraf() + bot.use(menu.init({actionCode: 'a:b'})) + + bot.context.editMessageText = (text, extra) => { + t.deepEqual(extra.reply_markup.inline_keyboard, [[ + { + text: 'bar a', + hide: false, + callback_data: 'a:b:c:a' + }, { + text: 'bar b', + hide: false, + callback_data: 'a:b:c:b' + } + ]]) + return Promise.resolve() + } + + await bot.handleUpdate({callback_query: {data: 'a:b'}}) +}) + +test('hides key in keyboard', async t => { + t.plan(1) + const menu = new TelegrafInlineMenu('foo') + menu.select('c', ['a', 'b'], { + setFunc: t.fail, + hide: (ctx, key) => key === 'a' + }) + + const bot = new Telegraf() + bot.use(menu.init({actionCode: 'a:b'})) + + bot.context.editMessageText = (text, extra) => { + t.deepEqual(extra.reply_markup.inline_keyboard, [[ + { + text: 'b', + hide: false, + callback_data: 'a:b:c:b' + } + ]]) + return Promise.resolve() + } + + await bot.handleUpdate({callback_query: {data: 'a:b'}}) +}) + +test('hidden key can not be set', async t => { + t.plan(2) + const menu = new TelegrafInlineMenu('foo') + menu.select('c', ['a', 'b'], { + setFunc: t.fail, + hide: (ctx, key) => key === 'a' + }) + + const bot = new Telegraf() + bot.use(menu.init({actionCode: 'a:b'})) + + bot.context.editMessageText = () => Promise.resolve(t.pass()) + bot.use(() => t.pass()) + + await bot.handleUpdate({callback_query: {data: 'a:b:c:a'}}) +}) diff --git a/test/submenu.js b/test/submenu.js new file mode 100644 index 0000000..e4f8d2f --- /dev/null +++ b/test/submenu.js @@ -0,0 +1,111 @@ +import test from 'ava' +import Telegraf from 'telegraf' + +import TelegrafInlineMenu from '../inline-menu' + +const menuKeyboard = [[{ + text: 'Submenu', + hide: false, + callback_data: 'a:b:c' +}]] + +const baseInitOptions = { + backButtonText: 'back…', + mainMenuButtonText: 'main…' +} + +test('root menu correct', async t => { + const menu = new TelegrafInlineMenu('foo') + menu.submenu('Submenu', 'c', new TelegrafInlineMenu('bar')) + + const bot = new Telegraf() + bot.use(menu.init({actionCode: 'a:b'})) + + bot.context.editMessageText = (text, extra) => { + t.deepEqual(extra.reply_markup.inline_keyboard, menuKeyboard) + return Promise.resolve() + } + + await bot.handleUpdate({callback_query: {data: 'a:b'}}) +}) + +test('no submenu on hide', async t => { + const menu = new TelegrafInlineMenu('foo') + menu.submenu('Submenu', 'c', new TelegrafInlineMenu('bar'), { + hide: () => true + }) + + const bot = new Telegraf() + bot.use(menu.init({actionCode: 'a:b'})) + + bot.context.editMessageText = () => Promise.resolve( + t.fail('so submenu on hide') + ) + bot.use(t.pass) + + await bot.handleUpdate({callback_query: {data: 'a:b:c'}}) +}) + +test('submenu without back button', async t => { + const menu = new TelegrafInlineMenu('foo') + menu.submenu('Submenu', 'c', new TelegrafInlineMenu('bar')) + + const bot = new Telegraf() + bot.use(menu.init({actionCode: 'a:b'})) + + bot.context.editMessageText = (text, extra) => { + t.deepEqual(extra.reply_markup.inline_keyboard, undefined) + return Promise.resolve() + } + + await bot.handleUpdate({callback_query: {data: 'a:b:c'}}) +}) + +test('submenu with back button', async t => { + const menu = new TelegrafInlineMenu('foo') + menu.submenu('Submenu', 'c', new TelegrafInlineMenu('bar')) + + const bot = new Telegraf() + bot.use(menu.init({backButtonText: baseInitOptions.backButtonText, actionCode: 'a:b'})) + + bot.context.editMessageText = (text, extra) => { + t.deepEqual(extra.reply_markup.inline_keyboard, [[{ + text: 'back…', + hide: false, + callback_data: 'a:b' + }]]) + return Promise.resolve() + } + + await bot.handleUpdate({callback_query: {data: 'a:b:c'}}) +}) + +test('submenu with main button', async t => { + const menu = new TelegrafInlineMenu('foo') + menu.submenu('Submenu', 'c', new TelegrafInlineMenu('bar')) + + const bot = new Telegraf() + bot.use(menu.init({...baseInitOptions})) + + bot.context.editMessageText = (text, extra) => { + t.deepEqual(extra.reply_markup.inline_keyboard, [[{ + text: 'main…', + hide: false, + callback_data: 'main' + }]]) + return Promise.resolve() + } + + await bot.handleUpdate({callback_query: {data: 'c'}}) +}) + +test('default init is main', async t => { + t.plan(1) + const menu = new TelegrafInlineMenu('foo') + + const bot = new Telegraf() + bot.use(menu.init({...baseInitOptions})) + + bot.context.editMessageText = () => Promise.resolve(t.pass()) + await bot.handleUpdate({callback_query: {data: 'main'}}) +}) diff --git a/test/toggle.js b/test/toggle.js new file mode 100644 index 0000000..244bb80 --- /dev/null +++ b/test/toggle.js @@ -0,0 +1,68 @@ +import test from 'ava' +import Telegraf from 'telegraf' + +import TelegrafInlineMenu from '../inline-menu' +import {emojiTrue} from '../prefix' + +test('menu correct', async t => { + const menu = new TelegrafInlineMenu('yaay') + menu.toggle('toggle me', 'c', { + setFunc: t.fail, + isSetFunc: () => true + }) + + const bot = new Telegraf() + bot.use(menu.init({actionCode: 'a:b'})) + + bot.context.editMessageText = (text, extra) => { + t.deepEqual(extra.reply_markup.inline_keyboard, [[{ + text: emojiTrue + ' toggle me', + hide: false, + callback_data: 'a:b:c:false' + }]]) + return Promise.resolve() + } + + await bot.handleUpdate({callback_query: {data: 'a:b'}}) +}) + +test('toggles', async t => { + const menu = new TelegrafInlineMenu('yaay') + menu.toggle('toggle me', 'c', { + setFunc: (ctx, newState) => t.true(newState), + isSetFunc: () => false + }) + + const bot = new Telegraf() + bot.context.editMessageText = () => Promise.resolve() + bot.use(menu.init({actionCode: 'a:b'})) + + await bot.handleUpdate({callback_query: {data: 'a:b:c:true'}}) +}) + +async function ownPrefixTest(t, currentState, prefix) { + const menu = new TelegrafInlineMenu('yaay') + menu.toggle('toggle me', 'c', { + setFunc: t.fail, + isSetFunc: () => currentState, + prefixTrue: '42', + prefixFalse: '666' + }) + + const bot = new Telegraf() + bot.use(menu.init({actionCode: 'a:b'})) + + bot.context.editMessageText = (text, extra) => { + t.deepEqual(extra.reply_markup.inline_keyboard, [[{ + text: prefix + ' toggle me', + hide: false, + callback_data: 'a:b:c:' + !currentState + }]]) + return Promise.resolve() + } + + await bot.handleUpdate({callback_query: {data: 'a:b'}}) +} + +test('own true prefix', ownPrefixTest, true, '42') +test('own false prefix', ownPrefixTest, false, '666')