teach-me · smolpaws · commit 68de7f6

Letting a group talk to the cat without an @mention

A four-line feature with an outsized effect on how a room feels. We'll build up the background, get the intuition with a worked example, walk the actual diff, and then you'll quiz yourself.

1Background

Deep background — how smolpaws decides to answer

Skip this if you already know how the WhatsApp message gate works.

smolpaws lives in group chats. A naive bot would reply to every message, which is intolerable in a busy room — imagine a cat that meows at every word you say. So smolpaws stays quiet unless a message is clearly meant for it. The usual signal is an @mention: you write @smolpaws and it wakes up.

Concretely, every incoming message runs through a single gate in processMessage. Two facts decide whether the cat bothers to think:

  • Is this a room where the cat is allowed to respond to anything (no mention needed)?
  • Or, failing that, does the message text actually contain the trigger @smolpaws?

The trigger itself is a regex, TRIGGER_PATTERN, built from the assistant's name (config.ts:28). If neither fact is true, the function simply returns and the message is ignored.

Narrow background — the "control scope"

Before this change, exactly one kind of room was allowed to skip the mention: the control scope — the "main" channel, smolpaws' private command room. The helper shouldRespondWithoutTrigger was a thin wrapper that answered a single question: is this the main channel?

// src/control-scope.ts — before
export function shouldRespondWithoutTrigger(scopeFolder: string): boolean {
  return isControlScope(scopeFolder);
}
ⓘ A scope is smolpaws' word for an isolated room + its state. The control scope (folder main) is the privileged one. Every other group is a normal scope that, until now, always needed an @mention.

2Intuition

The feature request is simple to state: let some trusted rooms treat the cat as a full participant — no @mention, just talk — exactly the way the main channel already works. The Hunting group (where Engel and the cat hunt bugs together) should feel like a conversation, not a command line.

The insight is that "responds without a trigger" was never really about being the control scope. That was just the only reason we had so far. Being trigger-free is a property a room can have, and the control scope is one thing that happens to have it. So instead of asking only "are you main?", the gate should ask "are you main, or have you been marked trigger-free?"

Here's the same message flowing through the gate, before and after, with real example data:

Before — a plain message in the Hunting group

message
"is the build green?"
group: hunting
gate
main? no
@smolpaws? no
outcome
ignored 😾

After — same message, group marked triggerFree

message
"is the build green?"
group: hunting
gate
main? no
triggerFree? yes
outcome
cat responds 🐾
★ The change doesn't add a new gate. It widens the one condition that already let the main channel skip the trigger, so any room can opt into the same behaviour.

3The code

Four files change, but they tell one story: add a flag, thread it into the one decision that reads it. Follow the flag from where it's stored to where it's used.

a. The flag exists — RegisteredGroup gains a field

A group's config gets an optional boolean. Optional means every existing group keeps working untouched — absent is the same as false. types.ts:41

export interface RegisteredGroup {
  folder: string;
  trigger: string;
  added_at: string;
+ triggerFree?: boolean;
  containerConfig?: ContainerConfig;
}

b. The decision widens — shouldRespondWithoutTrigger

The helper now takes the flag and ORs it in. This is the heart of the change. control-scope.ts:8

- export function shouldRespondWithoutTrigger(scopeFolder: string): boolean {
-   return isControlScope(scopeFolder);
+ export function shouldRespondWithoutTrigger(scopeFolder: string, triggerFree?: boolean): boolean {
+   return isControlScope(scopeFolder) || triggerFree === true;
}
ⓘ Note the explicit === true. Since triggerFree is boolean | undefined, this coerces "not set" cleanly to "not trigger-free" rather than leaking undefined into the boolean result.

c. The caller passes the flag — the gate in processMessage

The one call site now forwards the group's flag. The TRIGGER_PATTERN half is unchanged. index.ts:225

  // control scope AND trigger-free groups respond to ambient messages;
  // all others require an @mention.
- if (!shouldRespondWithoutTrigger(group.folder) && !TRIGGER_PATTERN.test(content)) return;
+ if (!shouldRespondWithoutTrigger(group.folder, group.triggerFree) && !TRIGGER_PATTERN.test(content)) return;

d. Someone opts in — registered_groups.json

Finally, the Hunting group's config sets "triggerFree": true. This is the only place the feature is actually switched on; every other group's behaviour is byte-for-byte identical to before.

★ Read top to bottom, the change is a clean data-flow: store the flag (types + json) → read it in the decision (control-scope) → thread it from the caller (index). No new control flow, no new gate.

4Quiz

Five questions to check you actually understood the change — not trivia. Click an answer to see whether it's right and why.