fix: honor per-group trigger patterns

This commit is contained in:
MrBob
2026-03-24 12:26:17 -03:00
parent b0671ef9e6
commit 0015931e37
3 changed files with 71 additions and 18 deletions

View File

@@ -4,7 +4,11 @@ import path from 'path';
import { readEnvFile } from './env.js'; import { readEnvFile } from './env.js';
// Read config values from .env (falls back to process.env). // Read config values from .env (falls back to process.env).
const envConfig = readEnvFile(['ASSISTANT_NAME', 'ASSISTANT_HAS_OWN_NUMBER', 'ONECLI_URL']); const envConfig = readEnvFile([
'ASSISTANT_NAME',
'ASSISTANT_HAS_OWN_NUMBER',
'ONECLI_URL',
]);
export const ASSISTANT_NAME = export const ASSISTANT_NAME =
process.env.ASSISTANT_NAME || envConfig.ASSISTANT_NAME || 'Andy'; process.env.ASSISTANT_NAME || envConfig.ASSISTANT_NAME || 'Andy';
@@ -58,10 +62,18 @@ function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
} }
export const TRIGGER_PATTERN = new RegExp( export function buildTriggerPattern(trigger: string): RegExp {
`^@${escapeRegex(ASSISTANT_NAME)}\\b`, return new RegExp(`^${escapeRegex(trigger.trim())}\\b`, 'i');
'i', }
);
export const DEFAULT_TRIGGER = `@${ASSISTANT_NAME}`;
export function getTriggerPattern(trigger?: string): RegExp {
const normalizedTrigger = trigger?.trim();
return buildTriggerPattern(normalizedTrigger || DEFAULT_TRIGGER);
}
export const TRIGGER_PATTERN = buildTriggerPattern(DEFAULT_TRIGGER);
// Timezone for scheduled tasks (cron expressions, etc.) // Timezone for scheduled tasks (cron expressions, etc.)
// Uses system timezone by default // Uses system timezone by default

View File

