@rozek/nanoclaw 1.2.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.json +1 -0
- package/.claude/skills/add-compact/SKILL.md +135 -0
- package/.claude/skills/add-discord/SKILL.md +203 -0
- package/.claude/skills/add-gmail/SKILL.md +220 -0
- package/.claude/skills/add-image-vision/SKILL.md +94 -0
- package/.claude/skills/add-ollama-tool/SKILL.md +153 -0
- package/.claude/skills/add-parallel/SKILL.md +290 -0
- package/.claude/skills/add-pdf-reader/SKILL.md +104 -0
- package/.claude/skills/add-reactions/SKILL.md +117 -0
- package/.claude/skills/add-slack/SKILL.md +207 -0
- package/.claude/skills/add-telegram/SKILL.md +222 -0
- package/.claude/skills/add-telegram-swarm/SKILL.md +384 -0
- package/.claude/skills/add-voice-transcription/SKILL.md +148 -0
- package/.claude/skills/add-whatsapp/SKILL.md +372 -0
- package/.claude/skills/convert-to-apple-container/SKILL.md +175 -0
- package/.claude/skills/customize/SKILL.md +110 -0
- package/.claude/skills/debug/SKILL.md +349 -0
- package/.claude/skills/get-qodo-rules/SKILL.md +122 -0
- package/.claude/skills/get-qodo-rules/references/output-format.md +41 -0
- package/.claude/skills/get-qodo-rules/references/pagination.md +33 -0
- package/.claude/skills/get-qodo-rules/references/repository-scope.md +26 -0
- package/.claude/skills/qodo-pr-resolver/SKILL.md +326 -0
- package/.claude/skills/qodo-pr-resolver/resources/providers.md +329 -0
- package/.claude/skills/setup/SKILL.md +218 -0
- package/.claude/skills/update-nanoclaw/SKILL.md +235 -0
- package/.claude/skills/update-skills/SKILL.md +130 -0
- package/.claude/skills/use-local-whisper/SKILL.md +152 -0
- package/.claude/skills/x-integration/SKILL.md +417 -0
- package/.claude/skills/x-integration/agent.ts +243 -0
- package/.claude/skills/x-integration/host.ts +159 -0
- package/.claude/skills/x-integration/lib/browser.ts +148 -0
- package/.claude/skills/x-integration/lib/config.ts +62 -0
- package/.claude/skills/x-integration/scripts/like.ts +56 -0
- package/.claude/skills/x-integration/scripts/post.ts +66 -0
- package/.claude/skills/x-integration/scripts/quote.ts +80 -0
- package/.claude/skills/x-integration/scripts/reply.ts +74 -0
- package/.claude/skills/x-integration/scripts/retweet.ts +62 -0
- package/.claude/skills/x-integration/scripts/setup.ts +87 -0
- package/.env.example +1 -0
- package/.github/CODEOWNERS +10 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +14 -0
- package/.github/workflows/bump-version.yml +32 -0
- package/.github/workflows/ci.yml +25 -0
- package/.github/workflows/merge-forward-skills.yml +160 -0
- package/.github/workflows/update-tokens.yml +42 -0
- package/.husky/pre-commit +1 -0
- package/.mcp.json +3 -0
- package/.nvmrc +1 -0
- package/.prettierrc +3 -0
- package/CHANGELOG.md +8 -0
- package/CLAUDE.md +64 -0
- package/CONTRIBUTING.md +23 -0
- package/CONTRIBUTORS.md +15 -0
- package/LICENSE +21 -0
- package/NanoClaw_with_Web-Support.md +290 -0
- package/README.md +261 -0
- package/README_zh.md +200 -0
- package/assets/nanoclaw-favicon.png +0 -0
- package/assets/nanoclaw-icon.png +0 -0
- package/assets/nanoclaw-logo-dark.png +0 -0
- package/assets/nanoclaw-logo.png +0 -0
- package/assets/nanoclaw-profile.jpeg +0 -0
- package/assets/nanoclaw-sales.png +0 -0
- package/assets/social-preview.jpg +0 -0
- package/config-examples/mount-allowlist.json +25 -0
- package/container/Dockerfile +70 -0
- package/container/agent-runner/package-lock.json +1524 -0
- package/container/agent-runner/package.json +21 -0
- package/container/agent-runner/src/index.ts +558 -0
- package/container/agent-runner/src/ipc-mcp-stdio.ts +338 -0
- package/container/agent-runner/tsconfig.json +15 -0
- package/container/build.sh +23 -0
- package/container/skills/agent-browser/SKILL.md +159 -0
- package/container/skills/capabilities/SKILL.md +100 -0
- package/container/skills/status/SKILL.md +104 -0
- package/dist/channels/index.d.ts +2 -0
- package/dist/channels/index.d.ts.map +1 -0
- package/dist/channels/index.js +9 -0
- package/dist/channels/index.js.map +1 -0
- package/dist/channels/registry.d.ts +13 -0
- package/dist/channels/registry.d.ts.map +1 -0
- package/dist/channels/registry.js +11 -0
- package/dist/channels/registry.js.map +1 -0
- package/dist/channels/registry.test.d.ts +2 -0
- package/dist/channels/registry.test.d.ts.map +1 -0
- package/dist/channels/registry.test.js +32 -0
- package/dist/channels/registry.test.js.map +1 -0
- package/dist/channels/web.d.ts +2 -0
- package/dist/channels/web.d.ts.map +1 -0
- package/dist/channels/web.js +1738 -0
- package/dist/channels/web.js.map +1 -0
- package/dist/cli.d.ts +11 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +182 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +19 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +36 -0
- package/dist/config.js.map +1 -0
- package/dist/container-runner.d.ts +44 -0
- package/dist/container-runner.d.ts.map +1 -0
- package/dist/container-runner.js +467 -0
- package/dist/container-runner.js.map +1 -0
- package/dist/container-runner.test.d.ts +2 -0
- package/dist/container-runner.test.d.ts.map +1 -0
- package/dist/container-runner.test.js +150 -0
- package/dist/container-runner.test.js.map +1 -0
- package/dist/container-runtime.d.ts +22 -0
- package/dist/container-runtime.d.ts.map +1 -0
- package/dist/container-runtime.js +96 -0
- package/dist/container-runtime.js.map +1 -0
- package/dist/container-runtime.test.d.ts +2 -0
- package/dist/container-runtime.test.d.ts.map +1 -0
- package/dist/container-runtime.test.js +93 -0
- package/dist/container-runtime.test.js.map +1 -0
- package/dist/credential-proxy.d.ts +21 -0
- package/dist/credential-proxy.d.ts.map +1 -0
- package/dist/credential-proxy.js +95 -0
- package/dist/credential-proxy.js.map +1 -0
- package/dist/credential-proxy.test.d.ts +2 -0
- package/dist/credential-proxy.test.d.ts.map +1 -0
- package/dist/credential-proxy.test.js +134 -0
- package/dist/credential-proxy.test.js.map +1 -0
- package/dist/db.d.ts +115 -0
- package/dist/db.d.ts.map +1 -0
- package/dist/db.js +549 -0
- package/dist/db.js.map +1 -0
- package/dist/db.test.d.ts +2 -0
- package/dist/db.test.d.ts.map +1 -0
- package/dist/db.test.js +360 -0
- package/dist/db.test.js.map +1 -0
- package/dist/env.d.ts +8 -0
- package/dist/env.d.ts.map +1 -0
- package/dist/env.js +42 -0
- package/dist/env.js.map +1 -0
- package/dist/formatting.test.d.ts +2 -0
- package/dist/formatting.test.d.ts.map +1 -0
- package/dist/formatting.test.js +183 -0
- package/dist/formatting.test.js.map +1 -0
- package/dist/group-folder.d.ts +5 -0
- package/dist/group-folder.d.ts.map +1 -0
- package/dist/group-folder.js +44 -0
- package/dist/group-folder.js.map +1 -0
- package/dist/group-folder.test.d.ts +2 -0
- package/dist/group-folder.test.d.ts.map +1 -0
- package/dist/group-folder.test.js +29 -0
- package/dist/group-folder.test.js.map +1 -0
- package/dist/group-queue.d.ts +34 -0
- package/dist/group-queue.d.ts.map +1 -0
- package/dist/group-queue.js +263 -0
- package/dist/group-queue.js.map +1 -0
- package/dist/group-queue.test.d.ts +2 -0
- package/dist/group-queue.test.d.ts.map +1 -0
- package/dist/group-queue.test.js +341 -0
- package/dist/group-queue.test.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +518 -0
- package/dist/index.js.map +1 -0
- package/dist/ipc-auth.test.d.ts +2 -0
- package/dist/ipc-auth.test.d.ts.map +1 -0
- package/dist/ipc-auth.test.js +434 -0
- package/dist/ipc-auth.test.js.map +1 -0
- package/dist/ipc.d.ts +32 -0
- package/dist/ipc.d.ts.map +1 -0
- package/dist/ipc.js +311 -0
- package/dist/ipc.js.map +1 -0
- package/dist/logger.d.ts +3 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +14 -0
- package/dist/logger.js.map +1 -0
- package/dist/mount-security.d.ts +34 -0
- package/dist/mount-security.d.ts.map +1 -0
- package/dist/mount-security.js +325 -0
- package/dist/mount-security.js.map +1 -0
- package/dist/remote-control.d.ts +32 -0
- package/dist/remote-control.d.ts.map +1 -0
- package/dist/remote-control.js +185 -0
- package/dist/remote-control.js.map +1 -0
- package/dist/remote-control.test.d.ts +2 -0
- package/dist/remote-control.test.d.ts.map +1 -0
- package/dist/remote-control.test.js +321 -0
- package/dist/remote-control.test.js.map +1 -0
- package/dist/router.d.ts +8 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +37 -0
- package/dist/router.js.map +1 -0
- package/dist/routing.test.d.ts +2 -0
- package/dist/routing.test.d.ts.map +1 -0
- package/dist/routing.test.js +81 -0
- package/dist/routing.test.js.map +1 -0
- package/dist/sender-allowlist.d.ts +14 -0
- package/dist/sender-allowlist.d.ts.map +1 -0
- package/dist/sender-allowlist.js +79 -0
- package/dist/sender-allowlist.js.map +1 -0
- package/dist/sender-allowlist.test.d.ts +2 -0
- package/dist/sender-allowlist.test.d.ts.map +1 -0
- package/dist/sender-allowlist.test.js +186 -0
- package/dist/sender-allowlist.test.js.map +1 -0
- package/dist/session-commands.d.ts +47 -0
- package/dist/session-commands.d.ts.map +1 -0
- package/dist/session-commands.js +102 -0
- package/dist/session-commands.js.map +1 -0
- package/dist/session-commands.test.d.ts +2 -0
- package/dist/session-commands.test.d.ts.map +1 -0
- package/dist/session-commands.test.js +190 -0
- package/dist/session-commands.test.js.map +1 -0
- package/dist/task-scheduler.d.ts +22 -0
- package/dist/task-scheduler.d.ts.map +1 -0
- package/dist/task-scheduler.js +210 -0
- package/dist/task-scheduler.js.map +1 -0
- package/dist/task-scheduler.test.d.ts +2 -0
- package/dist/task-scheduler.test.d.ts.map +1 -0
- package/dist/task-scheduler.test.js +107 -0
- package/dist/task-scheduler.test.js.map +1 -0
- package/dist/timezone.d.ts +6 -0
- package/dist/timezone.d.ts.map +1 -0
- package/dist/timezone.js +17 -0
- package/dist/timezone.js.map +1 -0
- package/dist/timezone.test.d.ts +2 -0
- package/dist/timezone.test.d.ts.map +1 -0
- package/dist/timezone.test.js +23 -0
- package/dist/timezone.test.js.map +1 -0
- package/dist/types.d.ts +78 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/docs/APPLE-CONTAINER-NETWORKING.md +90 -0
- package/docs/DEBUG_CHECKLIST.md +143 -0
- package/docs/REQUIREMENTS.md +196 -0
- package/docs/SDK_DEEP_DIVE.md +643 -0
- package/docs/SECURITY.md +122 -0
- package/docs/SPEC.md +785 -0
- package/docs/docker-sandboxes.md +359 -0
- package/docs/nanoclaw-architecture-final.md +1063 -0
- package/docs/nanorepo-architecture.md +168 -0
- package/docs/skills-as-branches.md +662 -0
- package/groups/global/CLAUDE.md +58 -0
- package/groups/main/CLAUDE.md +246 -0
- package/launchd/com.nanoclaw.plist +32 -0
- package/package.json +45 -0
- package/repo-tokens/README.md +113 -0
- package/repo-tokens/action.yml +186 -0
- package/repo-tokens/badge.svg +23 -0
- package/repo-tokens/examples/green.svg +14 -0
- package/repo-tokens/examples/red.svg +14 -0
- package/repo-tokens/examples/yellow-green.svg +14 -0
- package/repo-tokens/examples/yellow.svg +14 -0
- package/scripts/run-migrations.ts +105 -0
- package/setup/container.ts +144 -0
- package/setup/environment.test.ts +121 -0
- package/setup/environment.ts +94 -0
- package/setup/groups.ts +229 -0
- package/setup/index.ts +58 -0
- package/setup/mounts.ts +115 -0
- package/setup/platform.test.ts +120 -0
- package/setup/platform.ts +132 -0
- package/setup/register.test.ts +257 -0
- package/setup/register.ts +177 -0
- package/setup/service.test.ts +187 -0
- package/setup/service.ts +362 -0
- package/setup/status.ts +16 -0
- package/setup/verify.ts +192 -0
- package/setup.sh +161 -0
- package/src/channels/index.ts +12 -0
- package/src/channels/registry.test.ts +42 -0
- package/src/channels/registry.ts +32 -0
- package/src/channels/web.ts +1856 -0
- package/src/cli.ts +209 -0
- package/src/config.ts +73 -0
- package/src/container-runner.test.ts +210 -0
- package/src/container-runner.ts +707 -0
- package/src/container-runtime.test.ts +149 -0
- package/src/container-runtime.ts +127 -0
- package/src/credential-proxy.test.ts +192 -0
- package/src/credential-proxy.ts +125 -0
- package/src/db.test.ts +484 -0
- package/src/db.ts +803 -0
- package/src/env.ts +42 -0
- package/src/formatting.test.ts +256 -0
- package/src/group-folder.test.ts +43 -0
- package/src/group-folder.ts +44 -0
- package/src/group-queue.test.ts +484 -0
- package/src/group-queue.ts +365 -0
- package/src/index.ts +731 -0
- package/src/ipc-auth.test.ts +679 -0
- package/src/ipc.ts +461 -0
- package/src/logger.ts +16 -0
- package/src/mount-security.ts +419 -0
- package/src/remote-control.test.ts +397 -0
- package/src/remote-control.ts +224 -0
- package/src/router.ts +52 -0
- package/src/routing.test.ts +170 -0
- package/src/sender-allowlist.test.ts +216 -0
- package/src/sender-allowlist.ts +128 -0
- package/src/session-commands.test.ts +247 -0
- package/src/session-commands.ts +163 -0
- package/src/task-scheduler.test.ts +129 -0
- package/src/task-scheduler.ts +295 -0
- package/src/timezone.test.ts +29 -0
- package/src/timezone.ts +16 -0
- package/src/types.ts +107 -0
- package/tsconfig.json +20 -0
- package/vitest.config.ts +7 -0
- package/vitest.skills.config.ts +7 -0
package/src/env.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { logger } from './logger.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Parse the .env file and return values for the requested keys.
|
|
7
|
+
* Does NOT load anything into process.env — callers decide what to
|
|
8
|
+
* do with the values. This keeps secrets out of the process environment
|
|
9
|
+
* so they don't leak to child processes.
|
|
10
|
+
*/
|
|
11
|
+
export function readEnvFile(keys: string[]): Record<string, string> {
|
|
12
|
+
const envFile = path.join(process.cwd(), '.env');
|
|
13
|
+
let content: string;
|
|
14
|
+
try {
|
|
15
|
+
content = fs.readFileSync(envFile, 'utf-8');
|
|
16
|
+
} catch (err) {
|
|
17
|
+
logger.debug({ err }, '.env file not found, using defaults');
|
|
18
|
+
return {};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const result: Record<string, string> = {};
|
|
22
|
+
const wanted = new Set(keys);
|
|
23
|
+
|
|
24
|
+
for (const line of content.split('\n')) {
|
|
25
|
+
const trimmed = line.trim();
|
|
26
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
27
|
+
const eqIdx = trimmed.indexOf('=');
|
|
28
|
+
if (eqIdx === -1) continue;
|
|
29
|
+
const key = trimmed.slice(0, eqIdx).trim();
|
|
30
|
+
if (!wanted.has(key)) continue;
|
|
31
|
+
let value = trimmed.slice(eqIdx + 1).trim();
|
|
32
|
+
if (
|
|
33
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
34
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
35
|
+
) {
|
|
36
|
+
value = value.slice(1, -1);
|
|
37
|
+
}
|
|
38
|
+
if (value) result[key] = value;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { ASSISTANT_NAME, TRIGGER_PATTERN } from './config.js';
|
|
4
|
+
import {
|
|
5
|
+
escapeXml,
|
|
6
|
+
formatMessages,
|
|
7
|
+
formatOutbound,
|
|
8
|
+
stripInternalTags,
|
|
9
|
+
} from './router.js';
|
|
10
|
+
import { NewMessage } from './types.js';
|
|
11
|
+
|
|
12
|
+
function makeMsg(overrides: Partial<NewMessage> = {}): NewMessage {
|
|
13
|
+
return {
|
|
14
|
+
id: '1',
|
|
15
|
+
chat_jid: 'group@g.us',
|
|
16
|
+
sender: '123@s.whatsapp.net',
|
|
17
|
+
sender_name: 'Alice',
|
|
18
|
+
content: 'hello',
|
|
19
|
+
timestamp: '2024-01-01T00:00:00.000Z',
|
|
20
|
+
...overrides,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// --- escapeXml ---
|
|
25
|
+
|
|
26
|
+
describe('escapeXml', () => {
|
|
27
|
+
it('escapes ampersands', () => {
|
|
28
|
+
expect(escapeXml('a & b')).toBe('a & b');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('escapes less-than', () => {
|
|
32
|
+
expect(escapeXml('a < b')).toBe('a < b');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('escapes greater-than', () => {
|
|
36
|
+
expect(escapeXml('a > b')).toBe('a > b');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('escapes double quotes', () => {
|
|
40
|
+
expect(escapeXml('"hello"')).toBe('"hello"');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('handles multiple special characters together', () => {
|
|
44
|
+
expect(escapeXml('a & b < c > d "e"')).toBe(
|
|
45
|
+
'a & b < c > d "e"',
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('passes through strings with no special chars', () => {
|
|
50
|
+
expect(escapeXml('hello world')).toBe('hello world');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('handles empty string', () => {
|
|
54
|
+
expect(escapeXml('')).toBe('');
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// --- formatMessages ---
|
|
59
|
+
|
|
60
|
+
describe('formatMessages', () => {
|
|
61
|
+
const TZ = 'UTC';
|
|
62
|
+
|
|
63
|
+
it('formats a single message as XML with context header', () => {
|
|
64
|
+
const result = formatMessages([makeMsg()], TZ);
|
|
65
|
+
expect(result).toContain('<context timezone="UTC" />');
|
|
66
|
+
expect(result).toContain('<message sender="Alice"');
|
|
67
|
+
expect(result).toContain('>hello</message>');
|
|
68
|
+
expect(result).toContain('Jan 1, 2024');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('formats multiple messages', () => {
|
|
72
|
+
const msgs = [
|
|
73
|
+
makeMsg({
|
|
74
|
+
id: '1',
|
|
75
|
+
sender_name: 'Alice',
|
|
76
|
+
content: 'hi',
|
|
77
|
+
timestamp: '2024-01-01T00:00:00.000Z',
|
|
78
|
+
}),
|
|
79
|
+
makeMsg({
|
|
80
|
+
id: '2',
|
|
81
|
+
sender_name: 'Bob',
|
|
82
|
+
content: 'hey',
|
|
83
|
+
timestamp: '2024-01-01T01:00:00.000Z',
|
|
84
|
+
}),
|
|
85
|
+
];
|
|
86
|
+
const result = formatMessages(msgs, TZ);
|
|
87
|
+
expect(result).toContain('sender="Alice"');
|
|
88
|
+
expect(result).toContain('sender="Bob"');
|
|
89
|
+
expect(result).toContain('>hi</message>');
|
|
90
|
+
expect(result).toContain('>hey</message>');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('escapes special characters in sender names', () => {
|
|
94
|
+
const result = formatMessages([makeMsg({ sender_name: 'A & B <Co>' })], TZ);
|
|
95
|
+
expect(result).toContain('sender="A & B <Co>"');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('escapes special characters in content', () => {
|
|
99
|
+
const result = formatMessages(
|
|
100
|
+
[makeMsg({ content: '<script>alert("xss")</script>' })],
|
|
101
|
+
TZ,
|
|
102
|
+
);
|
|
103
|
+
expect(result).toContain(
|
|
104
|
+
'<script>alert("xss")</script>',
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('handles empty array', () => {
|
|
109
|
+
const result = formatMessages([], TZ);
|
|
110
|
+
expect(result).toContain('<context timezone="UTC" />');
|
|
111
|
+
expect(result).toContain('<messages>\n\n</messages>');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('converts timestamps to local time for given timezone', () => {
|
|
115
|
+
// 2024-01-01T18:30:00Z in America/New_York (EST) = 1:30 PM
|
|
116
|
+
const result = formatMessages(
|
|
117
|
+
[makeMsg({ timestamp: '2024-01-01T18:30:00.000Z' })],
|
|
118
|
+
'America/New_York',
|
|
119
|
+
);
|
|
120
|
+
expect(result).toContain('1:30');
|
|
121
|
+
expect(result).toContain('PM');
|
|
122
|
+
expect(result).toContain('<context timezone="America/New_York" />');
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// --- TRIGGER_PATTERN ---
|
|
127
|
+
|
|
128
|
+
describe('TRIGGER_PATTERN', () => {
|
|
129
|
+
const name = ASSISTANT_NAME;
|
|
130
|
+
const lower = name.toLowerCase();
|
|
131
|
+
const upper = name.toUpperCase();
|
|
132
|
+
|
|
133
|
+
it('matches @name at start of message', () => {
|
|
134
|
+
expect(TRIGGER_PATTERN.test(`@${name} hello`)).toBe(true);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('matches case-insensitively', () => {
|
|
138
|
+
expect(TRIGGER_PATTERN.test(`@${lower} hello`)).toBe(true);
|
|
139
|
+
expect(TRIGGER_PATTERN.test(`@${upper} hello`)).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('does not match when not at start of message', () => {
|
|
143
|
+
expect(TRIGGER_PATTERN.test(`hello @${name}`)).toBe(false);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('does not match partial name like @NameExtra (word boundary)', () => {
|
|
147
|
+
expect(TRIGGER_PATTERN.test(`@${name}extra hello`)).toBe(false);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('matches with word boundary before apostrophe', () => {
|
|
151
|
+
expect(TRIGGER_PATTERN.test(`@${name}'s thing`)).toBe(true);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('matches @name alone (end of string is a word boundary)', () => {
|
|
155
|
+
expect(TRIGGER_PATTERN.test(`@${name}`)).toBe(true);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('matches with leading whitespace after trim', () => {
|
|
159
|
+
// The actual usage trims before testing: TRIGGER_PATTERN.test(m.content.trim())
|
|
160
|
+
expect(TRIGGER_PATTERN.test(`@${name} hey`.trim())).toBe(true);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// --- Outbound formatting (internal tag stripping + prefix) ---
|
|
165
|
+
|
|
166
|
+
describe('stripInternalTags', () => {
|
|
167
|
+
it('strips single-line internal tags', () => {
|
|
168
|
+
expect(stripInternalTags('hello <internal>secret</internal> world')).toBe(
|
|
169
|
+
'hello world',
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('strips multi-line internal tags', () => {
|
|
174
|
+
expect(
|
|
175
|
+
stripInternalTags('hello <internal>\nsecret\nstuff\n</internal> world'),
|
|
176
|
+
).toBe('hello world');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('strips multiple internal tag blocks', () => {
|
|
180
|
+
expect(
|
|
181
|
+
stripInternalTags('<internal>a</internal>hello<internal>b</internal>'),
|
|
182
|
+
).toBe('hello');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('returns empty string when text is only internal tags', () => {
|
|
186
|
+
expect(stripInternalTags('<internal>only this</internal>')).toBe('');
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe('formatOutbound', () => {
|
|
191
|
+
it('returns text with internal tags stripped', () => {
|
|
192
|
+
expect(formatOutbound('hello world')).toBe('hello world');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('returns empty string when all text is internal', () => {
|
|
196
|
+
expect(formatOutbound('<internal>hidden</internal>')).toBe('');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('strips internal tags from remaining text', () => {
|
|
200
|
+
expect(
|
|
201
|
+
formatOutbound('<internal>thinking</internal>The answer is 42'),
|
|
202
|
+
).toBe('The answer is 42');
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// --- Trigger gating with requiresTrigger flag ---
|
|
207
|
+
|
|
208
|
+
describe('trigger gating (requiresTrigger interaction)', () => {
|
|
209
|
+
// Replicates the exact logic from processGroupMessages and startMessageLoop:
|
|
210
|
+
// if (!isMainGroup && group.requiresTrigger !== false) { check trigger }
|
|
211
|
+
function shouldRequireTrigger(
|
|
212
|
+
isMainGroup: boolean,
|
|
213
|
+
requiresTrigger: boolean | undefined,
|
|
214
|
+
): boolean {
|
|
215
|
+
return !isMainGroup && requiresTrigger !== false;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function shouldProcess(
|
|
219
|
+
isMainGroup: boolean,
|
|
220
|
+
requiresTrigger: boolean | undefined,
|
|
221
|
+
messages: NewMessage[],
|
|
222
|
+
): boolean {
|
|
223
|
+
if (!shouldRequireTrigger(isMainGroup, requiresTrigger)) return true;
|
|
224
|
+
return messages.some((m) => TRIGGER_PATTERN.test(m.content.trim()));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
it('main group always processes (no trigger needed)', () => {
|
|
228
|
+
const msgs = [makeMsg({ content: 'hello no trigger' })];
|
|
229
|
+
expect(shouldProcess(true, undefined, msgs)).toBe(true);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('main group processes even with requiresTrigger=true', () => {
|
|
233
|
+
const msgs = [makeMsg({ content: 'hello no trigger' })];
|
|
234
|
+
expect(shouldProcess(true, true, msgs)).toBe(true);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('non-main group with requiresTrigger=undefined requires trigger (defaults to true)', () => {
|
|
238
|
+
const msgs = [makeMsg({ content: 'hello no trigger' })];
|
|
239
|
+
expect(shouldProcess(false, undefined, msgs)).toBe(false);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('non-main group with requiresTrigger=true requires trigger', () => {
|
|
243
|
+
const msgs = [makeMsg({ content: 'hello no trigger' })];
|
|
244
|
+
expect(shouldProcess(false, true, msgs)).toBe(false);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('non-main group with requiresTrigger=true processes when trigger present', () => {
|
|
248
|
+
const msgs = [makeMsg({ content: `@${ASSISTANT_NAME} do something` })];
|
|
249
|
+
expect(shouldProcess(false, true, msgs)).toBe(true);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('non-main group with requiresTrigger=false always processes (no trigger needed)', () => {
|
|
253
|
+
const msgs = [makeMsg({ content: 'hello no trigger' })];
|
|
254
|
+
expect(shouldProcess(false, false, msgs)).toBe(true);
|
|
255
|
+
});
|
|
256
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
isValidGroupFolder,
|
|
7
|
+
resolveGroupFolderPath,
|
|
8
|
+
resolveGroupIpcPath,
|
|
9
|
+
} from './group-folder.js';
|
|
10
|
+
|
|
11
|
+
describe('group folder validation', () => {
|
|
12
|
+
it('accepts normal group folder names', () => {
|
|
13
|
+
expect(isValidGroupFolder('main')).toBe(true);
|
|
14
|
+
expect(isValidGroupFolder('family-chat')).toBe(true);
|
|
15
|
+
expect(isValidGroupFolder('Team_42')).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('rejects traversal and reserved names', () => {
|
|
19
|
+
expect(isValidGroupFolder('../../etc')).toBe(false);
|
|
20
|
+
expect(isValidGroupFolder('/tmp')).toBe(false);
|
|
21
|
+
expect(isValidGroupFolder('global')).toBe(false);
|
|
22
|
+
expect(isValidGroupFolder('')).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('resolves safe paths under groups directory', () => {
|
|
26
|
+
const resolved = resolveGroupFolderPath('family-chat');
|
|
27
|
+
expect(resolved.endsWith(`${path.sep}groups${path.sep}family-chat`)).toBe(
|
|
28
|
+
true,
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('resolves safe paths under data ipc directory', () => {
|
|
33
|
+
const resolved = resolveGroupIpcPath('family-chat');
|
|
34
|
+
expect(
|
|
35
|
+
resolved.endsWith(`${path.sep}data${path.sep}ipc${path.sep}family-chat`),
|
|
36
|
+
).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('throws for unsafe folder names', () => {
|
|
40
|
+
expect(() => resolveGroupFolderPath('../../etc')).toThrow();
|
|
41
|
+
expect(() => resolveGroupIpcPath('/tmp')).toThrow();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
|
|
3
|
+
import { DATA_DIR, GROUPS_DIR } from './config.js';
|
|
4
|
+
|
|
5
|
+
const GROUP_FOLDER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
|
6
|
+
const RESERVED_FOLDERS = new Set(['global']);
|
|
7
|
+
|
|
8
|
+
export function isValidGroupFolder(folder: string): boolean {
|
|
9
|
+
if (!folder) return false;
|
|
10
|
+
if (folder !== folder.trim()) return false;
|
|
11
|
+
if (!GROUP_FOLDER_PATTERN.test(folder)) return false;
|
|
12
|
+
if (folder.includes('/') || folder.includes('\\')) return false;
|
|
13
|
+
if (folder.includes('..')) return false;
|
|
14
|
+
if (RESERVED_FOLDERS.has(folder.toLowerCase())) return false;
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function assertValidGroupFolder(folder: string): void {
|
|
19
|
+
if (!isValidGroupFolder(folder)) {
|
|
20
|
+
throw new Error(`Invalid group folder "${folder}"`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function ensureWithinBase(baseDir: string, resolvedPath: string): void {
|
|
25
|
+
const rel = path.relative(baseDir, resolvedPath);
|
|
26
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
27
|
+
throw new Error(`Path escapes base directory: ${resolvedPath}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function resolveGroupFolderPath(folder: string): string {
|
|
32
|
+
assertValidGroupFolder(folder);
|
|
33
|
+
const groupPath = path.resolve(GROUPS_DIR, folder);
|
|
34
|
+
ensureWithinBase(GROUPS_DIR, groupPath);
|
|
35
|
+
return groupPath;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function resolveGroupIpcPath(folder: string): string {
|
|
39
|
+
assertValidGroupFolder(folder);
|
|
40
|
+
const ipcBaseDir = path.resolve(DATA_DIR, 'ipc');
|
|
41
|
+
const ipcPath = path.resolve(ipcBaseDir, folder);
|
|
42
|
+
ensureWithinBase(ipcBaseDir, ipcPath);
|
|
43
|
+
return ipcPath;
|
|
44
|
+
}
|