Skip to content

☕ event based and extensible messaging library with multi-network and multi-protocol support

License

Notifications You must be signed in to change notification settings

caffeinery/coffea

Repository files navigation

coffea

NPM version Dependencies

coffea lays the foundations you need to painlessly and effortlessly connect to multiple chat protocols

Not maintained: coffea is currently not maintained anymore due to lack of time. If you are interested in continuing this project feel free to contact me at [email protected]

Attention: beta15 changed event.channel to event.chat for more consistency across protocols. Furthermore, helper functions now accept only one argument with all the options for building the event. Make sure to update your code when upgrading! You can also use the improved reply function now (which defaults to the current chat if chat is not supplied):

reply('hello world!') // reply with a simple message
reply(message('hello world!')) // or you can do this
reply(message({ // reply with a more complex message
  text: 'hello world!',
  protocolSpecificOption: 'something'
}))

Table of contents:

Quickstart

Use the coffea-starter project to quickly get started developing with coffea!

Installation

You can install the latest coffea version like this:

npm install --save [email protected]

As for protocols, we're working on coffea-irc, coffea-slack and coffea-telegram. Feel free to build your own if you want to play around with coffea.

Connecting

The coffea core exposes a connect function (along with other functions, which are explained later). It can be imported like this:

import { connect } from 'coffea'

This function loads the required protocols (via node_modules) and returns an instance container, which has the on and send functions.

// create an instance container for one irc instance
// Note: options get passed to the protocol, which handles them (e.g. autojoin channels on irc)
//       please refer to the protocol documentation for more information about these options
//       (usually available at https://npmjs.com/package/coffea-PROTOCOLNAME)
const networks = connect([
  {
    protocol: 'irc',
    network: '...',
    channels: ['#foo', '#bar']
  }
])

// the instance container exposes the `on` and `send` functions:
networks.send({...}) // we'll learn about sending events later
networks.on('message', (msg, reply) => {...}) // we'll learn about listening to events later

Note: You need to install coffea-PROTOCOLNAME to use that protocol, e.g. npm install coffea-slack

You can now use this function to connect to networks and create instance containers! 🎉

Events

Events are the central concept in coffea. They have a certain structure (object with a type key):

{
  type: 'EVENT_NAME',
  ...
}

For a message, it could look like this (imagine a git bot):

{
  type: 'message',
  chat: '#dev',
  text: 'New commit!'
}

Note: In coffea, outgoing and ingoing events are always consistent - they look the same. That way you don't need to memorize two separate structures for sending/receiving events - awesome! (might even save some code)

Listening on events

coffea's connect function transforms the passed configuration array into an instance container, which is an enhanced array. This means you can use normal array functions, like map and filter. e.g. you could filter networks and only listen to slack networks, or you could use map to send a message to all networks. You could even combine them!

// only listen to `slack` networks:
networks.filter(network => network.protocol === 'slack')

// `map` and `filter` combined
networks
  .filter(network => network.protocol === 'slack')
  .map(network => console.log(network))

The array is enhanced with an on function (and a send function, more on that later), which allows you to listen to events on the instance container:

networks.on('event', (event, reply) => { ... })

networks
  .filter(network => network.protocol === 'slack')
  .on('message', msg => console.log(msg.text))

// sending events will be explained more later
const parrot = (msg, reply) => reply(msg.text)
networks.on('message', parrot)

Event helpers

You probably don't want to deal with raw event objects all the time - you write a lot of boilerplate and it's prone to error. That's why coffea (and the protocols) provide helper functions that create events, they can be imported like this:

// `message` is a core event helper (it works on all protocols)
import { message } from 'coffea'

// `attachment` is a protocol specific event helper (it only works on certain protocols)
import { attachment } from 'coffea-slack'

Note: Protocols should try to keep similar functionality consistent (e.g. if two protocols support attachments, keep the api consistent so you can use either helper function and it will work for both protocols).

Now you can create an event like this:

