Skip to content
/ xene Public

🤖 Modern library with simple API to build great conversational bots.

License

Notifications You must be signed in to change notification settings

toptal/xene

Repository files navigation

Travis npm first timers only

At 2023, Xene is out-of-date with a lot of outdated dependencies and using old Slack API. Use at own risk

Xene is a framework for building conversational bots with modern JavaScript(or TypeScript). From simple command-based bots to rich natural language bots the framework provides all of the features needed to manage the conversational aspects of a bot.

import { Slackbot } from '@xene/slack'

new Slackbot(/* API token */)
  .when(/hi|hello/i).say('Hi there!')
  .when(/talk/i).talk(async dialog => {
    const user = await dialog.bot.users.info(dialog.user)
    const topic = await dialog.ask('What about?', topicParser)
    await dialog.say(`Ok ${user.profile.firstName}, let's talk about ${topic}.`)
    // ...
  })
  .listen()

📦 Packages

Xene is split into different packages for different services and purposes. There are 2 main packages that differ from rest: @xene/core and @xene/test.

  • @xene/core is the place where actual conversation API is implemented and all other packages(like @xene/slack) derive from it.
  • @xene/test defines a convenient wrapper to help you test your bots. It works nicely with all other packages (Slack or Telegram).
  • @xene/slack provides Slackbot which provides all features to build communication and it also provides all api methods of Slack with promises and in camel case 🙂
  • @xene/telegram is still in progress but will be as smooth as Slackbot 😉

💬 Talking

Xene provides two main ways to talk with users — in response to some users' message and a way to start talking completely programmatically.

📥 Starting conversations in response to user's message

To talk with a user when a user says something, first of all, we need to match user's message. Xene bots provide .when() method for this.

import { Slackbot } from '@xene/slack'

new Slackbot(/* API token */)
  .when(/hi|hello/i).talk(dialog => /* ... */)

Once user says something that matches, callback passed to .talk() will be invoked with an instance of Dialog class. Which provides three main methods to parse something from most recent users' message(dialog.parse()), to say something to user(dialog.say()) and to ask a question to which user can reply (dialog.ask()). Read more about them and Dialog here.

import { Slackbot } from '@xene/slack'

new Slackbot(/* API token */)
  .when(/hi|hello/i).talk(async dialog => {
    await dialog.say('Hi there!')
    const play = await dialog.ask('Wanna play a game?', reply => /yes/i.test(reply))
    // start a game...
  })

📤 Starting conversations proactively

The dialog can also be created proactively when you need them. To do so you can call bot.dialog() method. It expects channel id (slack channel would do) and an array of users' ids. Rest is the same as in the above example.

import { Slackbot } from '@xene/slack'

const bot = new Slackbot(/* API token */)

const getGloriousPurpose = async () => {
  const dialog = bot.dialog('#channel-id', ['@user1-id', '@user2-id'])
  const purpose = await dialog.ask('Guys, what is my purpose?', reply => reply)
  const comment = purpose === 'to pass butter' ? 'Oh my god.' : 'Nice.'
  await dialog.say(`"${purpose}"... ${comment}`)
  bot.purpose = purpose
  dialog.end()
}

getGloriousPurpose()

⚙️ Dialog API

In the examples above we've been dealing with instances of Dialog class. It provides following methods and properties.

Click on ▶ to expand reference.

Dialog.prototype.bot — access an instance of the Bot to which dialog belongs to

Signature:

bot: Bot

Dialog.prototype.channel — the unique id of a channel where the dialog is happening

Signature:

channel: string

Dialog.prototype.users — an array of ids of all users to whom dialog is attached to

Signature:

users: Array<string>

Dialog.prototype.user — the id of the primary user to whom dialog is attached to

Signature:

user: string

Dialog.prototype.on() — add an event listener to life cycle events of a dialog

Signature:

on(event: string, callback: function)

Example:

dialog.on('end', _ => console.log('Dialog has ended.'))
dialog.on('abort', _ => console.log('Dialog was aborted by user.'))
dialog.on('pause', _ => console.log('Dialog was paused.'))
dialog.on('unpause', _ => console.log('Dialog was unpaused.'))
dialog.on('incomingMessage', m => console.log(`Incoming message ${JSON.stringify(m)}`))
dialog.on('outgoingMessage', m => console.log(`Outgoing message ${JSON.stringify(m)}`))

