DEV Community

Cover image for Building a Rules Engine for WhatsApp Automation: Keywords, Working Hours, Priorities and Loops
WAPGROWAI for WAPGROWAI

Posted on Edited on

Building a Rules Engine for WhatsApp Automation: Keywords, Working Hours, Priorities and Loops

A rules engine for WhatsApp automation looks simple: when a message arrives, check each rule's conditions and run its action. The bugs come from the details. Keyword matching catches "priceless" when you meant "price". A working-hours check runs in the server's time zone instead of the shop's. Three rules all reply to the same message. A redelivered webhook sends the welcome message twice. Two rules that update each other's attributes loop forever. This post builds a small engine that handles each of those, with tests.

The snippets are small, self-contained versions of the patterns, and each was run against the cases shown.

The shape of a rule

Every rule is a trigger, a filter and an action:

  • Trigger: the event, such as an inbound message, a payment, or an attribute changing.
  • Filter: conditions in groups. Conditions inside a group must all hold (AND); any one group is enough (OR). An empty filter matches every event.
  • Action: one thing to do: reply, notify the team, set an attribute.

AND inside groups and OR between them covers almost everything people ask for, such as "a pricing keyword and inside working hours, or any VIP customer", without a full expression language that non-developers can't edit.

Conditions that behave

// Filter conditions: groups are ORed, conditions inside a group are ANDed.
export type Context = {
  text: string;
  isFirstChat: boolean;
  attributes: Record<string, string>;
  at: Date;
  timeZone: string;
  hours: { open: string; close: string; days: number[] }; // "09:30", "18:00", 1 = Monday
};

export type Condition =
  | { kind: "keyword"; any: string[] }
  | { kind: "first-chat" }
  | { kind: "attribute"; key: string; equals: string }
  | { kind: "working-hours"; inside: boolean };

const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");

// Whole words, any case: "price" matches "Price?" but not "priceless".
export const hasKeyword = (text: string, words: string[]) =>
  words.some((w) => new RegExp(`(^|[^\\p{L}\\p{N}])${escape(w)}($|[^\\p{L}\\p{N}])`, "iu").test(text));

// The business's local time, not the server's.
export const isWithinHours = (at: Date, timeZone: string, hours: Context["hours"]) => {
  const parts = new Intl.DateTimeFormat("en-GB", { timeZone, weekday: "short", hour: "2-digit", minute: "2-digit", hourCycle: "h23" })
    .formatToParts(at);
  const get = (type: string) => parts.find((p) => p.type === type)?.value ?? "";
  const day = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"].indexOf(get("weekday")) + 1;
  const time = `${get("hour")}:${get("minute")}`;
  return hours.days.includes(day) && time >= hours.open && time < hours.close;
};

const holds = (c: Condition, ctx: Context): boolean => {
  switch (c.kind) {
    case "keyword": return hasKeyword(ctx.text, c.any);
    case "first-chat": return ctx.isFirstChat;
    case "attribute": return (ctx.attributes[c.key] ?? "").toLowerCase() === c.equals.toLowerCase();
    case "working-hours": return isWithinHours(ctx.at, ctx.timeZone, ctx.hours) === c.inside;
  }
};

export const matches = (groups: Condition[][], ctx: Context) =>
  groups.length === 0 || groups.some((group) => group.every((c) => holds(c, ctx)));
Enter fullscreen mode Exit fullscreen mode

Two of these are easy to get wrong.

Keywords should match whole words. A plain includes("price") fires on "priceless" and "pricey". \b doesn't help for most of the world's languages, because JavaScript's word boundary only understands ASCII letters, so a Hindi keyword never matches. The version above treats any Unicode letter or digit as part of a word, so "कीमत" matches, and it escapes the keyword so "c++" doesn't break the regex.

Working hours belong to the business, not the server. A server in UTC deciding whether a shop in Mumbai is open is wrong by five and a half hours. Store a time zone with the business's hours, and convert the event's time with Intl.DateTimeFormat before comparing. The test checks a Friday morning, a Friday evening, a Sunday, and the exact opening minute.

Running the rules

