diff --git a/src/config.ts b/src/config.ts index d5005a0..e1cbe11 100644 --- a/src/config.ts +++ b/src/config.ts @@ -64,10 +64,18 @@ function escapeRegex(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } -export const TRIGGER_PATTERN = new RegExp( - `^@${escapeRegex(ASSISTANT_NAME)}\\b`, - 'i', -); +export function buildTriggerPattern(trigger: string): RegExp { + return new RegExp(`^${escapeRegex(trigger.trim())}\\b`, '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, message formatting, etc. // Validates each candidate is a real IANA identifier before accepting. diff --git a/src/formatting.test.ts b/src/formatting.test.ts index 8a2160c..a630f20 100644 --- a/src/formatting.test.ts +++ b/src/formatting.test.ts @@ -1,6 +1,10 @@ import { describe, it, expect } from 'vitest'; -import { ASSISTANT_NAME, TRIGGER_PATTERN } from './config.js'; +import { + ASSISTANT_NAME, + getTriggerPattern, + TRIGGER_PATTERN, +} from './config.js'; import { escapeXml, 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) --- describe('stripInternalTags', () => { @@ -207,7 +233,7 @@ describe('formatOutbound', () => { describe('trigger gating (requiresTrigger interaction)', () => { // 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( isMainGroup: boolean, requiresTrigger: boolean | undefined, @@ -218,39 +244,51 @@ describe('trigger gating (requiresTrigger interaction)', () => { function shouldProcess( isMainGroup: boolean, requiresTrigger: boolean | undefined, + trigger: string | undefined, messages: NewMessage[], ): boolean { 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)', () => { 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', () => { 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)', () => { 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', () => { 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', () => { 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)', () => { const msgs = [makeMsg({ content: 'hello no trigger' })]; - expect(shouldProcess(false, false, msgs)).toBe(true); + expect(shouldProcess(false, false, undefined, msgs)).toBe(true); }); }); diff --git a/src/index.ts b/src/index.ts index b3746f8..60fe910 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,12 +5,13 @@ import { OneCLI } from '@onecli-sh/sdk'; import { ASSISTANT_NAME, + DEFAULT_TRIGGER, + getTriggerPattern, GROUPS_DIR, IDLE_TIMEOUT, ONECLI_URL, POLL_INTERVAL, TIMEZONE, - TRIGGER_PATTERN, } from './config.js'; import './channels/index.js'; import { @@ -215,10 +216,11 @@ async function processGroupMessages(chatJid: string): Promise { // For non-main groups, check if trigger is required and present if (!isMainGroup && group.requiresTrigger !== false) { + const triggerPattern = getTriggerPattern(group.trigger); const allowlistCfg = loadSenderAllowlist(); const hasTrigger = missedMessages.some( (m) => - TRIGGER_PATTERN.test(m.content.trim()) && + triggerPattern.test(m.content.trim()) && (m.is_from_me || isTriggerAllowed(chatJid, m.sender, allowlistCfg)), ); if (!hasTrigger) return true; @@ -397,7 +399,7 @@ async function startMessageLoop(): Promise { } messageLoopRunning = true; - logger.info(`NanoClaw running (trigger: @${ASSISTANT_NAME})`); + logger.info(`NanoClaw running (default trigger: ${DEFAULT_TRIGGER})`); while (true) { try { @@ -443,10 +445,11 @@ async function startMessageLoop(): Promise { // Non-trigger messages accumulate in DB and get pulled as // context when a trigger eventually arrives. if (needsTrigger) { + const triggerPattern = getTriggerPattern(group.trigger); const allowlistCfg = loadSenderAllowlist(); const hasTrigger = groupMessages.some( (m) => - TRIGGER_PATTERN.test(m.content.trim()) && + triggerPattern.test(m.content.trim()) && (m.is_from_me || isTriggerAllowed(chatJid, m.sender, allowlistCfg)), );