message(name, chat, options)
message('New commit!', '#dev', { protocolSpecificOption: 'something' })

Or you can use an object instead:

message({
  text: 'New commit!',
  chat: '#dev',
  protocolSpecificOption: 'something'
})

The structure for event helpers is:

eventName(argument1, argument2, ..., options) // for global events
eventName(argument1, argument2, ..., chat, options) // for events that are specific to a certain chat

Make sure your event helper is also usable with an object:

eventName({ argument1, argument2, ..., option1, option2, ...})
eventName({ argument1, argument2, ..., chat, option1, option2, ...})

(eventName should always equal the type of the event that is returned to avoid confusion!)

Multiple protocols can expose the same helper functions, but with enhanced functionality. e.g. for Slack you could do:

import { message, attachment } from 'coffea-slack'
message({ chat, text, attachment: attachment('test.png') })

Note: coffea core's message helper function (if you import with import { message } from 'coffea') does not implement the attachment option!

Core events

coffea defines certain event helpers that should be used when developing protocols in order to ensure consistency. You can import and use all helpers like this:

import { event, connection, message, privatemessage, command, error } from 'coffea'
event(name, data) // `name` required; e.g. event('ping')
connection()
message(text, chat, options) // `text` required; e.g. message('hi!')
privatemessage(text, chat, options) // `text` required; e.g. privatemessage('hi!')
command(cmd, args, chat, options) // `cmd` required; e.g. `command('ping')`
error(err, options) // `err` required; e.g. `error(new Error('fail'))`

You can alternatively pass an object as the first parameter instead, e.g.:

message({
  text: 'hello world',
  someOption: true // instead of using `options` as a separate argument, you can just pass them directly in the object
})

Example: Writing an event helper

import { isObject } from 'coffea'

/**
 * Make sure to add some information about the event helper here.
 *
 * @param  {type} arg
 * @param  {type} [optionalArg]
 * @return {Object} example event
 */
export const example = (arg, optionalArg, options) => {
  // make the helper work with an object
  if (isObject(arg)) {
    // we need to rename `arg` to `_arg` here to avoid overshadowing the variable
    let { arg: _arg, optionalArg, ...options } = arg
    return example(_arg, optionalArg, options)
  }

  // do some sanity checks
  if (!arg) {
    throw new Error(
      'An `example` event needs at least a `arg` parameter, ' +
      'e.g. example(\'arg\') or example({ arg: \'arg\' })'
    )
  }

  // create the event
  // make sure to put ...options first or it will overwrite other properties!
  return {
    ...options,
    type: 'example',
    arg, optionalArg
  }
}

Sending events

Now that you know how to create events, let's send them to the networks.

The instance container is also enhanced with a send function, which allows you to send calling events to the networks. e.g. sending a calling message event will send a message to the network.

Note: As mentioned before, in coffea calling events and receiving events always look the same.

You can use the message helper function to send an event to all networks:

import { message } from 'coffea'

// send to all networks:
networks.send(message({ chat: '#dev', text: 'Commit!' }))

// send to slack networks only:
networks
  .filter(network => network.protocol === 'slack')
  .send(message({ chat: '#random', text: 'Secret slack-only stuff.' }))

send in combination with on

If you're sending events as a response to another event, you should use the reply function that gets passed as an argument to the listener. It will automatically figure out where to send the message instead of sending it to all networks (like networks.send does):

networks.on('message', (msg, reply) => reply(msg.text))

You may want to keep the function definitions (const parrot = ...) separate from the on statement (networks.on(...)). This allows for easy unit tests:

// somefile.js
export const parrot = (msg, reply) => reply(msg.text)
export const reverse = (msg, reply) => {
  const reversedText = msg.text.split('').reverse().join('')
  reply(reversedText)
}

// unittests.js
import { assert } from 'my-favorite-testing-library'
import { parrot, reverse } from './somefile'
parrot('hello world', (msg) => assert(msg.text === 'hello world'))
reverse('hello world', (msg) => assert(msg.text === 'dlrow olleh'))