Dialog.prototype.end() — abort dialog, use this to stop dialog

Signature:

end()

Example: For example, this method might be used to abort active dialog when users ask to.

dialog.on('abort', _ => dialog.end())

Dialog.prototype.say() — send a message to channel

Signature:

say(message: Message, [unpause: Boolean = true])

Description:

Type of the message depends on the bot to which dialog belongs to. For Slackbot message can be either string or message object described here.

unpause is optional it's there to help you control whether dialog should be unpaused when bot says something or not. By default it's true and the dialog will be unpaused. Read more about pause.

Example:

dialog.say('Hello world!')
dialog.pause('Paused!')
dialog.say('Hi again', false)

Dialog.prototype.parse() — parse the most recent message from the user

Signature:

parse(parser: Function || { parse: Function, isValid: Function } , [onError: Message || Function])

Description: This method accepts one or two arguments.

If an error handler isn't provided, this method will return the result of the first attempt to apply parser even if it's an undefined.

Example:

new Slackbot(/* API token */)
  .when(/hi/i).talk(async dialog => {
    await dialog.say('Hi!')
    const parser = reply => (reply.match(/[A-Z][a-z]+/) || [])[0]
    const name = await dialog.parse(parser)
    if (!name) await dialog.say("I didn't get your name, but it's OK.")
    else await dialog.say(`Nice to meet you ${name}.`)
  })

If there is an error handler xene will call it for every failed attempt to parse user's message. Xene counts all parsing failed if null or undefined were returned from parser function. To fine-tune this behavior you can pass an object as a parser with two methods — parse and isValid. Xene will call isValid to determine if parsing failed.

new Slackbot(/* API token */)
  .when(/invite/i).talk(async dialog => {
    const parser = {
      parse: reply => reply.match(/[A-Z][a-z]+/),
      isValid: parsed => parsed && parsed.length
    }
    const names = await dialog.parse(parser, 'Whom to invite?')
    // ...
  })

Dialog.prototype.ask() — ask a question to user and parse response to the question

Signature:

ask(question: Message, parser: Function || { parse: Function, isValid: Function }, [onError: Message || Function])

Description:

Ask the question to a user and parse the response from the user to the question. If parsing fails and error handler onError is defined it will be called. If error handler onError isn't defined than question will be asked again.

Example:

new Slackbot(/* API token */)
  .when(/hi/i).talk(async dialog => {
    await dialog.say('Hi!')
    const parser = reply => (reply.match(/[A-Z][a-z]+/) || [])[0]
    const name = await dialog.ask('How can I call you?', parser, 'Sorry?')
    await dialog.say(`Nice to meet you, ${name}.`)
  })
This example also shows us importance of better parser then one based on capital letter in front of the words 😅.

Dialog.prototype.pause() — pause dialog, reply with pause message until unpaused

Signature:

pause(message: Message)

Description:

Reply to all incoming user's messages with message until the dialog is unpaused. Dialog unpauses when a message is sent to user or question is asked (.say() and .ask() methods). This method can help your bot to give status to a user during some heavy calculation.

Example:

new Slackbot(/* API token */)
  .when(/meaning of life/i).talk(async dialog => {
    dialog.pause(`Wait human, I'm thinking...`)
    await dialog.say('OK, let me think about this.', false)
    await new Promise(r => setTimeout(r, 24 * 60 * 60 * 1000)) // wait 24 hours
    await dialog.say('The answer is... 42.')
  })

✅ Testing bots

Xene provides test module to stub your bot and run assertions.

For example, let's test following bot:

const quizzes = {
  math: [ { q: '2 + 2 = x', a: '4' }, { q: '|-10| - x = 12', a: '2' }],
  // other quizes
]

