• Eventful message parser

    robots working

    This technical blog post will examine a modular and efficient code designed to parse incoming text messages and generate events based on them. This flexible architecture can be easily extended for a variety of use cases. Let’s get started and explore the principles behind this code, why it is event-ful, and how it can help you stay on top of your messaging game!

    The basic algo

    // The Message type represents a typical example of a message received by a bot.
    // It can contain not only a `text` input but a lot of other fields. However,
    // for simplicity in the examples, we will imagine it only has `text`.
    type Message = {
      text: string,
    }
    
    type MessageParser = [
      parseMessage: (message: Message) => Generator<EventCommand, void, unknown>,
      predicate: ParserCondition,
    ];
    
    type EventCommand = [command: Event, isFinished: boolean];
    
    // The ParserCondition is a simple function that helps us to determine
    // whether a message is eligible for a specific parser or not.
    // Think of it as the gatekeeper for parsing fun!
    type ParserCondition = (message: Message) => boolean;

    The processEvents function is the heart of this architecture. Given a list of message parsers and an incoming message, it generates a series of event commands. Each message parser checks if the predicate is satisfied, and if so, it executes the parseMessage function to generate event commands.

    // Get ready for some eventful magic! `processEvents` goes through each parser
    // and checks if the predicate is satisfied, then generates event commands!
    function* processEvents(
      parsers: MessageParser[],
      message: Message,
    ): Generator<EventCommand> {
      for (const parser of parsers) {
        try {
          const [parseMessage, predicate] = parser;
          if (predicate(message)) {
            const commands = parseMessage(message);
            for (const command of commands) {
              yield command;
            }
          }
        } catch (err) {
          console.error('Error parsing message', err);
        }
      }
    }
    
    // The `createMessageProcessor` function is the event commander-in-chief.
    // It publishes events and determines when to stop processing.
    const createMessageParser = (parsers: Parser[]) => async (message: Message) => {
      for (const [event, isFinished] of generateEvents(parsers, message)) {
        publishEvent(event);
        if (isFinished) {
          break;
        }
      }
    };

    Example usage

    Here’s a simple pure function to check if the message starts with the bot’s name. We only care about this one condition and don’t consider any other factors.

    import { storage } from '../../storage/index';
    
    // Here's a simple pure function to check if the message starts with the bot's name.
    // We only care about this one condition and don't consider any other factors.
    const messageStartsWithBotName = (message: Message): boolean =>
      message.text.toLowerCase().startsWith('botname');
    
    export { messageStartsWithBotName };

    // We can extend the function using composition to add a debugging feature. // The withLog function takes a title and a predicate, logs the result, and returns it.

    const withLog =
      (title: string, predicate: ParserCondition): ParserCondition =>
      (message) => {
        const result = predicate(message);
        console.log(title, result);
        return result;
      };
    
    const messageStartsWithBotNameWithLogger = withLog(
      'messageStartsWithBotName',
      messageStartsWithBotName,
    );

    To prevent the same check from being executed multiple times for the same Message, we use the withCache function. It stores the result of the predicate in a cache, so the next time it’s called with the same message, it returns the cached result.

    const withCache = (predicate: ParserCondition): ParserCondition => {
      const cache = new WeakMap<Message, boolean>();
      return (message) => {
        if (cache.has(message)) {
          return Boolean(cache.get(message));
        }
    
        const result = predicate(message);
        cache.set(message, result);
        return result;
      };
    };
    
    const messageStartsWithBotNameWithCache = withCache(
      messageStartsWithBotNameWithLogger
    )

    By using simple logical operations like or, we can compose more complex conditions. In this example, we create a new condition that checks if the message is addressed to the bot.

    const or = (...predicates: ParserCondition[]): ParserCondition => {
      return (...args) => {
        for (const predicate of predicates) {
          if (predicate(...args)) {
            return true;
          }
        }
        return false;
      };
    };
    
    const messageAddressedToBotWithCache = withCache(
      withLog(
        'messageAddressedToBot',
        or(
          messageStartsWithBotNameWithCache,
          messageInPersonalChatWithCache,
          messageRepliesToBotWithCache,
        ),
      ),
    );

    Tests

    Testing is crucial in software development because it helps ensure that your code behaves as expected, leading to fewer bugs and better overall code quality. Writing pure and simple functions makes testing easier, as these functions have no side effects and depend only on their input. This makes it straightforward to create test cases and verify the correctness of your code.

    // Sample data for testing
    const message = createMock < Message > {};
    
    // Sample predicates for testing
    const truePredicate1: ParserPredicate = () => true;
    const truePredicate2: ParserPredicate = () => true;
    const falsePredicate: ParserPredicate = () => false;
    
    describe('or', () => {
      test('returns true when at least one predicate is true', () => {
        const combinedPredicate = or(falsePredicate, truePredicate1);
        expect(combinedPredicate(message)).toBe(true);
      });
    
      test('returns false when all predicates are false', () => {
        const combinedPredicate = or(
          falsePredicate,
          (message: Message) => !truePredicate1(message),
        );
        expect(combinedPredicate(message)).toBe(false);
      });
    
      test('returns true when all predicates are true', () => {
        const combinedPredicate = or(truePredicate1, truePredicate2);
        expect(combinedPredicate(message)).toBe(true);
      });
    
      test('returns false when no predicates are provided', () => {
        const combinedPredicate = or();
        expect(combinedPredicate(message)).toBe(false);
      });
    
      test('short-circuits and stops evaluating predicates after the first true', () => {
        const mockPredicate1 = jest.fn(falsePredicate);
        const mockPredicate2 = jest.fn(truePredicate1);
        const mockPredicate3 = jest.fn(truePredicate2);
    
        const combinedPredicate = or(
          mockPredicate1,
          mockPredicate2,
          mockPredicate3,
        );
        combinedPredicate(message);
    
        expect(mockPredicate1).toHaveBeenCalled();
        expect(mockPredicate2).toHaveBeenCalled();
        expect(mockPredicate3).not.toHaveBeenCalled();
      });
    
      test('passes the same unmodified message to predicates', () => {
        const mockPredicate1 = jest.fn(falsePredicate);
        const mockPredicate2 = jest.fn(truePredicate1);
    
        const combinedPredicate = or(mockPredicate1, mockPredicate2);
        combinedPredicate(message);
    
        expect(mockPredicate1).toHaveBeenCalledWith(message);
        expect(mockPredicate2).toHaveBeenCalledWith(message);
      });
    });

    For example, the final parser can look like this:

    const parsers: Parser[] = [
      [
        parseAccessDenied,
        and(
          messageAddressedToBot$,
          not(or(userHasCommonRights$, userHasAdminRights$, userHasOwnerRights$)),
        ),
      ],
      [
        parseEnableSpecificChat,
        and(messageAddressedToBot$, messageHasOwnerRights$),
      ],
      [parseEnableThisChat, and(messageAddressedToBot$, messageHasOwnerRights$)],
      [
        parseDisableSpecificChat,
        and(messageAddressedToBot$, messageHasOwnerRights$),
      ],
      [parseDisableThisChat, and(messageAddressedToBot$, messageHasOwnerRights$)],
      [parseFetchNewsRss, and(messageAddressedToBot$, userHasCommonRights$)],
      [parseClearContext, and(messageAddressedToBot$, messageHasAdminRights$)],
      [
        parseAskGptQuestion,
        and(
          userHasCommonRights$,
          or(
            messageAddressedToBot$,
            and(messageMentionsBot$, chance(40)),
            chance(5),
          ),
        ),
      ],
    ];
    
    const parser = createMessageParser(parsers);
    parser({ text: 'botname, howdy?' });

    In conclusion, this event-ful message parser proves that a modular and efficient architecture can make all the difference when it comes to staying on top of your messaging game. By using composition, logical operations, and a healthy dose of code testing, you can create a powerful and flexible parser that’s ready to tackle any textual challenge that comes its way. So, why not give this message parsing architecture a try? You might just find that it’s the key to unlocking a whole new world of messaging possibilities. We’d love to hear about your experiences and any improvements you come up with! Happy parsing!

One is the pinnacle of evolution; another one is me

One on the picture is the pinnacle of evolution; another one is me: inspired developer, geek culture lover, sport and coffee addict