// main.js
import connect from 'coffea'
import { parrot, reverse } from './somefile'
const networks = connect([...]) // put network config here
networks.on('message', reverse)
// or...
networks.on('message', parrot)

Example: Reverse bot

import { connect, message } from 'coffea'

const networks = connect([
  {
    protocol: 'irc',
    network: '...',
    channels: ['#foo', '#bar']
  },
  {
    protocol: 'telegram',
    token: '...'
  },
  {
    protocol: 'slack',
    token: '...'
  }
])

const reverse = (msg, reply) => {
  const reversedText = msg.text.split('').reverse().join('')
  reply(reversedText)
}

networks.on('connection', (evt) => console.log('connected to ' + evt.network))

networks.on('message', reverse)

Protocols

This is a guide on how to implement a new protocol with coffea.

Protocols are functions that take config (a network configuration), and a dispatch function as arguments. They return a function that will handle all calling events sent to the protocol later.

A simple protocol could look like this:

export default const dummyProtocol = (config, dispatch) => {
  // mock connect event
  dispatch({
    type: 'connect',
    network: config.network
  })

  return event => {
    switch (event.type) {
      case 'message':
        dispatch({
          type: 'message',
          text: event.text
        })
        break
      default:
        dispatch({
          type: 'error',
          text: 'Unknown event'
        })
        break
    }
  }
}

To use this protocol, you have to pass the protocol function to connect:

import { connect, message } from 'coffea'
import dummyProtocol from './dummy'

const networks = connect([
  {
    protocol: dummyProtocol,
    network: 'test'
  }
])

const logListener = msg => console.log(msg)
networks.on('message', logListener)

networks.send(message('hello world!'))

dummyProtocol's event handler will then receive the following as the event argument:

{
  type: 'message',
  text: 'hello world!'
}

Which means it will dispatch the message event, which results in the on('message', listener) listeners getting called with the same event argument.

Finally, the logListener function will get called, which results in the following output on the console:

{
  type: 'message',
  text: 'hello world!'
}

forward helper

forward({
  eventName: function,
  ...
})

You probably don't want to use switch statements to parse the events, which is why coffea provides a forward helper function. It forwards the events (depending on their type) to the specific handler function and can be used like this:

import { forward } from 'coffea'

export default const dummyProtocol = (config, dispatch) => {
  // mock connect event
  dispatch({
    type: 'connect',
    network: config.network
  })

  return forward({
    'message': event => dispatch({
        type: 'message',
        text: event.text
      }),

    'default': event => dispatch({
        type: 'error',
        text: 'Unknown event'
      })
  })
}

Note: 'default' will be called if the event doesn't match any of the other defined types.

This helper also allows you to separate your event handlers from the protocol logic:

import { forward } from 'coffea'

const messageHandler = dispatch => event =>
  dispatch({
    type: 'message',
    text: event.text
  })

const defaultHandler = dispatch => event =>
  dispatch({
    type: 'error',
    err: new Error('Unknown event')
  })

export default const dummyProtocol = (config, dispatch) => {
  // mock connect event
  dispatch({
    type: 'connect',
    network: config.network
  })

  return forward({
    'message': messageHandler(dispatch),
    'default': defaultHandler(dispatch)
  })
}

You can (and should!) use the same coffea helpers for protocols:

import { forward, message, error } from 'coffea'

const messageHandler = dispatch => event =>
  dispatch(message({ text: event.text })

const defaultHandler = dispatch => event =>
  dispatch(error({ err: new Error('Unknown event') })

export default const dummyProtocol = (config, dispatch) => {
  // mock connect event
  dispatch({
    type: 'connect',
    network: config.network
  })

  return forward({
    'message': messageHandler(dispatch),
    'default': defaultHandler(dispatch)
  })
}

About

☕ event based and extensible messaging library with multi-network and multi-protocol support

Resources

License

Stars

Watchers

Forks

Packages

No packages published