const meanbot = new Slackbot(/* API token */)
  .when(/hi/i).say('Hi there')
  .when(/quiz/i).talk(async dialog => {
    const kind = await dialog.ask('Ok, what kind of quizzes do you prefer?')
    for (const quiz of quizes[kind]) {
      const answer = await dialog.ask(quiz.q, reply => reply)
      if (answer === quiz.a) await dialog.say('Not bad for a human.')
      else await dialog.say(`Stupid humans... Correct answer is ${quiz.a}.`)
    }
    await dialog.say(`These are all ${kind} quizes I've got.`)
  })

First, to be able to test the bot we need to wrap it in tester object to get access to assertions methods. The exact same thing as with Sinon but assertions provided by @xene/test are dialog specific. Anyhoo, let's write the first assertion.

import ava from 'ava'
import { wrap } from '@xene/test'

const subject = wrap(meanbot)

test('It does reply to "hi"', async t => {
  subject.user.says('Hi')
  t.true(subject.bot.said('Hi there'))
  // or the same but in one line
  t.true(await subject.bot.on('Hi').says('Hi there'))
})

That was simple. But dialogs can be quite complicated and to test them we need to write more stuff.

test('It plays the quiz', async t => {
  subject.user.says('quiz')
  subject.user.says('math')
  t.true(subject.bot.said('2 + 2 = x')
  t.true(await subject.bot.on('1').says('Not bad for a human.'))
  t.true(await subject.bot.on('1').says('Stupid humans... Correct answer is 2.'))
  t.is(subject.bot.lastMessage.message, "These are all math quizes I've got.")
  t.is(subject.bot.messages.length, 6)
})

This is it, there are minor things that might be useful in more advanced scenarios. For example, each assertion method can also take user id and channel id. Check out API to learn about them.

⚙️ Tester API

@xene/test module at this moment exposes single function wrap which wraps your bot in the Wrapper class.

Click on ▶ to expand reference.

wrap() — wrap bot for further testing, stubbing it

Signature:

wrap(bot: Bot)

Description:

Wraps bot under the test exposing assertion methods.

Example:

import { wrap } from '@xene/test'
import { bot } from '../somewhere/from/your/app'

const subject = wrap(bot)

Wrapper.prototype.user.says() — mock a message from a user

Signature:

wrapper.user.says(text: Message, [channel: String], [user: String])

Description:

Imitate incoming user message from any channel and any user. If channel or user arguments aren't provided wrapper will generate random channel and user ids.

Example:

import { wrap } from '@xene/test'
import { bot } from '../somewhere/from/your/app'

const subject = wrap(bot)
subject.user.says('Some message')
subject.user.says('Some message', '#some-channel')
subject.user.says('Some message', '#some-channel', '@some-user')

Wrapper.prototype.bot.lastMessage — retrieve last message bot have send in the tests

Signature:

wrapper.bot.lastMessage: { channel: String, message: BotMessage }

Wrapper.prototype.bot.messages — array of all messages bot have send during the tests

Signature:

wrapper.bot.messages: Array<{ channel: String, message: Message }>

Wrapper.prototype.bot.said() — assert particular message was send

Signature:

wrapper.bot.said(message: Message, [channel: String])

Description:

Assert presense of message in list of all messages bot have send. If channel isn't provided only message will be compared with existing messages. Otherwise both message and channel will be compared.

Wrapper.prototype.bot.reset() — reset assertions and messages

Signature:

wrapper.bot.reset()

Description:

Resets all messages and expectations. This method is designed to be used in beforeEach like test hooks.

Wrapper.prototype.bot.on() — register expextation

Signature:

wrapper.bot.on(text: Message, [channel: String], [user: String]) -> { says(message: Message, [channel: String]) }

Description:

Register async assertions which will be ran when bot replyes. channel and user are optional.

Example:

import { wrap } from '@xene/test'
import { bot } from '../somewhere/from/your/app'

test(async t => {
  const subject = wrap(bot)
  await subject.bot.on('hi').says('hola')
  await subject.bot.on('hi', '#channel').says('hola', '#channel')
  await subject.bot.on('hi', '#channel', '@user').says('hola', '#channel')
})

TypeScript

Xene is written in TypeScript and npm package already includes all typings.

made with ❤️ by @dempfi

About

🤖 Modern library with simple API to build great conversational bots.

Topics

Resources

License

Code of conduct

Stars

Watchers

Forks

Packages

No packages published