@rozek/nanoclaw 0.0.1
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 +325 -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.json +21 -0
- package/container/agent-runner/src/index.ts +774 -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/cwd/SKILL.md +32 -0
- package/container/skills/pwd/SKILL.md +19 -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 +10 -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 +1843 -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 +511 -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 +40 -0
- package/dist/group-queue.d.ts.map +1 -0
- package/dist/group-queue.js +276 -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 +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +592 -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 +104 -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 +194 -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 +241 -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 +79 -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 +15 -0
- package/src/channels/registry.test.ts +42 -0
- package/src/channels/registry.ts +32 -0
- package/src/channels/web.ts +1931 -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 +768 -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 +379 -0
- package/src/index.ts +832 -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 +328 -0
- package/src/timezone.test.ts +29 -0
- package/src/timezone.ts +16 -0
- package/src/types.ts +109 -0
- package/tsconfig.json +20 -0
- package/vitest.config.ts +7 -0
- package/vitest.skills.config.ts +7 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
extractSessionCommand,
|
|
4
|
+
handleSessionCommand,
|
|
5
|
+
isSessionCommandAllowed,
|
|
6
|
+
} from './session-commands.js';
|
|
7
|
+
import type { NewMessage } from './types.js';
|
|
8
|
+
import type { SessionCommandDeps } from './session-commands.js';
|
|
9
|
+
|
|
10
|
+
describe('extractSessionCommand', () => {
|
|
11
|
+
const trigger = /^@Andy\b/i;
|
|
12
|
+
|
|
13
|
+
it('detects bare /compact', () => {
|
|
14
|
+
expect(extractSessionCommand('/compact', trigger)).toBe('/compact');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('detects /compact with trigger prefix', () => {
|
|
18
|
+
expect(extractSessionCommand('@Andy /compact', trigger)).toBe('/compact');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('rejects /compact with extra text', () => {
|
|
22
|
+
expect(extractSessionCommand('/compact now please', trigger)).toBeNull();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('rejects partial matches', () => {
|
|
26
|
+
expect(extractSessionCommand('/compaction', trigger)).toBeNull();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('rejects regular messages', () => {
|
|
30
|
+
expect(
|
|
31
|
+
extractSessionCommand('please compact the conversation', trigger),
|
|
32
|
+
).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('handles whitespace', () => {
|
|
36
|
+
expect(extractSessionCommand(' /compact ', trigger)).toBe('/compact');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('is case-sensitive for the command', () => {
|
|
40
|
+
expect(extractSessionCommand('/Compact', trigger)).toBeNull();
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('isSessionCommandAllowed', () => {
|
|
45
|
+
it('allows main group regardless of sender', () => {
|
|
46
|
+
expect(isSessionCommandAllowed(true, false)).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('allows trusted/admin sender (is_from_me) in non-main group', () => {
|
|
50
|
+
expect(isSessionCommandAllowed(false, true)).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('denies untrusted sender in non-main group', () => {
|
|
54
|
+
expect(isSessionCommandAllowed(false, false)).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('allows trusted sender in main group', () => {
|
|
58
|
+
expect(isSessionCommandAllowed(true, true)).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
function makeMsg(
|
|
63
|
+
content: string,
|
|
64
|
+
overrides: Partial<NewMessage> = {},
|
|
65
|
+
): NewMessage {
|
|
66
|
+
return {
|
|
67
|
+
id: 'msg-1',
|
|
68
|
+
chat_jid: 'group@test',
|
|
69
|
+
sender: 'user@test',
|
|
70
|
+
sender_name: 'User',
|
|
71
|
+
content,
|
|
72
|
+
timestamp: '100',
|
|
73
|
+
...overrides,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function makeDeps(
|
|
78
|
+
overrides: Partial<SessionCommandDeps> = {},
|
|
79
|
+
): SessionCommandDeps {
|
|
80
|
+
return {
|
|
81
|
+
sendMessage: vi.fn().mockResolvedValue(undefined),
|
|
82
|
+
setTyping: vi.fn().mockResolvedValue(undefined),
|
|
83
|
+
runAgent: vi.fn().mockResolvedValue('success'),
|
|
84
|
+
closeStdin: vi.fn(),
|
|
85
|
+
advanceCursor: vi.fn(),
|
|
86
|
+
formatMessages: vi.fn().mockReturnValue('<formatted>'),
|
|
87
|
+
canSenderInteract: vi.fn().mockReturnValue(true),
|
|
88
|
+
...overrides,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const trigger = /^@Andy\b/i;
|
|
93
|
+
|
|
94
|
+
describe('handleSessionCommand', () => {
|
|
95
|
+
it('returns handled:false when no session command found', async () => {
|
|
96
|
+
const deps = makeDeps();
|
|
97
|
+
const result = await handleSessionCommand({
|
|
98
|
+
missedMessages: [makeMsg('hello')],
|
|
99
|
+
isMainGroup: true,
|
|
100
|
+
groupName: 'test',
|
|
101
|
+
triggerPattern: trigger,
|
|
102
|
+
timezone: 'UTC',
|
|
103
|
+
deps,
|
|
104
|
+
});
|
|
105
|
+
expect(result.handled).toBe(false);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('handles authorized /compact in main group', async () => {
|
|
109
|
+
const deps = makeDeps();
|
|
110
|
+
const result = await handleSessionCommand({
|
|
111
|
+
missedMessages: [makeMsg('/compact')],
|
|
112
|
+
isMainGroup: true,
|
|
113
|
+
groupName: 'test',
|
|
114
|
+
triggerPattern: trigger,
|
|
115
|
+
timezone: 'UTC',
|
|
116
|
+
deps,
|
|
117
|
+
});
|
|
118
|
+
expect(result).toEqual({ handled: true, success: true });
|
|
119
|
+
expect(deps.runAgent).toHaveBeenCalledWith(
|
|
120
|
+
'/compact',
|
|
121
|
+
expect.any(Function),
|
|
122
|
+
);
|
|
123
|
+
expect(deps.advanceCursor).toHaveBeenCalledWith('100');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('sends denial to interactable sender in non-main group', async () => {
|
|
127
|
+
const deps = makeDeps();
|
|
128
|
+
const result = await handleSessionCommand({
|
|
129
|
+
missedMessages: [makeMsg('/compact', { is_from_me: false })],
|
|
130
|
+
isMainGroup: false,
|
|
131
|
+
groupName: 'test',
|
|
132
|
+
triggerPattern: trigger,
|
|
133
|
+
timezone: 'UTC',
|
|
134
|
+
deps,
|
|
135
|
+
});
|
|
136
|
+
expect(result).toEqual({ handled: true, success: true });
|
|
137
|
+
expect(deps.sendMessage).toHaveBeenCalledWith(
|
|
138
|
+
'Session commands require admin access.',
|
|
139
|
+
);
|
|
140
|
+
expect(deps.runAgent).not.toHaveBeenCalled();
|
|
141
|
+
expect(deps.advanceCursor).toHaveBeenCalledWith('100');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('silently consumes denied command when sender cannot interact', async () => {
|
|
145
|
+
const deps = makeDeps({
|
|
146
|
+
canSenderInteract: vi.fn().mockReturnValue(false),
|
|
147
|
+
});
|
|
148
|
+
const result = await handleSessionCommand({
|
|
149
|
+
missedMessages: [makeMsg('/compact', { is_from_me: false })],
|
|
150
|
+
isMainGroup: false,
|
|
151
|
+
groupName: 'test',
|
|
152
|
+
triggerPattern: trigger,
|
|
153
|
+
timezone: 'UTC',
|
|
154
|
+
deps,
|
|
155
|
+
});
|
|
156
|
+
expect(result).toEqual({ handled: true, success: true });
|
|
157
|
+
expect(deps.sendMessage).not.toHaveBeenCalled();
|
|
158
|
+
expect(deps.advanceCursor).toHaveBeenCalledWith('100');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('processes pre-compact messages before /compact', async () => {
|
|
162
|
+
const deps = makeDeps();
|
|
163
|
+
const msgs = [
|
|
164
|
+
makeMsg('summarize this', { timestamp: '99' }),
|
|
165
|
+
makeMsg('/compact', { timestamp: '100' }),
|
|
166
|
+
];
|
|
167
|
+
const result = await handleSessionCommand({
|
|
168
|
+
missedMessages: msgs,
|
|
169
|
+
isMainGroup: true,
|
|
170
|
+
groupName: 'test',
|
|
171
|
+
triggerPattern: trigger,
|
|
172
|
+
timezone: 'UTC',
|
|
173
|
+
deps,
|
|
174
|
+
});
|
|
175
|
+
expect(result).toEqual({ handled: true, success: true });
|
|
176
|
+
expect(deps.formatMessages).toHaveBeenCalledWith([msgs[0]], 'UTC');
|
|
177
|
+
// Two runAgent calls: pre-compact + /compact
|
|
178
|
+
expect(deps.runAgent).toHaveBeenCalledTimes(2);
|
|
179
|
+
expect(deps.runAgent).toHaveBeenCalledWith(
|
|
180
|
+
'<formatted>',
|
|
181
|
+
expect.any(Function),
|
|
182
|
+
);
|
|
183
|
+
expect(deps.runAgent).toHaveBeenCalledWith(
|
|
184
|
+
'/compact',
|
|
185
|
+
expect.any(Function),
|
|
186
|
+
);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('allows is_from_me sender in non-main group', async () => {
|
|
190
|
+
const deps = makeDeps();
|
|
191
|
+
const result = await handleSessionCommand({
|
|
192
|
+
missedMessages: [makeMsg('/compact', { is_from_me: true })],
|
|
193
|
+
isMainGroup: false,
|
|
194
|
+
groupName: 'test',
|
|
195
|
+
triggerPattern: trigger,
|
|
196
|
+
timezone: 'UTC',
|
|
197
|
+
deps,
|
|
198
|
+
});
|
|
199
|
+
expect(result).toEqual({ handled: true, success: true });
|
|
200
|
+
expect(deps.runAgent).toHaveBeenCalledWith(
|
|
201
|
+
'/compact',
|
|
202
|
+
expect.any(Function),
|
|
203
|
+
);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('reports failure when command-stage runAgent returns error without streamed status', async () => {
|
|
207
|
+
// runAgent resolves 'error' but callback never gets status: 'error'
|
|
208
|
+
const deps = makeDeps({
|
|
209
|
+
runAgent: vi.fn().mockImplementation(async (prompt, onOutput) => {
|
|
210
|
+
await onOutput({ status: 'success', result: null });
|
|
211
|
+
return 'error';
|
|
212
|
+
}),
|
|
213
|
+
});
|
|
214
|
+
const result = await handleSessionCommand({
|
|
215
|
+
missedMessages: [makeMsg('/compact')],
|
|
216
|
+
isMainGroup: true,
|
|
217
|
+
groupName: 'test',
|
|
218
|
+
triggerPattern: trigger,
|
|
219
|
+
timezone: 'UTC',
|
|
220
|
+
deps,
|
|
221
|
+
});
|
|
222
|
+
expect(result).toEqual({ handled: true, success: true });
|
|
223
|
+
expect(deps.sendMessage).toHaveBeenCalledWith(
|
|
224
|
+
expect.stringContaining('failed'),
|
|
225
|
+
);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('returns success:false on pre-compact failure with no output', async () => {
|
|
229
|
+
const deps = makeDeps({ runAgent: vi.fn().mockResolvedValue('error') });
|
|
230
|
+
const msgs = [
|
|
231
|
+
makeMsg('summarize this', { timestamp: '99' }),
|
|
232
|
+
makeMsg('/compact', { timestamp: '100' }),
|
|
233
|
+
];
|
|
234
|
+
const result = await handleSessionCommand({
|
|
235
|
+
missedMessages: msgs,
|
|
236
|
+
isMainGroup: true,
|
|
237
|
+
groupName: 'test',
|
|
238
|
+
triggerPattern: trigger,
|
|
239
|
+
timezone: 'UTC',
|
|
240
|
+
deps,
|
|
241
|
+
});
|
|
242
|
+
expect(result).toEqual({ handled: true, success: false });
|
|
243
|
+
expect(deps.sendMessage).toHaveBeenCalledWith(
|
|
244
|
+
expect.stringContaining('Failed to process'),
|
|
245
|
+
);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import type { NewMessage } from './types.js';
|
|
2
|
+
import { logger } from './logger.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Extract a session slash command from a message, stripping the trigger prefix if present.
|
|
6
|
+
* Returns the slash command (e.g., '/compact') or null if not a session command.
|
|
7
|
+
*/
|
|
8
|
+
export function extractSessionCommand(
|
|
9
|
+
content: string,
|
|
10
|
+
triggerPattern: RegExp,
|
|
11
|
+
): string | null {
|
|
12
|
+
let text = content.trim();
|
|
13
|
+
text = text.replace(triggerPattern, '').trim();
|
|
14
|
+
if (text === '/compact') return '/compact';
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Check if a session command sender is authorized.
|
|
20
|
+
* Allowed: main group (any sender), or trusted/admin sender (is_from_me) in any group.
|
|
21
|
+
*/
|
|
22
|
+
export function isSessionCommandAllowed(
|
|
23
|
+
isMainGroup: boolean,
|
|
24
|
+
isFromMe: boolean,
|
|
25
|
+
): boolean {
|
|
26
|
+
return isMainGroup || isFromMe;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Minimal agent result interface — matches the subset of ContainerOutput used here. */
|
|
30
|
+
export interface AgentResult {
|
|
31
|
+
status: 'success' | 'error';
|
|
32
|
+
result?: string | object | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Dependencies injected by the orchestrator. */
|
|
36
|
+
export interface SessionCommandDeps {
|
|
37
|
+
sendMessage: (text: string) => Promise<void>;
|
|
38
|
+
setTyping: (typing: boolean) => Promise<void>;
|
|
39
|
+
runAgent: (
|
|
40
|
+
prompt: string,
|
|
41
|
+
onOutput: (result: AgentResult) => Promise<void>,
|
|
42
|
+
) => Promise<'success' | 'error'>;
|
|
43
|
+
closeStdin: () => void;
|
|
44
|
+
advanceCursor: (timestamp: string) => void;
|
|
45
|
+
formatMessages: (msgs: NewMessage[], timezone: string) => string;
|
|
46
|
+
/** Whether the denied sender would normally be allowed to interact (for denial messages). */
|
|
47
|
+
canSenderInteract: (msg: NewMessage) => boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function resultToText(result: string | object | null | undefined): string {
|
|
51
|
+
if (!result) return '';
|
|
52
|
+
const raw = typeof result === 'string' ? result : JSON.stringify(result);
|
|
53
|
+
return raw.replace(/<internal>[\s\S]*?<\/internal>/g, '').trim();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Handle session command interception in processGroupMessages.
|
|
58
|
+
* Scans messages for a session command, handles auth + execution.
|
|
59
|
+
* Returns { handled: true, success } if a command was found; { handled: false } otherwise.
|
|
60
|
+
* success=false means the caller should retry (cursor was not advanced).
|
|
61
|
+
*/
|
|
62
|
+
export async function handleSessionCommand(opts: {
|
|
63
|
+
missedMessages: NewMessage[];
|
|
64
|
+
isMainGroup: boolean;
|
|
65
|
+
groupName: string;
|
|
66
|
+
triggerPattern: RegExp;
|
|
67
|
+
timezone: string;
|
|
68
|
+
deps: SessionCommandDeps;
|
|
69
|
+
}): Promise<{ handled: false } | { handled: true; success: boolean }> {
|
|
70
|
+
const {
|
|
71
|
+
missedMessages,
|
|
72
|
+
isMainGroup,
|
|
73
|
+
groupName,
|
|
74
|
+
triggerPattern,
|
|
75
|
+
timezone,
|
|
76
|
+
deps,
|
|
77
|
+
} = opts;
|
|
78
|
+
|
|
79
|
+
const cmdMsg = missedMessages.find(
|
|
80
|
+
(m) => extractSessionCommand(m.content, triggerPattern) !== null,
|
|
81
|
+
);
|
|
82
|
+
const command = cmdMsg
|
|
83
|
+
? extractSessionCommand(cmdMsg.content, triggerPattern)
|
|
84
|
+
: null;
|
|
85
|
+
|
|
86
|
+
if (!command || !cmdMsg) return { handled: false };
|
|
87
|
+
|
|
88
|
+
if (!isSessionCommandAllowed(isMainGroup, cmdMsg.is_from_me === true)) {
|
|
89
|
+
// DENIED: send denial if the sender would normally be allowed to interact,
|
|
90
|
+
// then silently consume the command by advancing the cursor past it.
|
|
91
|
+
// Trade-off: other messages in the same batch are also consumed (cursor is
|
|
92
|
+
// a high-water mark). Acceptable for this narrow edge case.
|
|
93
|
+
if (deps.canSenderInteract(cmdMsg)) {
|
|
94
|
+
await deps.sendMessage('Session commands require admin access.');
|
|
95
|
+
}
|
|
96
|
+
deps.advanceCursor(cmdMsg.timestamp);
|
|
97
|
+
return { handled: true, success: true };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// AUTHORIZED: process pre-compact messages first, then run the command
|
|
101
|
+
logger.info({ group: groupName, command }, 'Session command');
|
|
102
|
+
|
|
103
|
+
const cmdIndex = missedMessages.indexOf(cmdMsg);
|
|
104
|
+
const preCompactMsgs = missedMessages.slice(0, cmdIndex);
|
|
105
|
+
|
|
106
|
+
// Send pre-compact messages to the agent so they're in the session context.
|
|
107
|
+
if (preCompactMsgs.length > 0) {
|
|
108
|
+
const prePrompt = deps.formatMessages(preCompactMsgs, timezone);
|
|
109
|
+
let hadPreError = false;
|
|
110
|
+
let preOutputSent = false;
|
|
111
|
+
|
|
112
|
+
const preResult = await deps.runAgent(prePrompt, async (result) => {
|
|
113
|
+
if (result.status === 'error') hadPreError = true;
|
|
114
|
+
const text = resultToText(result.result);
|
|
115
|
+
if (text) {
|
|
116
|
+
await deps.sendMessage(text);
|
|
117
|
+
preOutputSent = true;
|
|
118
|
+
}
|
|
119
|
+
// Close stdin on session-update marker — emitted after query completes,
|
|
120
|
+
// so all results (including multi-result runs) are already written.
|
|
121
|
+
if (result.status === 'success' && result.result === null) {
|
|
122
|
+
deps.closeStdin();
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
if (preResult === 'error' || hadPreError) {
|
|
127
|
+
logger.warn(
|
|
128
|
+
{ group: groupName },
|
|
129
|
+
'Pre-compact processing failed, aborting session command',
|
|
130
|
+
);
|
|
131
|
+
await deps.sendMessage(
|
|
132
|
+
`Failed to process messages before ${command}. Try again.`,
|
|
133
|
+
);
|
|
134
|
+
if (preOutputSent) {
|
|
135
|
+
// Output was already sent — don't retry or it will duplicate.
|
|
136
|
+
// Advance cursor past pre-compact messages, leave command pending.
|
|
137
|
+
deps.advanceCursor(preCompactMsgs[preCompactMsgs.length - 1].timestamp);
|
|
138
|
+
return { handled: true, success: true };
|
|
139
|
+
}
|
|
140
|
+
return { handled: true, success: false };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Forward the literal slash command as the prompt (no XML formatting)
|
|
145
|
+
await deps.setTyping(true);
|
|
146
|
+
|
|
147
|
+
let hadCmdError = false;
|
|
148
|
+
const cmdOutput = await deps.runAgent(command, async (result) => {
|
|
149
|
+
if (result.status === 'error') hadCmdError = true;
|
|
150
|
+
const text = resultToText(result.result);
|
|
151
|
+
if (text) await deps.sendMessage(text);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// Advance cursor to the command — messages AFTER it remain pending for next poll.
|
|
155
|
+
deps.advanceCursor(cmdMsg.timestamp);
|
|
156
|
+
await deps.setTyping(false);
|
|
157
|
+
|
|
158
|
+
if (cmdOutput === 'error' || hadCmdError) {
|
|
159
|
+
await deps.sendMessage(`${command} failed. The session is unchanged.`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return { handled: true, success: true };
|
|
163
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { _initTestDatabase, createTask, getTaskById } from './db.js';
|
|
4
|
+
import {
|
|
5
|
+
_resetSchedulerLoopForTests,
|
|
6
|
+
computeNextRun,
|
|
7
|
+
startSchedulerLoop,
|
|
8
|
+
} from './task-scheduler.js';
|
|
9
|
+
|
|
10
|
+
describe('task scheduler', () => {
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
_initTestDatabase();
|
|
13
|
+
_resetSchedulerLoopForTests();
|
|
14
|
+
vi.useFakeTimers();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
vi.useRealTimers();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('pauses due tasks with invalid group folders to prevent retry churn', async () => {
|
|
22
|
+
createTask({
|
|
23
|
+
id: 'task-invalid-folder',
|
|
24
|
+
group_folder: '../../outside',
|
|
25
|
+
chat_jid: 'bad@g.us',
|
|
26
|
+
prompt: 'run',
|
|
27
|
+
schedule_type: 'once',
|
|
28
|
+
schedule_value: '2026-02-22T00:00:00.000Z',
|
|
29
|
+
context_mode: 'isolated',
|
|
30
|
+
next_run: new Date(Date.now() - 60_000).toISOString(),
|
|
31
|
+
status: 'active',
|
|
32
|
+
created_at: '2026-02-22T00:00:00.000Z',
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const enqueueTask = vi.fn(
|
|
36
|
+
(_groupJid: string, _taskId: string, fn: () => Promise<void>) => {
|
|
37
|
+
void fn();
|
|
38
|
+
},
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
startSchedulerLoop({
|
|
42
|
+
registeredGroups: () => ({}),
|
|
43
|
+
getSessions: () => ({}),
|
|
44
|
+
queue: { enqueueTask } as any,
|
|
45
|
+
onProcess: () => {},
|
|
46
|
+
sendMessage: async () => {},
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
await vi.advanceTimersByTimeAsync(10);
|
|
50
|
+
|
|
51
|
+
const task = getTaskById('task-invalid-folder');
|
|
52
|
+
expect(task?.status).toBe('paused');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('computeNextRun anchors interval tasks to scheduled time to prevent drift', () => {
|
|
56
|
+
const scheduledTime = new Date(Date.now() - 2000).toISOString(); // 2s ago
|
|
57
|
+
const task = {
|
|
58
|
+
id: 'drift-test',
|
|
59
|
+
group_folder: 'test',
|
|
60
|
+
chat_jid: 'test@g.us',
|
|
61
|
+
prompt: 'test',
|
|
62
|
+
schedule_type: 'interval' as const,
|
|
63
|
+
schedule_value: '60000', // 1 minute
|
|
64
|
+
context_mode: 'isolated' as const,
|
|
65
|
+
next_run: scheduledTime,
|
|
66
|
+
last_run: null,
|
|
67
|
+
last_result: null,
|
|
68
|
+
status: 'active' as const,
|
|
69
|
+
created_at: '2026-01-01T00:00:00.000Z',
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const nextRun = computeNextRun(task);
|
|
73
|
+
expect(nextRun).not.toBeNull();
|
|
74
|
+
|
|
75
|
+
// Should be anchored to scheduledTime + 60s, NOT Date.now() + 60s
|
|
76
|
+
const expected = new Date(scheduledTime).getTime() + 60000;
|
|
77
|
+
expect(new Date(nextRun!).getTime()).toBe(expected);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('computeNextRun returns null for once-tasks', () => {
|
|
81
|
+
const task = {
|
|
82
|
+
id: 'once-test',
|
|
83
|
+
group_folder: 'test',
|
|
84
|
+
chat_jid: 'test@g.us',
|
|
85
|
+
prompt: 'test',
|
|
86
|
+
schedule_type: 'once' as const,
|
|
87
|
+
schedule_value: '2026-01-01T00:00:00.000Z',
|
|
88
|
+
context_mode: 'isolated' as const,
|
|
89
|
+
next_run: new Date(Date.now() - 1000).toISOString(),
|
|
90
|
+
last_run: null,
|
|
91
|
+
last_result: null,
|
|
92
|
+
status: 'active' as const,
|
|
93
|
+
created_at: '2026-01-01T00:00:00.000Z',
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
expect(computeNextRun(task)).toBeNull();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('computeNextRun skips missed intervals without infinite loop', () => {
|
|
100
|
+
// Task was due 10 intervals ago (missed)
|
|
101
|
+
const ms = 60000;
|
|
102
|
+
const missedBy = ms * 10;
|
|
103
|
+
const scheduledTime = new Date(Date.now() - missedBy).toISOString();
|
|
104
|
+
|
|
105
|
+
const task = {
|
|
106
|
+
id: 'skip-test',
|
|
107
|
+
group_folder: 'test',
|
|
108
|
+
chat_jid: 'test@g.us',
|
|
109
|
+
prompt: 'test',
|
|
110
|
+
schedule_type: 'interval' as const,
|
|
111
|
+
schedule_value: String(ms),
|
|
112
|
+
context_mode: 'isolated' as const,
|
|
113
|
+
next_run: scheduledTime,
|
|
114
|
+
last_run: null,
|
|
115
|
+
last_result: null,
|
|
116
|
+
status: 'active' as const,
|
|
117
|
+
created_at: '2026-01-01T00:00:00.000Z',
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const nextRun = computeNextRun(task);
|
|
121
|
+
expect(nextRun).not.toBeNull();
|
|
122
|
+
// Must be in the future
|
|
123
|
+
expect(new Date(nextRun!).getTime()).toBeGreaterThan(Date.now());
|
|
124
|
+
// Must be aligned to the original schedule grid
|
|
125
|
+
const offset =
|
|
126
|
+
(new Date(nextRun!).getTime() - new Date(scheduledTime).getTime()) % ms;
|
|
127
|
+
expect(offset).toBe(0);
|
|
128
|
+
});
|
|
129
|
+
});
|