The Event System

Joe is powered by an asynchronous event system which is also used internally when you register your message handlers using the Bot.Respond(…) function. What happens is that the chat Adapter emits a joe.ReceiveMessageEvent for each incoming message. The handler function that you registered is executed if the bot sees such an event with a message text that matches your regular expression.

func (b *Bot) RespondRegex(expr string, fun func(Message) error) {
	// other code omitted for brevity …

	b.Brain.RegisterHandler(func(ctx context.Context, evt ReceiveMessageEvent) error {
		matches := regex.FindStringSubmatch(evt.Text)
		if len(matches) == 0 {
			return nil

		// If the event text matches our regular expression we can already mark
		// the event context as done so the Brain does not run any other handlers
		// that might match the received message.

		return fun(Message{
			ID:       evt.ID,
			Text:     evt.Text,
			// and other many fields

This means that you could also register message handlers directly on the joe.ReceiveMessageEvent yourself if you wanted to (e.g. if you want to get notified for each incoming message).

The Brain

This event system is implemented in the joe.Brain. When it sees a new event it finds all registered event handlers for the event type and then executes them all in the same sequence in which they have been registered.

By default all matching handlers are executed but you can prevent other handlers from being executed after your handler, by calling the joe.FinishEventContent() function. As you can see in the code snippet above, this is also what happens automatically to message handlers you register via Bot.Respond(…).

The Brain itself also emits two events to signal when it is starting up and when it is shutting down:

  • joe.InitEvent
  • joe.ShutdownEvent

You can use those events both in unit tests as well as your own logic to hook into the lifecycle of the bot.

Chaining events

The event system is also useful for other kinds of events. For instance, as you can see in the next recipes, this is how cron jobs are implemented in Joe. Generally speaking, there are sources that can trigger events and there are handlers that get executed when the matching event is emitted. Since handlers can also be event sources, this means you can chain events asynchronously.

For example we can setup a handler that should be executed for each incoming HTTP request. It should check if the request came from GitLab and if so, decode the request body it into another event type:

import (

	joehttp ""

func (b *Bot) HTTPCallback(req joehttp.RequestEvent) error {
	if req.Header.Get("X-Gitlab-Event") == "" {
		return nil

	var event GitLabEvent
	err := json.Unmarshal(req.Body, &event)
	if err != nil {
		return fmt.Errorf("failed to unmarshal gitlab event as JSON: %w", err)

	return nil

Now we can define another handler that will be executed on the GitLabEvent type:

func (b *Bot) GitLabCallback(event GitLabEvent) error {
	b.Logger.Info("Received gitlab event",
		zap.String("event_type", event.EventType),
		zap.String("object_kind", event.ObjectKind),
		zap.String("action", event.ObjectAttributes.Action),
		zap.String("project", event.Project.PathWithNamespace),
		zap.String("title", event.ObjectAttributes.Title),
		zap.String("url", event.ObjectAttributes.URL),

	switch event.EventType {
	case "merge_request":
		return b.HandleMergeRequestEvent(event)

	case "note":
		return b.HandleGitlabNoteEvent(event)

		b.Logger.Info("Unknown event from gitlab", zap.String("object_kind", event.ObjectKind))
		return nil

Finally to make this all work together, we need to register the two handlers when we setup the bot:

func New(conf Config) *Bot {
	b := &Bot{
		Bot: joe.New("joe", conf.Modules()...),

	// Define any custom event and message handlers here
	return b

If you want to learn more about how the Brain works internally, start by looking at the GoDoc and then the code itself.

Happy event hacking 🤖.