@@ -1,6 +1,10 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { ASSISTANT_NAME, TRIGGER_PATTERN } from './config.js'; import {
ASSISTANT_NAME,
getTriggerPattern,
TRIGGER_PATTERN,
} from './config.js';
import { import {
escapeXml, escapeXml,
formatMessages, formatMessages,
@@ -161,6 +165,28 @@ describe('TRIGGER_PATTERN', () => {
}); });
}); });
describe('getTriggerPattern', () => {
it('uses the configured per-group trigger when provided', () => {
const pattern = getTriggerPattern('@Claw');
expect(pattern.test('@Claw hello')).toBe(true);
expect(pattern.test(`@${ASSISTANT_NAME} hello`)).toBe(false);
});
it('falls back to the default trigger when group trigger is missing', () => {
const pattern = getTriggerPattern(undefined);
expect(pattern.test(`@${ASSISTANT_NAME} hello`)).toBe(true);
});
it('treats regex characters in custom triggers literally', () => {
const pattern = getTriggerPattern('@C.L.A.U.D.E');
expect(pattern.test('@C.L.A.U.D.E hello')).toBe(true);
expect(pattern.test('@CXLXAUXDXE hello')).toBe(false);
});
});
// --- Outbound formatting (internal tag stripping + prefix) --- // --- Outbound formatting (internal tag stripping + prefix) ---
describe('stripInternalTags', () => { describe('stripInternalTags', () => {
@@ -207,7 +233,7 @@ describe('formatOutbound', () => {
describe('trigger gating (requiresTrigger interaction)', () => { describe('trigger gating (requiresTrigger interaction)', () => {
// Replicates the exact logic from processGroupMessages and startMessageLoop: // Replicates the exact logic from processGroupMessages and startMessageLoop:
// if (!isMainGroup && group.requiresTrigger !== false) { check trigger } // if (!isMainGroup && group.requiresTrigger !== false) { check group.trigger }
function shouldRequireTrigger( function shouldRequireTrigger(
isMainGroup: boolean, isMainGroup: boolean,
requiresTrigger: boolean | undefined, requiresTrigger: boolean | undefined,
@@ -218,39 +244,51 @@ describe('trigger gating (requiresTrigger interaction)', () => {
function shouldProcess( function shouldProcess(
isMainGroup: boolean, isMainGroup: boolean,
requiresTrigger: boolean | undefined, requiresTrigger: boolean | undefined,
trigger: string | undefined,
messages: NewMessage[], messages: NewMessage[],
): boolean { ): boolean {
if (!shouldRequireTrigger(isMainGroup, requiresTrigger)) return true; if (!shouldRequireTrigger(isMainGroup, requiresTrigger)) return true;
return messages.some((m) => TRIGGER_PATTERN.test(m.content.trim())); const triggerPattern = getTriggerPattern(trigger);
return messages.some((m) => triggerPattern.test(m.content.trim()));
} }
it('main group always processes (no trigger needed)', () => { it('main group always processes (no trigger needed)', () => {
const msgs = [makeMsg({ content: 'hello no trigger' })]; const msgs = [makeMsg({ content: 'hello no trigger' })];
expect(shouldProcess(true, undefined, msgs)).toBe(true); expect(shouldProcess(true, undefined, undefined, msgs)).toBe(true);
}); });
it('main group processes even with requiresTrigger=true', () => { it('main group processes even with requiresTrigger=true', () => {
const msgs = [makeMsg({ content: 'hello no trigger' })]; const msgs = [makeMsg({ content: 'hello no trigger' })];
expect(shouldProcess(true, true, msgs)).toBe(true); expect(shouldProcess(true, true, undefined, msgs)).toBe(true);
}); });
it('non-main group with requiresTrigger=undefined requires trigger (defaults to true)', () => { it('non-main group with requiresTrigger=undefined requires trigger (defaults to true)', () => {
const msgs = [makeMsg({ content: 'hello no trigger' })]; const msgs = [makeMsg({ content: 'hello no trigger' })];
expect(shouldProcess(false, undefined, msgs)).toBe(false); expect(shouldProcess(false, undefined, undefined, msgs)).toBe(false);
}); });
it('non-main group with requiresTrigger=true requires trigger', () => { it('non-main group with requiresTrigger=true requires trigger', () => {
const msgs = [makeMsg({ content: 'hello no trigger' })]; const msgs = [makeMsg({ content: 'hello no trigger' })];
expect(shouldProcess(false, true, msgs)).toBe(false); expect(shouldProcess(false, true, undefined, msgs)).toBe(false);
}); });
it('non-main group with requiresTrigger=true processes when trigger present', () => { it('non-main group with requiresTrigger=true processes when trigger present', () => {
const msgs = [makeMsg({ content: `@${ASSISTANT_NAME} do something` })]; const msgs = [makeMsg({ content: `@${ASSISTANT_NAME} do something` })];
expect(shouldProcess(false, true, msgs)).toBe(true); expect(shouldProcess(false, true, undefined, msgs)).toBe(true);
});
it('non-main group uses its per-group trigger instead of the default trigger', () => {
const msgs = [makeMsg({ content: '@Claw do something' })];
expect(shouldProcess(false, true, '@Claw', msgs)).toBe(true);
});
it('non-main group does not process when only the default trigger is present for a custom-trigger group', () => {
const msgs = [makeMsg({ content: `@${ASSISTANT_NAME} do something` })];
expect(shouldProcess(false, true, '@Claw', msgs)).toBe(false);
}); });
it('non-main group with requiresTrigger=false always processes (no trigger needed)', () => { it('non-main group with requiresTrigger=false always processes (no trigger needed)', () => {
const msgs = [makeMsg({ content: 'hello no trigger' })]; const msgs = [makeMsg({ content: 'hello no trigger' })];
expect(shouldProcess(false, false, msgs)).toBe(true); expect(shouldProcess(false, false, undefined, msgs)).toBe(true);
}); });
}); });

View File

@@ -5,11 +5,12 @@ import { OneCLI } from '@onecli-sh/sdk';
import { import {
ASSISTANT_NAME, ASSISTANT_NAME,
DEFAULT_TRIGGER,
getTriggerPattern,
IDLE_TIMEOUT, IDLE_TIMEOUT,
ONECLI_URL, ONECLI_URL,
POLL_INTERVAL, POLL_INTERVAL,
TIMEZONE, TIMEZONE,
TRIGGER_PATTERN,
} from './config.js'; } from './config.js';
import './channels/index.js'; import './channels/index.js';
import { import {
@@ -194,10 +195,11 @@ async function processGroupMessages(chatJid: string): Promise<boolean> {
// For non-main groups, check if trigger is required and present // For non-main groups, check if trigger is required and present
if (!isMainGroup && group.requiresTrigger !== false) { if (!isMainGroup && group.requiresTrigger !== false) {
const triggerPattern = getTriggerPattern(group.trigger);
const allowlistCfg = loadSenderAllowlist(); const allowlistCfg = loadSenderAllowlist();
const hasTrigger = missedMessages.some( const hasTrigger = missedMessages.some(
(m) => (m) =>
TRIGGER_PATTERN.test(m.content.trim()) && triggerPattern.test(m.content.trim()) &&
(m.is_from_me || isTriggerAllowed(chatJid, m.sender, allowlistCfg)), (m.is_from_me || isTriggerAllowed(chatJid, m.sender, allowlistCfg)),
); );
if (!hasTrigger) return true; if (!hasTrigger) return true;
@@ -376,7 +378,7 @@ async function startMessageLoop(): Promise<void> {
} }
messageLoopRunning = true; messageLoopRunning = true;
logger.info(`NanoClaw running (trigger: @${ASSISTANT_NAME})`); logger.info(`NanoClaw running (default trigger: ${DEFAULT_TRIGGER})`);
while (true) { while (true) {
try { try {
@@ -422,10 +424,11 @@ async function startMessageLoop(): Promise<void> {
// Non-trigger messages accumulate in DB and get pulled as // Non-trigger messages accumulate in DB and get pulled as
// context when a trigger eventually arrives. // context when a trigger eventually arrives.
if (needsTrigger) { if (needsTrigger) {
const triggerPattern = getTriggerPattern(group.trigger);
const allowlistCfg = loadSenderAllowlist(); const allowlistCfg = loadSenderAllowlist();
const hasTrigger = groupMessages.some( const hasTrigger = groupMessages.some(
(m) => (m) =>
TRIGGER_PATTERN.test(m.content.trim()) && triggerPattern.test(m.content.trim()) &&
(m.is_from_me || (m.is_from_me ||
isTriggerAllowed(chatJid, m.sender, allowlistCfg)), isTriggerAllowed(chatJid, m.sender, allowlistCfg)),
); );