import { matches, type Condition, type Context } from "./conditions.ts";

export type Action =
  | { kind: "reply"; text: string }
  | { kind: "notify"; text: string }
  | { kind: "set-attribute"; key: string; value: string };

export type Rule = { id: string; priority: number; enabled: boolean; groups: Condition[][]; action: Action };
export type Event = { messageId: string; conversationId: string; ctx: Context };

export type EngineDeps = {
  claim: (key: string) => Promise<boolean>; // atomic insert; false if already taken
  run: (rule: Rule, event: Event) => Promise<void>;
};

// One inbound message: at most one customer-facing reply, every matching
// internal action, and nothing twice if the webhook is redelivered.
export const runRules = async (rules: Rule[], event: Event, deps: EngineDeps) => {
  const fired: string[] = [];
  let replied = false;
  const ordered = rules.filter((r) => r.enabled).sort((a, b) => a.priority - b.priority);
  for (const rule of ordered) {
    if (!matches(rule.groups, event.ctx)) continue;
    if (rule.action.kind === "reply" && replied) continue;
    if (!(await deps.claim(`${rule.id}:${event.messageId}`))) continue;
    await deps.run(rule, event);
    fired.push(rule.id);
    if (rule.action.kind === "reply") replied = true;
  }
  return fired;
};

// Actions that change data can trigger more rules. Cap the depth so two
// rules that set each other's attributes can't loop forever.
export const MAX_CHAIN_DEPTH = 3;

export const runChain = async (
  start: Event,
  next: (event: Event, depth: number) => Promise<Event[]>,
  depth = 0,
): Promise<number> => {
  if (depth >= MAX_CHAIN_DEPTH) return depth;
  let deepest = depth;
  for (const follow of await next(start, depth)) {
    deepest = Math.max(deepest, await runChain(follow, next, depth + 1));
  }
  return deepest;
};
Enter fullscreen mode Exit fullscreen mode

Priority, and one reply per message. Several rules often match the same message: it's someone's first chat, it's after hours, and it mentions a price. Sending three replies in a row looks broken. The engine sorts rules by priority, lets only the first matching reply rule send, and still runs every matching internal action, such as a team notification. In the test, a first-time customer writing after hours gets the out-of-hours reply, not the welcome too, and the team alert still fires.

Claim before you act. WhatsApp webhooks can be delivered more than once, so the same inbound message can reach the engine twice. Before running a rule, claim the pair rule + message ID with an atomic insert, such as a unique index in your database. If the claim fails, that rule already ran for this message.

Cap chains. If an action can change data that other rules listen to, such as an attribute, rules can trigger rules. Two rules that set each other's attributes will loop. Either don't let rule actions trigger further rules at all, or carry a depth counter and stop at a small number. In our app we took the first option: an attribute written by a rule doesn't start other rules. It's simpler, at the cost of some chains people would like.

Leave people alone

Automation and people share the same chats. Decide what rules may do once a team member is handling a conversation. In our app, a rule that would start a chatbot skips any chat assigned to an agent, because a keyword shouldn't pull a customer out of a human conversation into a bot. Notifications and attribute updates still run, because they don't change who the customer is talking to.

Make it observable

Rules fail quietly: a keyword that never matches looks the same as a rule nobody needs. Record each run, with the rule, the event and the outcome, and show a run count next to every rule. "Executed: 0" after a week is the fastest way to find a broken filter.

Checklist

  • Model filters as OR-of-AND groups; an empty filter means always.
  • Match keywords as whole words, Unicode-aware, with the keyword escaped.
  • Evaluate working hours in the business's time zone.
  • Sort by priority and send at most one customer-facing reply per message.
  • Claim rule + message ID atomically before acting.
  • Stop rule chains, either entirely or with a depth cap.
  • Decide which actions are allowed on chats a person is handling.
  • Count runs per rule, and show the count.

WAPGROWAI is built by DI Solutions, a custom software and AI development company. This series covers what we've learned building WAPGROWAI on the WhatsApp Cloud API. Related: building a visual chatbot engine and handling webhooks in Node.js.

Top comments (0)