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);
}
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
@smolpaws? no
After — same message, group marked triggerFree
triggerFree? yes
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;
}
=== 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.
4Quiz
Five questions to check you actually understood the change — not trivia. Click an answer to see whether it's right and why.