@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
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
// Mock config before importing the module under test
|
|
5
|
+
vi.mock('./config.js', () => ({
|
|
6
|
+
DATA_DIR: '/tmp/nanoclaw-rc-test',
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
// Mock child_process
|
|
10
|
+
const spawnMock = vi.fn();
|
|
11
|
+
vi.mock('child_process', () => ({
|
|
12
|
+
spawn: (...args: any[]) => spawnMock(...args),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
startRemoteControl,
|
|
17
|
+
stopRemoteControl,
|
|
18
|
+
restoreRemoteControl,
|
|
19
|
+
getActiveSession,
|
|
20
|
+
_resetForTesting,
|
|
21
|
+
_getStateFilePath,
|
|
22
|
+
} from './remote-control.js';
|
|
23
|
+
|
|
24
|
+
// --- Helpers ---
|
|
25
|
+
|
|
26
|
+
function createMockProcess(pid = 12345) {
|
|
27
|
+
return {
|
|
28
|
+
pid,
|
|
29
|
+
unref: vi.fn(),
|
|
30
|
+
kill: vi.fn(),
|
|
31
|
+
stdin: { write: vi.fn(), end: vi.fn() },
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('remote-control', () => {
|
|
36
|
+
const STATE_FILE = _getStateFilePath();
|
|
37
|
+
let readFileSyncSpy: ReturnType<typeof vi.spyOn>;
|
|
38
|
+
let writeFileSyncSpy: ReturnType<typeof vi.spyOn>;
|
|
39
|
+
let unlinkSyncSpy: ReturnType<typeof vi.spyOn>;
|
|
40
|
+
let mkdirSyncSpy: ReturnType<typeof vi.spyOn>;
|
|
41
|
+
let openSyncSpy: ReturnType<typeof vi.spyOn>;
|
|
42
|
+
let closeSyncSpy: ReturnType<typeof vi.spyOn>;
|
|
43
|
+
|
|
44
|
+
// Track what readFileSync should return for the stdout file
|
|
45
|
+
let stdoutFileContent: string;
|
|
46
|
+
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
_resetForTesting();
|
|
49
|
+
spawnMock.mockReset();
|
|
50
|
+
stdoutFileContent = '';
|
|
51
|
+
|
|
52
|
+
// Default fs mocks
|
|
53
|
+
mkdirSyncSpy = vi
|
|
54
|
+
.spyOn(fs, 'mkdirSync')
|
|
55
|
+
.mockImplementation(() => undefined as any);
|
|
56
|
+
writeFileSyncSpy = vi
|
|
57
|
+
.spyOn(fs, 'writeFileSync')
|
|
58
|
+
.mockImplementation(() => {});
|
|
59
|
+
unlinkSyncSpy = vi.spyOn(fs, 'unlinkSync').mockImplementation(() => {});
|
|
60
|
+
openSyncSpy = vi.spyOn(fs, 'openSync').mockReturnValue(42 as any);
|
|
61
|
+
closeSyncSpy = vi.spyOn(fs, 'closeSync').mockImplementation(() => {});
|
|
62
|
+
|
|
63
|
+
// readFileSync: return stdoutFileContent for the stdout file, state file, etc.
|
|
64
|
+
readFileSyncSpy = vi.spyOn(fs, 'readFileSync').mockImplementation(((
|
|
65
|
+
p: string,
|
|
66
|
+
) => {
|
|
67
|
+
if (p.endsWith('remote-control.stdout')) return stdoutFileContent;
|
|
68
|
+
if (p.endsWith('remote-control.json')) {
|
|
69
|
+
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
|
70
|
+
}
|
|
71
|
+
return '';
|
|
72
|
+
}) as any);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
afterEach(() => {
|
|
76
|
+
_resetForTesting();
|
|
77
|
+
vi.restoreAllMocks();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// --- startRemoteControl ---
|
|
81
|
+
|
|
82
|
+
describe('startRemoteControl', () => {
|
|
83
|
+
it('spawns claude remote-control and returns the URL', async () => {
|
|
84
|
+
const proc = createMockProcess();
|
|
85
|
+
spawnMock.mockReturnValue(proc);
|
|
86
|
+
|
|
87
|
+
// Simulate URL appearing in stdout file on first poll
|
|
88
|
+
stdoutFileContent =
|
|
89
|
+
'Session URL: https://claude.ai/code?bridge=env_abc123\n';
|
|
90
|
+
vi.spyOn(process, 'kill').mockImplementation((() => true) as any);
|
|
91
|
+
|
|
92
|
+
const result = await startRemoteControl('user1', 'tg:123', '/project');
|
|
93
|
+
|
|
94
|
+
expect(result).toEqual({
|
|
95
|
+
ok: true,
|
|
96
|
+
url: 'https://claude.ai/code?bridge=env_abc123',
|
|
97
|
+
});
|
|
98
|
+
expect(spawnMock).toHaveBeenCalledWith(
|
|
99
|
+
'claude',
|
|
100
|
+
['remote-control', '--name', 'NanoClaw Remote'],
|
|
101
|
+
expect.objectContaining({ cwd: '/project', detached: true }),
|
|
102
|
+
);
|
|
103
|
+
expect(proc.unref).toHaveBeenCalled();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('uses file descriptors for stdout/stderr (not pipes)', async () => {
|
|
107
|
+
const proc = createMockProcess();
|
|
108
|
+
spawnMock.mockReturnValue(proc);
|
|
109
|
+
stdoutFileContent = 'https://claude.ai/code?bridge=env_test\n';
|
|
110
|
+
vi.spyOn(process, 'kill').mockImplementation((() => true) as any);
|
|
111
|
+
|
|
112
|
+
await startRemoteControl('user1', 'tg:123', '/project');
|
|
113
|
+
|
|
114
|
+
const spawnCall = spawnMock.mock.calls[0];
|
|
115
|
+
const options = spawnCall[2];
|
|
116
|
+
// stdio[0] is 'pipe' so we can write 'y' to accept the prompt
|
|
117
|
+
expect(options.stdio[0]).toBe('pipe');
|
|
118
|
+
expect(typeof options.stdio[1]).toBe('number');
|
|
119
|
+
expect(typeof options.stdio[2]).toBe('number');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('closes file descriptors in parent after spawn', async () => {
|
|
123
|
+
const proc = createMockProcess();
|
|
124
|
+
spawnMock.mockReturnValue(proc);
|
|
125
|
+
stdoutFileContent = 'https://claude.ai/code?bridge=env_test\n';
|
|
126
|
+
vi.spyOn(process, 'kill').mockImplementation((() => true) as any);
|
|
127
|
+
|
|
128
|
+
await startRemoteControl('user1', 'tg:123', '/project');
|
|
129
|
+
|
|
130
|
+
// Two openSync calls (stdout + stderr), two closeSync calls
|
|
131
|
+
expect(openSyncSpy).toHaveBeenCalledTimes(2);
|
|
132
|
+
expect(closeSyncSpy).toHaveBeenCalledTimes(2);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('saves state to disk after capturing URL', async () => {
|
|
136
|
+
const proc = createMockProcess(99999);
|
|
137
|
+
spawnMock.mockReturnValue(proc);
|
|
138
|
+
stdoutFileContent = 'https://claude.ai/code?bridge=env_save\n';
|
|
139
|
+
vi.spyOn(process, 'kill').mockImplementation((() => true) as any);
|
|
140
|
+
|
|
141
|
+
await startRemoteControl('user1', 'tg:123', '/project');
|
|
142
|
+
|
|
143
|
+
expect(writeFileSyncSpy).toHaveBeenCalledWith(
|
|
144
|
+
STATE_FILE,
|
|
145
|
+
expect.stringContaining('"pid":99999'),
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('returns existing URL if session is already active', async () => {
|
|
150
|
+
const proc = createMockProcess();
|
|
151
|
+
spawnMock.mockReturnValue(proc);
|
|
152
|
+
stdoutFileContent = 'https://claude.ai/code?bridge=env_existing\n';
|
|
153
|
+
vi.spyOn(process, 'kill').mockImplementation((() => true) as any);
|
|
154
|
+
|
|
155
|
+
await startRemoteControl('user1', 'tg:123', '/project');
|
|
156
|
+
|
|
157
|
+
// Second call should return existing URL without spawning
|
|
158
|
+
const result = await startRemoteControl('user2', 'tg:456', '/project');
|
|
159
|
+
expect(result).toEqual({
|
|
160
|
+
ok: true,
|
|
161
|
+
url: 'https://claude.ai/code?bridge=env_existing',
|
|
162
|
+
});
|
|
163
|
+
expect(spawnMock).toHaveBeenCalledTimes(1);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('starts new session if existing process is dead', async () => {
|
|
167
|
+
const proc1 = createMockProcess(11111);
|
|
168
|
+
const proc2 = createMockProcess(22222);
|
|
169
|
+
spawnMock.mockReturnValueOnce(proc1).mockReturnValueOnce(proc2);
|
|
170
|
+
|
|
171
|
+
// First start: process alive, URL found
|
|
172
|
+
const killSpy = vi
|
|
173
|
+
.spyOn(process, 'kill')
|
|
174
|
+
.mockImplementation((() => true) as any);
|
|
175
|
+
stdoutFileContent = 'https://claude.ai/code?bridge=env_first\n';
|
|
176
|
+
await startRemoteControl('user1', 'tg:123', '/project');
|
|
177
|
+
|
|
178
|
+
// Old process (11111) is dead, new process (22222) is alive
|
|
179
|
+
killSpy.mockImplementation(((pid: number, sig: any) => {
|
|
180
|
+
if (pid === 11111 && (sig === 0 || sig === undefined)) {
|
|
181
|
+
throw new Error('ESRCH');
|
|
182
|
+
}
|
|
183
|
+
return true;
|
|
184
|
+
}) as any);
|
|
185
|
+
|
|
186
|
+
stdoutFileContent = 'https://claude.ai/code?bridge=env_second\n';
|
|
187
|
+
const result = await startRemoteControl('user1', 'tg:123', '/project');
|
|
188
|
+
|
|
189
|
+
expect(result).toEqual({
|
|
190
|
+
ok: true,
|
|
191
|
+
url: 'https://claude.ai/code?bridge=env_second',
|
|
192
|
+
});
|
|
193
|
+
expect(spawnMock).toHaveBeenCalledTimes(2);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('returns error if process exits before URL', async () => {
|
|
197
|
+
const proc = createMockProcess(33333);
|
|
198
|
+
spawnMock.mockReturnValue(proc);
|
|
199
|
+
stdoutFileContent = '';
|
|
200
|
+
|
|
201
|
+
// Process is dead (poll will detect this)
|
|
202
|
+
vi.spyOn(process, 'kill').mockImplementation((() => {
|
|
203
|
+
throw new Error('ESRCH');
|
|
204
|
+
}) as any);
|
|
205
|
+
|
|
206
|
+
const result = await startRemoteControl('user1', 'tg:123', '/project');
|
|
207
|
+
expect(result).toEqual({
|
|
208
|
+
ok: false,
|
|
209
|
+
error: 'Process exited before producing URL',
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('times out if URL never appears', async () => {
|
|
214
|
+
vi.useFakeTimers();
|
|
215
|
+
const proc = createMockProcess(44444);
|
|
216
|
+
spawnMock.mockReturnValue(proc);
|
|
217
|
+
stdoutFileContent = 'no url here';
|
|
218
|
+
vi.spyOn(process, 'kill').mockImplementation((() => true) as any);
|
|
219
|
+
|
|
220
|
+
const promise = startRemoteControl('user1', 'tg:123', '/project');
|
|
221
|
+
|
|
222
|
+
// Advance past URL_TIMEOUT_MS (30s), with enough steps for polls
|
|
223
|
+
for (let i = 0; i < 160; i++) {
|
|
224
|
+
await vi.advanceTimersByTimeAsync(200);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const result = await promise;
|
|
228
|
+
expect(result).toEqual({
|
|
229
|
+
ok: false,
|
|
230
|
+
error: 'Timed out waiting for Remote Control URL',
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
vi.useRealTimers();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('returns error if spawn throws', async () => {
|
|
237
|
+
spawnMock.mockImplementation(() => {
|
|
238
|
+
throw new Error('ENOENT');
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
const result = await startRemoteControl('user1', 'tg:123', '/project');
|
|
242
|
+
expect(result).toEqual({
|
|
243
|
+
ok: false,
|
|
244
|
+
error: 'Failed to start: ENOENT',
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// --- stopRemoteControl ---
|
|
250
|
+
|
|
251
|
+
describe('stopRemoteControl', () => {
|
|
252
|
+
it('kills the process and clears state', async () => {
|
|
253
|
+
const proc = createMockProcess(55555);
|
|
254
|
+
spawnMock.mockReturnValue(proc);
|
|
255
|
+
stdoutFileContent = 'https://claude.ai/code?bridge=env_stop\n';
|
|
256
|
+
const killSpy = vi
|
|
257
|
+
.spyOn(process, 'kill')
|
|
258
|
+
.mockImplementation((() => true) as any);
|
|
259
|
+
|
|
260
|
+
await startRemoteControl('user1', 'tg:123', '/project');
|
|
261
|
+
|
|
262
|
+
const result = stopRemoteControl();
|
|
263
|
+
expect(result).toEqual({ ok: true });
|
|
264
|
+
expect(killSpy).toHaveBeenCalledWith(55555, 'SIGTERM');
|
|
265
|
+
expect(unlinkSyncSpy).toHaveBeenCalledWith(STATE_FILE);
|
|
266
|
+
expect(getActiveSession()).toBeNull();
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('returns error when no session is active', () => {
|
|
270
|
+
const result = stopRemoteControl();
|
|
271
|
+
expect(result).toEqual({
|
|
272
|
+
ok: false,
|
|
273
|
+
error: 'No active Remote Control session',
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// --- restoreRemoteControl ---
|
|
279
|
+
|
|
280
|
+
describe('restoreRemoteControl', () => {
|
|
281
|
+
it('restores session if state file exists and process is alive', () => {
|
|
282
|
+
const session = {
|
|
283
|
+
pid: 77777,
|
|
284
|
+
url: 'https://claude.ai/code?bridge=env_restored',
|
|
285
|
+
startedBy: 'user1',
|
|
286
|
+
startedInChat: 'tg:123',
|
|
287
|
+
startedAt: '2026-01-01T00:00:00.000Z',
|
|
288
|
+
};
|
|
289
|
+
readFileSyncSpy.mockImplementation(((p: string) => {
|
|
290
|
+
if (p.endsWith('remote-control.json')) return JSON.stringify(session);
|
|
291
|
+
return '';
|
|
292
|
+
}) as any);
|
|
293
|
+
vi.spyOn(process, 'kill').mockImplementation((() => true) as any);
|
|
294
|
+
|
|
295
|
+
restoreRemoteControl();
|
|
296
|
+
|
|
297
|
+
const active = getActiveSession();
|
|
298
|
+
expect(active).not.toBeNull();
|
|
299
|
+
expect(active!.pid).toBe(77777);
|
|
300
|
+
expect(active!.url).toBe('https://claude.ai/code?bridge=env_restored');
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('clears state if process is dead', () => {
|
|
304
|
+
const session = {
|
|
305
|
+
pid: 88888,
|
|
306
|
+
url: 'https://claude.ai/code?bridge=env_dead',
|
|
307
|
+
startedBy: 'user1',
|
|
308
|
+
startedInChat: 'tg:123',
|
|
309
|
+
startedAt: '2026-01-01T00:00:00.000Z',
|
|
310
|
+
};
|
|
311
|
+
readFileSyncSpy.mockImplementation(((p: string) => {
|
|
312
|
+
if (p.endsWith('remote-control.json')) return JSON.stringify(session);
|
|
313
|
+
return '';
|
|
314
|
+
}) as any);
|
|
315
|
+
vi.spyOn(process, 'kill').mockImplementation((() => {
|
|
316
|
+
throw new Error('ESRCH');
|
|
317
|
+
}) as any);
|
|
318
|
+
|
|
319
|
+
restoreRemoteControl();
|
|
320
|
+
|
|
321
|
+
expect(getActiveSession()).toBeNull();
|
|
322
|
+
expect(unlinkSyncSpy).toHaveBeenCalled();
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('does nothing if no state file exists', () => {
|
|
326
|
+
// readFileSyncSpy default throws ENOENT for .json
|
|
327
|
+
restoreRemoteControl();
|
|
328
|
+
expect(getActiveSession()).toBeNull();
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('clears state on corrupted JSON', () => {
|
|
332
|
+
readFileSyncSpy.mockImplementation(((p: string) => {
|
|
333
|
+
if (p.endsWith('remote-control.json')) return 'not json{{{';
|
|
334
|
+
return '';
|
|
335
|
+
}) as any);
|
|
336
|
+
|
|
337
|
+
restoreRemoteControl();
|
|
338
|
+
|
|
339
|
+
expect(getActiveSession()).toBeNull();
|
|
340
|
+
expect(unlinkSyncSpy).toHaveBeenCalled();
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
// ** This is the key integration test: restore → stop must work **
|
|
344
|
+
it('stopRemoteControl works after restoreRemoteControl', () => {
|
|
345
|
+
const session = {
|
|
346
|
+
pid: 77777,
|
|
347
|
+
url: 'https://claude.ai/code?bridge=env_restored',
|
|
348
|
+
startedBy: 'user1',
|
|
349
|
+
startedInChat: 'tg:123',
|
|
350
|
+
startedAt: '2026-01-01T00:00:00.000Z',
|
|
351
|
+
};
|
|
352
|
+
readFileSyncSpy.mockImplementation(((p: string) => {
|
|
353
|
+
if (p.endsWith('remote-control.json')) return JSON.stringify(session);
|
|
354
|
+
return '';
|
|
355
|
+
}) as any);
|
|
356
|
+
const killSpy = vi
|
|
357
|
+
.spyOn(process, 'kill')
|
|
358
|
+
.mockImplementation((() => true) as any);
|
|
359
|
+
|
|
360
|
+
restoreRemoteControl();
|
|
361
|
+
expect(getActiveSession()).not.toBeNull();
|
|
362
|
+
|
|
363
|
+
const result = stopRemoteControl();
|
|
364
|
+
expect(result).toEqual({ ok: true });
|
|
365
|
+
expect(killSpy).toHaveBeenCalledWith(77777, 'SIGTERM');
|
|
366
|
+
expect(unlinkSyncSpy).toHaveBeenCalled();
|
|
367
|
+
expect(getActiveSession()).toBeNull();
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('startRemoteControl returns restored URL without spawning', () => {
|
|
371
|
+
const session = {
|
|
372
|
+
pid: 77777,
|
|
373
|
+
url: 'https://claude.ai/code?bridge=env_restored',
|
|
374
|
+
startedBy: 'user1',
|
|
375
|
+
startedInChat: 'tg:123',
|
|
376
|
+
startedAt: '2026-01-01T00:00:00.000Z',
|
|
377
|
+
};
|
|
378
|
+
readFileSyncSpy.mockImplementation(((p: string) => {
|
|
379
|
+
if (p.endsWith('remote-control.json')) return JSON.stringify(session);
|
|
380
|
+
return '';
|
|
381
|
+
}) as any);
|
|
382
|
+
vi.spyOn(process, 'kill').mockImplementation((() => true) as any);
|
|
383
|
+
|
|
384
|
+
restoreRemoteControl();
|
|
385
|
+
|
|
386
|
+
return startRemoteControl('user2', 'tg:456', '/project').then(
|
|
387
|
+
(result) => {
|
|
388
|
+
expect(result).toEqual({
|
|
389
|
+
ok: true,
|
|
390
|
+
url: 'https://claude.ai/code?bridge=env_restored',
|
|
391
|
+
});
|
|
392
|
+
expect(spawnMock).not.toHaveBeenCalled();
|
|
393
|
+
},
|
|
394
|
+
);
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
});
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
|
|
5
|
+
import { DATA_DIR } from './config.js';
|
|
6
|
+
import { logger } from './logger.js';
|
|
7
|
+
|
|
8
|
+
interface RemoteControlSession {
|
|
9
|
+
pid: number;
|
|
10
|
+
url: string;
|
|
11
|
+
startedBy: string;
|
|
12
|
+
startedInChat: string;
|
|
13
|
+
startedAt: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let activeSession: RemoteControlSession | null = null;
|
|
17
|
+
|
|
18
|
+
const URL_REGEX = /https:\/\/claude\.ai\/code\S+/;
|
|
19
|
+
const URL_TIMEOUT_MS = 30_000;
|
|
20
|
+
const URL_POLL_MS = 200;
|
|
21
|
+
const STATE_FILE = path.join(DATA_DIR, 'remote-control.json');
|
|
22
|
+
const STDOUT_FILE = path.join(DATA_DIR, 'remote-control.stdout');
|
|
23
|
+
const STDERR_FILE = path.join(DATA_DIR, 'remote-control.stderr');
|
|
24
|
+
|
|
25
|
+
function saveState(session: RemoteControlSession): void {
|
|
26
|
+
fs.mkdirSync(path.dirname(STATE_FILE), { recursive: true });
|
|
27
|
+
fs.writeFileSync(STATE_FILE, JSON.stringify(session));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function clearState(): void {
|
|
31
|
+
try {
|
|
32
|
+
fs.unlinkSync(STATE_FILE);
|
|
33
|
+
} catch {
|
|
34
|
+
// ignore
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isProcessAlive(pid: number): boolean {
|
|
39
|
+
try {
|
|
40
|
+
process.kill(pid, 0);
|
|
41
|
+
return true;
|
|
42
|
+
} catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Restore session from disk on startup.
|
|
49
|
+
* If the process is still alive, adopt it. Otherwise, clean up.
|
|
50
|
+
*/
|
|
51
|
+
export function restoreRemoteControl(): void {
|
|
52
|
+
let data: string;
|
|
53
|
+
try {
|
|
54
|
+
data = fs.readFileSync(STATE_FILE, 'utf-8');
|
|
55
|
+
} catch {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const session: RemoteControlSession = JSON.parse(data);
|
|
61
|
+
if (session.pid && isProcessAlive(session.pid)) {
|
|
62
|
+
activeSession = session;
|
|
63
|
+
logger.info(
|
|
64
|
+
{ pid: session.pid, url: session.url },
|
|
65
|
+
'Restored Remote Control session from previous run',
|
|
66
|
+
);
|
|
67
|
+
} else {
|
|
68
|
+
clearState();
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
clearState();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function getActiveSession(): RemoteControlSession | null {
|
|
76
|
+
return activeSession;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** @internal — exported for testing only */
|
|
80
|
+
export function _resetForTesting(): void {
|
|
81
|
+
activeSession = null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** @internal — exported for testing only */
|
|
85
|
+
export function _getStateFilePath(): string {
|
|
86
|
+
return STATE_FILE;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function startRemoteControl(
|
|
90
|
+
sender: string,
|
|
91
|
+
chatJid: string,
|
|
92
|
+
cwd: string,
|
|
93
|
+
): Promise<{ ok: true; url: string } | { ok: false; error: string }> {
|
|
94
|
+
if (activeSession) {
|
|
95
|
+
// Verify the process is still alive
|
|
96
|
+
if (isProcessAlive(activeSession.pid)) {
|
|
97
|
+
return { ok: true, url: activeSession.url };
|
|
98
|
+
}
|
|
99
|
+
// Process died — clean up and start a new one
|
|
100
|
+
activeSession = null;
|
|
101
|
+
clearState();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Redirect stdout/stderr to files so the process has no pipes to the parent.
|
|
105
|
+
// This prevents SIGPIPE when NanoClaw restarts.
|
|
106
|
+
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
107
|
+
const stdoutFd = fs.openSync(STDOUT_FILE, 'w');
|
|
108
|
+
const stderrFd = fs.openSync(STDERR_FILE, 'w');
|
|
109
|
+
|
|
110
|
+
let proc;
|
|
111
|
+
try {
|
|
112
|
+
proc = spawn('claude', ['remote-control', '--name', 'NanoClaw Remote'], {
|
|
113
|
+
cwd,
|
|
114
|
+
stdio: ['pipe', stdoutFd, stderrFd],
|
|
115
|
+
detached: true,
|
|
116
|
+
});
|
|
117
|
+
} catch (err: any) {
|
|
118
|
+
fs.closeSync(stdoutFd);
|
|
119
|
+
fs.closeSync(stderrFd);
|
|
120
|
+
return { ok: false, error: `Failed to start: ${err.message}` };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Auto-accept the "Enable Remote Control?" prompt
|
|
124
|
+
if (proc.stdin) {
|
|
125
|
+
proc.stdin.write('y\n');
|
|
126
|
+
proc.stdin.end();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Close FDs in the parent — the child inherited copies
|
|
130
|
+
fs.closeSync(stdoutFd);
|
|
131
|
+
fs.closeSync(stderrFd);
|
|
132
|
+
|
|
133
|
+
// Fully detach from parent
|
|
134
|
+
proc.unref();
|
|
135
|
+
|
|
136
|
+
const pid = proc.pid;
|
|
137
|
+
if (!pid) {
|
|
138
|
+
return { ok: false, error: 'Failed to get process PID' };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Poll the stdout file for the URL
|
|
142
|
+
return new Promise((resolve) => {
|
|
143
|
+
const startTime = Date.now();
|
|
144
|
+
|
|
145
|
+
const poll = () => {
|
|
146
|
+
// Check if process died
|
|
147
|
+
if (!isProcessAlive(pid)) {
|
|
148
|
+
resolve({ ok: false, error: 'Process exited before producing URL' });
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Check for URL in stdout file
|
|
153
|
+
let content = '';
|
|
154
|
+
try {
|
|
155
|
+
content = fs.readFileSync(STDOUT_FILE, 'utf-8');
|
|
156
|
+
} catch {
|
|
157
|
+
// File might not have content yet
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const match = content.match(URL_REGEX);
|
|
161
|
+
if (match) {
|
|
162
|
+
const session: RemoteControlSession = {
|
|
163
|
+
pid,
|
|
164
|
+
url: match[0],
|
|
165
|
+
startedBy: sender,
|
|
166
|
+
startedInChat: chatJid,
|
|
167
|
+
startedAt: new Date().toISOString(),
|
|
168
|
+
};
|
|
169
|
+
activeSession = session;
|
|
170
|
+
saveState(session);
|
|
171
|
+
|
|
172
|
+
logger.info(
|
|
173
|
+
{ url: match[0], pid, sender, chatJid },
|
|
174
|
+
'Remote Control session started',
|
|
175
|
+
);
|
|
176
|
+
resolve({ ok: true, url: match[0] });
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Timeout check
|
|
181
|
+
if (Date.now() - startTime >= URL_TIMEOUT_MS) {
|
|
182
|
+
try {
|
|
183
|
+
process.kill(-pid, 'SIGTERM');
|
|
184
|
+
} catch {
|
|
185
|
+
try {
|
|
186
|
+
process.kill(pid, 'SIGTERM');
|
|
187
|
+
} catch {
|
|
188
|
+
// already dead
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
resolve({
|
|
192
|
+
ok: false,
|
|
193
|
+
error: 'Timed out waiting for Remote Control URL',
|
|
194
|
+
});
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
setTimeout(poll, URL_POLL_MS);
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
poll();
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function stopRemoteControl():
|
|
206
|
+
| {
|
|
207
|
+
ok: true;
|
|
208
|
+
}
|
|
209
|
+
| { ok: false; error: string } {
|
|
210
|
+
if (!activeSession) {
|
|
211
|
+
return { ok: false, error: 'No active Remote Control session' };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const { pid } = activeSession;
|
|
215
|
+
try {
|
|
216
|
+
process.kill(pid, 'SIGTERM');
|
|
217
|
+
} catch {
|
|
218
|
+
// already dead
|
|
219
|
+
}
|
|
220
|
+
activeSession = null;
|
|
221
|
+
clearState();
|
|
222
|
+
logger.info({ pid }, 'Remote Control session stopped');
|
|
223
|
+
return { ok: true };
|
|
224
|
+
}
|
package/src/router.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Channel, NewMessage } from './types.js';
|
|
2
|
+
import { formatLocalTime } from './timezone.js';
|
|
3
|
+
|
|
4
|
+
export function escapeXml(s: string): string {
|
|
5
|
+
if (!s) return '';
|
|
6
|
+
return s
|
|
7
|
+
.replace(/&/g, '&')
|
|
8
|
+
.replace(/</g, '<')
|
|
9
|
+
.replace(/>/g, '>')
|
|
10
|
+
.replace(/"/g, '"');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function formatMessages(
|
|
14
|
+
messages: NewMessage[],
|
|
15
|
+
timezone: string,
|
|
16
|
+
): string {
|
|
17
|
+
const lines = messages.map((m) => {
|
|
18
|
+
const displayTime = formatLocalTime(m.timestamp, timezone);
|
|
19
|
+
return `<message sender="${escapeXml(m.sender_name)}" time="${escapeXml(displayTime)}">${escapeXml(m.content)}</message>`;
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const header = `<context timezone="${escapeXml(timezone)}" />\n`;
|
|
23
|
+
|
|
24
|
+
return `${header}<messages>\n${lines.join('\n')}\n</messages>`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function stripInternalTags(text: string): string {
|
|
28
|
+
return text.replace(/<internal>[\s\S]*?<\/internal>/g, '').trim();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function formatOutbound(rawText: string): string {
|
|
32
|
+
const text = stripInternalTags(rawText);
|
|
33
|
+
if (!text) return '';
|
|
34
|
+
return text;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function routeOutbound(
|
|
38
|
+
channels: Channel[],
|
|
39
|
+
jid: string,
|
|
40
|
+
text: string,
|
|
41
|
+
): Promise<void> {
|
|
42
|
+
const channel = channels.find((c) => c.ownsJid(jid) && c.isConnected());
|
|
43
|
+
if (!channel) throw new Error(`No channel for JID: ${jid}`);
|
|
44
|
+
return channel.sendMessage(jid, text);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function findChannel(
|
|
48
|
+
channels: Channel[],
|
|
49
|
+
jid: string,
|
|
50
|
+
): Channel | undefined {
|
|
51
|
+
return channels.find((c) => c.ownsJid(jid));
|
|
52
|
+
}
|