mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-18 18:29:35 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cd7997c99f |
@@ -1,243 +0,0 @@
|
||||
---
|
||||
name: add-atomic-chat-tool
|
||||
description: Add Atomic Chat MCP server so the container agent can call local models served by the Atomic Chat desktop app via its OpenAI-compatible API.
|
||||
---
|
||||
|
||||
# Add Atomic Chat Integration
|
||||
|
||||
This skill adds a stdio-based MCP server that exposes models running in the local [Atomic Chat](https://github.com/AtomicBot-ai/Atomic-Chat) desktop app as tools for the container agent. Claude remains the orchestrator but can offload work to local models served by Atomic Chat on `http://127.0.0.1:1337/v1` (OpenAI-compatible).
|
||||
|
||||
Tools exposed:
|
||||
- `atomic_chat_list_models` — list models currently available in Atomic Chat (`GET /v1/models`)
|
||||
- `atomic_chat_generate` — send a prompt to a specified model and return the response (`POST /v1/chat/completions`)
|
||||
|
||||
Model management (download, delete) is done through the **Atomic Chat desktop UI** — the app is a fork of Jan and manages its own model library.
|
||||
|
||||
The skill ships the MCP server source in this folder and copies it into the agent-runner tree at install time, then wires it up with small edits to `index.ts`, `providers/claude.ts`, and `container-runner.ts`. No branch merge — all edits are additive and idempotent.
|
||||
|
||||
## Phase 1: Pre-flight
|
||||
|
||||
### Check if already applied
|
||||
|
||||
Check if `container/agent-runner/src/atomic-chat-mcp-stdio.ts` exists. If it does, skip to Phase 3 (Configure).
|
||||
|
||||
### Check prerequisites
|
||||
|
||||
Verify Atomic Chat is installed and its local API server is running. On the host:
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:1337/v1/models | head
|
||||
```
|
||||
|
||||
If the request fails:
|
||||
|
||||
1. Install Atomic Chat from the [latest release](https://github.com/AtomicBot-ai/Atomic-Chat/releases) (macOS only for now — `atomic-chat.dmg`).
|
||||
2. Open the app.
|
||||
3. Open **Settings → Local API Server** and make sure it's enabled on port `1337`.
|
||||
4. Go to the **Hub** (or **Models**) tab and download at least one model (e.g. Llama 3.2 3B, Qwen 2.5 Coder 7B).
|
||||
5. Load the model once by sending any message in Atomic Chat's UI to warm it up.
|
||||
|
||||
## Phase 2: Apply Code Changes
|
||||
|
||||
### Copy the MCP server source
|
||||
|
||||
```bash
|
||||
cp .claude/skills/add-atomic-chat-tool/atomic-chat-mcp-stdio.ts container/agent-runner/src/atomic-chat-mcp-stdio.ts
|
||||
```
|
||||
|
||||
### Register the MCP server in the agent-runner
|
||||
|
||||
Edit `container/agent-runner/src/index.ts`. Find the `mcpServers` object that currently looks like this:
|
||||
|
||||
```ts
|
||||
const mcpServers: Record<string, { command: string; args: string[]; env: Record<string, string> }> = {
|
||||
nanoclaw: {
|
||||
command: 'bun',
|
||||
args: ['run', mcpServerPath],
|
||||
env: {},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
Add an `atomic_chat` entry alongside `nanoclaw`:
|
||||
|
||||
```ts
|
||||
const mcpServers: Record<string, { command: string; args: string[]; env: Record<string, string> }> = {
|
||||
nanoclaw: {
|
||||
command: 'bun',
|
||||
args: ['run', mcpServerPath],
|
||||
env: {},
|
||||
},
|
||||
atomic_chat: {
|
||||
command: 'bun',
|
||||
args: ['run', path.join(__dirname, 'atomic-chat-mcp-stdio.ts')],
|
||||
env: {
|
||||
...(process.env.ATOMIC_CHAT_HOST ? { ATOMIC_CHAT_HOST: process.env.ATOMIC_CHAT_HOST } : {}),
|
||||
...(process.env.ATOMIC_CHAT_API_KEY ? { ATOMIC_CHAT_API_KEY: process.env.ATOMIC_CHAT_API_KEY } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Add the tool glob to the allowlist
|
||||
|
||||
Edit `container/agent-runner/src/providers/claude.ts`. Find `'mcp__nanoclaw__*',` in the `TOOL_ALLOWLIST` array and add `'mcp__atomic_chat__*',` on the following line:
|
||||
|
||||
```ts
|
||||
'mcp__nanoclaw__*',
|
||||
'mcp__atomic_chat__*',
|
||||
];
|
||||
```
|
||||
|
||||
### Forward host env vars into the container
|
||||
|
||||
Edit `src/container-runner.ts` in `buildContainerArgs`. Find the `TZ` env line:
|
||||
|
||||
```ts
|
||||
args.push('-e', `TZ=${TIMEZONE}`);
|
||||
```
|
||||
|
||||
Add ATOMIC_CHAT forwarding right after it:
|
||||
|
||||
```ts
|
||||
args.push('-e', `TZ=${TIMEZONE}`);
|
||||
|
||||
// Atomic Chat MCP tool: forward host overrides if set (default is host.docker.internal:1337).
|
||||
if (process.env.ATOMIC_CHAT_HOST) {
|
||||
args.push('-e', `ATOMIC_CHAT_HOST=${process.env.ATOMIC_CHAT_HOST}`);
|
||||
}
|
||||
if (process.env.ATOMIC_CHAT_API_KEY) {
|
||||
args.push('-e', `ATOMIC_CHAT_API_KEY=${process.env.ATOMIC_CHAT_API_KEY}`);
|
||||
}
|
||||
```
|
||||
|
||||
### Surface `[ATOMIC]` log lines at info level
|
||||
|
||||
In the same file, find the stderr logger:
|
||||
|
||||
```ts
|
||||
container.stderr?.on('data', (data) => {
|
||||
for (const line of data.toString().trim().split('\n')) {
|
||||
if (line) log.debug(line, { container: agentGroup.folder });
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
Replace it with:
|
||||
|
||||
```ts
|
||||
container.stderr?.on('data', (data) => {
|
||||
for (const line of data.toString().trim().split('\n')) {
|
||||
if (!line) continue;
|
||||
if (line.includes('[ATOMIC]')) {
|
||||
log.info(line, { container: agentGroup.folder });
|
||||
} else {
|
||||
log.debug(line, { container: agentGroup.folder });
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Add env-var stubs to `.env.example`
|
||||
|
||||
Append to `.env.example`:
|
||||
|
||||
```bash
|
||||
# Atomic Chat MCP tool (.claude/skills/add-atomic-chat-tool)
|
||||
# Override the host where Atomic Chat exposes its OpenAI-compatible API.
|
||||
# Default: http://host.docker.internal:1337 (with fallback to localhost)
|
||||
# ATOMIC_CHAT_HOST=http://host.docker.internal:1337
|
||||
|
||||
# Optional API key. Leave unset for a local Atomic Chat install — it does not require auth.
|
||||
# ATOMIC_CHAT_API_KEY=
|
||||
```
|
||||
|
||||
### Validate code changes
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit
|
||||
./container/build.sh
|
||||
```
|
||||
|
||||
All three must be clean before proceeding.
|
||||
|
||||
## Phase 3: Configure
|
||||
|
||||
### Set Atomic Chat host (optional)
|
||||
|
||||
By default, the MCP server connects to `http://host.docker.internal:1337` (Docker Desktop) with a fallback to `localhost`. To use a custom host, add to `.env`:
|
||||
|
||||
```bash
|
||||
ATOMIC_CHAT_HOST=http://your-atomic-chat-host:1337
|
||||
```
|
||||
|
||||
### Set API key (optional)
|
||||
|
||||
Atomic Chat does **not require authentication** when running locally — leave this unset. Only set it if you've put Atomic Chat behind a reverse proxy that enforces auth:
|
||||
|
||||
```bash
|
||||
ATOMIC_CHAT_API_KEY=sk-...
|
||||
```
|
||||
|
||||
### Restart the service
|
||||
|
||||
```bash
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
# Linux: systemctl --user restart nanoclaw
|
||||
```
|
||||
|
||||
## Phase 4: Verify
|
||||
|
||||
### Test inference
|
||||
|
||||
Tell the user:
|
||||
|
||||
> Send a message like: "use atomic chat to tell me the capital of France"
|
||||
>
|
||||
> The agent should use `atomic_chat_list_models` to find available models, then `atomic_chat_generate` to get a response.
|
||||
|
||||
### Check logs if needed
|
||||
|
||||
```bash
|
||||
tail -f logs/nanoclaw.log | grep -i atomic
|
||||
```
|
||||
|
||||
Look for:
|
||||
- `[ATOMIC] Listing models...` — list request started
|
||||
- `[ATOMIC] Found N models` — models discovered
|
||||
- `[ATOMIC] >>> Generating with <model>` — generation started
|
||||
- `[ATOMIC] <<< Done: <model> | Xs | N tokens | M chars` — generation completed
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Agent says "Atomic Chat is not installed" or tries to run a CLI
|
||||
|
||||
The agent is looking for a CLI that doesn't exist instead of using the MCP tools. This means:
|
||||
1. The MCP server wasn't copied — check `container/agent-runner/src/atomic-chat-mcp-stdio.ts` exists
|
||||
2. The MCP server wasn't registered — check `container/agent-runner/src/index.ts` has the `atomic_chat` entry in `mcpServers`
|
||||
3. The allowlist wasn't updated — check `container/agent-runner/src/providers/claude.ts` includes `mcp__atomic_chat__*` in `TOOL_ALLOWLIST`
|
||||
4. The container wasn't rebuilt — run `./container/build.sh`
|
||||
|
||||
### "Failed to connect to Atomic Chat"
|
||||
|
||||
1. Verify the host API is reachable: `curl http://127.0.0.1:1337/v1/models`
|
||||
2. Confirm the Local API Server is enabled in Atomic Chat's settings
|
||||
3. Check Docker can reach the host: `docker run --rm curlimages/curl curl -s http://host.docker.internal:1337/v1/models`
|
||||
4. If using a custom host, check `ATOMIC_CHAT_HOST` in `.env`
|
||||
|
||||
### `model not found` / 404 on generate
|
||||
|
||||
The model ID passed to `atomic_chat_generate` must exactly match one of the IDs returned by `atomic_chat_list_models`. Ask the agent to list models first, then pick one from that list.
|
||||
|
||||
### Slow first response
|
||||
|
||||
Atomic Chat lazy-loads models into memory on first use. The initial call may take longer while the model warms up. Subsequent calls against the same model are fast.
|
||||
|
||||
### Agent doesn't use Atomic Chat tools
|
||||
|
||||
The agent may not know about the tools. Try being explicit: "use the atomic_chat_generate tool with llama3.2-3b-instruct to answer: ..."
|
||||
|
||||
### Context window or output size issues
|
||||
|
||||
Atomic Chat respects each model's native context length. If you hit limits, pass `max_tokens` explicitly when calling `atomic_chat_generate`, or switch to a model with a larger context window in the Atomic Chat UI.
|
||||
@@ -1,229 +0,0 @@
|
||||
/**
|
||||
* Atomic Chat MCP Server for NanoClaw
|
||||
* Exposes local Atomic Chat models (OpenAI-compatible, /v1) as tools for the container agent.
|
||||
* Uses host.docker.internal to reach the host's Atomic Chat desktop app from Docker.
|
||||
*/
|
||||
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { z } from 'zod';
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const ATOMIC_CHAT_HOST =
|
||||
process.env.ATOMIC_CHAT_HOST || 'http://host.docker.internal:1337';
|
||||
const ATOMIC_CHAT_API_KEY = process.env.ATOMIC_CHAT_API_KEY || '';
|
||||
const ATOMIC_CHAT_STATUS_FILE = '/workspace/ipc/atomic_chat_status.json';
|
||||
|
||||
function log(msg: string): void {
|
||||
console.error(`[ATOMIC] ${msg}`);
|
||||
}
|
||||
|
||||
function writeStatus(status: string, detail?: string): void {
|
||||
try {
|
||||
const data = { status, detail, timestamp: new Date().toISOString() };
|
||||
const tmpPath = `${ATOMIC_CHAT_STATUS_FILE}.tmp`;
|
||||
fs.mkdirSync(path.dirname(ATOMIC_CHAT_STATUS_FILE), { recursive: true });
|
||||
fs.writeFileSync(tmpPath, JSON.stringify(data));
|
||||
fs.renameSync(tmpPath, ATOMIC_CHAT_STATUS_FILE);
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
async function atomicFetch(
|
||||
apiPath: string,
|
||||
options?: RequestInit,
|
||||
): Promise<Response> {
|
||||
const url = `${ATOMIC_CHAT_HOST}${apiPath}`;
|
||||
const headers: Record<string, string> = {
|
||||
...((options?.headers as Record<string, string>) || {}),
|
||||
};
|
||||
if (ATOMIC_CHAT_API_KEY) {
|
||||
headers.Authorization = `Bearer ${ATOMIC_CHAT_API_KEY}`;
|
||||
}
|
||||
const finalOptions: RequestInit = { ...options, headers };
|
||||
try {
|
||||
return await fetch(url, finalOptions);
|
||||
} catch (err) {
|
||||
// Fallback to localhost if host.docker.internal fails
|
||||
if (ATOMIC_CHAT_HOST.includes('host.docker.internal')) {
|
||||
const fallbackUrl = url.replace('host.docker.internal', 'localhost');
|
||||
return await fetch(fallbackUrl, finalOptions);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const server = new McpServer({
|
||||
name: 'atomic_chat',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
server.tool(
|
||||
'atomic_chat_list_models',
|
||||
'List all models available in the local Atomic Chat desktop app. Use this to see which models are loaded before calling atomic_chat_generate.',
|
||||
{},
|
||||
async () => {
|
||||
log('Listing models...');
|
||||
writeStatus('listing', 'Listing available models');
|
||||
try {
|
||||
const res = await atomicFetch('/v1/models');
|
||||
if (!res.ok) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Atomic Chat API error: ${res.status} ${res.statusText}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
data?: Array<{ id: string; owned_by?: string }>;
|
||||
};
|
||||
const models = data.data || [];
|
||||
|
||||
if (models.length === 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: 'No models available. Open Atomic Chat on the host and download a model from the Hub.',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const list = models
|
||||
.map((m) => `- ${m.id}${m.owned_by ? ` (${m.owned_by})` : ''}`)
|
||||
.join('\n');
|
||||
|
||||
log(`Found ${models.length} models`);
|
||||
return {
|
||||
content: [
|
||||
{ type: 'text' as const, text: `Available models:\n${list}` },
|
||||
],
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Failed to connect to Atomic Chat at ${ATOMIC_CHAT_HOST}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'atomic_chat_generate',
|
||||
'Send a prompt to a local Atomic Chat model and get a response. Good for cheaper/faster tasks like summarization, translation, or general queries. Use atomic_chat_list_models first to see available models.',
|
||||
{
|
||||
model: z
|
||||
.string()
|
||||
.describe(
|
||||
'The model ID as returned by atomic_chat_list_models (e.g. "llama3.2-3b-instruct")',
|
||||
),
|
||||
prompt: z.string().describe('The prompt to send to the model'),
|
||||
system: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Optional system prompt to set model behavior'),
|
||||
temperature: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe('Sampling temperature (0.0–2.0). Defaults to model default.'),
|
||||
max_tokens: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe('Maximum number of tokens to generate in the response.'),
|
||||
},
|
||||
async (args) => {
|
||||
log(`>>> Generating with ${args.model} (${args.prompt.length} chars)...`);
|
||||
writeStatus('generating', `Generating with ${args.model}`);
|
||||
try {
|
||||
const messages: Array<{ role: string; content: string }> = [];
|
||||
if (args.system) {
|
||||
messages.push({ role: 'system', content: args.system });
|
||||
}
|
||||
messages.push({ role: 'user', content: args.prompt });
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
model: args.model,
|
||||
messages,
|
||||
stream: false,
|
||||
};
|
||||
if (args.temperature !== undefined) body.temperature = args.temperature;
|
||||
if (args.max_tokens !== undefined) body.max_tokens = args.max_tokens;
|
||||
|
||||
const startedAt = Date.now();
|
||||
const res = await atomicFetch('/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text();
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Atomic Chat error (${res.status}): ${errorText}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
choices?: Array<{ message?: { content?: string } }>;
|
||||
usage?: {
|
||||
prompt_tokens?: number;
|
||||
completion_tokens?: number;
|
||||
total_tokens?: number;
|
||||
};
|
||||
};
|
||||
|
||||
const response = data.choices?.[0]?.message?.content ?? '';
|
||||
const elapsedSec = ((Date.now() - startedAt) / 1000).toFixed(1);
|
||||
const completionTokens = data.usage?.completion_tokens;
|
||||
|
||||
const meta = `\n\n[${args.model} | ${elapsedSec}s${
|
||||
completionTokens !== undefined ? ` | ${completionTokens} tokens` : ''
|
||||
}]`;
|
||||
|
||||
log(
|
||||
`<<< Done: ${args.model} | ${elapsedSec}s | ${
|
||||
completionTokens ?? '?'
|
||||
} tokens | ${response.length} chars`,
|
||||
);
|
||||
writeStatus(
|
||||
'done',
|
||||
`${args.model} | ${elapsedSec}s | ${completionTokens ?? '?'} tokens`,
|
||||
);
|
||||
|
||||
return { content: [{ type: 'text' as const, text: response + meta }] };
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Failed to call Atomic Chat: ${err instanceof Error ? err.message : String(err)}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
@@ -1,161 +0,0 @@
|
||||
---
|
||||
name: add-codex
|
||||
description: Use Codex (CLI + AppServer) as the full agent provider — planning, tool orchestration, native compaction, MCP tools, session resume — in place of the Claude Agent SDK. ChatGPT subscription or OPENAI_API_KEY. Per-group via agent_provider. Distinct from using OpenAI as an MCP tool (where Claude remains the planner).
|
||||
---
|
||||
|
||||
# Codex agent provider
|
||||
|
||||
NanoClaw runs agents in a long-lived **poll loop** inside the container. The backend is selected with **`AGENT_PROVIDER`** (`claude` | `opencode` | `codex` | `mock`).
|
||||
|
||||
Trunk ships with only the `claude` provider baked in. This skill copies the Codex provider files in from the `providers` branch, wires them into the host and container barrels, updates the Dockerfile to install the Codex CLI, and rebuilds the image.
|
||||
|
||||
The Codex provider runs `codex app-server` as a child process and speaks JSON-RPC over stdio. That gives it native session resume, streaming events, MCP tool access, and `thread/compact/start` compaction — same feature bar as the Claude Agent SDK, without the Anthropic-only lock-in.
|
||||
|
||||
## Install
|
||||
|
||||
### Pre-flight
|
||||
|
||||
If all of the following are already present, skip to **Configuration**:
|
||||
|
||||
- `src/providers/codex.ts`
|
||||
- `container/agent-runner/src/providers/codex.ts`
|
||||
- `container/agent-runner/src/providers/codex-app-server.ts`
|
||||
- `container/agent-runner/src/providers/codex.factory.test.ts`
|
||||
- `import './codex.js';` line in `src/providers/index.ts`
|
||||
- `import './codex.js';` line in `container/agent-runner/src/providers/index.ts`
|
||||
- `ARG CODEX_VERSION` and `"@openai/codex@${CODEX_VERSION}"` in the pnpm global-install block in `container/Dockerfile`
|
||||
|
||||
Missing pieces — continue below. All steps are idempotent; re-running is safe.
|
||||
|
||||
### 1. Fetch the providers branch
|
||||
|
||||
```bash
|
||||
git fetch origin providers
|
||||
```
|
||||
|
||||
### 2. Copy the Codex source files
|
||||
|
||||
Wholesale copies (owned entirely by this skill — user edits to these files won't survive a re-run, as designed):
|
||||
|
||||
```bash
|
||||
git show origin/providers:src/providers/codex.ts > src/providers/codex.ts
|
||||
git show origin/providers:container/agent-runner/src/providers/codex.ts > container/agent-runner/src/providers/codex.ts
|
||||
git show origin/providers:container/agent-runner/src/providers/codex-app-server.ts > container/agent-runner/src/providers/codex-app-server.ts
|
||||
git show origin/providers:container/agent-runner/src/providers/codex.factory.test.ts > container/agent-runner/src/providers/codex.factory.test.ts
|
||||
```
|
||||
|
||||
### 3. Append the self-registration imports
|
||||
|
||||
Each barrel gets one line — alphabetical placement keeps diffs small.
|
||||
|
||||
`src/providers/index.ts`:
|
||||
|
||||
```typescript
|
||||
import './codex.js';
|
||||
```
|
||||
|
||||
`container/agent-runner/src/providers/index.ts`:
|
||||
|
||||
```typescript
|
||||
import './codex.js';
|
||||
```
|
||||
|
||||
### 4. Add the Codex CLI to the container Dockerfile
|
||||
|
||||
Two edits to `container/Dockerfile`, both idempotent (skip if already present):
|
||||
|
||||
**(a)** In the "Pin CLI versions" ARG block (around line 18), add after `ARG CLAUDE_CODE_VERSION=...`:
|
||||
|
||||
```dockerfile
|
||||
ARG CODEX_VERSION=0.124.0
|
||||
```
|
||||
|
||||
**(b)** Add a new standalone `RUN` block for the Codex CLI, after the existing per-CLI install blocks (around line 106, right after the `@anthropic-ai/claude-code` block). The Dockerfile splits each global CLI into its own layer for cache granularity — keep that pattern; do not collapse them into a single combined `pnpm install -g` call:
|
||||
|
||||
```dockerfile
|
||||
RUN --mount=type=cache,target=/root/.cache/pnpm \
|
||||
pnpm install -g "@openai/codex@${CODEX_VERSION}"
|
||||
```
|
||||
|
||||
Note: **no agent-runner package dependency** — Codex is a CLI binary, not a library. Unlike OpenCode, there's nothing to add to `container/agent-runner/package.json`.
|
||||
|
||||
### 5. Build
|
||||
|
||||
```bash
|
||||
pnpm run build # host
|
||||
pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit # container typecheck
|
||||
./container/build.sh # agent image
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Codex supports two primary auth paths and one experimental BYO-endpoint path. Pick the one that matches your setup.
|
||||
|
||||
### Option A — ChatGPT subscription (recommended for individuals)
|
||||
|
||||
On the host (not inside the container), run Codex's OAuth login:
|
||||
|
||||
```bash
|
||||
codex login
|
||||
```
|
||||
|
||||
This writes `~/.codex/auth.json` with a subscription token. The host-side Codex provider ([src/providers/codex.ts](../../../src/providers/codex.ts)) copies `auth.json` into a per-session `~/.codex` directory mounted into the container — your host's own Codex CLI is never touched.
|
||||
|
||||
No `.env` variables required for this mode.
|
||||
|
||||
### Option B — API key (recommended for CI or API billing)
|
||||
|
||||
```env
|
||||
OPENAI_API_KEY=sk-...
|
||||
CODEX_MODEL=gpt-5.4-mini
|
||||
```
|
||||
|
||||
The host forwards both variables into the container. If both subscription (`auth.json`) and `OPENAI_API_KEY` are present, Codex prefers the subscription.
|
||||
|
||||
### Option C — BYO OpenAI-compatible endpoint (experimental)
|
||||
|
||||
Codex's built-in `openai` provider honors the `OPENAI_BASE_URL` env var directly. Point it at any OpenAI-compatible endpoint — Groq, Together, self-hosted vLLM, an OpenAI proxy, etc.
|
||||
|
||||
```env
|
||||
OPENAI_API_KEY=...
|
||||
OPENAI_BASE_URL=https://api.groq.com/openai/v1
|
||||
CODEX_MODEL=llama-3.3-70b-versatile
|
||||
```
|
||||
|
||||
Codex also ships first-class local-runner flags — `codex --oss --local-provider ollama` or `--local-provider lmstudio` — that auto-detect a local server. To use those inside NanoClaw, set `CODEX_MODEL` to a model your local runner serves and add the corresponding base URL; see the Codex CLI docs for the full `model_provider = oss` configuration.
|
||||
|
||||
**Experimental caveat:** tool-calling quality depends on the model and endpoint. Not every OpenAI-compat provider implements the full function-calling spec, and smaller models (< 30B) often struggle with multi-step tool orchestration. Test before committing.
|
||||
|
||||
### Per group / per session
|
||||
|
||||
Set `"provider": "codex"` in the group's **`container.json`** (`groups/<folder>/container.json`) — the in-container runner reads `provider` from there, not from the DB. The DB columns **`agent_groups.agent_provider`** and **`sessions.agent_provider`** (session overrides group) only drive host-side provider contribution — per-session `~/.codex` mount, `OPENAI_*` / `CODEX_MODEL` env passthrough — and do not propagate into `container.json` at spawn time. Set both, or just edit `container.json`; if they disagree, the runner uses `container.json` and the host-side resolver falls back through session → group → `container.json` → `'claude'`.
|
||||
|
||||
`CODEX_MODEL` applies process-wide via `.env`; if you need different models for different groups, set them via `container_config.env` on the group.
|
||||
|
||||
Extra MCP servers still come from **`NANOCLAW_MCP_SERVERS`** / `container_config.mcpServers` on the host. The runner merges them into the same `mcpServers` object passed to all providers.
|
||||
|
||||
## Operational notes
|
||||
|
||||
- **Spawn-per-query:** Codex's app-server is spawned fresh per query invocation, matching the OpenCode pattern. No long-lived daemon to keep healthy across sessions.
|
||||
- **Per-session `~/.codex` isolation:** each group gets its own copy of the host's `auth.json`. The container can rewrite `config.toml` freely on every wake without touching the host's Codex config.
|
||||
- **Native compaction:** kicks in automatically at 40K cumulative input tokens between turns, via `thread/compact/start`. If compaction fails, the provider logs and continues uncompacted — no fatal error.
|
||||
- **Approvals:** auto-accepted inside the container (the container is the sandbox; same posture as Claude/OpenCode).
|
||||
- **Mid-turn input:** Codex turns don't accept mid-turn messages. Follow-up `push()` calls queue and drain between turns, matching the OpenCode pattern. The poll-loop only pushes between turns anyway, so no messages are dropped.
|
||||
- **Stale thread recovery:** `isSessionInvalid` matches on stale-thread-ID errors (`thread not found`, `unknown thread`, etc.) so a cold-started app-server can recover cleanly when it sees a stored continuation it no longer has.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
grep -q "./codex.js" container/agent-runner/src/providers/index.ts && echo "container barrel: OK"
|
||||
grep -q "./codex.js" src/providers/index.ts && echo "host barrel: OK"
|
||||
grep -q "@openai/codex@" container/Dockerfile && echo "Dockerfile install: OK"
|
||||
cd container/agent-runner && bun test src/providers/codex.factory.test.ts && cd -
|
||||
```
|
||||
|
||||
After the image rebuild, set `agent_provider = 'codex'` on a test group and send a message. Successful round-trip looks like:
|
||||
|
||||
- `init` event with a stable thread ID as continuation
|
||||
- One or more `activity` / `progress` events during the turn
|
||||
- `result` event with the model's reply
|
||||
|
||||
If the agent hangs or errors, check `~/.codex/auth.json` exists on the host (Option A) or that `OPENAI_API_KEY` is forwarding correctly (Option B) — `docker exec` into a running container and `env | grep -i openai` to confirm.
|
||||
@@ -1,62 +0,0 @@
|
||||
# Remove DeltaChat
|
||||
|
||||
## 1. Disable the adapter
|
||||
|
||||
Comment out the import in `src/channels/index.ts`:
|
||||
|
||||
```typescript
|
||||
// import './deltachat.js';
|
||||
```
|
||||
|
||||
## 2. Remove credentials
|
||||
|
||||
Remove the `DC_*` lines from `.env`:
|
||||
|
||||
```bash
|
||||
DC_EMAIL
|
||||
DC_PASSWORD
|
||||
DC_IMAP_HOST
|
||||
DC_IMAP_PORT
|
||||
DC_SMTP_HOST
|
||||
DC_SMTP_PORT
|
||||
```
|
||||
|
||||
## 3. Rebuild and restart
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
|
||||
# Linux
|
||||
systemctl --user restart nanoclaw
|
||||
|
||||
# macOS
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
|
||||
```
|
||||
|
||||
## 4. Remove account data (optional)
|
||||
|
||||
To fully remove all account data including DeltaChat encryption keys:
|
||||
|
||||
```bash
|
||||
rm -rf dc-account/
|
||||
```
|
||||
|
||||
> **Warning:** This deletes the Autocrypt keys. Contacts who have verified your bot's key will need to re-verify if the same email address is re-used with a new account.
|
||||
|
||||
To keep the account for later reinstall, leave `dc-account/` intact.
|
||||
|
||||
## 5. Remove the package (optional)
|
||||
|
||||
```bash
|
||||
pnpm remove @deltachat/stdio-rpc-server
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
After removal, confirm the adapter is no longer starting:
|
||||
|
||||
```bash
|
||||
grep "deltachat" logs/nanoclaw.log | tail -5
|
||||
```
|
||||
|
||||
Expected: no `Channel adapter started` entry after the last restart.
|
||||
@@ -1,254 +0,0 @@
|
||||
---
|
||||
name: add-deltachat
|
||||
description: Add DeltaChat channel integration via @deltachat/stdio-rpc-server. Native adapter — no Chat SDK bridge. Email-based messaging with end-to-end encryption.
|
||||
---
|
||||
|
||||
# Add DeltaChat Channel
|
||||
|
||||
The adapter drives the `@deltachat/stdio-rpc-server` JSON-RPC subprocess directly — pure Node.js against the DeltaChat core library. Messages are delivered over email with Autocrypt/OpenPGP encryption.
|
||||
|
||||
## Install
|
||||
|
||||
### Pre-flight (idempotent)
|
||||
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
|
||||
- `src/channels/deltachat.ts` exists
|
||||
- `src/channels/index.ts` contains `import './deltachat.js';`
|
||||
- `@deltachat/stdio-rpc-server` is listed in `package.json` dependencies
|
||||
|
||||
Otherwise continue. Every step below is safe to re-run.
|
||||
|
||||
### 1. Fetch the channels branch
|
||||
|
||||
```bash
|
||||
git fetch origin channels
|
||||
```
|
||||
|
||||
### 2. Copy the adapter
|
||||
|
||||
```bash
|
||||
git show origin/channels:src/channels/deltachat.ts > src/channels/deltachat.ts
|
||||
```
|
||||
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if already present):
|
||||
|
||||
```typescript
|
||||
import './deltachat.js';
|
||||
```
|
||||
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @deltachat/stdio-rpc-server@2.49.0
|
||||
```
|
||||
|
||||
### 5. Build
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
## Account Setup
|
||||
|
||||
A dedicated email account is strongly recommended — it will accumulate DeltaChat-formatted messages and store encryption keys. Not all providers work well with DeltaChat; check https://providers.delta.chat/ before picking one.
|
||||
|
||||
**Default security modes:** IMAP uses SSL/TLS (port 993), SMTP uses STARTTLS (port 587). Both are configurable via `.env` — see Credentials below.
|
||||
|
||||
To find the correct hostnames for a domain:
|
||||
|
||||
```bash
|
||||
node -e "require('dns').resolveMx('example.com', (e,r) => console.log(r))"
|
||||
```
|
||||
|
||||
Most providers publish their IMAP/SMTP hostnames in their help docs under "manual setup" or "IMAP access."
|
||||
|
||||
## Credentials
|
||||
|
||||
Add to `.env`:
|
||||
|
||||
```bash
|
||||
DC_EMAIL=bot@example.com
|
||||
DC_PASSWORD=your-app-password
|
||||
DC_IMAP_HOST=imap.example.com
|
||||
DC_IMAP_PORT=993
|
||||
DC_IMAP_SECURITY=1 # 1=SSL/TLS (default), 2=STARTTLS, 3=plain
|
||||
DC_SMTP_HOST=smtp.example.com
|
||||
DC_SMTP_PORT=587
|
||||
DC_SMTP_SECURITY=2 # 2=STARTTLS (default), 1=SSL/TLS, 3=plain
|
||||
```
|
||||
|
||||
Security settings are applied on every startup, so changing them in `.env` and restarting takes effect without wiping the account.
|
||||
|
||||
Sync to container: `mkdir -p data/env && cp .env data/env/env`
|
||||
|
||||
### Optional settings
|
||||
|
||||
The following are read from the process environment (not `.env`). To override them, add `Environment=` lines to the systemd service unit or your launchd plist:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `DC_ACCOUNT_DIR` | `dc-account` | Directory for DeltaChat account data (IMAP state, keys, blobs) |
|
||||
| `DC_DISPLAY_NAME` | `NanoClaw` | Bot display name shown in DeltaChat |
|
||||
| `DC_AVATAR_PATH` | _(none)_ | Absolute path to avatar image; set at startup only |
|
||||
|
||||
The `/set-avatar` command (send an image with that caption) is the easiest way to set the avatar at runtime without modifying the service file. Only users with `owner` or global `admin` role can use it.
|
||||
|
||||
### Restart
|
||||
|
||||
```bash
|
||||
# Linux
|
||||
systemctl --user restart nanoclaw
|
||||
|
||||
# macOS
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
|
||||
```
|
||||
|
||||
On first start the adapter configures the email account (IMAP/SMTP credentials, calls `configure()`). Subsequent starts skip straight to `startIo()`. Account data is stored in `dc-account/` in the project root (or your `DC_ACCOUNT_DIR`).
|
||||
|
||||
## Wiring
|
||||
|
||||
### DMs
|
||||
|
||||
**DeltaChat contacts cannot be added by email alone** — to start a chat, the user must open the bot's invite link in their DeltaChat app or scan its QR code. This triggers the SecureJoin handshake.
|
||||
|
||||
#### Step 1 — Get the invite link
|
||||
|
||||
After the service starts, the adapter logs the invite URL and writes a QR SVG:
|
||||
|
||||
```bash
|
||||
grep "invite link" logs/nanoclaw.log | tail -1
|
||||
# url field contains the https://i.delta.chat/... invite link
|
||||
# also written to dc-account/invite-qr.svg (or $DC_ACCOUNT_DIR/invite-qr.svg)
|
||||
```
|
||||
|
||||
The invite URL is stable (tied to the bot's email and encryption keys) so it stays valid across restarts.
|
||||
|
||||
#### Step 2 — Add the bot in DeltaChat
|
||||
|
||||
Two options for the user to connect:
|
||||
|
||||
- **Link**: Copy the `https://i.delta.chat/...` URL and open it on the device running DeltaChat. The app recognises it and shows a "Start chat" prompt.
|
||||
- **QR code**: Open `dc-account/invite-qr.svg` in a browser or image viewer, display it on screen, and scan it from the DeltaChat app using the QR-scan button on the new-chat screen.
|
||||
|
||||
After accepting, DeltaChat exchanges keys and creates the chat automatically.
|
||||
|
||||
#### Step 3 — Wire the chat to an agent
|
||||
|
||||
Once the first message arrives the router auto-creates a `messaging_groups` row. Look up the chat ID:
|
||||
|
||||
```bash
|
||||
sqlite3 data/v2.db \
|
||||
"SELECT platform_id, name FROM messaging_groups WHERE channel_type='deltachat' AND is_group=0 ORDER BY created_at DESC LIMIT 5"
|
||||
```
|
||||
|
||||
Then run `/init-first-agent` — it creates the agent group, grants the user owner access, and wires the messaging group in one step:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/init-first-agent.ts \
|
||||
--channel deltachat \
|
||||
--user-id deltachat:user@example.com \
|
||||
--platform-id <platform_id from above> \
|
||||
--display-name "Your Name"
|
||||
```
|
||||
|
||||
### Groups
|
||||
|
||||
Add the bot email to a DeltaChat group. When any member sends a message, the router creates a `messaging_groups` row with `is_group = 1`. Run `/manage-channels` to wire it to an agent group.
|
||||
|
||||
## Next Steps
|
||||
|
||||
If you're in the middle of `/setup`, return to the setup flow now.
|
||||
|
||||
Otherwise, run `/init-first-agent` to create an agent and wire it to your DeltaChat DM (see Wiring above), or `/manage-channels` to wire this channel to an existing agent group.
|
||||
|
||||
## Channel Info
|
||||
|
||||
- **type**: `deltachat`
|
||||
- **terminology**: DeltaChat calls them "chats" (1:1 DMs) and "groups"
|
||||
- **supports-threads**: no — DeltaChat has no thread model
|
||||
- **platform-id-format**: numeric chat ID as a string (e.g. `"12"`) — the DeltaChat core's internal chat identifier
|
||||
- **user-id-format**: `deltachat:{email}` — the contact's email address
|
||||
- **how-to-find-id**: Send a message from DeltaChat to the bot email, then query `messaging_groups` as shown above
|
||||
- **typical-use**: Personal assistant over DeltaChat DMs; small groups where participants use DeltaChat
|
||||
- **default-isolation**: One agent per bot identity. Multiple chats with the same operator can share an agent group; groups with other people should typically use `isolated` session mode
|
||||
|
||||
### Features
|
||||
|
||||
- File attachments — inbound and outbound; inbound waits up to 30 seconds for large-message download to complete
|
||||
- Invite link logged on every startup — URL + QR SVG written to `dc-account/invite-qr.svg`; see Wiring for the bootstrap flow
|
||||
- `/set-avatar` — send an image with this caption to change the bot's DeltaChat avatar (admin/owner only)
|
||||
- Connectivity watchdog — restarts IO if IMAP goes quiet for 20 minutes or connectivity drops below threshold for two consecutive 5-minute checks
|
||||
- Network nudge — `maybeNetwork()` called every 10 minutes to recover from prolonged idle
|
||||
|
||||
Not supported: DeltaChat reactions, message editing/deletion, read receipts.
|
||||
|
||||
### Connectivity model
|
||||
|
||||
`isConnected()` returns `true` when the internal connectivity value is ≥ 3000:
|
||||
|
||||
| Range | Meaning |
|
||||
|-------|---------|
|
||||
| 1000–1999 | Not connected |
|
||||
| 2000–2999 | Connecting |
|
||||
| 3000–3999 | Working (IMAP fetching) |
|
||||
| ≥ 4000 | Fully connected (IMAP IDLE) |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Adapter not starting — credentials missing
|
||||
|
||||
```bash
|
||||
grep "Channel credentials missing" logs/nanoclaw.log | grep deltachat
|
||||
```
|
||||
|
||||
All six required vars (`DC_EMAIL`, `DC_PASSWORD`, `DC_IMAP_HOST`, `DC_IMAP_PORT`, `DC_SMTP_HOST`, `DC_SMTP_PORT`) must be present in `.env`.
|
||||
|
||||
### Account configure fails
|
||||
|
||||
```bash
|
||||
grep "DeltaChat" logs/nanoclaw.log | tail -20
|
||||
```
|
||||
|
||||
Common causes:
|
||||
- Wrong IMAP/SMTP hostnames — double-check provider docs
|
||||
- App password not generated — Gmail and some others require this when 2FA is enabled
|
||||
- Port/security mismatch — defaults are port 993 + SSL/TLS for IMAP and port 587 + STARTTLS for SMTP; override with `DC_IMAP_PORT`/`DC_IMAP_SECURITY` or `DC_SMTP_PORT`/`DC_SMTP_SECURITY` in `.env`
|
||||
|
||||
### Provider uses SMTP port 465 (SSL/TLS) instead of 587
|
||||
|
||||
Set `DC_SMTP_SECURITY=1` and `DC_SMTP_PORT=465` in `.env`, then restart.
|
||||
|
||||
### Messages not arriving
|
||||
|
||||
1. Check the service is running and the adapter started: `grep "Channel adapter started.*deltachat" logs/nanoclaw.log`
|
||||
2. Check connectivity: `grep "DeltaChat: IO started" logs/nanoclaw.log`
|
||||
3. Check the sender has been granted access — run `/init-first-agent` to create their user record and wire the chat
|
||||
4. Verify the messaging group is wired: `sqlite3 data/v2.db "SELECT mg.platform_id, mga.agent_group_id FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id WHERE mg.channel_type='deltachat'"`
|
||||
|
||||
### Stale lock file after crash
|
||||
|
||||
```bash
|
||||
rm -f dc-account/accounts.lock
|
||||
systemctl --user restart nanoclaw
|
||||
```
|
||||
|
||||
### Bot not responding after restart
|
||||
|
||||
The account is already configured — IO restarts automatically on service start. If the RPC subprocess is stuck, restart the service. Check for errors:
|
||||
|
||||
```bash
|
||||
grep "DeltaChat" logs/nanoclaw.error.log | tail -20
|
||||
```
|
||||
|
||||
### Messages received but agent not responding
|
||||
|
||||
The messaging group exists but may not be wired to an agent group. Run:
|
||||
|
||||
```bash
|
||||
sqlite3 data/v2.db "SELECT id, platform_id, name FROM messaging_groups WHERE channel_type='deltachat'"
|
||||
```
|
||||
|
||||
If the group has no entry in `messaging_group_agents`, wire it with `/manage-channels`.
|
||||
@@ -1,54 +0,0 @@
|
||||
# Verify DeltaChat
|
||||
|
||||
## 1. Check the adapter started
|
||||
|
||||
```bash
|
||||
grep "Channel adapter started.*deltachat" logs/nanoclaw.log | tail -1
|
||||
```
|
||||
|
||||
Expected: `Channel adapter started { channel: 'deltachat', type: 'deltachat' }`
|
||||
|
||||
## 2. Check IMAP/SMTP connectivity
|
||||
|
||||
Replace with your provider's hostnames from `.env`:
|
||||
|
||||
```bash
|
||||
DC_IMAP=$(grep '^DC_IMAP_HOST=' .env | cut -d= -f2)
|
||||
DC_SMTP=$(grep '^DC_SMTP_HOST=' .env | cut -d= -f2)
|
||||
|
||||
bash -c "echo >/dev/tcp/$DC_IMAP/993" && echo "IMAP open" || echo "IMAP blocked"
|
||||
bash -c "echo >/dev/tcp/$DC_SMTP/587" && echo "SMTP open" || echo "SMTP blocked"
|
||||
```
|
||||
|
||||
## 3. End-to-end message test
|
||||
|
||||
1. Open DeltaChat on your device
|
||||
2. Add the bot email address as a contact
|
||||
3. Send a message
|
||||
4. The bot should respond within a few seconds
|
||||
|
||||
If nothing arrives, check:
|
||||
|
||||
```bash
|
||||
grep "DeltaChat" logs/nanoclaw.log | tail -20
|
||||
grep "DeltaChat" logs/nanoclaw.error.log | tail -10
|
||||
```
|
||||
|
||||
## 4. Check messaging group was created
|
||||
|
||||
```bash
|
||||
sqlite3 data/v2.db \
|
||||
"SELECT id, platform_id, name FROM messaging_groups WHERE channel_type='deltachat' ORDER BY created_at DESC LIMIT 5"
|
||||
```
|
||||
|
||||
If a row appears, the inbound routing is working. If not, the adapter isn't receiving the message — check logs for `DeltaChat: error handling incoming message`.
|
||||
|
||||
## 5. Verify user access
|
||||
|
||||
If the message arrived but the agent didn't respond, the sender may not have access:
|
||||
|
||||
```bash
|
||||
sqlite3 data/v2.db "SELECT id, display_name FROM users WHERE id LIKE 'deltachat:%'"
|
||||
```
|
||||
|
||||
Grant access as shown in the SKILL.md "Grant user access" section.
|
||||
@@ -1,210 +0,0 @@
|
||||
---
|
||||
name: add-gcal-tool
|
||||
description: Add Google Calendar as an MCP tool (list calendars, list/search/create events, free/busy queries) using OneCLI-managed OAuth. Multi-calendar and multi-account supported. Mirrors /add-gmail-tool's stub pattern — no raw credentials ever reach the container; OneCLI injects real tokens at request time.
|
||||
---
|
||||
|
||||
# Add Google Calendar Tool (OneCLI-native)
|
||||
|
||||
This skill wires [`@cocal/google-calendar-mcp`](https://github.com/cocal-com/google-calendar-mcp) into selected agent groups. The MCP server reads stub credentials containing the `onecli-managed` placeholder; the OneCLI gateway intercepts outbound calls to `calendar.googleapis.com` / `oauth2.googleapis.com` and swaps the bearer for the real OAuth token from its vault.
|
||||
|
||||
**Why this package (and not gongrzhe's):** `@gongrzhe/server-calendar-autoauth-mcp` only supports the `primary` calendar and exposes 5 tools (no `list_calendars`). `@cocal/google-calendar-mcp` explicitly supports multi-calendar and multi-account, and is actively maintained.
|
||||
|
||||
Tools exposed (surfaced as `mcp__calendar__<name>`, exact set depends on version — run `tools/list` against the MCP server to enumerate): `list-calendars`, `list-events`, `search-events`, `create-event`, `update-event`, `delete-event`, `get-event`, `list-colors`, `get-freebusy`, `get-current-time`, plus multi-account management tools.
|
||||
|
||||
**Why this pattern:** v2's invariant is that containers never receive raw API keys (CHANGELOG 2.0.0). Same stub pattern `/add-gmail-tool` uses. This skill is deliberately a sibling, not a combined "Google Workspace" skill — installs independently and removes cleanly.
|
||||
|
||||
## Phase 1: Pre-flight
|
||||
|
||||
### Verify OneCLI has Google Calendar connected
|
||||
|
||||
```bash
|
||||
onecli apps get --provider google-calendar
|
||||
```
|
||||
|
||||
Expected: `"connection": { "status": "connected" }` with scopes including `calendar.readonly` and `calendar.events`.
|
||||
|
||||
If not connected, tell the user:
|
||||
|
||||
> Open the OneCLI web UI at http://127.0.0.1:10254, go to Apps → Google Calendar, and click Connect. Sign in with the Google account the agent should act as. `calendar.readonly` + `calendar.events` are the minimum useful scopes.
|
||||
|
||||
### Verify stub credentials exist
|
||||
|
||||
The stub lives at `~/.calendar-mcp/` by convention (shared with `/add-gmail-tool`'s sibling). cocal doesn't default to this path (it uses `~/.config/google-calendar-mcp/tokens.json`) — we override via env vars below so it reads our stubs instead.
|
||||
|
||||
```bash
|
||||
ls -la ~/.calendar-mcp/gcp-oauth.keys.json ~/.calendar-mcp/credentials.json 2>&1
|
||||
```
|
||||
|
||||
If both exist with `onecli-managed`:
|
||||
|
||||
```bash
|
||||
grep -l onecli-managed ~/.calendar-mcp/gcp-oauth.keys.json ~/.calendar-mcp/credentials.json
|
||||
```
|
||||
|
||||
...skip to Phase 2. If either file has real credentials (no `onecli-managed`), **STOP** — back up and delete before proceeding.
|
||||
|
||||
If absent, write them:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.calendar-mcp
|
||||
cat > ~/.calendar-mcp/gcp-oauth.keys.json <<'EOF'
|
||||
{
|
||||
"installed": {
|
||||
"client_id": "onecli-managed.apps.googleusercontent.com",
|
||||
"client_secret": "onecli-managed",
|
||||
"redirect_uris": ["http://localhost:3000/oauth2callback"]
|
||||
}
|
||||
}
|
||||
EOF
|
||||
cat > ~/.calendar-mcp/credentials.json <<'EOF'
|
||||
{
|
||||
"access_token": "onecli-managed",
|
||||
"refresh_token": "onecli-managed",
|
||||
"token_type": "Bearer",
|
||||
"expiry_date": 99999999999999,
|
||||
"scope": "https://www.googleapis.com/auth/calendar.readonly https://www.googleapis.com/auth/calendar.events"
|
||||
}
|
||||
EOF
|
||||
chmod 600 ~/.calendar-mcp/*.json
|
||||
```
|
||||
|
||||
### Verify mount allowlist covers the path
|
||||
|
||||
```bash
|
||||
cat ~/.config/nanoclaw/mount-allowlist.json
|
||||
```
|
||||
|
||||
`~/.calendar-mcp` must sit under an `allowedRoots` entry.
|
||||
|
||||
### Check agent secret-mode
|
||||
|
||||
For each target agent group, confirm OneCLI will inject the Google Calendar token:
|
||||
|
||||
```bash
|
||||
onecli agents list
|
||||
```
|
||||
|
||||
`secretMode: all` is sufficient. If `selective`, explicitly assign the Calendar secret.
|
||||
|
||||
## Phase 2: Apply Code Changes
|
||||
|
||||
### Check if already applied
|
||||
|
||||
```bash
|
||||
grep -q 'CALENDAR_MCP_VERSION' container/Dockerfile && \
|
||||
grep -q "mcp__calendar__\*" container/agent-runner/src/providers/claude.ts && \
|
||||
echo "ALREADY APPLIED — skip to Phase 3"
|
||||
```
|
||||
|
||||
### Add MCP server to Dockerfile
|
||||
|
||||
Edit `container/Dockerfile`. Find the pinned-version ARG block and add:
|
||||
|
||||
```dockerfile
|
||||
ARG CALENDAR_MCP_VERSION=2.6.1
|
||||
```
|
||||
|
||||
If `/add-gmail-tool` has already been applied, the pnpm global-install block already exists with its `zod-to-json-schema@3.22.5` pin. Just append the calendar package — **the calendar-mcp uses `zod@4.x` and does NOT need that pin**, but it's harmless to share the block:
|
||||
|
||||
```dockerfile
|
||||
RUN --mount=type=cache,target=/root/.cache/pnpm \
|
||||
pnpm install -g \
|
||||
"@gongrzhe/server-gmail-autoauth-mcp@${GMAIL_MCP_VERSION}" \
|
||||
"@cocal/google-calendar-mcp@${CALENDAR_MCP_VERSION}" \
|
||||
"zod-to-json-schema@3.22.5"
|
||||
```
|
||||
|
||||
If `/add-gmail-tool` hasn't been applied, install Calendar standalone:
|
||||
|
||||
```dockerfile
|
||||
RUN --mount=type=cache,target=/root/.cache/pnpm \
|
||||
pnpm install -g "@cocal/google-calendar-mcp@${CALENDAR_MCP_VERSION}"
|
||||
```
|
||||
|
||||
### Add tools to allowlist
|
||||
|
||||
Edit `container/agent-runner/src/providers/claude.ts`. Add `'mcp__calendar__*'` to `TOOL_ALLOWLIST` after `'mcp__nanoclaw__*'` (or after `'mcp__gmail__*'` if present).
|
||||
|
||||
### Rebuild the container image
|
||||
|
||||
```bash
|
||||
./container/build.sh
|
||||
```
|
||||
|
||||
## Phase 3: Wire Per-Agent-Group
|
||||
|
||||
For each agent group, merge into `groups/<folder>/container.json`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"mcpServers": {
|
||||
"calendar": {
|
||||
"command": "google-calendar-mcp",
|
||||
"args": [],
|
||||
"env": {
|
||||
"GOOGLE_OAUTH_CREDENTIALS": "/workspace/extra/.calendar-mcp/gcp-oauth.keys.json",
|
||||
"GOOGLE_CALENDAR_MCP_TOKEN_PATH": "/workspace/extra/.calendar-mcp/credentials.json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalMounts": [
|
||||
{
|
||||
"hostPath": "/home/<user>/.calendar-mcp",
|
||||
"containerPath": ".calendar-mcp",
|
||||
"readonly": false
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Substitute `<user>` with `echo $HOME`. `containerPath` is relative (mount-security rejects absolute paths — additional mounts land at `/workspace/extra/<relative>`).
|
||||
|
||||
**Same-group-as-gmail tip:** if this group already has the gmail MCP + `.gmail-mcp` mount, **merge, don't replace** — both entries coexist in `mcpServers` and `additionalMounts`.
|
||||
|
||||
## Phase 4: Build and Restart
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
systemctl --user restart nanoclaw # Linux
|
||||
# launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
```
|
||||
|
||||
Kill any existing agent containers so they respawn with the new mcpServers config:
|
||||
|
||||
```bash
|
||||
docker ps -q --filter 'name=nanoclaw-v2-' | xargs -r docker kill
|
||||
```
|
||||
|
||||
## Phase 5: Verify
|
||||
|
||||
### Test from a wired agent
|
||||
|
||||
> Send: **"list my calendars"** or **"what's on my work calendar next Monday?"**.
|
||||
>
|
||||
> First call takes 2–3s while the MCP server starts and OneCLI does the token exchange.
|
||||
|
||||
### Check logs if the tool isn't working
|
||||
|
||||
```bash
|
||||
tail -100 logs/nanoclaw.log | grep -iE 'calendar|mcp'
|
||||
```
|
||||
|
||||
Common signals:
|
||||
- `command not found: google-calendar-mcp` → image not rebuilt.
|
||||
- `ENOENT ...credentials.json` → mount missing. Check the mount allowlist.
|
||||
- `401 Unauthorized` from `*.googleapis.com` → OneCLI isn't injecting; verify agent's secret mode and that Google Calendar is connected.
|
||||
- Agent says "I don't have calendar tools" → `mcp__calendar__*` missing from `TOOL_ALLOWLIST`, or image cache stale (`./container/build.sh` again).
|
||||
|
||||
## Removal
|
||||
|
||||
1. Delete `"calendar"` from `mcpServers` and the `.calendar-mcp` mount from `additionalMounts` in each group's `container.json`.
|
||||
2. Remove `'mcp__calendar__*'` from `TOOL_ALLOWLIST`.
|
||||
3. Remove `CALENDAR_MCP_VERSION` ARG and the calendar package from the Dockerfile install block.
|
||||
4. `pnpm run build && ./container/build.sh && systemctl --user restart nanoclaw`.
|
||||
5. Optional: `rm -rf ~/.calendar-mcp/` and `onecli apps disconnect --provider google-calendar`.
|
||||
|
||||
## Credits & references
|
||||
|
||||
- **MCP server:** [`@cocal/google-calendar-mcp`](https://github.com/cocal-com/google-calendar-mcp) — MIT-licensed, actively maintained, multi-account and multi-calendar.
|
||||
- **Why not gongrzhe:** earlier versions of this skill used `@gongrzhe/server-calendar-autoauth-mcp@1.0.2` which only supports the primary calendar with 5 event-level tools. The cocal server supersedes it.
|
||||
- **Skill pattern:** direct sibling of [`/add-gmail-tool`](../add-gmail-tool/SKILL.md); same OneCLI stub mechanism.
|
||||
@@ -1,229 +0,0 @@
|
||||
---
|
||||
name: add-gmail-tool
|
||||
description: Add Gmail as an MCP tool (read, search, send, label, draft) using OneCLI-managed OAuth. The agent gets Gmail tools in every enabled group; OneCLI injects real tokens at request time so no raw credentials are ever in the container or on disk in usable form.
|
||||
---
|
||||
|
||||
# Add Gmail Tool (OneCLI-native)
|
||||
|
||||
This skill wires the [`@gongrzhe/server-gmail-autoauth-mcp`](https://www.npmjs.com/package/@gongrzhe/server-gmail-autoauth-mcp) stdio MCP server into selected agent groups. The MCP server reads stub credentials containing the `onecli-managed` placeholder; the OneCLI gateway intercepts outbound calls to `gmail.googleapis.com` and injects the real OAuth bearer from its vault.
|
||||
|
||||
Tools exposed (from `gmail-mcp@1.1.11`, surfaced to the agent as `mcp__gmail__<name>`): `search_emails`, `read_email`, `send_email`, `draft_email`, `delete_email`, `modify_email`, `batch_modify_emails`, `batch_delete_emails`, `download_attachment`, `list_email_labels`, `create_label`, `update_label`, `delete_label`, `get_or_create_label`, `list_filters`, `get_filter`, `create_filter`, `create_filter_from_template`, `delete_filter`.
|
||||
|
||||
**Why this pattern:** v2's invariant is that containers never receive raw API keys — OneCLI is the sole credential path (see CHANGELOG v2.0.0). The stub-file pattern satisfies this: the container sees `"onecli-managed"` placeholders, the gateway swaps them in flight.
|
||||
|
||||
## Phase 1: Pre-flight
|
||||
|
||||
### Verify OneCLI has Gmail connected
|
||||
|
||||
```bash
|
||||
onecli apps get --provider gmail
|
||||
```
|
||||
|
||||
Expected: `"connection": { "status": "connected" }` with scopes including `gmail.readonly`, `gmail.modify`, `gmail.send`.
|
||||
|
||||
If not connected, tell the user:
|
||||
|
||||
> Open the OneCLI web UI at http://127.0.0.1:10254, go to Apps → Gmail, and click Connect. Sign in with the Google account you want the agent to act as.
|
||||
|
||||
### Verify stub credentials exist
|
||||
|
||||
```bash
|
||||
ls -la ~/.gmail-mcp/gcp-oauth.keys.json ~/.gmail-mcp/credentials.json 2>&1
|
||||
```
|
||||
|
||||
If both exist and contain `"onecli-managed"`:
|
||||
|
||||
```bash
|
||||
grep -l onecli-managed ~/.gmail-mcp/gcp-oauth.keys.json ~/.gmail-mcp/credentials.json
|
||||
```
|
||||
|
||||
...skip to Phase 2.
|
||||
|
||||
If either file exists but does **not** contain `onecli-managed`, **STOP** and tell the user — these are real OAuth credentials from a previous non-OneCLI install. Back them up, then delete before proceeding. The OneCLI migration normally handles this; if it didn't, something is wrong.
|
||||
|
||||
If both files are absent, write them now:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.gmail-mcp
|
||||
cat > ~/.gmail-mcp/gcp-oauth.keys.json <<'EOF'
|
||||
{
|
||||
"installed": {
|
||||
"client_id": "onecli-managed.apps.googleusercontent.com",
|
||||
"client_secret": "onecli-managed",
|
||||
"redirect_uris": ["http://localhost:3000/oauth2callback"]
|
||||
}
|
||||
}
|
||||
EOF
|
||||
cat > ~/.gmail-mcp/credentials.json <<'EOF'
|
||||
{
|
||||
"access_token": "onecli-managed",
|
||||
"refresh_token": "onecli-managed",
|
||||
"token_type": "Bearer",
|
||||
"expiry_date": 99999999999999,
|
||||
"scope": "https://www.googleapis.com/auth/gmail.readonly https://www.googleapis.com/auth/gmail.modify https://www.googleapis.com/auth/gmail.send"
|
||||
}
|
||||
EOF
|
||||
chmod 600 ~/.gmail-mcp/gcp-oauth.keys.json ~/.gmail-mcp/credentials.json
|
||||
```
|
||||
|
||||
### Verify mount allowlist covers the path
|
||||
|
||||
```bash
|
||||
cat ~/.config/nanoclaw/mount-allowlist.json
|
||||
```
|
||||
|
||||
`~/.gmail-mcp` must sit under an `allowedRoots` entry (e.g. `/home/<user>`). If it doesn't, tell the user to run `/manage-mounts` first or add their home directory.
|
||||
|
||||
### Check agent secret-mode
|
||||
|
||||
For each target agent group, confirm OneCLI will inject Gmail secrets into its container. Find the OneCLI agent ID that matches the group's `agentGroupId`:
|
||||
|
||||
```bash
|
||||
onecli agents list
|
||||
```
|
||||
|
||||
If that agent's `secretMode` is `all`, you're done — Gmail secrets (identified by OneCLI's Gmail hostPattern) will auto-inject. If it's `selective`, explicitly assign the Gmail secrets:
|
||||
|
||||
```bash
|
||||
onecli secrets list # find Gmail secret IDs (OneCLI creates one per connected app)
|
||||
onecli agents set-secrets --id <agent-id> --secret-ids <gmail-secret-id>
|
||||
```
|
||||
|
||||
## Phase 2: Apply Code Changes
|
||||
|
||||
### Check if already applied
|
||||
|
||||
```bash
|
||||
grep -q 'GMAIL_MCP_VERSION' container/Dockerfile && \
|
||||
grep -q "mcp__gmail__\*" container/agent-runner/src/providers/claude.ts && \
|
||||
echo "ALREADY APPLIED — skip to Phase 3"
|
||||
```
|
||||
|
||||
### Add MCP server to Dockerfile
|
||||
|
||||
Edit `container/Dockerfile`. Find the pinned-version ARG block:
|
||||
|
||||
```dockerfile
|
||||
ARG CLAUDE_CODE_VERSION=2.1.116
|
||||
ARG AGENT_BROWSER_VERSION=latest
|
||||
ARG VERCEL_VERSION=latest
|
||||
ARG BUN_VERSION=1.3.12
|
||||
```
|
||||
|
||||
Add a new line:
|
||||
|
||||
```dockerfile
|
||||
ARG GMAIL_MCP_VERSION=1.1.11
|
||||
```
|
||||
|
||||
Then find the last pnpm global-install `RUN` block (the one that installs `@anthropic-ai/claude-code`) and add a new block after it, before `# ---- Entrypoint`:
|
||||
|
||||
```dockerfile
|
||||
RUN --mount=type=cache,target=/root/.cache/pnpm \
|
||||
pnpm install -g \
|
||||
"@gongrzhe/server-gmail-autoauth-mcp@${GMAIL_MCP_VERSION}" \
|
||||
"zod-to-json-schema@3.22.5"
|
||||
```
|
||||
|
||||
Pinned version matters — `minimumReleaseAge` in `pnpm-workspace.yaml` gates trunk installs, and CLAUDE.md requires a fixed ARG version for all Node CLIs installed into the image.
|
||||
|
||||
**Why the `zod-to-json-schema` pin:** `@gongrzhe/server-gmail-autoauth-mcp@1.1.11` has loose deps (`zod-to-json-schema: ^3.22.1`, `zod: ^3.22.4`). pnpm resolves `zod-to-json-schema` to the latest 3.25.x, which imports `zod/v3` — a subpath that only exists in `zod>=3.25`. But `zod` resolves to `3.24.x` (highest satisfying `^3.22.4` without breaking peer ranges). Result: `ERR_PACKAGE_PATH_NOT_EXPORTED` at import time. Pinning `zod-to-json-schema` to a pre-v3-subpath version avoids it. Re-check if you bump `GMAIL_MCP_VERSION`.
|
||||
|
||||
### Add tools to allowlist
|
||||
|
||||
Edit `container/agent-runner/src/providers/claude.ts`. Find `'mcp__nanoclaw__*',` in `TOOL_ALLOWLIST` and add `'mcp__gmail__*',` after it.
|
||||
|
||||
### Rebuild the container image
|
||||
|
||||
```bash
|
||||
./container/build.sh
|
||||
```
|
||||
|
||||
Must complete cleanly. The new `pnpm install -g` layer is ~60s first time (cached on rebuild).
|
||||
|
||||
## Phase 3: Wire Per-Agent-Group
|
||||
|
||||
For each agent group that should have Gmail (ask the user — typically their personal DM and CLI agents, sometimes shared household agents), edit `groups/<folder>/container.json` to add the mount and MCP server.
|
||||
|
||||
Merge these into the group's `container.json`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"mcpServers": {
|
||||
"gmail": {
|
||||
"command": "gmail-mcp",
|
||||
"args": [],
|
||||
"env": {
|
||||
"GMAIL_OAUTH_PATH": "/workspace/extra/.gmail-mcp/gcp-oauth.keys.json",
|
||||
"GMAIL_CREDENTIALS_PATH": "/workspace/extra/.gmail-mcp/credentials.json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalMounts": [
|
||||
{
|
||||
"hostPath": "/home/<user>/.gmail-mcp",
|
||||
"containerPath": ".gmail-mcp",
|
||||
"readonly": false
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Substitute `<user>` with the host user's home (use `echo $HOME`, don't assume `~` will expand — `container-runner.ts` does expand `~` via `expandPath`, but an explicit absolute path is clearer and matches what `/manage-mounts` writes).
|
||||
|
||||
**Why the container path is relative:** `mount-security` rejects absolute `containerPath` values. Additional mounts are prefixed with `/workspace/extra/`, so `containerPath: ".gmail-mcp"` lands at `/workspace/extra/.gmail-mcp`. The MCP server's `GMAIL_OAUTH_PATH` / `GMAIL_CREDENTIALS_PATH` env vars point at that absolute location inside the container.
|
||||
|
||||
## Phase 4: Build and Restart
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
systemctl --user restart nanoclaw # Linux
|
||||
# launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
```
|
||||
|
||||
## Phase 5: Verify
|
||||
|
||||
### Test from the wired agent
|
||||
|
||||
Tell the user:
|
||||
|
||||
> In your `<agent-name>` chat, send: **"list my gmail labels"** or **"search my inbox for invoices from last month"**.
|
||||
>
|
||||
> The agent should use `mcp__gmail__list_labels` / `mcp__gmail__search`. The first call may take a second or two while the MCP server starts and OneCLI does the token exchange.
|
||||
|
||||
### Check logs if the tool isn't working
|
||||
|
||||
```bash
|
||||
tail -100 logs/nanoclaw.log logs/nanoclaw.error.log | grep -iE 'gmail|mcp'
|
||||
# Per-container logs — session-scoped:
|
||||
ls data/v2-sessions/*/stderr.log | head
|
||||
```
|
||||
|
||||
Common signals:
|
||||
- `command not found: gmail-mcp` → image wasn't rebuilt or PATH doesn't include `/pnpm` (should — `ENV PATH="$PNPM_HOME:$PATH"` in Dockerfile).
|
||||
- `ENOENT: no such file or directory, open '/workspace/extra/.gmail-mcp/credentials.json'` → mount is missing. Check `~/.config/nanoclaw/mount-allowlist.json` includes a parent of `~/.gmail-mcp`.
|
||||
- `401 Unauthorized` from `gmail.googleapis.com` → OneCLI isn't injecting. Check the agent's secret mode (`onecli agents secrets --id <agent-id>`) and that the Gmail app is connected (`onecli apps get --provider gmail`).
|
||||
- Agent says "I don't have Gmail tools" → `mcp__gmail__*` wasn't added to `TOOL_ALLOWLIST`, or the agent-runner wasn't rebuilt (image cache — run `./container/build.sh` again with `--no-cache` if suspicious).
|
||||
|
||||
## Removal
|
||||
|
||||
1. Delete the `"gmail"` entry from `mcpServers` and the `.gmail-mcp` entry from `additionalMounts` in each group's `container.json`.
|
||||
2. Remove `'mcp__gmail__*'` from `TOOL_ALLOWLIST` in `container/agent-runner/src/providers/claude.ts`.
|
||||
3. Remove the `GMAIL_MCP_VERSION` ARG and the `pnpm install -g @gongrzhe/server-gmail-autoauth-mcp` block from `container/Dockerfile`.
|
||||
4. `pnpm run build && ./container/build.sh && systemctl --user restart nanoclaw`.
|
||||
5. (Optional) `rm -rf ~/.gmail-mcp/` if no other host-side tool needs the stubs.
|
||||
6. (Optional) Disconnect Gmail in OneCLI: `onecli apps disconnect --provider gmail`.
|
||||
|
||||
## Notes
|
||||
|
||||
- **Stub format is OneCLI-prescribed.** The `access_token: "onecli-managed"` pattern with `expiry_date: 99999999999999` tells the Google auth client the token is valid; OneCLI intercepts the outgoing Gmail API call and rewrites `Authorization: Bearer onecli-managed` to the real token. `expiry_date: 0` (refresh-interception) is an alternative the OneCLI docs describe — both work but OneCLI's own `migrate` command writes the far-future variant, which is what this skill assumes.
|
||||
- **Scopes are set at OAuth connect time.** If the agent needs scopes beyond what's currently connected (e.g. the user later wants `calendar.readonly` for combined email/calendar workflows), disconnect and reconnect Gmail in the OneCLI web UI with the expanded scope set.
|
||||
- **This is tool-only.** Inbound email as a channel (emails trigger the agent) is a separate piece of work — it needs a `src/channels/gmail.ts` adapter that polls the inbox and routes to a messaging group. The pre-v2 qwibitai skill had this; it has not been ported to v2's channel architecture as of v2.0.0.
|
||||
|
||||
## Credits & references
|
||||
|
||||
- **MCP server:** [`@gongrzhe/server-gmail-autoauth-mcp`](https://github.com/GongRzhe/Gmail-MCP-Server) by GongRzhe — MIT-licensed.
|
||||
- **OneCLI credential stubs:** pattern documented at `https://onecli.sh/docs/guides/credential-stubs/gmail.md`.
|
||||
- **Skill pattern:** modeled on [`add-atomic-chat-tool`](../add-atomic-chat-tool/SKILL.md) and [`add-vercel`](../add-vercel/SKILL.md).
|
||||
- **Addresses:** [issue #1500](https://github.com/qwibitai/nanoclaw/issues/1500) (proxy Gmail/Calendar OAuth tokens through credential proxy) for the Gmail side.
|
||||
- **Related PRs:** [#1810](https://github.com/qwibitai/nanoclaw/pull/1810) (pre-install Gmail/Notion MCP) overlaps on the "install the MCP server in the image" idea but bundles many unrelated changes; this skill is the focused OneCLI-native version.
|
||||
@@ -208,7 +208,7 @@ onecli secrets create --name "OpenCode Zen" --type generic \
|
||||
|
||||
### Per group / per session
|
||||
|
||||
Set `"provider": "opencode"` in the group's **`container.json`** (`groups/<folder>/container.json`) — the in-container runner reads `provider` from there, not from the DB. The DB columns **`agent_groups.agent_provider`** and **`sessions.agent_provider`** (session overrides group) only drive host-side provider contribution — per-session XDG mount, `OPENCODE_*` env passthrough — and do not propagate into `container.json` at spawn time. Set both, or just edit `container.json`; if they disagree, the runner uses `container.json` and the host-side resolver falls back through session → group → `container.json` → `'claude'`.
|
||||
Schema: **`agent_groups.agent_provider`** and **`sessions.agent_provider`**. Set to `opencode` for groups or sessions that should use OpenCode. The container receives `AGENT_PROVIDER` from the resolved value (session overrides group).
|
||||
|
||||
Extra MCP servers still come from **`NANOCLAW_MCP_SERVERS`** / `container_config.mcpServers` on the host; the runner merges them into the same `mcpServers` object passed to **both** Claude and OpenCode providers.
|
||||
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
# Remove Signal
|
||||
|
||||
1. Comment out `import './signal.js'` in `src/channels/index.ts`
|
||||
2. Remove `SIGNAL_ACCOUNT` (and any other `SIGNAL_*` vars) from `.env`
|
||||
3. Rebuild and restart
|
||||
|
||||
If you also want to unlink the Signal account from `signal-cli`:
|
||||
|
||||
```bash
|
||||
signal-cli -a +1YOURNUMBER removeDevice --deviceId <id>
|
||||
```
|
||||
|
||||
(Find the device id with `signal-cli -a +1YOURNUMBER listDevices`.)
|
||||
@@ -1,318 +0,0 @@
|
||||
---
|
||||
name: add-signal
|
||||
description: Add Signal channel integration via signal-cli TCP daemon. Native adapter — no Chat SDK bridge.
|
||||
---
|
||||
|
||||
# Add Signal Channel
|
||||
|
||||
Adds Signal messaging support via a native adapter that speaks JSON-RPC to a [signal-cli](https://github.com/AsamK/signal-cli) TCP daemon. No Chat SDK bridge — only Node.js builtins (`node:net`, `node:child_process`, `node:fs`).
|
||||
|
||||
Unlike Telegram or Discord, Signal has no bot API. NanoClaw registers as a full Signal account on a dedicated phone number (recommended) or links as a secondary device on your existing number.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Java
|
||||
|
||||
signal-cli requires Java 17+:
|
||||
|
||||
```bash
|
||||
java -version
|
||||
```
|
||||
|
||||
If missing:
|
||||
- **macOS:** `brew install --cask temurin@17`
|
||||
- **Debian/Ubuntu:** `sudo apt-get install -y default-jre`
|
||||
- **RHEL/Fedora:** `sudo dnf install -y java-17-openjdk`
|
||||
|
||||
Java 17–25 all work.
|
||||
|
||||
### signal-cli
|
||||
|
||||
- **macOS:** `brew install signal-cli`
|
||||
- **Linux:** download the native binary from [GitHub releases](https://github.com/AsamK/signal-cli/releases):
|
||||
|
||||
```bash
|
||||
SIGNAL_CLI_VERSION=$(curl -fsSL https://api.github.com/repos/AsamK/signal-cli/releases/latest | python3 -c "import sys,json; print(json.load(sys.stdin)['tag_name'][1:])")
|
||||
curl -fsSL "https://github.com/AsamK/signal-cli/releases/download/v${SIGNAL_CLI_VERSION}/signal-cli-${SIGNAL_CLI_VERSION}-Linux-native.tar.gz" \
|
||||
| tar -xz -C ~/.local
|
||||
ln -sf ~/.local/signal-cli ~/.local/bin/signal-cli
|
||||
signal-cli --version
|
||||
```
|
||||
|
||||
> The Linux native tarball extracts a single binary directly to `~/.local/signal-cli` (not into a subdirectory). The symlink above puts it on PATH.
|
||||
|
||||
## Registration
|
||||
|
||||
Two paths. The new-number path is recommended and battle-tested.
|
||||
|
||||
### Path A: Register a new number (recommended)
|
||||
|
||||
Use a dedicated SIM or VoIP number. NanoClaw owns it entirely.
|
||||
|
||||
> **VoIP numbers:** Signal requires SMS verification before voice. Some VoIP providers are blocked even for voice calls. If registration fails with an auth error, try a different provider or a physical SIM.
|
||||
|
||||
**Step 1: Solve the CAPTCHA**
|
||||
|
||||
Signal requires a CAPTCHA on first registration:
|
||||
|
||||
1. Open `https://signalcaptchas.org/registration/generate.html` in a browser
|
||||
2. Solve the captcha
|
||||
3. Right-click the **"Open Signal"** button → **Copy Link**
|
||||
4. The link starts with `signalcaptcha://` — the token is everything after that prefix
|
||||
|
||||
**Step 2: Request SMS verification**
|
||||
|
||||
```bash
|
||||
signal-cli -a +1YOURNUMBER register --captcha "PASTE_TOKEN_HERE"
|
||||
```
|
||||
|
||||
**Step 3: Voice call fallback (if your number can't receive SMS)**
|
||||
|
||||
Wait ~60 seconds after the SMS request, then:
|
||||
|
||||
```bash
|
||||
signal-cli -a +1YOURNUMBER register --voice --captcha "SAME_TOKEN"
|
||||
```
|
||||
|
||||
Signal calls your number and reads a 6-digit code. The same captcha token is reusable — no need to solve a new one.
|
||||
|
||||
> You must request SMS first. Requesting voice immediately fails with `Invalid verification method: Before requesting voice verification…`
|
||||
|
||||
**Step 4: Verify**
|
||||
|
||||
```bash
|
||||
signal-cli -a +1YOURNUMBER verify CODE
|
||||
```
|
||||
|
||||
No output = success.
|
||||
|
||||
**Step 5: Set profile name (optional)**
|
||||
|
||||
> ⚠ Stop NanoClaw before running signal-cli commands — the daemon holds an exclusive lock on its data directory while running.
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist
|
||||
signal-cli -a +1YOURNUMBER updateProfile --name "YourBotName"
|
||||
# optionally: --avatar /path/to/avatar.jpg
|
||||
launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
|
||||
|
||||
# Linux
|
||||
systemctl --user stop nanoclaw
|
||||
signal-cli -a +1YOURNUMBER updateProfile --name "YourBotName"
|
||||
systemctl --user start nanoclaw
|
||||
```
|
||||
|
||||
### Path B: Link as secondary device
|
||||
|
||||
Joins an existing Signal account as a secondary device. Simpler, but NanoClaw shares your personal number.
|
||||
|
||||
```bash
|
||||
signal-cli -a +1YOURNUMBER link --name "NanoClaw"
|
||||
```
|
||||
|
||||
This prints a `tsdevice:` URI. Scan it as a QR code on your phone: **Settings → Linked Devices → Link New Device**. QR codes expire in ~30 seconds — re-run if it expires.
|
||||
|
||||
## Install
|
||||
|
||||
### Pre-flight (idempotent)
|
||||
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
|
||||
- `src/channels/signal.ts` and `src/channels/signal.test.ts` both exist
|
||||
- `src/channels/index.ts` contains `import './signal.js';`
|
||||
|
||||
Otherwise continue. Every step below is safe to re-run.
|
||||
|
||||
### 1. Fetch the channels branch
|
||||
|
||||
```bash
|
||||
git fetch origin channels
|
||||
```
|
||||
|
||||
### 2. Copy the adapter and tests
|
||||
|
||||
```bash
|
||||
git show origin/channels:src/channels/signal.ts > src/channels/signal.ts
|
||||
git show origin/channels:src/channels/signal.test.ts > src/channels/signal.test.ts
|
||||
```
|
||||
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if the line is already present):
|
||||
|
||||
```typescript
|
||||
import './signal.js';
|
||||
```
|
||||
|
||||
### 4. Build
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
No npm packages to install — the adapter uses only Node.js builtins.
|
||||
|
||||
## Credentials
|
||||
|
||||
Add to `.env`:
|
||||
|
||||
```bash
|
||||
SIGNAL_ACCOUNT=+1YOURNUMBER
|
||||
```
|
||||
|
||||
### Optional settings
|
||||
|
||||
```bash
|
||||
# TCP daemon host and port (default: 127.0.0.1:7583)
|
||||
SIGNAL_TCP_HOST=127.0.0.1
|
||||
SIGNAL_TCP_PORT=7583
|
||||
|
||||
# Path to the signal-cli binary (default: resolved on PATH)
|
||||
SIGNAL_CLI_PATH=/usr/local/bin/signal-cli
|
||||
|
||||
# Whether NanoClaw manages the daemon lifecycle (default: true).
|
||||
# Set to false if you run signal-cli daemon externally.
|
||||
SIGNAL_MANAGE_DAEMON=true
|
||||
|
||||
# signal-cli data directory (default: ~/.local/share/signal-cli)
|
||||
SIGNAL_DATA_DIR=~/.local/share/signal-cli
|
||||
```
|
||||
|
||||
**Security note:** keep the TCP host on `127.0.0.1`. The daemon has no auth — binding it to a public interface would expose your full Signal account to the network.
|
||||
|
||||
Sync to container: `mkdir -p data/env && cp .env data/env/env`
|
||||
|
||||
### Restart
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
|
||||
|
||||
# Linux
|
||||
systemctl --user restart nanoclaw
|
||||
```
|
||||
|
||||
## Wiring
|
||||
|
||||
### DMs
|
||||
|
||||
After the service starts, send any message to the Signal number from your personal Signal app. The router auto-creates a `messaging_groups` row. Then:
|
||||
|
||||
```bash
|
||||
sqlite3 data/v2.db \
|
||||
"SELECT id, platform_id FROM messaging_groups WHERE channel_type='signal' ORDER BY created_at DESC LIMIT 5"
|
||||
```
|
||||
|
||||
Pass the `id` to `/init-first-agent` or `/manage-channels` to wire it to an agent group.
|
||||
|
||||
### Groups
|
||||
|
||||
Add the Signal number to a group from your phone, send any message, then wire the resulting row the same way. For isolated per-group sessions:
|
||||
|
||||
```bash
|
||||
NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
|
||||
sqlite3 data/v2.db "
|
||||
INSERT OR IGNORE INTO messaging_group_agents
|
||||
(id, messaging_group_id, agent_group_id, session_mode, priority, created_at)
|
||||
VALUES
|
||||
('mga-'||hex(randomblob(8)), 'mg-GROUPID', 'ag-AGENTID', 'isolated', 0, '$NOW');
|
||||
"
|
||||
```
|
||||
|
||||
### Grant user access
|
||||
|
||||
New Signal users (including the owner's Signal identity) are silently dropped with `not_member` until granted access. After the user's first message appears in `messaging_groups`:
|
||||
|
||||
```bash
|
||||
NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
|
||||
sqlite3 data/v2.db "
|
||||
INSERT OR REPLACE INTO user_roles (user_id, role, agent_group_id, granted_by, granted_at)
|
||||
VALUES ('signal:UUID', 'owner', NULL, 'system', '$NOW');
|
||||
INSERT OR IGNORE INTO agent_group_members (user_id, agent_group_id, added_by, added_at)
|
||||
VALUES ('signal:UUID', 'ag-AGENTID', 'system', '$NOW');
|
||||
"
|
||||
```
|
||||
|
||||
Find the UUID from `messaging_groups.platform_id` or the `users` table.
|
||||
|
||||
## Next Steps
|
||||
|
||||
If you're in the middle of `/setup`, return to the setup flow now.
|
||||
|
||||
Otherwise, run `/init-first-agent` to create an agent and wire it to your Signal DM, or `/manage-channels` to wire this channel to an existing agent group.
|
||||
|
||||
## Channel Info
|
||||
|
||||
- **type**: `signal`
|
||||
- **terminology**: Signal has "chats" (1:1 DMs) and "groups"
|
||||
- **supports-threads**: no
|
||||
- **platform-id-format**:
|
||||
- DM: `signal:{UUID}` — sender's Signal UUID (ACI), **not** their phone number
|
||||
- Group: `signal:{base64GroupId}` — base64-encoded GroupV2 ID
|
||||
- **how-to-find-id**: Send a message to the bot, then query `messaging_groups` as shown above
|
||||
- **typical-use**: Personal assistant via Signal DMs or small group chats
|
||||
- **default-isolation**: One agent per Signal account. Multiple chats with the same operator can share an agent group; groups with other people should typically use `isolated` session mode
|
||||
|
||||
### Features
|
||||
|
||||
- Markdown formatting — `**bold**`, `*italic*` / `_italic_`, `` `code` ``, ` ```code fence``` `, `~~strike~~`, `||spoiler||` (converted to Signal's offset-based text styles)
|
||||
- Quoted replies — `replyTo*` fields populated from Signal quotes
|
||||
- Typing indicators — DMs only (Signal doesn't support group typing)
|
||||
- Echo suppression — outbound messages matched on `(platformId, text)` within a 10 s TTL to avoid syncMessage loops
|
||||
- Note to Self — messages you send to your own account from another device route to the agent as inbound with `isFromMe: true`
|
||||
- Voice attachments — detected but not transcribed by default; the agent receives `[Voice Message]` placeholder text. Run `/add-voice-transcription` for local transcription via parakeet-mlx
|
||||
|
||||
Not supported yet: outbound file attachments (logged and dropped), edit/delete messages, reactions.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Daemon not reachable
|
||||
|
||||
```bash
|
||||
grep "Signal" logs/nanoclaw.log | tail
|
||||
```
|
||||
|
||||
If you see `Signal daemon failed to start. Is signal-cli installed and your account linked?`:
|
||||
- Confirm `signal-cli` is on PATH (or set `SIGNAL_CLI_PATH`)
|
||||
- Confirm the account is linked: `signal-cli -a +1YOURNUMBER listIdentities` should succeed without prompting
|
||||
|
||||
If you see `Signal daemon not reachable at 127.0.0.1:7583` and `SIGNAL_MANAGE_DAEMON=false`, start the daemon yourself: `signal-cli -a +1YOURNUMBER daemon --tcp 127.0.0.1:7583`.
|
||||
|
||||
### Bot not responding
|
||||
|
||||
1. Channel initialized: `grep "Signal channel connected" logs/nanoclaw.log | tail -1`
|
||||
2. Channel wired: `sqlite3 data/v2.db "SELECT mg.platform_id, mg.name FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id WHERE mg.channel_type='signal'"`
|
||||
3. Service running: `launchctl print gui/$(id -u)/com.nanoclaw` (macOS) / `systemctl --user status nanoclaw` (Linux)
|
||||
|
||||
### Lost connection mid-session
|
||||
|
||||
If you see `Signal channel lost TCP connection to signal-cli daemon` in the logs, the daemon dropped the connection. Restart the service to re-establish.
|
||||
|
||||
### Messages dropped with `not_member`
|
||||
|
||||
The Signal user hasn't been granted membership. See "Grant user access" above. This affects every new Signal user, including the owner's Signal identity — which is a separate user record from their identity on other channels even if it's the same person.
|
||||
|
||||
### Captcha required
|
||||
|
||||
Signal requires a captcha for new registrations. Go to `https://signalcaptchas.org/registration/generate.html`, solve it, right-click "Open Signal", copy the link, extract the token after `signalcaptcha://`.
|
||||
|
||||
### `Invalid verification method: Before requesting voice verification…`
|
||||
|
||||
You must request SMS first, wait ~60 seconds, then request voice. Both steps can use the same captcha token.
|
||||
|
||||
### Config file in use / daemon lock
|
||||
|
||||
signal-cli holds an exclusive lock on its data directory while the daemon is running. Stop NanoClaw before running any `signal-cli` commands directly, then restart afterward.
|
||||
|
||||
### Group replies going to DM instead of group
|
||||
|
||||
Modern Signal groups use GroupV2. The adapter must extract the group ID from `envelope?.dataMessage?.groupV2?.id` — not `groupInfo?.groupId`, which is GroupV1/legacy. If group messages are routing as DMs, check `src/channels/signal.ts` and confirm the groupId extraction falls through to `groupV2.id`.
|
||||
|
||||
### Java not found
|
||||
|
||||
Install Java 17+ — see the Prerequisites section above.
|
||||
|
||||
### QR code expired (Path B)
|
||||
|
||||
QR codes expire in ~30 seconds. Re-run the link command to generate a new one.
|
||||
@@ -1,5 +0,0 @@
|
||||
# Verify Signal
|
||||
|
||||
Send a message to your own Signal number (Note to Self) from another device, or have someone send your linked number a DM. The bot should respond within a few seconds.
|
||||
|
||||
If nothing happens, tail `logs/nanoclaw.log` for `Signal channel connected` and `Signal message received`.
|
||||
@@ -57,7 +57,7 @@ groups: () => import('./groups.js'),
|
||||
### 5. Install the adapter packages (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @whiskeysockets/baileys@7.0.0-rc.9 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0
|
||||
pnpm install @whiskeysockets/baileys@6.17.16 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0
|
||||
```
|
||||
|
||||
### 6. Build
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
name: Label PR
|
||||
|
||||
# SECURITY: this workflow runs with write access to the base repo on fork PRs,
|
||||
# because `pull_request_target` executes in the context of the base branch.
|
||||
# Keep it metadata-only — do NOT add actions/checkout or any step that
|
||||
# executes PR-supplied content (install scripts, build commands, etc.).
|
||||
# See https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/
|
||||
on:
|
||||
pull_request_target:
|
||||
pull_request:
|
||||
types: [opened, edited]
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import { beforeEach, describe, expect, test } from 'bun:test';
|
||||
|
||||
import { getOutboundDb, initTestSessionDb } from './connection.js';
|
||||
import {
|
||||
clearContinuation,
|
||||
getContinuation,
|
||||
migrateLegacyContinuation,
|
||||
setContinuation,
|
||||
} from './session-state.js';
|
||||
|
||||
beforeEach(() => {
|
||||
initTestSessionDb();
|
||||
});
|
||||
|
||||
function seedLegacy(value: string): void {
|
||||
getOutboundDb()
|
||||
.prepare('INSERT INTO session_state (key, value, updated_at) VALUES (?, ?, ?)')
|
||||
.run('sdk_session_id', value, new Date().toISOString());
|
||||
}
|
||||
|
||||
describe('session-state — per-provider continuations', () => {
|
||||
test('set/get round-trip, case-insensitive provider key', () => {
|
||||
setContinuation('claude', 'claude-conv-1');
|
||||
expect(getContinuation('claude')).toBe('claude-conv-1');
|
||||
expect(getContinuation('Claude')).toBe('claude-conv-1');
|
||||
expect(getContinuation('CLAUDE')).toBe('claude-conv-1');
|
||||
});
|
||||
|
||||
test('providers are isolated — switching reads the right slot', () => {
|
||||
setContinuation('claude', 'claude-conv-1');
|
||||
setContinuation('codex', 'codex-thread-xyz');
|
||||
|
||||
expect(getContinuation('claude')).toBe('claude-conv-1');
|
||||
expect(getContinuation('codex')).toBe('codex-thread-xyz');
|
||||
});
|
||||
|
||||
test('clearContinuation only affects the specified provider', () => {
|
||||
setContinuation('claude', 'keep-me');
|
||||
setContinuation('codex', 'drop-me');
|
||||
|
||||
clearContinuation('codex');
|
||||
|
||||
expect(getContinuation('claude')).toBe('keep-me');
|
||||
expect(getContinuation('codex')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('unknown provider returns undefined', () => {
|
||||
expect(getContinuation('never-used')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('session-state — legacy migration', () => {
|
||||
test('adopts legacy value into current provider when current is empty', () => {
|
||||
seedLegacy('old-session-id');
|
||||
|
||||
const adopted = migrateLegacyContinuation('claude');
|
||||
|
||||
expect(adopted).toBe('old-session-id');
|
||||
expect(getContinuation('claude')).toBe('old-session-id');
|
||||
});
|
||||
|
||||
test('always deletes legacy row regardless of migration outcome', () => {
|
||||
seedLegacy('old-session-id');
|
||||
setContinuation('claude', 'existing');
|
||||
|
||||
migrateLegacyContinuation('claude');
|
||||
|
||||
// After migration the legacy key must be gone, whether or not it was adopted.
|
||||
// A subsequent migration for a different provider must not see it.
|
||||
const resultAfterSecondCall = migrateLegacyContinuation('codex');
|
||||
expect(resultAfterSecondCall).toBeUndefined();
|
||||
});
|
||||
|
||||
test('prefers existing current-provider slot over legacy', () => {
|
||||
seedLegacy('legacy-value');
|
||||
setContinuation('claude', 'claude-value');
|
||||
|
||||
const result = migrateLegacyContinuation('claude');
|
||||
|
||||
expect(result).toBe('claude-value');
|
||||
expect(getContinuation('claude')).toBe('claude-value');
|
||||
});
|
||||
|
||||
test('no legacy row — returns current provider value (possibly undefined)', () => {
|
||||
expect(migrateLegacyContinuation('claude')).toBeUndefined();
|
||||
|
||||
setContinuation('codex', 'codex-value');
|
||||
expect(migrateLegacyContinuation('codex')).toBe('codex-value');
|
||||
});
|
||||
|
||||
test('migration is idempotent on a second call (legacy already gone)', () => {
|
||||
seedLegacy('once');
|
||||
|
||||
const first = migrateLegacyContinuation('claude');
|
||||
expect(first).toBe('once');
|
||||
|
||||
const second = migrateLegacyContinuation('claude');
|
||||
expect(second).toBe('once');
|
||||
});
|
||||
});
|
||||
@@ -2,20 +2,12 @@
|
||||
* Persistent key/value state for the container. Lives in outbound.db
|
||||
* (container-owned, already scoped per channel/thread).
|
||||
*
|
||||
* Primary use: remember each provider's opaque continuation id so the
|
||||
* agent's conversation resumes across container restarts. Keyed per
|
||||
* provider because continuations are provider-private — a Claude
|
||||
* conversation id means nothing to Codex and vice versa. Switching
|
||||
* providers is therefore lossless: each provider's last thread stays
|
||||
* on file and resumes cleanly if the user flips back.
|
||||
* Primary use: remember the SDK session ID so the agent's conversation
|
||||
* resumes across container restarts. Cleared by /clear.
|
||||
*/
|
||||
import { getOutboundDb } from './connection.js';
|
||||
|
||||
const LEGACY_KEY = 'sdk_session_id';
|
||||
|
||||
function continuationKey(providerName: string): string {
|
||||
return `continuation:${providerName.toLowerCase()}`;
|
||||
}
|
||||
const SDK_SESSION_KEY = 'sdk_session_id';
|
||||
|
||||
function getValue(key: string): string | undefined {
|
||||
const row = getOutboundDb()
|
||||
@@ -26,7 +18,9 @@ function getValue(key: string): string | undefined {
|
||||
|
||||
function setValue(key: string, value: string): void {
|
||||
getOutboundDb()
|
||||
.prepare('INSERT OR REPLACE INTO session_state (key, value, updated_at) VALUES (?, ?, ?)')
|
||||
.prepare(
|
||||
'INSERT OR REPLACE INTO session_state (key, value, updated_at) VALUES (?, ?, ?)',
|
||||
)
|
||||
.run(key, value, new Date().toISOString());
|
||||
}
|
||||
|
||||
@@ -34,46 +28,14 @@ function deleteValue(key: string): void {
|
||||
getOutboundDb().prepare('DELETE FROM session_state WHERE key = ?').run(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time migration of the pre-per-provider continuation row.
|
||||
*
|
||||
* Before this was keyed per provider, continuations lived under the
|
||||
* single key `sdk_session_id`. On container start, if that legacy row
|
||||
* exists and the current provider has no continuation of its own, adopt
|
||||
* the legacy value into the current provider's slot (best-guess — the
|
||||
* legacy row was written by whatever provider ran last). The legacy row
|
||||
* is always deleted so future provider flips never re-read a stale id
|
||||
* through the wrong lens.
|
||||
*
|
||||
* Returns the continuation the caller should use at startup (either the
|
||||
* current provider's existing value, the adopted legacy value, or
|
||||
* undefined).
|
||||
*/
|
||||
export function migrateLegacyContinuation(providerName: string): string | undefined {
|
||||
const legacy = getValue(LEGACY_KEY);
|
||||
const currentKey = continuationKey(providerName);
|
||||
const current = getValue(currentKey);
|
||||
|
||||
if (legacy === undefined) return current;
|
||||
|
||||
// Always drop the legacy row so no future provider reads it.
|
||||
deleteValue(LEGACY_KEY);
|
||||
|
||||
// Prefer the current provider's own slot if one already exists.
|
||||
if (current !== undefined) return current;
|
||||
|
||||
setValue(currentKey, legacy);
|
||||
return legacy;
|
||||
export function getStoredSessionId(): string | undefined {
|
||||
return getValue(SDK_SESSION_KEY);
|
||||
}
|
||||
|
||||
export function getContinuation(providerName: string): string | undefined {
|
||||
return getValue(continuationKey(providerName));
|
||||
export function setStoredSessionId(sessionId: string): void {
|
||||
setValue(SDK_SESSION_KEY, sessionId);
|
||||
}
|
||||
|
||||
export function setContinuation(providerName: string, id: string): void {
|
||||
setValue(continuationKey(providerName), id);
|
||||
}
|
||||
|
||||
export function clearContinuation(providerName: string): void {
|
||||
deleteValue(continuationKey(providerName));
|
||||
export function clearStoredSessionId(): void {
|
||||
deleteValue(SDK_SESSION_KEY);
|
||||
}
|
||||
|
||||
@@ -95,7 +95,6 @@ async function main(): Promise<void> {
|
||||
|
||||
await runPollLoop({
|
||||
provider,
|
||||
providerName,
|
||||
cwd: CWD,
|
||||
systemContext: { instructions },
|
||||
});
|
||||
|
||||
@@ -98,7 +98,6 @@ async function runPollLoopWithTimeout(provider: MockProvider, signal: AbortSigna
|
||||
return Promise.race([
|
||||
runPollLoop({
|
||||
provider,
|
||||
providerName: 'mock',
|
||||
cwd: '/tmp',
|
||||
}),
|
||||
new Promise<void>((_, reject) => {
|
||||
|
||||
@@ -2,11 +2,7 @@ import { findByName, getAllDestinations, type DestinationEntry } from './destina
|
||||
import { getPendingMessages, markProcessing, markCompleted, type MessageInRow } from './db/messages-in.js';
|
||||
import { writeMessageOut } from './db/messages-out.js';
|
||||
import { touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js';
|
||||
import {
|
||||
clearContinuation,
|
||||
migrateLegacyContinuation,
|
||||
setContinuation,
|
||||
} from './db/session-state.js';
|
||||
import { getStoredSessionId, setStoredSessionId, clearStoredSessionId } from './db/session-state.js';
|
||||
import { formatMessages, extractRouting, categorizeMessage, isClearCommand, stripInternalTags, type RoutingContext } from './formatter.js';
|
||||
import type { AgentProvider, AgentQuery, ProviderEvent } from './providers/types.js';
|
||||
|
||||
@@ -23,12 +19,6 @@ function generateId(): string {
|
||||
|
||||
export interface PollLoopConfig {
|
||||
provider: AgentProvider;
|
||||
/**
|
||||
* Name of the provider (e.g. "claude", "codex", "opencode"). Used to key
|
||||
* the stored continuation per-provider so flipping providers doesn't
|
||||
* resurrect a stale id from a different backend.
|
||||
*/
|
||||
providerName: string;
|
||||
cwd: string;
|
||||
systemContext?: {
|
||||
instructions?: string;
|
||||
@@ -49,9 +39,8 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
||||
// Resume the agent's prior session from a previous container run if one
|
||||
// was persisted. The continuation is opaque to the poll-loop — the
|
||||
// provider decides how to use it (Claude resumes a .jsonl transcript,
|
||||
// other providers may reload a thread ID, etc.). Keyed per-provider so
|
||||
// a Codex thread id never gets handed to Claude or vice versa.
|
||||
let continuation: string | undefined = migrateLegacyContinuation(config.providerName);
|
||||
// other providers may reload a thread ID, etc.).
|
||||
let continuation: string | undefined = getStoredSessionId();
|
||||
|
||||
if (continuation) {
|
||||
log(`Resuming agent session ${continuation}`);
|
||||
@@ -105,7 +94,7 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
||||
if ((msg.kind === 'chat' || msg.kind === 'chat-sdk') && isClearCommand(msg)) {
|
||||
log('Clearing session (resetting continuation)');
|
||||
continuation = undefined;
|
||||
clearContinuation(config.providerName);
|
||||
clearStoredSessionId();
|
||||
writeMessageOut({
|
||||
id: generateId(),
|
||||
kind: 'chat',
|
||||
@@ -171,10 +160,10 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
||||
const skippedSet = new Set(skipped);
|
||||
const processingIds = ids.filter((id) => !commandIds.includes(id) && !skippedSet.has(id));
|
||||
try {
|
||||
const result = await processQuery(query, routing, processingIds, config.providerName);
|
||||
const result = await processQuery(query, routing, processingIds);
|
||||
if (result.continuation && result.continuation !== continuation) {
|
||||
continuation = result.continuation;
|
||||
setContinuation(config.providerName, continuation);
|
||||
setStoredSessionId(continuation);
|
||||
}
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
@@ -186,7 +175,7 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
||||
if (continuation && config.provider.isSessionInvalid(err)) {
|
||||
log(`Stale session detected (${continuation}) — clearing for next retry`);
|
||||
continuation = undefined;
|
||||
clearContinuation(config.providerName);
|
||||
clearStoredSessionId();
|
||||
}
|
||||
|
||||
// Write error response so the user knows something went wrong
|
||||
@@ -249,7 +238,6 @@ async function processQuery(
|
||||
query: AgentQuery,
|
||||
routing: RoutingContext,
|
||||
initialBatchIds: string[],
|
||||
providerName: string,
|
||||
): Promise<QueryResult> {
|
||||
let queryContinuation: string | undefined;
|
||||
let done = false;
|
||||
@@ -300,7 +288,7 @@ async function processQuery(
|
||||
// container died between `init` and `result`, the SDK session was
|
||||
// effectively orphaned and the next message started a blank
|
||||
// Claude session with no prior context.
|
||||
setContinuation(providerName, event.continuation);
|
||||
setStoredSessionId(event.continuation);
|
||||
} else if (event.type === 'result') {
|
||||
// A result — with or without text — means the turn is done. Mark
|
||||
// the initial batch completed now so the host sweep doesn't see
|
||||
|
||||
@@ -1,504 +0,0 @@
|
||||
;;; nanoclaw.el --- Emacs interface for NanoClaw AI assistant -*- lexical-binding: t -*-
|
||||
|
||||
;; Author: NanoClaw
|
||||
;; Version: 0.1.0
|
||||
;; Package-Requires: ((emacs "27.1"))
|
||||
;; Keywords: ai, assistant, chat
|
||||
;;
|
||||
;; Vanilla Emacs (init.el):
|
||||
;; (load-file "~/src/nanoclaw/emacs/nanoclaw.el")
|
||||
;; (global-set-key (kbd "C-c n c") #'nanoclaw-chat)
|
||||
;; (global-set-key (kbd "C-c n o") #'nanoclaw-org-send)
|
||||
;;
|
||||
;; Spacemacs (~/.spacemacs, in dotspacemacs/user-config):
|
||||
;; (load-file "~/src/nanoclaw/emacs/nanoclaw.el")
|
||||
;; (spacemacs/set-leader-keys "aNc" #'nanoclaw-chat)
|
||||
;; (spacemacs/set-leader-keys "aNo" #'nanoclaw-org-send)
|
||||
;;
|
||||
;; Doom Emacs (config.el):
|
||||
;; (load (expand-file-name "~/src/nanoclaw/emacs/nanoclaw.el"))
|
||||
;; (map! :leader
|
||||
;; :prefix ("N" . "NanoClaw")
|
||||
;; :desc "Chat buffer" "c" #'nanoclaw-chat
|
||||
;; :desc "Send org" "o" #'nanoclaw-org-send)
|
||||
;; ;; Evil users: teach evil about the C-c C-c send binding
|
||||
;; (after! evil
|
||||
;; (evil-define-key '(normal insert) nanoclaw-chat-mode-map
|
||||
;; (kbd "C-c C-c") #'nanoclaw-chat-send))
|
||||
|
||||
;;; Code:
|
||||
|
||||
(require 'cl-lib)
|
||||
(require 'url)
|
||||
(require 'json)
|
||||
(require 'org)
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Customization
|
||||
|
||||
(defgroup nanoclaw nil
|
||||
"NanoClaw AI assistant interface."
|
||||
:group 'tools
|
||||
:prefix "nanoclaw-")
|
||||
|
||||
(defcustom nanoclaw-host "localhost"
|
||||
"Hostname where NanoClaw is running."
|
||||
:type 'string
|
||||
:group 'nanoclaw)
|
||||
|
||||
(defcustom nanoclaw-port 8766
|
||||
"Port for the NanoClaw Emacs channel HTTP server."
|
||||
:type 'integer
|
||||
:group 'nanoclaw)
|
||||
|
||||
(defcustom nanoclaw-auth-token nil
|
||||
"Bearer token for NanoClaw authentication (matches EMACS_AUTH_TOKEN in .env).
|
||||
Leave nil if EMACS_AUTH_TOKEN is not set."
|
||||
:type '(choice (const nil) string)
|
||||
:group 'nanoclaw)
|
||||
|
||||
(defcustom nanoclaw-poll-interval 1.5
|
||||
"Seconds between response polls when waiting for a reply."
|
||||
:type 'number
|
||||
:group 'nanoclaw)
|
||||
|
||||
(defcustom nanoclaw-agent-name "Andy"
|
||||
"Display name for the NanoClaw agent (matches ASSISTANT_NAME in .env)."
|
||||
:type 'string
|
||||
:group 'nanoclaw)
|
||||
|
||||
(defcustom nanoclaw-convert-to-org t
|
||||
"When non-nil, convert agent responses to org-mode format.
|
||||
Uses pandoc when available; falls back to regex substitutions."
|
||||
:type 'boolean
|
||||
:group 'nanoclaw)
|
||||
|
||||
(defcustom nanoclaw-timestamp-format "%H:%M"
|
||||
"Format string for timestamps shown next to agent replies in the chat buffer.
|
||||
Passed to `format-time-string'. Set to nil to suppress timestamps."
|
||||
:type '(choice (const nil) string)
|
||||
:group 'nanoclaw)
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Formatting helpers
|
||||
|
||||
(defun nanoclaw--to-org (text)
|
||||
"Convert TEXT (markdown or plain) to org-mode markup.
|
||||
Tries pandoc -f gfm -t org when available; falls back to regex."
|
||||
(if (not nanoclaw-convert-to-org)
|
||||
text
|
||||
(if (executable-find "pandoc")
|
||||
(with-temp-buffer
|
||||
(insert text)
|
||||
(let* ((coding-system-for-read 'utf-8)
|
||||
(coding-system-for-write 'utf-8)
|
||||
(exit (call-process-region
|
||||
(point-min) (point-max)
|
||||
"pandoc" t t nil "-f" "gfm" "-t" "org" "--wrap=none")))
|
||||
(if (zerop exit)
|
||||
(string-trim (buffer-string))
|
||||
text)))
|
||||
(nanoclaw--md-to-org-regex text))))
|
||||
|
||||
;; NOTE: This function expects standard markdown as input (e.g. **bold**, *italic*).
|
||||
;; Agents responding on this channel must output markdown, not org-mode syntax.
|
||||
;; If the agent outputs org-mode directly, markers like *bold* will be incorrectly
|
||||
;; re-converted to /bold/ by the italic rule.
|
||||
(defun nanoclaw--md-to-org-regex (text)
|
||||
"Lightweight markdown → org conversion using regexp substitutions."
|
||||
(let ((s text))
|
||||
;; Fenced code blocks ```lang\n…\n``` → #+begin_src lang\n…\n#+end_src
|
||||
;; (must run before inline-code to avoid mangling backticks)
|
||||
(setq s (replace-regexp-in-string
|
||||
"```\\([a-zA-Z0-9_-]*\\)\n\\(\\(?:.\\|\n\\)*?\\)```"
|
||||
(lambda (m)
|
||||
(let ((lang (match-string 1 m))
|
||||
(body (match-string 2 m)))
|
||||
(concat "#+begin_src " (if (string-empty-p lang) "text" lang)
|
||||
"\n" body "#+end_src")))
|
||||
s t))
|
||||
;; Bold **text** → *text*, italic *text* → /text/
|
||||
;; Two-pass to prevent the italic regex from re-matching the bold result:
|
||||
;; 1. Mark bold spans with a placeholder (control char \x01)
|
||||
(setq s (replace-regexp-in-string "\\*\\*\\(.+?\\)\\*\\*" "\x01\\1\x01" s))
|
||||
;; 2. Convert remaining single-star spans to italic
|
||||
(setq s (replace-regexp-in-string "\\*\\(.+?\\)\\*" "/\\1/" s))
|
||||
;; 3. Resolve bold placeholders to org bold markers
|
||||
(setq s (replace-regexp-in-string "\x01\\(.+?\\)\x01" "*\\1*" s))
|
||||
;; Strikethrough ~~text~~ → +text+
|
||||
(setq s (replace-regexp-in-string "~~\\(.+?\\)~~" "+\\1+" s))
|
||||
;; Underline __text__ → _text_
|
||||
(setq s (replace-regexp-in-string "__\\(.+?\\)__" "_\\1_" s))
|
||||
;; Inline code `code` → ~code~
|
||||
(setq s (replace-regexp-in-string "`\\([^`]+\\)`" "~\\1~" s))
|
||||
;; ATX headings ## … → ** …
|
||||
(setq s (replace-regexp-in-string
|
||||
"^\\(#+\\) "
|
||||
(lambda (m) (concat (make-string (length (match-string 1 m)) ?*) " "))
|
||||
s))
|
||||
;; Links [text](url) → [[url][text]]
|
||||
(setq s (replace-regexp-in-string
|
||||
"\\[\\([^]]+\\)\\](\\([^)]+\\))" "[[\\2][\\1]]" s))
|
||||
s))
|
||||
|
||||
(defun nanoclaw--format-timestamp ()
|
||||
"Return a formatted timestamp string, or nil if disabled."
|
||||
(when nanoclaw-timestamp-format
|
||||
(format-time-string nanoclaw-timestamp-format)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Internal state
|
||||
|
||||
(defvar nanoclaw--poll-timer nil
|
||||
"Timer used to poll for responses in the chat buffer.")
|
||||
|
||||
(defvar nanoclaw--last-timestamp 0
|
||||
"Epoch ms of the most recently received message.")
|
||||
|
||||
(defvar nanoclaw--pending nil
|
||||
"Non-nil while waiting for a response.")
|
||||
|
||||
(defvar-local nanoclaw--thinking-dot-count 0
|
||||
"Dot cycle counter for the animated thinking indicator.")
|
||||
|
||||
(defvar-local nanoclaw--input-beg nil
|
||||
"Marker for the start of the current user input area.")
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; HTTP helpers
|
||||
|
||||
(defun nanoclaw--url (path)
|
||||
"Return the full URL for PATH on the NanoClaw server."
|
||||
(format "http://%s:%d%s" nanoclaw-host nanoclaw-port path))
|
||||
|
||||
(defun nanoclaw--headers ()
|
||||
"Return alist of HTTP headers for NanoClaw requests."
|
||||
(let ((hdrs '(("Content-Type" . "application/json"))))
|
||||
(when nanoclaw-auth-token
|
||||
(push (cons "Authorization" (concat "Bearer " nanoclaw-auth-token)) hdrs))
|
||||
hdrs))
|
||||
|
||||
(defun nanoclaw--post (text callback)
|
||||
"POST TEXT to NanoClaw and call CALLBACK with the response alist."
|
||||
(let* ((url-request-method "POST")
|
||||
(url-request-extra-headers (nanoclaw--headers))
|
||||
(url-request-data (encode-coding-string
|
||||
(json-encode `((text . ,text)))
|
||||
'utf-8)))
|
||||
(url-retrieve
|
||||
(nanoclaw--url "/api/message")
|
||||
(lambda (status)
|
||||
(if (plist-get status :error)
|
||||
(message "NanoClaw: POST error %s" (plist-get status :error))
|
||||
(goto-char (point-min))
|
||||
(re-search-forward "\n\n" nil t)
|
||||
(let ((data (ignore-errors (json-read))))
|
||||
(funcall callback data))))
|
||||
nil t t)))
|
||||
|
||||
(defun nanoclaw--poll (since callback)
|
||||
"GET messages newer than SINCE (epoch ms) and call CALLBACK with the list."
|
||||
(let* ((url-request-method "GET")
|
||||
(url-request-extra-headers (nanoclaw--headers)))
|
||||
(url-retrieve
|
||||
(nanoclaw--url (format "/api/messages?since=%d" since))
|
||||
(lambda (status)
|
||||
(unless (plist-get status :error)
|
||||
(goto-char (point-min))
|
||||
(re-search-forward "\n\n" nil t)
|
||||
(let* ((raw (buffer-substring-no-properties (point) (point-max)))
|
||||
(body (decode-coding-string raw 'utf-8))
|
||||
(data (ignore-errors (json-read-from-string body)))
|
||||
(msgs (cdr (assq 'messages data))))
|
||||
(when msgs (funcall callback (append msgs nil))))))
|
||||
nil t t)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Chat buffer
|
||||
|
||||
(defvar nanoclaw-chat-mode-map
|
||||
(let ((map (make-sparse-keymap)))
|
||||
(define-key map (kbd "RET") #'newline)
|
||||
(define-key map (kbd "<return>") #'newline)
|
||||
(define-key map (kbd "C-c C-c") #'nanoclaw-chat-send)
|
||||
map)
|
||||
"Keymap for `nanoclaw-chat-mode'.")
|
||||
|
||||
(define-derived-mode nanoclaw-chat-mode org-mode "NanoClaw"
|
||||
"Major mode for the NanoClaw chat buffer.
|
||||
Derives from org-mode so that org markup (headings, bold, code blocks,
|
||||
etc.) is fontified automatically. RET and <return> insert plain newlines
|
||||
for multi-line input; send with C-c C-c."
|
||||
(setq-local word-wrap t)
|
||||
(visual-line-mode 1)
|
||||
;; Disable org features that conflict with a linear chat buffer
|
||||
(setq-local org-return-follows-link nil)
|
||||
(setq-local org-cycle-emulate-tab nil)
|
||||
;; Ensure send binding beats org-mode's C-c C-c via the buffer-local map
|
||||
(local-set-key (kbd "C-c C-c") #'nanoclaw-chat-send))
|
||||
|
||||
(defun nanoclaw--advance-input-beg ()
|
||||
"Move `nanoclaw--input-beg' to point-max in the chat buffer."
|
||||
(with-current-buffer (nanoclaw--chat-buffer)
|
||||
(when nanoclaw--input-beg (set-marker nanoclaw--input-beg nil))
|
||||
(setq nanoclaw--input-beg (copy-marker (point-max)))))
|
||||
|
||||
(defun nanoclaw--chat-buffer ()
|
||||
"Return the NanoClaw chat buffer, creating it if necessary."
|
||||
(or (get-buffer "*NanoClaw*")
|
||||
(with-current-buffer (get-buffer-create "*NanoClaw*")
|
||||
(nanoclaw-chat-mode)
|
||||
(set-buffer-file-coding-system 'utf-8)
|
||||
(add-hook 'kill-buffer-hook #'nanoclaw--stop-poll nil t)
|
||||
(nanoclaw--insert-header)
|
||||
(setq nanoclaw--input-beg (copy-marker (point-max)))
|
||||
(current-buffer))))
|
||||
|
||||
(defun nanoclaw--insert-header ()
|
||||
"Insert the welcome header into the chat buffer."
|
||||
(let ((inhibit-read-only t))
|
||||
(insert (propertize
|
||||
(format "── NanoClaw (%s) ──────────────────────────────\n\n"
|
||||
nanoclaw-agent-name)
|
||||
'face 'font-lock-comment-face))))
|
||||
|
||||
(defun nanoclaw--chat-insert (speaker text)
|
||||
"Append SPEAKER: TEXT to the chat buffer."
|
||||
(with-current-buffer (nanoclaw--chat-buffer)
|
||||
(let* ((inhibit-read-only t)
|
||||
(is-agent (not (string= speaker "You")))
|
||||
(display-text (if is-agent (nanoclaw--to-org text) text))
|
||||
(ts (nanoclaw--format-timestamp))
|
||||
(label (if ts (format "%s [%s]" speaker ts) speaker))
|
||||
(face (if is-agent 'font-lock-string-face 'font-lock-keyword-face)))
|
||||
(goto-char (point-max))
|
||||
(insert (propertize (concat label ": ") 'face face))
|
||||
(insert display-text "\n\n")
|
||||
(goto-char (point-max))
|
||||
(when is-agent
|
||||
(nanoclaw--advance-input-beg)))))
|
||||
|
||||
;;;###autoload
|
||||
(defun nanoclaw-chat ()
|
||||
"Open the NanoClaw chat buffer."
|
||||
(interactive)
|
||||
(pop-to-buffer (nanoclaw--chat-buffer))
|
||||
(goto-char (point-max)))
|
||||
|
||||
(defun nanoclaw-chat-send ()
|
||||
"Send the accumulated input area as a message to NanoClaw.
|
||||
Use C-c C-c to send; RET inserts a plain newline for multi-line messages."
|
||||
(interactive)
|
||||
(when nanoclaw--pending
|
||||
(message "NanoClaw: waiting for previous response...")
|
||||
(cl-return-from nanoclaw-chat-send))
|
||||
(let* ((beg (if (and nanoclaw--input-beg (marker-buffer nanoclaw--input-beg))
|
||||
(marker-position nanoclaw--input-beg)
|
||||
(line-beginning-position)))
|
||||
(text (string-trim (buffer-substring-no-properties beg (point-max)))))
|
||||
(when (string-empty-p text)
|
||||
(user-error "Nothing to send"))
|
||||
(let ((inhibit-read-only t))
|
||||
(delete-region beg (point-max)))
|
||||
(nanoclaw--chat-insert "You" text)
|
||||
(nanoclaw--advance-input-beg)
|
||||
(setq nanoclaw--pending t)
|
||||
(nanoclaw--post text
|
||||
(lambda (data)
|
||||
(when data
|
||||
(setq nanoclaw--last-timestamp
|
||||
(or (cdr (assq 'timestamp data))
|
||||
nanoclaw--last-timestamp))
|
||||
(nanoclaw--start-thinking)
|
||||
(nanoclaw--start-poll))))))
|
||||
|
||||
(defun nanoclaw--start-poll ()
|
||||
"Start polling for new messages."
|
||||
(nanoclaw--stop-poll)
|
||||
(setq nanoclaw--poll-timer
|
||||
(run-with-timer nanoclaw-poll-interval nanoclaw-poll-interval
|
||||
#'nanoclaw--poll-tick)))
|
||||
|
||||
(defun nanoclaw--stop-poll ()
|
||||
"Stop the polling timer."
|
||||
(when nanoclaw--poll-timer
|
||||
(cancel-timer nanoclaw--poll-timer)
|
||||
(setq nanoclaw--poll-timer nil)))
|
||||
|
||||
(defun nanoclaw--start-thinking ()
|
||||
"Insert an animated thinking indicator at the end of the chat buffer."
|
||||
(with-current-buffer (nanoclaw--chat-buffer)
|
||||
(let ((inhibit-read-only t))
|
||||
(goto-char (point-max))
|
||||
(setq nanoclaw--thinking-dot-count 1)
|
||||
(insert (propertize (format "%s: .\n\n" nanoclaw-agent-name)
|
||||
'nanoclaw-thinking t
|
||||
'face 'font-lock-string-face)))))
|
||||
|
||||
(defun nanoclaw--tick-thinking ()
|
||||
"Advance the dot animation in the thinking indicator."
|
||||
(let ((buf (get-buffer "*NanoClaw*")))
|
||||
(when buf
|
||||
(with-current-buffer buf
|
||||
(when nanoclaw--pending
|
||||
(let* ((inhibit-read-only t)
|
||||
(pos (text-property-any (point-min) (point-max)
|
||||
'nanoclaw-thinking t)))
|
||||
(when pos
|
||||
(let* ((end (or (next-single-property-change
|
||||
pos 'nanoclaw-thinking) (point-max)))
|
||||
(n (1+ (mod nanoclaw--thinking-dot-count 3))))
|
||||
(setq nanoclaw--thinking-dot-count n)
|
||||
(delete-region pos end)
|
||||
(save-excursion
|
||||
(goto-char pos)
|
||||
(insert (propertize
|
||||
(format "%s: %s\n\n" nanoclaw-agent-name
|
||||
(make-string n ?.))
|
||||
'nanoclaw-thinking t
|
||||
'face 'font-lock-string-face)))))))))))
|
||||
|
||||
(defun nanoclaw--clear-thinking ()
|
||||
"Remove the thinking indicator from the chat buffer."
|
||||
(let ((buf (get-buffer "*NanoClaw*")))
|
||||
(when buf
|
||||
(with-current-buffer buf
|
||||
(let* ((inhibit-read-only t)
|
||||
(pos (text-property-any (point-min) (point-max)
|
||||
'nanoclaw-thinking t)))
|
||||
(when pos
|
||||
(delete-region pos (or (next-single-property-change
|
||||
pos 'nanoclaw-thinking) (point-max)))))))))
|
||||
|
||||
(defun nanoclaw--poll-tick ()
|
||||
"Poll for new messages and insert them into the chat buffer."
|
||||
(nanoclaw--tick-thinking)
|
||||
(nanoclaw--poll
|
||||
nanoclaw--last-timestamp
|
||||
(lambda (msgs)
|
||||
(dolist (msg msgs)
|
||||
(let ((text (cdr (assq 'text msg)))
|
||||
(ts (cdr (assq 'timestamp msg))))
|
||||
(when (and text (> ts nanoclaw--last-timestamp))
|
||||
(setq nanoclaw--last-timestamp ts)
|
||||
(nanoclaw--clear-thinking)
|
||||
(nanoclaw--chat-insert nanoclaw-agent-name text))))
|
||||
(when msgs
|
||||
(setq nanoclaw--pending nil)
|
||||
(nanoclaw--stop-poll)))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Org integration
|
||||
|
||||
;;;###autoload
|
||||
(defun nanoclaw-org-send ()
|
||||
"Send the current org subtree to NanoClaw and insert the response as a child.
|
||||
|
||||
If a region is active, send the region text instead."
|
||||
(interactive)
|
||||
(unless (derived-mode-p 'org-mode)
|
||||
(user-error "Not in an org-mode buffer"))
|
||||
(let ((text (if (use-region-p)
|
||||
(buffer-substring-no-properties (region-beginning) (region-end))
|
||||
(nanoclaw--org-subtree-text))))
|
||||
(when (string-empty-p (string-trim text))
|
||||
(user-error "Nothing to send"))
|
||||
(message "NanoClaw: sending to %s..." nanoclaw-agent-name)
|
||||
(let ((marker (point-marker))
|
||||
(buf (current-buffer)))
|
||||
(nanoclaw--post
|
||||
text
|
||||
(lambda (data)
|
||||
(let* ((ts (or (cdr (assq 'timestamp data)) (nanoclaw--now-ms)))
|
||||
(level (with-current-buffer buf
|
||||
(save-excursion (goto-char marker) (org-outline-level))))
|
||||
(ph (with-current-buffer buf
|
||||
(save-excursion
|
||||
(goto-char marker)
|
||||
(nanoclaw--org-insert-placeholder level)))))
|
||||
(nanoclaw--poll-until-response
|
||||
ts
|
||||
(lambda (response)
|
||||
(with-current-buffer buf
|
||||
(save-excursion
|
||||
(when (marker-buffer ph)
|
||||
(let* ((inhibit-read-only t)
|
||||
(beg (marker-position ph))
|
||||
(end (save-excursion
|
||||
(goto-char (1+ beg))
|
||||
(org-next-visible-heading 1)
|
||||
(point))))
|
||||
(delete-region beg end))
|
||||
(set-marker ph nil))
|
||||
(goto-char marker)
|
||||
(nanoclaw--org-insert-response response))))
|
||||
(lambda ()
|
||||
(message "NanoClaw: timed out waiting for response")
|
||||
(when (marker-buffer ph)
|
||||
(with-current-buffer (marker-buffer ph)
|
||||
(let* ((inhibit-read-only t)
|
||||
(beg (marker-position ph))
|
||||
(end (save-excursion
|
||||
(goto-char (1+ beg))
|
||||
(org-next-visible-heading 1)
|
||||
(point))))
|
||||
(delete-region beg end))
|
||||
(set-marker ph nil)))))))))))
|
||||
|
||||
(defun nanoclaw--org-insert-placeholder (level)
|
||||
"Insert a processing child heading at LEVEL+1 and return a marker at its start."
|
||||
(org-back-to-heading t)
|
||||
(org-end-of-subtree t t)
|
||||
(let ((beg (point)))
|
||||
(insert "\n" (make-string (1+ level) ?*) " "
|
||||
nanoclaw-agent-name " [processing...]\n\n")
|
||||
(copy-marker beg)))
|
||||
|
||||
(defun nanoclaw--org-subtree-text ()
|
||||
"Return the text of the org subtree at point (heading + body)."
|
||||
(org-with-wide-buffer
|
||||
(org-back-to-heading t)
|
||||
(let ((start (point))
|
||||
(end (progn (org-end-of-subtree t t) (point))))
|
||||
(buffer-substring-no-properties start end))))
|
||||
|
||||
(defun nanoclaw--org-insert-response (text)
|
||||
"Insert TEXT as a child org heading under the current subtree."
|
||||
(org-back-to-heading t)
|
||||
(let* ((level (org-outline-level))
|
||||
(child-stars (make-string (1+ level) ?*))
|
||||
(timestamp (format-time-string "[%Y-%m-%d %a %H:%M]"))
|
||||
(body (nanoclaw--to-org text)))
|
||||
(org-end-of-subtree t t)
|
||||
(insert "\n" child-stars " " nanoclaw-agent-name " " timestamp "\n"
|
||||
body "\n")))
|
||||
|
||||
(defun nanoclaw--now-ms ()
|
||||
"Return current time as milliseconds since epoch."
|
||||
(let ((time (current-time)))
|
||||
(+ (* (+ (* (car time) 65536) (cadr time)) 1000)
|
||||
(/ (caddr time) 1000))))
|
||||
|
||||
(defun nanoclaw--poll-until-response (since callback timeout-fn &optional attempts)
|
||||
"Poll until a message newer than SINCE arrives, then call CALLBACK.
|
||||
Calls TIMEOUT-FN after 60 attempts (~90s)."
|
||||
(let ((n (or attempts 0)))
|
||||
(if (>= n 60)
|
||||
(funcall timeout-fn)
|
||||
(nanoclaw--poll
|
||||
since
|
||||
(lambda (msgs)
|
||||
(let ((fresh (seq-filter (lambda (m) (> (cdr (assq 'timestamp m)) since))
|
||||
msgs)))
|
||||
(if fresh
|
||||
(let ((text (mapconcat (lambda (m) (cdr (assq 'text m)))
|
||||
fresh "\n")))
|
||||
(funcall callback text))
|
||||
(run-with-timer nanoclaw-poll-interval nil
|
||||
#'nanoclaw--poll-until-response
|
||||
since callback timeout-fn (1+ n)))))))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(provide 'nanoclaw)
|
||||
;;; nanoclaw.el ends here
|
||||
+7
-19
@@ -129,10 +129,10 @@ rm -f "$PROGRESS_LOG"
|
||||
mkdir -p "$STEPS_DIR" "$LOGS_DIR"
|
||||
write_header
|
||||
|
||||
# NanoClaw wordmark — clack's intro carries the "let's get you set up" framing,
|
||||
# so we don't print a subtitle here. setup:auto sees NANOCLAW_BOOTSTRAPPED=1 and
|
||||
# skips re-printing the wordmark, keeping the flow visually continuous.
|
||||
printf '\n %s%s\n\n' "$(bold 'Nano')" "$(brand_bold 'Claw')"
|
||||
# NanoClaw wordmark + subtitle — setup:auto will see NANOCLAW_BOOTSTRAPPED=1
|
||||
# and skip printing these again, so the flow stays visually continuous.
|
||||
printf '\n %s%s\n' "$(bold 'Nano')" "$(brand_bold 'Claw')"
|
||||
printf ' %s\n\n' "$(dim 'Setting up your personal AI assistant')"
|
||||
|
||||
# ─── pre-flight: Homebrew on macOS ─────────────────────────────────────
|
||||
# setup/install-node.sh and setup/install-docker.sh both require `brew` on
|
||||
@@ -190,7 +190,7 @@ BOOTSTRAP_START=$(date +%s)
|
||||
|
||||
# One-line "why" that teaches a differentiator while the user waits.
|
||||
printf '%s %s\n' "$(gray '│')" \
|
||||
"$(dim "Small. Runs on your machine. Yours to modify.")"
|
||||
"$(dim "NanoClaw is small and runs entirely on your machine. Yours to modify.")"
|
||||
spinner_start "$BOOTSTRAP_LABEL"
|
||||
|
||||
# Run in the background so we can tick elapsed time. Capture exit code via
|
||||
@@ -222,7 +222,7 @@ rm -f "$BOOTSTRAP_EXIT_FILE"
|
||||
BOOTSTRAP_DUR=$(( $(date +%s) - BOOTSTRAP_START ))
|
||||
|
||||
if [ "$BOOTSTRAP_RC" -eq 0 ]; then
|
||||
spinner_success "Basics ready" "$BOOTSTRAP_DUR"
|
||||
spinner_success "Basics installed" "$BOOTSTRAP_DUR"
|
||||
write_bootstrap_entry success "$BOOTSTRAP_DUR" "$BOOTSTRAP_RAW"
|
||||
else
|
||||
spinner_failure "Couldn't install the basics" "$BOOTSTRAP_DUR"
|
||||
@@ -245,19 +245,7 @@ fi
|
||||
# wipe it.
|
||||
export NANOCLAW_BOOTSTRAPPED=1
|
||||
|
||||
# setup.sh may have just installed pnpm via npm into a prefix that's not on
|
||||
# our PATH (custom `npm config set prefix`, or the default prefix missing
|
||||
# from the shell's login PATH). Its PATH mutation doesn't propagate back
|
||||
# to us — so replay the same lookup here before the exec.
|
||||
if ! command -v pnpm >/dev/null 2>&1 && command -v npm >/dev/null 2>&1; then
|
||||
NPM_PREFIX="$(npm config get prefix 2>/dev/null)"
|
||||
if [ -n "$NPM_PREFIX" ] && [ -x "$NPM_PREFIX/bin/pnpm" ]; then
|
||||
export PATH="$NPM_PREFIX/bin:$PATH"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --silent suppresses pnpm's `> nanoclaw@2.0.0 setup:auto / > tsx setup/auto.ts`
|
||||
# preamble so the flow continues visually from "Basics installed" straight
|
||||
# into setup:auto's spinner. exec so signals (Ctrl-C) propagate directly.
|
||||
# `-- "$@"` forwards any flags (e.g. --onecli-api-host) to setup:auto.
|
||||
exec pnpm --silent run setup:auto -- "$@"
|
||||
exec pnpm --silent run setup:auto
|
||||
|
||||
+2
-20
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nanoclaw",
|
||||
"version": "2.0.14",
|
||||
"version": "2.0.4",
|
||||
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
@@ -24,31 +24,13 @@
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@beeper/chat-adapter-matrix": "^0.2.0",
|
||||
"@bitbasti/chat-adapter-webex": "^0.1.0",
|
||||
"@chat-adapter/discord": "^4.24.0",
|
||||
"@chat-adapter/gchat": "^4.24.0",
|
||||
"@chat-adapter/github": "^4.24.0",
|
||||
"@chat-adapter/linear": "^4.26.0",
|
||||
"@chat-adapter/slack": "^4.24.0",
|
||||
"@chat-adapter/state-memory": "^4.24.0",
|
||||
"@chat-adapter/teams": "^4.24.0",
|
||||
"@chat-adapter/telegram": "4.26.0",
|
||||
"@chat-adapter/whatsapp": "^4.24.0",
|
||||
"@clack/core": "^1.2.0",
|
||||
"@clack/prompts": "^1.2.0",
|
||||
"@onecli-sh/sdk": "^0.3.1",
|
||||
"@resend/chat-sdk-adapter": "^0.1.1",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@whiskeysockets/baileys": "7.0.0-rc.9",
|
||||
"better-sqlite3": "11.10.0",
|
||||
"chat": "^4.24.0",
|
||||
"chat-adapter-imessage": "^0.1.1",
|
||||
"cron-parser": "5.5.0",
|
||||
"kleur": "^4.1.5",
|
||||
"pino": "^9.6.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"wechat-ilink-client": "^0.1.0"
|
||||
"kleur": "^4.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.35.0",
|
||||
|
||||
Generated
+2
-3870
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="133k tokens, 66% of context window">
|
||||
<title>133k tokens, 66% of context window</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="128k tokens, 64% of context window">
|
||||
<title>128k tokens, 64% of context window</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,8 +15,8 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
||||
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
|
||||
<text x="26" y="14">tokens</text>
|
||||
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">133k</text>
|
||||
<text x="71" y="14">133k</text>
|
||||
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">128k</text>
|
||||
<text x="71" y="14">128k</text>
|
||||
</g>
|
||||
</g>
|
||||
</a>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Initialize the scratch CLI agent used during `/setup`.
|
||||
* Initialize the scratch CLI agent used during `/new-setup`.
|
||||
*
|
||||
* Creates the synthetic `cli:local` user, grants owner role if no owner
|
||||
* exists yet, builds an agent group with a minimal CLAUDE.md, and wires it
|
||||
|
||||
@@ -48,7 +48,6 @@ import { addMember } from '../src/modules/permissions/db/agent-group-members.js'
|
||||
import { getUserRoles, grantRole } from '../src/modules/permissions/db/user-roles.js';
|
||||
import { upsertUser } from '../src/modules/permissions/db/users.js';
|
||||
import { initGroupFilesystem } from '../src/group-init.js';
|
||||
import { namespacedPlatformId } from '../src/platform-id.js';
|
||||
import type { AgentGroup, MessagingGroup } from '../src/types.js';
|
||||
|
||||
type Role = 'owner' | 'admin' | 'member';
|
||||
@@ -138,6 +137,16 @@ function namespacedUserId(channel: string, raw: string): string {
|
||||
return raw.includes(':') ? raw : `${channel}:${raw}`;
|
||||
}
|
||||
|
||||
function namespacedPlatformId(channel: string, raw: string): string {
|
||||
if (raw.startsWith(`${channel}:`)) return raw;
|
||||
// Adapters using native JID format (WhatsApp: <phone>@s.whatsapp.net,
|
||||
// <groupId>@g.us) store platform_id without a channel prefix. The '@' is
|
||||
// the discriminator — telegram/discord platform_ids don't contain it
|
||||
// except after a channel prefix, which is already handled above.
|
||||
if (raw.includes('@')) return raw;
|
||||
return `${channel}:${raw}`;
|
||||
}
|
||||
|
||||
function generateId(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
@@ -120,20 +120,6 @@ install_deps() {
|
||||
|| true
|
||||
fi
|
||||
|
||||
# `npm install -g` writes to npm's global prefix, which isn't always on the
|
||||
# shell PATH — common on macOS where the user has `npm config set prefix
|
||||
# ~/.npm-global` to avoid sudo, or on Linux where /usr/local/bin isn't in
|
||||
# PATH. Discover the prefix and prepend its bin dir so `command -v pnpm`
|
||||
# sees the new install.
|
||||
if ! command -v pnpm >/dev/null 2>&1 && command -v npm >/dev/null 2>&1; then
|
||||
local npm_prefix
|
||||
npm_prefix=$(npm config get prefix 2>/dev/null)
|
||||
if [ -n "$npm_prefix" ] && [ -x "$npm_prefix/bin/pnpm" ]; then
|
||||
export PATH="$npm_prefix/bin:$PATH"
|
||||
log "Prepended npm prefix bin to PATH: $npm_prefix/bin"
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! command -v pnpm >/dev/null 2>&1; then
|
||||
log "pnpm not on PATH after corepack + npm fallback"
|
||||
return
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Install the Signal adapter in an already-running NanoClaw checkout.
|
||||
# Non-interactive — the operator-facing "install signal-cli" + QR scan
|
||||
# live in setup/channels/signal.ts. This script only:
|
||||
#
|
||||
# 1. Fetches src/channels/signal.ts + signal.test.ts from the channels
|
||||
# branch.
|
||||
# 2. Appends the self-registration import to src/channels/index.ts.
|
||||
# 3. Installs qrcode (for setup-flow QR rendering — adapter itself has
|
||||
# no npm deps).
|
||||
# 4. Builds.
|
||||
#
|
||||
# SIGNAL_ACCOUNT is persisted separately by the driver once signal-cli
|
||||
# link has produced a number; that keeps this script idempotent and
|
||||
# re-runnable without re-auth.
|
||||
#
|
||||
# Emits exactly one status block on stdout (ADD_SIGNAL) at the end. All
|
||||
# chatty progress goes to stderr so setup:auto's raw-log capture sees
|
||||
# the full story without cluttering the final block for the parser.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Keep in sync with .claude/skills/add-signal/SKILL.md.
|
||||
QRCODE_VERSION="qrcode@1.5.4"
|
||||
QRCODE_TYPES_VERSION="@types/qrcode@1.5.6"
|
||||
|
||||
# shellcheck source=setup/lib/channels-remote.sh
|
||||
source "$PROJECT_ROOT/setup/lib/channels-remote.sh"
|
||||
CHANNELS_REMOTE=$(resolve_channels_remote)
|
||||
CHANNELS_BRANCH="${CHANNELS_REMOTE}/channels"
|
||||
|
||||
emit_status() {
|
||||
local status=$1 error=${2:-}
|
||||
local already=${ADAPTER_ALREADY_INSTALLED:-false}
|
||||
echo "=== NANOCLAW SETUP: ADD_SIGNAL ==="
|
||||
echo "STATUS: ${status}"
|
||||
echo "ADAPTER_ALREADY_INSTALLED: ${already}"
|
||||
[ -n "$error" ] && echo "ERROR: ${error}"
|
||||
echo "=== END ==="
|
||||
}
|
||||
|
||||
log() { echo "[add-signal] $*" >&2; }
|
||||
|
||||
need_install() {
|
||||
[ ! -f src/channels/signal.ts ] && return 0
|
||||
! grep -q "^import './signal.js';" src/channels/index.ts 2>/dev/null && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
ADAPTER_ALREADY_INSTALLED=true
|
||||
if need_install; then
|
||||
ADAPTER_ALREADY_INSTALLED=false
|
||||
log "Fetching channels branch…"
|
||||
git fetch "$CHANNELS_REMOTE" channels >&2 2>/dev/null || {
|
||||
emit_status failed "git fetch ${CHANNELS_REMOTE} channels failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
log "Copying adapter files from ${CHANNELS_BRANCH}…"
|
||||
for f in \
|
||||
src/channels/signal.ts \
|
||||
src/channels/signal.test.ts
|
||||
do
|
||||
git show "${CHANNELS_BRANCH}:$f" > "$f" || {
|
||||
emit_status failed "git show ${CHANNELS_BRANCH}:$f failed"
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
if ! grep -q "^import './signal.js';" src/channels/index.ts; then
|
||||
echo "import './signal.js';" >> src/channels/index.ts
|
||||
fi
|
||||
fi
|
||||
|
||||
# qrcode is needed by setup/signal-auth.ts to render the linking URL as a
|
||||
# terminal QR. Install idempotently — if it's already present (e.g. from a
|
||||
# prior WhatsApp install) pnpm is a no-op.
|
||||
if ! node -e "require.resolve('qrcode')" >/dev/null 2>&1; then
|
||||
log "Installing ${QRCODE_VERSION}…"
|
||||
pnpm install "${QRCODE_VERSION}" "${QRCODE_TYPES_VERSION}" >&2 2>/dev/null || {
|
||||
emit_status failed "pnpm install ${QRCODE_VERSION} failed"
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
log "Building…"
|
||||
pnpm run build >&2 2>/dev/null || {
|
||||
emit_status failed "pnpm run build failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
emit_status success
|
||||
@@ -16,7 +16,7 @@ PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Keep in sync with .claude/skills/add-whatsapp/SKILL.md.
|
||||
BAILEYS_VERSION="@whiskeysockets/baileys@7.0.0-rc.9"
|
||||
BAILEYS_VERSION="@whiskeysockets/baileys@6.17.16"
|
||||
QRCODE_VERSION="qrcode@1.5.4"
|
||||
QRCODE_TYPES_VERSION="@types/qrcode@1.5.6"
|
||||
PINO_VERSION="pino@9.6.0"
|
||||
|
||||
+2
-2
@@ -7,7 +7,7 @@
|
||||
* already exists unless --force is passed.
|
||||
*
|
||||
* The actual user-facing prompt (subscription vs API key, paste the token)
|
||||
* stays in the /setup SKILL.md. This step is just the machine side:
|
||||
* stays in the /new-setup SKILL.md. This step is just the machine side:
|
||||
* it calls `onecli secrets list` / `onecli secrets create` and emits a
|
||||
* structured status block. The token value is never logged.
|
||||
*/
|
||||
@@ -124,7 +124,7 @@ export async function run(args: string[]): Promise<void> {
|
||||
emitStatus('AUTH', {
|
||||
STATUS: 'failed',
|
||||
ERROR: 'onecli_list_failed',
|
||||
HINT: 'Is OneCLI running? Run `/setup` from the onecli step.',
|
||||
HINT: 'Is OneCLI running? Run `/new-setup` from the onecli step.',
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
process.exit(1);
|
||||
|
||||
+127
-302
@@ -22,15 +22,12 @@
|
||||
* headless `claude -p` call for IANA-zone resolution.
|
||||
*/
|
||||
import { spawn, spawnSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import { runDiscordChannel } from './channels/discord.js';
|
||||
import { runIMessageChannel } from './channels/imessage.js';
|
||||
import { runSignalChannel } from './channels/signal.js';
|
||||
import { runSlackChannel } from './channels/slack.js';
|
||||
import { runTeamsChannel } from './channels/teams.js';
|
||||
import { runTelegramChannel } from './channels/telegram.js';
|
||||
@@ -38,17 +35,12 @@ import { runWhatsAppChannel } from './channels/whatsapp.js';
|
||||
import { pingCliAgent, type PingResult } from './lib/agent-ping.js';
|
||||
import { brightSelect } from './lib/bright-select.js';
|
||||
import { offerClaudeAssist } from './lib/claude-assist.js';
|
||||
import {
|
||||
applyToEnv,
|
||||
parseFlags,
|
||||
printHelp,
|
||||
readFromEnv,
|
||||
} from './lib/setup-config-parse.js';
|
||||
import { runAdvancedScreen } from './lib/setup-config-screen.js';
|
||||
import { runWindowedStep } from './lib/windowed-runner.js';
|
||||
import { pollHealth } from './onecli.js';
|
||||
import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js';
|
||||
import { claudeCliAvailable, resolveTimezoneViaClaude } from './lib/tz-from-claude.js';
|
||||
import {
|
||||
claudeCliAvailable,
|
||||
resolveTimezoneViaClaude,
|
||||
} from './lib/tz-from-claude.js';
|
||||
import * as setupLog from './logs.js';
|
||||
import { ensureAnswer, fail, runQuietChild, runQuietStep } from './lib/runner.js';
|
||||
import { emit as phEmit } from './lib/diagnostics.js';
|
||||
@@ -58,48 +50,20 @@ import { isValidTimezone } from '../src/timezone.js';
|
||||
const CLI_AGENT_NAME = 'Terminal Agent';
|
||||
const RUN_START = Date.now();
|
||||
|
||||
type ChannelChoice = 'telegram' | 'discord' | 'whatsapp' | 'signal' | 'teams' | 'slack' | 'imessage' | 'skip';
|
||||
type ChannelChoice =
|
||||
| 'telegram'
|
||||
| 'discord'
|
||||
| 'whatsapp'
|
||||
| 'teams'
|
||||
| 'slack'
|
||||
| 'imessage'
|
||||
| 'skip';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
// Parse CLI flags first — `--help` short-circuits before we render anything,
|
||||
// and flag values get folded into process.env so existing step code reading
|
||||
// NANOCLAW_* sees them unchanged.
|
||||
const flagResult = parseFlags(process.argv.slice(2));
|
||||
if (flagResult.help) {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
}
|
||||
if (flagResult.errors.length > 0) {
|
||||
for (const err of flagResult.errors) console.error(`error: ${err}`);
|
||||
console.error('');
|
||||
console.error('Run with --help for the full list of supported flags.');
|
||||
process.exit(1);
|
||||
}
|
||||
let configValues = { ...readFromEnv(), ...flagResult.values };
|
||||
applyToEnv(configValues);
|
||||
|
||||
printIntro();
|
||||
initProgressionLog();
|
||||
phEmit('auto_started');
|
||||
|
||||
// Welcome menu — default path or open advanced overrides before any setup
|
||||
// work begins. Default lands on standard so Enter is the happy path.
|
||||
const startChoice = ensureAnswer(
|
||||
await brightSelect<'default' | 'advanced'>({
|
||||
message: 'How would you like to begin?',
|
||||
options: [
|
||||
{ value: 'default', label: 'Standard setup' },
|
||||
{ value: 'advanced', label: 'Advanced', hint: 'override defaults' },
|
||||
],
|
||||
initialValue: 'default',
|
||||
}),
|
||||
) as 'default' | 'advanced';
|
||||
setupLog.userInput('start_choice', startChoice);
|
||||
if (startChoice === 'advanced') {
|
||||
configValues = await runAdvancedScreen(configValues);
|
||||
applyToEnv(configValues);
|
||||
}
|
||||
|
||||
const skip = new Set(
|
||||
(process.env.NANOCLAW_SKIP ?? '')
|
||||
.split(',')
|
||||
@@ -122,7 +86,12 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
if (!skip.has('container')) {
|
||||
p.log.message(dimWrap('Your assistant lives in its own sandbox. It can only see what you explicitly share.', 4));
|
||||
p.log.message(
|
||||
dimWrap(
|
||||
'Your assistant lives in its own sandbox. It can only see what you explicitly share.',
|
||||
4,
|
||||
),
|
||||
);
|
||||
p.log.message(
|
||||
dimWrap(
|
||||
'The first build pulls a base image and installs a few tools. On a fresh machine this usually takes 3–10 minutes.',
|
||||
@@ -167,95 +136,57 @@ async function main(): Promise<void> {
|
||||
),
|
||||
);
|
||||
|
||||
const remoteHost = process.env.NANOCLAW_ONECLI_API_HOST?.trim();
|
||||
// Respect an existing OneCLI install. Re-running the installer would
|
||||
// rebind the listener and knock any other app using that gateway
|
||||
// offline — confirm with the user before doing that.
|
||||
const existing = detectExistingOnecli();
|
||||
let reuse = false;
|
||||
if (existing) {
|
||||
const choice = ensureAnswer(
|
||||
await brightSelect({
|
||||
message: `Found an existing OneCLI at ${existing.apiHost}. What would you like to do?`,
|
||||
options: [
|
||||
{
|
||||
value: 'reuse',
|
||||
label: 'Use the existing instance',
|
||||
hint: 'recommended — keeps other apps bound to this vault working',
|
||||
},
|
||||
{
|
||||
value: 'fresh',
|
||||
label: 'Install a fresh instance for NanoClaw',
|
||||
hint: 'reinstalls onecli; other apps may need to reconnect',
|
||||
},
|
||||
],
|
||||
}),
|
||||
) as 'reuse' | 'fresh';
|
||||
setupLog.userInput('onecli_choice', choice);
|
||||
reuse = choice === 'reuse';
|
||||
}
|
||||
|
||||
if (remoteHost) {
|
||||
// Advanced-settings override: user has already named a remote vault,
|
||||
// so skip the local-vs-fresh prompt entirely. Health-check it here
|
||||
// rather than letting the step fail silently — a typo in the URL is a
|
||||
// common mistake and the answer is human-fixable.
|
||||
const s = p.spinner();
|
||||
s.start(`Checking remote OneCLI at ${remoteHost}…`);
|
||||
const healthy = await pollHealth(remoteHost, 5000);
|
||||
if (!healthy) {
|
||||
s.stop(`Couldn't reach OneCLI at ${remoteHost}.`, 1);
|
||||
const res = await runQuietStep(
|
||||
'onecli',
|
||||
{
|
||||
running: reuse
|
||||
? 'Hooking up to your existing OneCLI…'
|
||||
: "Setting up OneCLI, your agent's vault…",
|
||||
done: 'OneCLI vault ready.',
|
||||
},
|
||||
reuse ? ['--reuse'] : [],
|
||||
);
|
||||
if (!res.ok) {
|
||||
const err = res.terminal?.fields.ERROR;
|
||||
if (err === 'onecli_not_on_path_after_install') {
|
||||
await fail(
|
||||
'onecli',
|
||||
`Couldn't reach OneCLI at ${remoteHost}.`,
|
||||
'Check the URL and that OneCLI is running on the remote machine, then retry.',
|
||||
'OneCLI was installed but your shell needs to refresh to see it.',
|
||||
'Open a new shell or run `export PATH="$HOME/.local/bin:$PATH"`, then retry.',
|
||||
);
|
||||
}
|
||||
s.stop('Remote OneCLI is reachable.');
|
||||
|
||||
const res = await runQuietStep(
|
||||
await fail(
|
||||
'onecli',
|
||||
{
|
||||
running: `Connecting to remote OneCLI at ${remoteHost}…`,
|
||||
done: 'OneCLI vault ready.',
|
||||
},
|
||||
['--remote-url', remoteHost],
|
||||
`Couldn't set up OneCLI (${err ?? 'unknown error'}).`,
|
||||
'Make sure curl is installed and ~/.local/bin is writable, then retry.',
|
||||
);
|
||||
if (!res.ok) {
|
||||
const err = res.terminal?.fields.ERROR;
|
||||
await fail(
|
||||
'onecli',
|
||||
`Couldn't connect to remote OneCLI (${err ?? 'unknown error'}).`,
|
||||
'Check the URL and that OneCLI is running on the remote machine, then retry.',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Respect an existing OneCLI install. Re-running the installer would
|
||||
// rebind the listener and knock any other app using that gateway
|
||||
// offline — confirm with the user before doing that.
|
||||
const existing = detectExistingOnecli();
|
||||
let reuse = false;
|
||||
if (existing) {
|
||||
const choice = ensureAnswer(
|
||||
await brightSelect({
|
||||
message: `Found an existing OneCLI at ${existing.apiHost}. What would you like to do?`,
|
||||
options: [
|
||||
{
|
||||
value: 'reuse',
|
||||
label: 'Use the existing instance',
|
||||
hint: 'recommended — keeps other apps bound to this vault working',
|
||||
},
|
||||
{
|
||||
value: 'fresh',
|
||||
label: 'Install a fresh instance for NanoClaw',
|
||||
hint: 'reinstalls onecli; other apps may need to reconnect',
|
||||
},
|
||||
],
|
||||
}),
|
||||
) as 'reuse' | 'fresh';
|
||||
setupLog.userInput('onecli_choice', choice);
|
||||
reuse = choice === 'reuse';
|
||||
}
|
||||
|
||||
const res = await runQuietStep(
|
||||
'onecli',
|
||||
{
|
||||
running: reuse
|
||||
? 'Hooking up to your existing OneCLI…'
|
||||
: "Setting up OneCLI, your agent's vault…",
|
||||
done: 'OneCLI vault ready.',
|
||||
},
|
||||
reuse ? ['--reuse'] : [],
|
||||
);
|
||||
if (!res.ok) {
|
||||
const err = res.terminal?.fields.ERROR;
|
||||
if (err === 'onecli_not_on_path_after_install') {
|
||||
await fail(
|
||||
'onecli',
|
||||
'OneCLI was installed but your shell needs to refresh to see it.',
|
||||
'Open a new shell or run `export PATH="$HOME/.local/bin:$PATH"`, then retry.',
|
||||
);
|
||||
}
|
||||
await fail(
|
||||
'onecli',
|
||||
`Couldn't set up OneCLI (${err ?? 'unknown error'}).`,
|
||||
'Make sure curl is installed and ~/.local/bin is writable, then retry.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,12 +215,21 @@ async function main(): Promise<void> {
|
||||
done: 'NanoClaw is running.',
|
||||
});
|
||||
if (!res.ok) {
|
||||
await fail('service', "Couldn't start NanoClaw.", 'See logs/nanoclaw.error.log for details.');
|
||||
await fail(
|
||||
'service',
|
||||
"Couldn't start NanoClaw.",
|
||||
'See logs/nanoclaw.error.log for details.',
|
||||
);
|
||||
}
|
||||
if (res.terminal?.fields.DOCKER_GROUP_STALE === 'true') {
|
||||
p.log.warn("NanoClaw's permissions need a tweak before it can reach Docker.");
|
||||
p.log.warn(
|
||||
"NanoClaw's permissions need a tweak before it can reach Docker.",
|
||||
);
|
||||
p.log.message(
|
||||
' sudo setfacl -m u:$(whoami):rw /var/run/docker.sock\n' + ` systemctl --user restart ${getSystemdUnit()}`,
|
||||
k.dim(
|
||||
' sudo setfacl -m u:$(whoami):rw /var/run/docker.sock\n' +
|
||||
' systemctl --user restart nanoclaw',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -319,33 +259,10 @@ async function main(): Promise<void> {
|
||||
);
|
||||
}
|
||||
if (!skip.has('first-chat')) {
|
||||
p.log.message(
|
||||
dimWrap(
|
||||
"Your assistant runs in an isolated sandbox. I'm going to send it a quick test message (ping) and wait for a reply (pong) to confirm it's responding. First startup typically takes 30–60 seconds while the sandbox warms up.",
|
||||
4,
|
||||
),
|
||||
);
|
||||
const ping = await confirmAssistantResponds();
|
||||
if (ping === 'ok') {
|
||||
phEmit('first_chat_ready');
|
||||
const next = ensureAnswer(
|
||||
await p.select({
|
||||
message: 'What next?',
|
||||
options: [
|
||||
{
|
||||
value: 'continue',
|
||||
label: 'Continue with setup',
|
||||
hint: 'recommended',
|
||||
},
|
||||
{
|
||||
value: 'chat',
|
||||
label: 'Pause here and chat with your agent from the terminal',
|
||||
},
|
||||
],
|
||||
}),
|
||||
) as 'continue' | 'chat';
|
||||
setupLog.userInput('first_chat_choice', next);
|
||||
if (next === 'chat') await runFirstChat();
|
||||
await runFirstChat();
|
||||
} else {
|
||||
phEmit('first_chat_failed', { reason: ping });
|
||||
renderPingFailureNote(ping);
|
||||
@@ -354,7 +271,7 @@ async function main(): Promise<void> {
|
||||
msg:
|
||||
ping === 'socket_error'
|
||||
? "NanoClaw service isn't listening on its CLI socket."
|
||||
: 'No reply from the assistant within 30 seconds.',
|
||||
: "No reply from the assistant within 30 seconds.",
|
||||
hint:
|
||||
ping === 'socket_error'
|
||||
? 'Socket at data/cli.sock did not accept a connection.'
|
||||
@@ -377,8 +294,6 @@ async function main(): Promise<void> {
|
||||
await runDiscordChannel(displayName!);
|
||||
} else if (channelChoice === 'whatsapp') {
|
||||
await runWhatsAppChannel(displayName!);
|
||||
} else if (channelChoice === 'signal') {
|
||||
await runSignalChannel(displayName!);
|
||||
} else if (channelChoice === 'teams') {
|
||||
await runTeamsChannel(displayName!);
|
||||
} else if (channelChoice === 'slack') {
|
||||
@@ -404,7 +319,7 @@ async function main(): Promise<void> {
|
||||
if (!res.ok) {
|
||||
const notes: string[] = [];
|
||||
if (res.terminal?.fields.CREDENTIALS !== 'configured') {
|
||||
notes.push("• Your Claude account isn't connected. Re-run setup and try again.");
|
||||
notes.push('• Your Claude account isn\'t connected. Re-run setup and try again.');
|
||||
}
|
||||
const service = res.terminal?.fields.SERVICE;
|
||||
if (service === 'running_other_checkout') {
|
||||
@@ -430,9 +345,7 @@ async function main(): Promise<void> {
|
||||
}
|
||||
}
|
||||
if (!res.terminal?.fields.CONFIGURED_CHANNELS) {
|
||||
notes.push(
|
||||
'• Want to chat from your phone? Add a messaging app with `/add-telegram`, `/add-slack`, or `/add-discord`.',
|
||||
);
|
||||
notes.push('• Want to chat from your phone? Add a messaging app with `/add-telegram`, `/add-slack`, or `/add-discord`.');
|
||||
}
|
||||
if (notes.length > 0) {
|
||||
p.note(notes.join('\n'), "What's left");
|
||||
@@ -466,7 +379,9 @@ async function main(): Promise<void> {
|
||||
['Open Claude Code:', 'claude'],
|
||||
];
|
||||
const labelWidth = Math.max(...rows.map(([l]) => l.length));
|
||||
const nextSteps = rows.map(([l, c]) => `${k.cyan(l.padEnd(labelWidth))} ${c}`).join('\n');
|
||||
const nextSteps = rows
|
||||
.map(([l, c]) => `${k.cyan(l.padEnd(labelWidth))} ${c}`)
|
||||
.join('\n');
|
||||
p.note(nextSteps, 'Try these');
|
||||
|
||||
// Always-on warning goes before the "check your DMs" directive so the
|
||||
@@ -488,7 +403,10 @@ async function main(): Promise<void> {
|
||||
// that the welcome-message signal was too easy to miss. Use p.note so it
|
||||
// renders with a visible box, cyan-bold the directive line, and put it
|
||||
// as the last thing before outro.
|
||||
p.note(`${brandBold('→')} ${k.bold(`Check your ${dmTarget} — your assistant is saying hi.`)}`, 'Go say hi');
|
||||
p.note(
|
||||
`${brandBold('→')} ${k.bold(`Check your ${dmTarget} — your assistant is saying hi.`)}`,
|
||||
'Go say hi',
|
||||
);
|
||||
p.outro(k.green("You're set."));
|
||||
} else {
|
||||
p.outro(k.green("You're ready! Chat with `pnpm run chat hi`."));
|
||||
@@ -503,8 +421,6 @@ function channelDmLabel(choice: ChannelChoice): string | null {
|
||||
return 'Discord DMs';
|
||||
case 'whatsapp':
|
||||
return 'WhatsApp';
|
||||
case 'signal':
|
||||
return 'Signal';
|
||||
case 'teams':
|
||||
return 'Teams';
|
||||
case 'imessage':
|
||||
@@ -545,11 +461,13 @@ async function confirmAssistantResponds(): Promise<PingResult> {
|
||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
||||
const suffix = ` (${elapsed}s)`;
|
||||
if (result === 'ok') {
|
||||
s.stop(`${k.bold(fitToWidth('Your assistant is ready.', suffix))}${k.dim(suffix)}`);
|
||||
s.stop(`${fitToWidth('Your assistant is ready.', suffix)}${k.dim(suffix)}`);
|
||||
} else {
|
||||
const msg =
|
||||
result === 'socket_error' ? "Couldn't reach the NanoClaw service." : "Your assistant didn't reply in time.";
|
||||
s.stop(`${k.bold(fitToWidth(msg, suffix))}${k.dim(suffix)}`, 1);
|
||||
result === 'socket_error'
|
||||
? "Couldn't reach the NanoClaw service."
|
||||
: "Your assistant didn't reply in time.";
|
||||
s.stop(`${fitToWidth(msg, suffix)}${k.dim(suffix)}`, 1);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -563,8 +481,8 @@ function renderPingFailureNote(result: PingResult): void {
|
||||
6,
|
||||
),
|
||||
'',
|
||||
` macOS: launchctl kickstart -k gui/$(id -u)/${getLaunchdLabel()}`,
|
||||
` Linux: systemctl --user restart ${getSystemdUnit()}`,
|
||||
k.dim(` macOS: launchctl kickstart -k gui/$(id -u)/${getLaunchdLabel()}`),
|
||||
k.dim(` Linux: systemctl --user restart ${getSystemdUnit()}`),
|
||||
].join('\n')
|
||||
: wrapForGutter(
|
||||
'No reply from your assistant within 30 seconds. Check `logs/nanoclaw.log` for clues, then try `pnpm run chat hi`.',
|
||||
@@ -604,7 +522,9 @@ async function runFirstChat(): Promise<void> {
|
||||
message: first
|
||||
? 'Try a quick hello — or press Enter to continue setup'
|
||||
: 'Another message? Press Enter to continue setup',
|
||||
placeholder: first ? 'e.g. "hi, what can you do?"' : 'press Enter to continue',
|
||||
placeholder: first
|
||||
? 'e.g. "hi, what can you do?"'
|
||||
: 'press Enter to continue',
|
||||
}),
|
||||
);
|
||||
first = false;
|
||||
@@ -620,9 +540,11 @@ function sendChatMessage(message: string): Promise<void> {
|
||||
// agent's reply reads as a clean block under the prompt. Splitting on
|
||||
// whitespace mirrors `pnpm run chat hello world` — chat.ts joins argv
|
||||
// with spaces on the far side.
|
||||
const child = spawn('pnpm', ['--silent', 'run', 'chat', ...message.split(/\s+/)], {
|
||||
stdio: ['ignore', 'inherit', 'inherit'],
|
||||
});
|
||||
const child = spawn(
|
||||
'pnpm',
|
||||
['--silent', 'run', 'chat', ...message.split(/\s+/)],
|
||||
{ stdio: ['ignore', 'inherit', 'inherit'] },
|
||||
);
|
||||
child.on('close', () => resolve());
|
||||
child.on('error', () => resolve());
|
||||
});
|
||||
@@ -637,16 +559,6 @@ async function runAuthStep(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Custom Anthropic-compatible endpoint flow. Both URL and token must be set;
|
||||
// OneCLI stores the token as a generic Bearer secret keyed to the URL host,
|
||||
// so the container only ever sees ANTHROPIC_BASE_URL + a placeholder.
|
||||
const customBaseUrl = process.env.NANOCLAW_ANTHROPIC_BASE_URL?.trim();
|
||||
const customAuthToken = process.env.NANOCLAW_ANTHROPIC_AUTH_TOKEN?.trim();
|
||||
if (customBaseUrl && customAuthToken) {
|
||||
await runCustomEndpointAuth(customBaseUrl, customAuthToken);
|
||||
return;
|
||||
}
|
||||
|
||||
const method = ensureAnswer(
|
||||
await brightSelect({
|
||||
message: 'How would you like to connect to Claude?',
|
||||
@@ -680,11 +592,15 @@ async function runAuthStep(): Promise<void> {
|
||||
}
|
||||
|
||||
async function runSubscriptionAuth(): Promise<void> {
|
||||
p.log.step('Opening the Claude sign-in flow…');
|
||||
console.log(k.dim(' (a browser will open for sign-in; this part is interactive)'));
|
||||
p.log.step("Opening the Claude sign-in flow…");
|
||||
console.log(
|
||||
k.dim(' (a browser will open for sign-in; this part is interactive)'),
|
||||
);
|
||||
console.log();
|
||||
const start = Date.now();
|
||||
const code = await runInheritScript('bash', ['setup/register-claude-token.sh']);
|
||||
const code = await runInheritScript('bash', [
|
||||
'setup/register-claude-token.sh',
|
||||
]);
|
||||
const durationMs = Date.now() - start;
|
||||
console.log();
|
||||
if (code !== 0) {
|
||||
@@ -724,16 +640,11 @@ async function runPasteAuth(method: 'oauth' | 'api'): Promise<void> {
|
||||
'auth',
|
||||
'onecli',
|
||||
[
|
||||
'secrets',
|
||||
'create',
|
||||
'--name',
|
||||
'Anthropic',
|
||||
'--type',
|
||||
'anthropic',
|
||||
'--value',
|
||||
token,
|
||||
'--host-pattern',
|
||||
'api.anthropic.com',
|
||||
'secrets', 'create',
|
||||
'--name', 'Anthropic',
|
||||
'--type', 'anthropic',
|
||||
'--value', token,
|
||||
'--host-pattern', 'api.anthropic.com',
|
||||
],
|
||||
{
|
||||
running: `Saving your ${label} to your OneCLI vault…`,
|
||||
@@ -752,92 +663,6 @@ async function runPasteAuth(method: 'oauth' | 'api'): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up Anthropic auth for a custom endpoint. The token is stored as a
|
||||
* OneCLI generic secret with header injection so the proxy rewrites the
|
||||
* Authorization header on the wire — the container only ever sees
|
||||
* ANTHROPIC_BASE_URL + a placeholder bearer.
|
||||
*/
|
||||
async function runCustomEndpointAuth(
|
||||
baseUrl: string,
|
||||
token: string,
|
||||
): Promise<void> {
|
||||
let host: string;
|
||||
try {
|
||||
host = new URL(baseUrl).hostname;
|
||||
} catch {
|
||||
await fail(
|
||||
'auth',
|
||||
`Invalid Anthropic base URL: ${baseUrl}`,
|
||||
'Check --anthropic-base-url and retry.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await runQuietChild(
|
||||
'auth',
|
||||
'onecli',
|
||||
[
|
||||
'secrets',
|
||||
'create',
|
||||
'--name',
|
||||
'Anthropic',
|
||||
'--type',
|
||||
'generic',
|
||||
'--value',
|
||||
token,
|
||||
'--host-pattern',
|
||||
host,
|
||||
'--header-name',
|
||||
'Authorization',
|
||||
'--value-format',
|
||||
'Bearer {value}',
|
||||
],
|
||||
{
|
||||
running: `Saving your Anthropic auth token to your OneCLI vault…`,
|
||||
done: 'Claude account connected.',
|
||||
},
|
||||
{ extraFields: { METHOD: 'custom-endpoint', HOST: host } },
|
||||
);
|
||||
if (!res.ok) {
|
||||
await fail(
|
||||
'auth',
|
||||
`Couldn't save your Anthropic auth token to the vault.`,
|
||||
'Make sure OneCLI is running (`onecli version`), then retry.',
|
||||
);
|
||||
}
|
||||
|
||||
// ANTHROPIC_BASE_URL has to be in .env so the runtime provider config
|
||||
// reads it when building container env. The token is *not* written —
|
||||
// OneCLI holds it.
|
||||
writeEnvLine('ANTHROPIC_BASE_URL', baseUrl);
|
||||
|
||||
// Register the claude provider so the runtime passes ANTHROPIC_BASE_URL
|
||||
// and the placeholder bearer into the container. Only appended when the
|
||||
// user has configured a custom endpoint; standard installs don't load
|
||||
// the file at all.
|
||||
appendProviderImport('./claude.js');
|
||||
}
|
||||
|
||||
function writeEnvLine(key: string, value: string): void {
|
||||
const envFile = path.join(process.cwd(), '.env');
|
||||
const content = fs.existsSync(envFile) ? fs.readFileSync(envFile, 'utf-8') : '';
|
||||
const re = new RegExp(`^${key}=.*$`, 'm');
|
||||
const next = re.test(content)
|
||||
? content.replace(re, `${key}=${value}`)
|
||||
: content.trimEnd() + (content ? '\n' : '') + `${key}=${value}\n`;
|
||||
fs.writeFileSync(envFile, next);
|
||||
}
|
||||
|
||||
function appendProviderImport(modulePath: string): void {
|
||||
const file = path.join(process.cwd(), 'src', 'providers', 'index.ts');
|
||||
const content = fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : '';
|
||||
const line = `import '${modulePath}';`;
|
||||
if (content.includes(line)) return;
|
||||
const sep = content && !content.endsWith('\n') ? '\n' : '';
|
||||
fs.writeFileSync(file, content + sep + line + '\n');
|
||||
}
|
||||
|
||||
// ─── timezone step ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -858,7 +683,10 @@ async function runTimezoneStep(): Promise<void> {
|
||||
const fields = res.terminal?.fields ?? {};
|
||||
const resolvedTz = fields.RESOLVED_TZ;
|
||||
const needsInput = fields.NEEDS_USER_INPUT === 'true';
|
||||
const isUtc = resolvedTz === 'UTC' || resolvedTz === 'Etc/UTC' || resolvedTz === 'Universal';
|
||||
const isUtc =
|
||||
resolvedTz === 'UTC' ||
|
||||
resolvedTz === 'Etc/UTC' ||
|
||||
resolvedTz === 'Universal';
|
||||
|
||||
// Three branches:
|
||||
// - no TZ detected: ask where they are (or leave as UTC)
|
||||
@@ -880,8 +708,8 @@ async function runTimezoneStep(): Promise<void> {
|
||||
const message = needsInput
|
||||
? "Your system didn't expose a timezone. Which one are you in?"
|
||||
: !isUtc
|
||||
? 'Where are you, then?'
|
||||
: 'Your system reports UTC as the timezone. Is that right, or are you somewhere else?';
|
||||
? "Where are you, then?"
|
||||
: "Your system reports UTC as the timezone. Is that right, or are you somewhere else?";
|
||||
|
||||
// For the non-UTC "detected-but-wrong" branch we skip the select and jump
|
||||
// straight to the free-text prompt — the user already said "not that".
|
||||
@@ -908,7 +736,7 @@ async function runTimezoneStep(): Promise<void> {
|
||||
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message: 'Where are you? (city, region, or IANA zone)',
|
||||
message: "Where are you? (city, region, or IANA zone)",
|
||||
placeholder: 'e.g. New York, London, Asia/Tokyo',
|
||||
validate: (v) => (v && v.trim() ? undefined : 'Required'),
|
||||
}),
|
||||
@@ -986,11 +814,6 @@ async function askChannelChoice(): Promise<ChannelChoice> {
|
||||
{ value: 'telegram', label: 'Yes, connect Telegram', hint: 'recommended' },
|
||||
{ value: 'discord', label: 'Yes, connect Discord' },
|
||||
{ value: 'whatsapp', label: 'Yes, connect WhatsApp' },
|
||||
{
|
||||
value: 'signal',
|
||||
label: 'Yes, connect Signal',
|
||||
hint: 'needs signal-cli installed',
|
||||
},
|
||||
{
|
||||
value: 'imessage',
|
||||
label: 'Yes, connect iMessage (experimental)',
|
||||
@@ -1104,15 +927,17 @@ function printIntro(): void {
|
||||
const wordmark = `${k.bold('Nano')}${brandBold('Claw')}`;
|
||||
|
||||
if (isReexec) {
|
||||
p.intro(`${brandChip(' Welcome ')} ${wordmark} ${k.dim('· picking up where we left off')}`);
|
||||
p.intro(
|
||||
`${brandChip(' Welcome ')} ${wordmark} ${k.dim('· picking up where we left off')}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// bash already printed the wordmark above us; the clack intro carries the
|
||||
// welcome framing alone so the two don't double up. Standalone runs of
|
||||
// setup:auto still see this as the first line — fine without the wordmark
|
||||
// since the line itself signals the start of the flow.
|
||||
p.intro("Let's get you set up.");
|
||||
// Always include the wordmark inside the clack intro line. When bash ran
|
||||
// first (NANOCLAW_BOOTSTRAPPED=1) it already printed its own wordmark
|
||||
// above us; the small repeat is worth it to keep the brand anchored at
|
||||
// the visible top of the clack session once the bash output scrolls away.
|
||||
p.intro(`${wordmark} ${k.dim("Let's get you set up.")}`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,357 +0,0 @@
|
||||
/**
|
||||
* Signal channel flow for setup:auto.
|
||||
*
|
||||
* `runSignalChannel(displayName)` owns the full branch from signal-cli
|
||||
* presence check through the welcome DM:
|
||||
*
|
||||
* 1. Probe signal-cli on PATH (or SIGNAL_CLI_PATH). On macOS without it,
|
||||
* offer `brew install signal-cli` inline. On Linux, surface the
|
||||
* GitHub releases URL and bail with an actionable error.
|
||||
* 2. Install the adapter + qrcode via setup/add-signal.sh (idempotent).
|
||||
* 3. Run the signal-auth step, rendering each SIGNAL_AUTH_QR block as
|
||||
* a terminal QR the operator scans from Signal → Linked Devices.
|
||||
* 4. Persist SIGNAL_ACCOUNT to .env (+ data/env/env).
|
||||
* 5. Kick the service so the adapter picks up the new credentials.
|
||||
* 6. Ask operator role + agent name.
|
||||
* 7. Wire the agent via scripts/init-first-agent.ts; the existing welcome
|
||||
* DM path delivers the greeting through the adapter.
|
||||
*
|
||||
* Signal's `link` flow creates a *secondary* device. The phone number
|
||||
* comes from the primary (the phone that scanned the QR); this host then
|
||||
* sends/receives as that primary number. No registration of new numbers.
|
||||
*
|
||||
* Output obeys the three-level contract: clack UI for the user, structured
|
||||
* entries in logs/setup.log, full raw output in per-step files under
|
||||
* logs/setup-steps/. See docs/setup-flow.md.
|
||||
*/
|
||||
import { spawnSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import * as setupLog from '../logs.js';
|
||||
import { getLaunchdLabel, getSystemdUnit } from '../../src/install-slug.js';
|
||||
import {
|
||||
type Block,
|
||||
type StepResult,
|
||||
dumpTranscriptOnFailure,
|
||||
ensureAnswer,
|
||||
fail,
|
||||
runQuietChild,
|
||||
spawnStep,
|
||||
writeStepEntry,
|
||||
} from '../lib/runner.js';
|
||||
import { askOperatorRole } from '../lib/role-prompt.js';
|
||||
|
||||
const DEFAULT_AGENT_NAME = 'Nano';
|
||||
|
||||
export async function runSignalChannel(displayName: string): Promise<void> {
|
||||
await ensureSignalCli();
|
||||
|
||||
const install = await runQuietChild(
|
||||
'signal-install',
|
||||
'bash',
|
||||
['setup/add-signal.sh'],
|
||||
{
|
||||
running: 'Installing the Signal adapter…',
|
||||
done: 'Signal adapter installed.',
|
||||
skipped: 'Signal adapter already installed.',
|
||||
},
|
||||
);
|
||||
if (!install.ok) {
|
||||
await fail(
|
||||
'signal-install',
|
||||
"Couldn't install the Signal adapter.",
|
||||
'See logs/setup-steps/ for details, then retry setup.',
|
||||
);
|
||||
}
|
||||
|
||||
const auth = await runSignalAuth();
|
||||
if (!auth.ok) {
|
||||
const reason = auth.terminal?.fields.ERROR ?? 'unknown';
|
||||
await fail(
|
||||
'signal-auth',
|
||||
`Signal link failed (${reason}).`,
|
||||
reason === 'qr_timeout'
|
||||
? 'The code expired. Re-run setup to get a fresh one.'
|
||||
: 'Re-run setup to try again.',
|
||||
);
|
||||
}
|
||||
|
||||
const account = auth.terminal?.fields.ACCOUNT;
|
||||
if (!account) {
|
||||
await fail(
|
||||
'signal-auth',
|
||||
'Linked with Signal but couldn\'t read the phone number back.',
|
||||
'Run `signal-cli listAccounts` to confirm, then re-run setup.',
|
||||
);
|
||||
}
|
||||
|
||||
writeSignalAccount(account!);
|
||||
await restartService();
|
||||
|
||||
const role = await askOperatorRole('Signal');
|
||||
setupLog.userInput('signal_role', role);
|
||||
|
||||
const agentName = await resolveAgentName();
|
||||
|
||||
const init = await runQuietChild(
|
||||
'init-first-agent',
|
||||
'pnpm',
|
||||
[
|
||||
'exec', 'tsx', 'scripts/init-first-agent.ts',
|
||||
'--channel', 'signal',
|
||||
'--user-id', account!,
|
||||
'--platform-id', account!,
|
||||
'--display-name', displayName,
|
||||
'--agent-name', agentName,
|
||||
'--role', role,
|
||||
],
|
||||
{
|
||||
running: `Connecting ${agentName} to Signal…`,
|
||||
done: `${agentName} is ready. Check Signal for a welcome message.`,
|
||||
},
|
||||
{
|
||||
extraFields: {
|
||||
CHANNEL: 'signal',
|
||||
AGENT_NAME: agentName,
|
||||
PLATFORM_ID: account!,
|
||||
ROLE: role,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!init.ok) {
|
||||
await fail(
|
||||
'init-first-agent',
|
||||
`Couldn't finish connecting ${agentName}.`,
|
||||
'You can retry later with `/manage-channels`.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureSignalCli(): Promise<void> {
|
||||
const cli = process.env.SIGNAL_CLI_PATH || 'signal-cli';
|
||||
const probe = spawnSync(cli, ['--version'], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
if (!probe.error && probe.status === 0) return;
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
p.note(
|
||||
[
|
||||
"NanoClaw talks to Signal through signal-cli, which isn't installed yet.",
|
||||
'',
|
||||
'The quickest way on macOS is Homebrew:',
|
||||
'',
|
||||
k.cyan(' brew install signal-cli'),
|
||||
'',
|
||||
"Install it in another terminal, then re-run setup.",
|
||||
].join('\n'),
|
||||
'signal-cli not found',
|
||||
);
|
||||
} else {
|
||||
p.note(
|
||||
[
|
||||
"NanoClaw talks to Signal through signal-cli, which isn't installed yet.",
|
||||
'',
|
||||
'Grab the latest release from GitHub:',
|
||||
'',
|
||||
k.cyan(' https://github.com/AsamK/signal-cli/releases'),
|
||||
'',
|
||||
"Install it, make sure `signal-cli --version` works, then re-run setup.",
|
||||
].join('\n'),
|
||||
'signal-cli not found',
|
||||
);
|
||||
}
|
||||
await fail(
|
||||
'signal-install',
|
||||
'signal-cli is required but not installed.',
|
||||
'Install it and re-run setup.',
|
||||
);
|
||||
}
|
||||
|
||||
async function runSignalAuth(): Promise<
|
||||
StepResult & { rawLog: string; durationMs: number }
|
||||
> {
|
||||
const rawLog = setupLog.stepRawLog('signal-auth');
|
||||
const start = Date.now();
|
||||
const s = p.spinner();
|
||||
s.start('Starting Signal link…');
|
||||
let spinnerActive = true;
|
||||
|
||||
const stopSpinner = (msg: string, code?: number): void => {
|
||||
if (spinnerActive) {
|
||||
s.stop(msg, code);
|
||||
spinnerActive = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Tracks how many lines the QR block occupies so we can wipe it in-place
|
||||
// once linking succeeds (Signal's link URL doesn't rotate like WhatsApp's,
|
||||
// but we still want to erase the QR from screen once it's served).
|
||||
let qrLinesPrinted = 0;
|
||||
|
||||
const result = await spawnStep(
|
||||
'signal-auth',
|
||||
[],
|
||||
(block: Block) => {
|
||||
if (block.type === 'SIGNAL_AUTH_QR') {
|
||||
const qr = block.fields.QR ?? '';
|
||||
if (!qr) return;
|
||||
void renderQr(qr).then((lines) => {
|
||||
stopSpinner('Scan this QR from Signal → Settings → Linked Devices.');
|
||||
process.stdout.write(lines.join('\n') + '\n');
|
||||
qrLinesPrinted = lines.length;
|
||||
s.start('Waiting for you to scan…');
|
||||
spinnerActive = true;
|
||||
});
|
||||
} else if (block.type === 'SIGNAL_AUTH') {
|
||||
const status = block.fields.STATUS;
|
||||
// Wipe the QR block regardless of outcome — it's either scanned
|
||||
// and useless, or expired and misleading.
|
||||
if (qrLinesPrinted > 0) {
|
||||
process.stdout.write(`\x1b[${qrLinesPrinted}A\x1b[0J`);
|
||||
qrLinesPrinted = 0;
|
||||
}
|
||||
const account = block.fields.ACCOUNT;
|
||||
if (status === 'skipped') {
|
||||
stopSpinner(
|
||||
account
|
||||
? `Signal already linked as ${k.cyan(account)}.`
|
||||
: 'Signal already linked.',
|
||||
);
|
||||
} else if (status === 'success') {
|
||||
stopSpinner(`Signal linked as ${k.cyan(String(account ?? ''))}.`);
|
||||
} else if (status === 'failed') {
|
||||
const err = block.fields.ERROR ?? 'unknown';
|
||||
stopSpinner(`Signal link failed: ${err}`, 1);
|
||||
}
|
||||
}
|
||||
},
|
||||
rawLog,
|
||||
);
|
||||
const durationMs = Date.now() - start;
|
||||
|
||||
if (spinnerActive) {
|
||||
stopSpinner(
|
||||
result.ok ? 'Done.' : 'Signal link ended unexpectedly.',
|
||||
result.ok ? 0 : 1,
|
||||
);
|
||||
if (!result.ok) dumpTranscriptOnFailure(result.transcript);
|
||||
}
|
||||
|
||||
writeStepEntry('signal-auth', result, durationMs, rawLog);
|
||||
return { ...result, rawLog, durationMs };
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the raw linking URL as a block-art QR, returned line-by-line so
|
||||
* the caller can count lines for in-place cleanup. Uses small-mode so the
|
||||
* code stays scannable on 24-row terminals. If qrcode isn't installed
|
||||
* (add-signal.sh should have handled it, but we're defensive), fall back
|
||||
* to the raw URL and ask the user to paste it into an external renderer.
|
||||
*/
|
||||
async function renderQr(url: string): Promise<string[]> {
|
||||
try {
|
||||
const QRCode = await import('qrcode');
|
||||
const qrText = await QRCode.toString(url, { type: 'terminal', small: true });
|
||||
const caption = k.dim(
|
||||
' Signal → Settings → Linked Devices → Link New Device → scan.',
|
||||
);
|
||||
return [...qrText.trimEnd().split('\n'), '', caption];
|
||||
} catch {
|
||||
return [
|
||||
'Linking URL (render at https://qr.io or similar):',
|
||||
'',
|
||||
url,
|
||||
'',
|
||||
k.dim('Signal → Settings → Linked Devices → Link New Device → scan.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist SIGNAL_ACCOUNT to .env and mirror to data/env/env for the container. */
|
||||
function writeSignalAccount(account: string): void {
|
||||
const envPath = path.join(process.cwd(), '.env');
|
||||
let contents = '';
|
||||
try {
|
||||
contents = fs.readFileSync(envPath, 'utf-8');
|
||||
} catch {
|
||||
contents = '';
|
||||
}
|
||||
if (/^SIGNAL_ACCOUNT=/m.test(contents)) {
|
||||
contents = contents.replace(
|
||||
/^SIGNAL_ACCOUNT=.*$/m,
|
||||
`SIGNAL_ACCOUNT=${account}`,
|
||||
);
|
||||
} else {
|
||||
if (contents.length > 0 && !contents.endsWith('\n')) contents += '\n';
|
||||
contents += `SIGNAL_ACCOUNT=${account}\n`;
|
||||
}
|
||||
fs.writeFileSync(envPath, contents);
|
||||
|
||||
const containerEnvDir = path.join(process.cwd(), 'data', 'env');
|
||||
fs.mkdirSync(containerEnvDir, { recursive: true });
|
||||
fs.copyFileSync(envPath, path.join(containerEnvDir, 'env'));
|
||||
|
||||
setupLog.userInput('signal_account', account);
|
||||
}
|
||||
|
||||
async function restartService(): Promise<void> {
|
||||
const s = p.spinner();
|
||||
s.start('Restarting NanoClaw so it sees your Signal account…');
|
||||
const start = Date.now();
|
||||
const platform = process.platform;
|
||||
try {
|
||||
if (platform === 'darwin') {
|
||||
spawnSync(
|
||||
'launchctl',
|
||||
['kickstart', '-k', `gui/${process.getuid?.() ?? 501}/${getLaunchdLabel()}`],
|
||||
{ stdio: 'ignore' },
|
||||
);
|
||||
} else if (platform === 'linux') {
|
||||
const unit = getSystemdUnit();
|
||||
const user = spawnSync('systemctl', ['--user', 'restart', unit], {
|
||||
stdio: 'ignore',
|
||||
});
|
||||
if (user.status !== 0) {
|
||||
spawnSync('sudo', ['systemctl', 'restart', unit], { stdio: 'ignore' });
|
||||
}
|
||||
}
|
||||
// Give the adapter a moment to connect to signal-cli before
|
||||
// init-first-agent's welcome DM hits the delivery path.
|
||||
await new Promise((r) => setTimeout(r, 5000));
|
||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
||||
s.stop(`NanoClaw restarted. ${k.dim(`(${elapsed}s)`)}`);
|
||||
setupLog.step('signal-restart', 'success', Date.now() - start, {
|
||||
PLATFORM: platform,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
s.stop(`Restart may have failed: ${message}`, 1);
|
||||
setupLog.step('signal-restart', 'failed', Date.now() - start, {
|
||||
ERROR: message,
|
||||
});
|
||||
// Non-fatal — the user can restart manually if init-first-agent fails.
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveAgentName(): Promise<string> {
|
||||
const preset = process.env.NANOCLAW_AGENT_NAME?.trim();
|
||||
if (preset) {
|
||||
setupLog.userInput('agent_name', preset);
|
||||
return preset;
|
||||
}
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message: 'What should your assistant be called?',
|
||||
placeholder: DEFAULT_AGENT_NAME,
|
||||
defaultValue: DEFAULT_AGENT_NAME,
|
||||
}),
|
||||
);
|
||||
const value = (answer as string).trim() || DEFAULT_AGENT_NAME;
|
||||
setupLog.userInput('agent_name', value);
|
||||
return value;
|
||||
}
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Step: cli-agent — Create the scratch CLI agent for `/setup`.
|
||||
* Step: cli-agent — Create the scratch CLI agent for `/new-setup`.
|
||||
*
|
||||
* Thin wrapper around `scripts/init-cli-agent.ts`. Emits a status block so
|
||||
* /setup SKILL.md can parse the result without having to read the
|
||||
* /new-setup SKILL.md can parse the result without having to read the
|
||||
* script's plain stdout.
|
||||
*
|
||||
* Args:
|
||||
|
||||
+45
-52
@@ -1,7 +1,5 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
@@ -19,63 +17,58 @@ describe('environment detection', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectRegisteredGroups', () => {
|
||||
let tempDir: string;
|
||||
describe('registered groups DB query', () => {
|
||||
let db: Database.Database;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-env-test-'));
|
||||
fs.mkdirSync(path.join(tempDir, 'data'), { recursive: true });
|
||||
db = new Database(':memory:');
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS registered_groups (
|
||||
jid TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
folder TEXT NOT NULL UNIQUE,
|
||||
trigger_pattern TEXT NOT NULL,
|
||||
added_at TEXT NOT NULL,
|
||||
container_config TEXT,
|
||||
requires_trigger INTEGER DEFAULT 1
|
||||
)`);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
it('returns 0 for empty table', () => {
|
||||
const row = db
|
||||
.prepare('SELECT COUNT(*) as count FROM registered_groups')
|
||||
.get() as { count: number };
|
||||
expect(row.count).toBe(0);
|
||||
});
|
||||
|
||||
it('returns false when no registration state exists', async () => {
|
||||
const { detectRegisteredGroups } = await import('./environment.js');
|
||||
expect(detectRegisteredGroups(tempDir)).toBe(false);
|
||||
});
|
||||
|
||||
it('detects pre-migration registered_groups.json', async () => {
|
||||
const { detectRegisteredGroups } = await import('./environment.js');
|
||||
fs.writeFileSync(path.join(tempDir, 'data', 'registered_groups.json'), '[]');
|
||||
expect(detectRegisteredGroups(tempDir)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for an empty v2 central DB', async () => {
|
||||
const { detectRegisteredGroups } = await import('./environment.js');
|
||||
const db = new Database(path.join(tempDir, 'data', 'v2.db'));
|
||||
db.exec(`
|
||||
CREATE TABLE agent_groups (id TEXT PRIMARY KEY);
|
||||
CREATE TABLE messaging_group_agents (
|
||||
id TEXT PRIMARY KEY,
|
||||
messaging_group_id TEXT NOT NULL,
|
||||
agent_group_id TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
db.close();
|
||||
|
||||
expect(detectRegisteredGroups(tempDir)).toBe(false);
|
||||
});
|
||||
|
||||
it('detects wired agent groups in the v2 central DB', async () => {
|
||||
const { detectRegisteredGroups } = await import('./environment.js');
|
||||
const db = new Database(path.join(tempDir, 'data', 'v2.db'));
|
||||
db.exec(`
|
||||
CREATE TABLE agent_groups (id TEXT PRIMARY KEY);
|
||||
CREATE TABLE messaging_group_agents (
|
||||
id TEXT PRIMARY KEY,
|
||||
messaging_group_id TEXT NOT NULL,
|
||||
agent_group_id TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
db.prepare('INSERT INTO agent_groups (id) VALUES (?)').run('ag-1');
|
||||
it('returns correct count after inserts', () => {
|
||||
db.prepare(
|
||||
'INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id) VALUES (?, ?, ?)',
|
||||
).run('mga-1', 'mg-1', 'ag-1');
|
||||
db.close();
|
||||
`INSERT INTO registered_groups (jid, name, folder, trigger_pattern, added_at, requires_trigger)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
'123@g.us',
|
||||
'Group 1',
|
||||
'group-1',
|
||||
'@Andy',
|
||||
'2024-01-01T00:00:00.000Z',
|
||||
1,
|
||||
);
|
||||
|
||||
expect(detectRegisteredGroups(tempDir)).toBe(true);
|
||||
db.prepare(
|
||||
`INSERT INTO registered_groups (jid, name, folder, trigger_pattern, added_at, requires_trigger)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
'456@g.us',
|
||||
'Group 2',
|
||||
'group-2',
|
||||
'@Andy',
|
||||
'2024-01-01T00:00:00.000Z',
|
||||
1,
|
||||
);
|
||||
|
||||
const row = db
|
||||
.prepare('SELECT COUNT(*) as count FROM registered_groups')
|
||||
.get() as { count: number };
|
||||
expect(row.count).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+21
-26
@@ -7,35 +7,11 @@ import path from 'path';
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
import { STORE_DIR } from '../src/config.js';
|
||||
import { log } from '../src/log.js';
|
||||
import { commandExists, getPlatform, isHeadless, isWSL } from './platform.js';
|
||||
import { emitStatus } from './status.js';
|
||||
|
||||
export function detectRegisteredGroups(projectRoot: string): boolean {
|
||||
if (fs.existsSync(path.join(projectRoot, 'data', 'registered_groups.json'))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const dbPath = path.join(projectRoot, 'data', 'v2.db');
|
||||
if (!fs.existsSync(dbPath)) return false;
|
||||
|
||||
let db: Database.Database | null = null;
|
||||
try {
|
||||
db = new Database(dbPath, { readonly: true });
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT COUNT(DISTINCT ag.id) as count FROM agent_groups ag
|
||||
JOIN messaging_group_agents mga ON mga.agent_group_id = ag.id`,
|
||||
)
|
||||
.get() as { count: number };
|
||||
return row.count > 0;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
db?.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function run(_args: string[]): Promise<void> {
|
||||
const projectRoot = process.cwd();
|
||||
|
||||
@@ -63,7 +39,26 @@ export async function run(_args: string[]): Promise<void> {
|
||||
const authDir = path.join(projectRoot, 'store', 'auth');
|
||||
const hasAuth = fs.existsSync(authDir) && fs.readdirSync(authDir).length > 0;
|
||||
|
||||
const hasRegisteredGroups = detectRegisteredGroups(projectRoot);
|
||||
let hasRegisteredGroups = false;
|
||||
// Check JSON file first (pre-migration)
|
||||
if (fs.existsSync(path.join(projectRoot, 'data', 'registered_groups.json'))) {
|
||||
hasRegisteredGroups = true;
|
||||
} else {
|
||||
// Check SQLite directly using better-sqlite3 (no sqlite3 CLI needed)
|
||||
const dbPath = path.join(STORE_DIR, 'messages.db');
|
||||
if (fs.existsSync(dbPath)) {
|
||||
try {
|
||||
const db = new Database(dbPath, { readonly: true });
|
||||
const row = db
|
||||
.prepare('SELECT COUNT(*) as count FROM registered_groups')
|
||||
.get() as { count: number };
|
||||
if (row.count > 0) hasRegisteredGroups = true;
|
||||
db.close();
|
||||
} catch {
|
||||
// Table might not exist yet
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for existing OpenClaw installation
|
||||
const homedir = (await import('os')).homedir();
|
||||
|
||||
-229
@@ -1,229 +0,0 @@
|
||||
/**
|
||||
* Step: groups — Fetch group metadata from messaging platforms, write to DB.
|
||||
* WhatsApp requires an upfront sync (Baileys groupFetchAllParticipating).
|
||||
* Other channels discover group names at runtime — this step auto-skips for them.
|
||||
* Replaces 05-sync-groups.sh + 05b-list-groups.sh
|
||||
*/
|
||||
import { execSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
import { STORE_DIR } from '../src/config.js';
|
||||
import { log } from '../src/log.js';
|
||||
import { emitStatus } from './status.js';
|
||||
|
||||
function parseArgs(args: string[]): { list: boolean; limit: number } {
|
||||
let list = false;
|
||||
let limit = 30;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--list') list = true;
|
||||
if (args[i] === '--limit' && args[i + 1]) {
|
||||
limit = parseInt(args[i + 1], 10);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return { list, limit };
|
||||
}
|
||||
|
||||
export async function run(args: string[]): Promise<void> {
|
||||
const projectRoot = process.cwd();
|
||||
const { list, limit } = parseArgs(args);
|
||||
|
||||
if (list) {
|
||||
await listGroups(limit);
|
||||
return;
|
||||
}
|
||||
|
||||
await syncGroups(projectRoot);
|
||||
}
|
||||
|
||||
async function listGroups(limit: number): Promise<void> {
|
||||
const dbPath = path.join(STORE_DIR, 'messages.db');
|
||||
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
console.error('ERROR: database not found');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const db = new Database(dbPath, { readonly: true });
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT jid, name FROM chats
|
||||
WHERE jid LIKE '%@g.us' AND jid <> '__group_sync__' AND name <> jid
|
||||
ORDER BY last_message_time DESC
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(limit) as Array<{ jid: string; name: string }>;
|
||||
db.close();
|
||||
|
||||
for (const row of rows) {
|
||||
console.log(`${row.jid}|${row.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function syncGroups(projectRoot: string): Promise<void> {
|
||||
// Only WhatsApp needs an upfront group sync; other channels resolve names at runtime.
|
||||
// Detect WhatsApp by checking for auth credentials on disk.
|
||||
const authDir = path.join(projectRoot, 'store', 'auth');
|
||||
const hasWhatsAppAuth =
|
||||
fs.existsSync(authDir) && fs.readdirSync(authDir).length > 0;
|
||||
|
||||
if (!hasWhatsAppAuth) {
|
||||
log.info('WhatsApp auth not found — skipping group sync');
|
||||
emitStatus('SYNC_GROUPS', {
|
||||
BUILD: 'skipped',
|
||||
SYNC: 'skipped',
|
||||
GROUPS_IN_DB: 0,
|
||||
REASON: 'whatsapp_not_configured',
|
||||
STATUS: 'success',
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Build TypeScript first
|
||||
log.info('Building TypeScript');
|
||||
let buildOk = false;
|
||||
try {
|
||||
execSync('pnpm run build', {
|
||||
cwd: projectRoot,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
buildOk = true;
|
||||
log.info('Build succeeded');
|
||||
} catch {
|
||||
log.error('Build failed');
|
||||
emitStatus('SYNC_GROUPS', {
|
||||
BUILD: 'failed',
|
||||
SYNC: 'skipped',
|
||||
GROUPS_IN_DB: 0,
|
||||
STATUS: 'failed',
|
||||
ERROR: 'build_failed',
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Run sync script via a temp file to avoid shell escaping issues with node -e
|
||||
log.info('Fetching group metadata');
|
||||
let syncOk = false;
|
||||
try {
|
||||
const syncScript = `
|
||||
import makeWASocket, { useMultiFileAuthState, makeCacheableSignalKeyStore, Browsers } from '@whiskeysockets/baileys';
|
||||
import pino from 'pino';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
const logger = pino({ level: 'silent' });
|
||||
const authDir = path.join('store', 'auth');
|
||||
const dbPath = path.join('store', 'messages.db');
|
||||
|
||||
if (!fs.existsSync(authDir)) {
|
||||
console.error('NO_AUTH');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const db = new Database(dbPath);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.exec('CREATE TABLE IF NOT EXISTS chats (jid TEXT PRIMARY KEY, name TEXT, last_message_time TEXT)');
|
||||
|
||||
const upsert = db.prepare(
|
||||
'INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?) ON CONFLICT(jid) DO UPDATE SET name = excluded.name'
|
||||
);
|
||||
|
||||
const { state, saveCreds } = await useMultiFileAuthState(authDir);
|
||||
|
||||
const sock = makeWASocket({
|
||||
auth: { creds: state.creds, keys: makeCacheableSignalKeyStore(state.keys, logger) },
|
||||
printQRInTerminal: false,
|
||||
logger,
|
||||
browser: Browsers.macOS('Chrome'),
|
||||
});
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
console.error('TIMEOUT');
|
||||
process.exit(1);
|
||||
}, 30000);
|
||||
|
||||
sock.ev.on('creds.update', saveCreds);
|
||||
|
||||
sock.ev.on('connection.update', async (update) => {
|
||||
if (update.connection === 'open') {
|
||||
try {
|
||||
const groups = await sock.groupFetchAllParticipating();
|
||||
const now = new Date().toISOString();
|
||||
let count = 0;
|
||||
for (const [jid, metadata] of Object.entries(groups)) {
|
||||
if (metadata.subject) {
|
||||
upsert.run(jid, metadata.subject, now);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
console.log('SYNCED:' + count);
|
||||
} catch (err) {
|
||||
console.error('FETCH_ERROR:' + err.message);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
sock.end(undefined);
|
||||
db.close();
|
||||
process.exit(0);
|
||||
}
|
||||
} else if (update.connection === 'close') {
|
||||
clearTimeout(timeout);
|
||||
console.error('CONNECTION_CLOSED');
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
`;
|
||||
|
||||
const tmpScript = path.join(projectRoot, '.tmp-group-sync.mjs');
|
||||
fs.writeFileSync(tmpScript, syncScript, 'utf-8');
|
||||
try {
|
||||
const output = execSync(`node ${tmpScript}`, {
|
||||
cwd: projectRoot,
|
||||
encoding: 'utf-8',
|
||||
timeout: 45000,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
syncOk = output.includes('SYNCED:');
|
||||
log.info('Sync output', { output: output.trim() });
|
||||
} finally {
|
||||
try { fs.unlinkSync(tmpScript); } catch { /* ignore cleanup errors */ }
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('Sync failed', { err });
|
||||
}
|
||||
|
||||
// Count groups in DB using better-sqlite3 (no sqlite3 CLI)
|
||||
let groupsInDb = 0;
|
||||
const dbPath = path.join(STORE_DIR, 'messages.db');
|
||||
if (fs.existsSync(dbPath)) {
|
||||
try {
|
||||
const db = new Database(dbPath, { readonly: true });
|
||||
const row = db
|
||||
.prepare(
|
||||
"SELECT COUNT(*) as count FROM chats WHERE jid LIKE '%@g.us' AND jid <> '__group_sync__'",
|
||||
)
|
||||
.get() as { count: number };
|
||||
groupsInDb = row.count;
|
||||
db.close();
|
||||
} catch {
|
||||
// DB may not exist yet
|
||||
}
|
||||
}
|
||||
|
||||
const status = syncOk ? 'success' : 'failed';
|
||||
|
||||
emitStatus('SYNC_GROUPS', {
|
||||
BUILD: buildOk ? 'success' : 'failed',
|
||||
SYNC: syncOk ? 'success' : 'failed',
|
||||
GROUPS_IN_DB: groupsInDb,
|
||||
STATUS: status,
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
|
||||
if (status === 'failed') process.exit(1);
|
||||
}
|
||||
+1
-3
@@ -13,11 +13,9 @@ const STEPS: Record<
|
||||
'set-env': () => import('./set-env.js'),
|
||||
environment: () => import('./environment.js'),
|
||||
container: () => import('./container.js'),
|
||||
groups: () => import('./groups.js'),
|
||||
register: () => import('./register.js'),
|
||||
'pair-telegram': () => import('./pair-telegram.js'),
|
||||
groups: () => import('./groups.js'),
|
||||
'whatsapp-auth': () => import('./whatsapp-auth.js'),
|
||||
'signal-auth': () => import('./signal-auth.js'),
|
||||
mounts: () => import('./mounts.js'),
|
||||
service: () => import('./service.js'),
|
||||
verify: () => import('./verify.js'),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-discord — bundles the preflight + install commands
|
||||
# from the /add-discord skill into one idempotent script so /setup can
|
||||
# from the /add-discord skill into one idempotent script so /new-setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the Discord adapter in from the `channels` branch; appends the
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-docker — bundles Docker install into one idempotent
|
||||
# script so /setup can run it without needing `curl | sh` in the allowlist
|
||||
# script so /new-setup can run it without needing `curl | sh` in the allowlist
|
||||
# (pipelines split at matching time, and `sh` receiving stdin can't be
|
||||
# pre-approved safely).
|
||||
#
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-gchat — bundles the preflight + install commands
|
||||
# from the /add-gchat skill into one idempotent script so /setup can
|
||||
# from the /add-gchat skill into one idempotent script so /new-setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the Google Chat adapter in from the `channels` branch; appends the
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-github — bundles the preflight + install commands
|
||||
# from the /add-github skill into one idempotent script so /setup can
|
||||
# from the /add-github skill into one idempotent script so /new-setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the GitHub adapter in from the `channels` branch; appends the
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-imessage — bundles the preflight + install commands
|
||||
# from the /add-imessage skill into one idempotent script so /setup can
|
||||
# from the /add-imessage skill into one idempotent script so /new-setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the iMessage adapter in from the `channels` branch; appends the
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-linear — bundles the preflight + install commands
|
||||
# from the /add-linear skill into one idempotent script so /setup can
|
||||
# from the /add-linear skill into one idempotent script so /new-setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the Linear adapter in from the `channels` branch; appends the
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-matrix — bundles the preflight + install commands
|
||||
# from the /add-matrix skill into one idempotent script so /setup can
|
||||
# from the /add-matrix skill into one idempotent script so /new-setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the Matrix adapter in from the `channels` branch; appends the
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-node — bundles Node 22 install into one idempotent
|
||||
# script so /setup can run it without needing `curl | sudo -E bash -` in
|
||||
# script so /new-setup can run it without needing `curl | sudo -E bash -` in
|
||||
# the allowlist (that pattern is inherently unmatchable — bash reads from
|
||||
# stdin, so pre-approval can't inspect what's being executed).
|
||||
#
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-resend — bundles the preflight + install commands
|
||||
# from the /add-resend skill into one idempotent script so /setup can
|
||||
# from the /add-resend skill into one idempotent script so /new-setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the Resend adapter in from the `channels` branch; appends the
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-slack — bundles the preflight + install commands
|
||||
# from the /add-slack skill into one idempotent script so /setup can
|
||||
# from the /add-slack skill into one idempotent script so /new-setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the Slack adapter in from the `channels` branch; appends the
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-teams — bundles the preflight + install commands
|
||||
# from the /add-teams skill into one idempotent script so /setup can
|
||||
# from the /add-teams skill into one idempotent script so /new-setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the Teams adapter in from the `channels` branch; appends the
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-telegram — bundles the preflight + install commands
|
||||
# from the /add-telegram skill into one idempotent script so /setup can
|
||||
# from the /add-telegram skill into one idempotent script so /new-setup can
|
||||
# run them programmatically before continuing to credentials and pairing.
|
||||
#
|
||||
# Copies the Telegram adapter, helpers, tests, and the pair-telegram setup
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-webex — bundles the preflight + install commands
|
||||
# from the /add-webex skill into one idempotent script so /setup can
|
||||
# from the /add-webex skill into one idempotent script so /new-setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the Webex adapter in from the `channels` branch; appends the
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-whatsapp-cloud — bundles the preflight + install
|
||||
# commands from the /add-whatsapp-cloud skill into one idempotent script so
|
||||
# /setup can run them programmatically before continuing to credentials.
|
||||
# /new-setup can run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the WhatsApp Cloud adapter in from the `channels` branch; appends the
|
||||
# self-registration import; installs the pinned @chat-adapter/whatsapp package;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-whatsapp — bundles the preflight + install commands
|
||||
# from the /add-whatsapp skill into one idempotent script so /setup can
|
||||
# from the /add-whatsapp skill into one idempotent script so /new-setup can
|
||||
# run them programmatically before continuing to QR/pairing-code auth.
|
||||
#
|
||||
# Copies the native Baileys WhatsApp adapter, its whatsapp-auth and groups
|
||||
@@ -66,7 +66,7 @@ if ! grep -q "'whatsapp-auth':" setup/index.ts; then
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @whiskeysockets/baileys@7.0.0-rc.9 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0
|
||||
pnpm install @whiskeysockets/baileys@6.17.16 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { classifyPingResult } from './agent-ping.js';
|
||||
|
||||
describe('classifyPingResult', () => {
|
||||
it('treats a normal text reply as ok', () => {
|
||||
expect(classifyPingResult(0, 'pong\n')).toBe('ok');
|
||||
});
|
||||
|
||||
it('detects Anthropic auth errors printed as a chat reply', () => {
|
||||
expect(
|
||||
classifyPingResult(
|
||||
0,
|
||||
'Failed to authenticate. API Error: 401 {"type":"error","error":{"type":"authentication_error","message":"Invalid bearer token"}}',
|
||||
),
|
||||
).toBe('auth_error');
|
||||
});
|
||||
|
||||
it('detects auth errors on stderr too', () => {
|
||||
expect(classifyPingResult(1, '', 'Authentication error')).toBe('auth_error');
|
||||
});
|
||||
|
||||
it('preserves socket errors', () => {
|
||||
expect(classifyPingResult(2, '')).toBe('socket_error');
|
||||
});
|
||||
|
||||
it('treats empty output as no reply', () => {
|
||||
expect(classifyPingResult(0, '')).toBe('no_reply');
|
||||
});
|
||||
});
|
||||
+4
-20
@@ -13,21 +13,7 @@
|
||||
*/
|
||||
import { spawn } from 'child_process';
|
||||
|
||||
export type PingResult = 'ok' | 'no_reply' | 'socket_error' | 'auth_error';
|
||||
|
||||
export function classifyPingResult(exitCode: number | null, stdout: string, stderr = ''): PingResult {
|
||||
const output = `${stdout}\n${stderr}`;
|
||||
if (
|
||||
/Invalid bearer token/i.test(output) ||
|
||||
/authentication[_ ]error/i.test(output) ||
|
||||
/Failed to authenticate/i.test(output)
|
||||
) {
|
||||
return 'auth_error';
|
||||
}
|
||||
if (exitCode === 2) return 'socket_error';
|
||||
if (exitCode === 0 && stdout.trim().length > 0) return 'ok';
|
||||
return 'no_reply';
|
||||
}
|
||||
export type PingResult = 'ok' | 'no_reply' | 'socket_error';
|
||||
|
||||
export function pingCliAgent(timeoutMs = 30_000): Promise<PingResult> {
|
||||
return new Promise((resolve) => {
|
||||
@@ -35,7 +21,6 @@ export function pingCliAgent(timeoutMs = 30_000): Promise<PingResult> {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
@@ -47,14 +32,13 @@ export function pingCliAgent(timeoutMs = 30_000): Promise<PingResult> {
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
stdout += chunk.toString('utf-8');
|
||||
});
|
||||
child.stderr.on('data', (chunk: Buffer) => {
|
||||
stderr += chunk.toString('utf-8');
|
||||
});
|
||||
child.on('close', (code) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve(classifyPingResult(code, stdout, stderr));
|
||||
if (code === 2) resolve('socket_error');
|
||||
else if (code === 0 && stdout.trim().length > 0) resolve('ok');
|
||||
else resolve('no_reply');
|
||||
});
|
||||
child.on('error', () => {
|
||||
if (settled) return;
|
||||
|
||||
+15
-40
@@ -119,7 +119,7 @@ export async function offerClaudeAssist(
|
||||
const run = ensureAnswer(
|
||||
await p.confirm({
|
||||
message: 'Run this command? (you can edit it before executing)',
|
||||
initialValue: true,
|
||||
initialValue: false,
|
||||
}),
|
||||
);
|
||||
if (!run) return false;
|
||||
@@ -283,24 +283,18 @@ async function queryClaudeUnderSpinner(
|
||||
// No hard timeout — debugging can take a long time, and the cost of
|
||||
// cutting Claude off mid-investigation is worse than letting the
|
||||
// spinner run. The user can Ctrl-C if they want to abort.
|
||||
//
|
||||
// Resume the same session on repeat invocations so Claude carries
|
||||
// context across failures in one setup run.
|
||||
const claudeArgs = [
|
||||
'-p',
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--verbose',
|
||||
'--permission-mode',
|
||||
'bypassPermissions',
|
||||
];
|
||||
if (claudeSessionId) {
|
||||
claudeArgs.push('--resume', claudeSessionId);
|
||||
}
|
||||
const child = spawn('claude', claudeArgs, {
|
||||
cwd: projectRoot,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
const child = spawn(
|
||||
'claude',
|
||||
[
|
||||
'-p',
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--verbose',
|
||||
'--permission-mode',
|
||||
'bypassPermissions',
|
||||
],
|
||||
{ cwd: projectRoot, stdio: ['pipe', 'pipe', 'pipe'] },
|
||||
);
|
||||
|
||||
child.stdout.on('data', (c: Buffer) => {
|
||||
lineBuf += c.toString('utf-8');
|
||||
@@ -311,16 +305,6 @@ async function queryClaudeUnderSpinner(
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const event = JSON.parse(line) as StreamEvent;
|
||||
// Capture the session id on the very first claude invocation of
|
||||
// this process so later calls can --resume it.
|
||||
if (
|
||||
!claudeSessionId &&
|
||||
event.type === 'system' &&
|
||||
event.subtype === 'init' &&
|
||||
typeof event.session_id === 'string'
|
||||
) {
|
||||
claudeSessionId = event.session_id;
|
||||
}
|
||||
handleStreamEvent(event, {
|
||||
setAction: (a) => {
|
||||
actions.push(a);
|
||||
@@ -355,14 +339,10 @@ async function queryClaudeUnderSpinner(
|
||||
}
|
||||
|
||||
// Minimal shape of the stream-json events we care about. Claude emits
|
||||
// many more, but we only read tool_use blocks (for breadcrumbs), text
|
||||
// blocks (to reassemble the final REASON/COMMAND answer), and the
|
||||
// session_id on the init event so follow-up invocations can resume the
|
||||
// same conversation.
|
||||
// many more, but we only read tool_use blocks (for breadcrumbs) and text
|
||||
// blocks (to reassemble the final REASON/COMMAND answer).
|
||||
interface StreamEvent {
|
||||
type: string;
|
||||
subtype?: string;
|
||||
session_id?: string;
|
||||
message?: {
|
||||
content?: Array<
|
||||
| { type: 'text'; text: string }
|
||||
@@ -371,11 +351,6 @@ interface StreamEvent {
|
||||
};
|
||||
}
|
||||
|
||||
// The session id from the first claude-assist invocation in this process.
|
||||
// Subsequent invocations pass `--resume <id>` so Claude sees prior failures
|
||||
// as conversation history instead of treating each failure in isolation.
|
||||
let claudeSessionId: string | null = null;
|
||||
|
||||
function handleStreamEvent(
|
||||
event: StreamEvent,
|
||||
cb: { setAction: (a: string) => void; appendText: (t: string) => void },
|
||||
|
||||
+2
-4
@@ -322,12 +322,10 @@ async function runUnderSpinner<
|
||||
if (result.ok) {
|
||||
const isSkipped = result.terminal?.fields.STATUS === 'skipped';
|
||||
const msg = isSkipped && labels.skipped ? labels.skipped : labels.done;
|
||||
// Bold the outcome so the step's headline reads stronger than the prose
|
||||
// body copy around it. The trailing `(Ns)` timing stays dim.
|
||||
s.stop(`${k.bold(fitToWidth(msg, suffix))}${k.dim(suffix)}`);
|
||||
s.stop(`${fitToWidth(msg, suffix)}${k.dim(suffix)}`);
|
||||
} else {
|
||||
const failMsg = labels.failed ?? labels.running.replace(/…$/, ' failed');
|
||||
s.stop(`${k.bold(fitToWidth(failMsg, suffix))}${k.dim(suffix)}`, 1);
|
||||
s.stop(`${fitToWidth(failMsg, suffix)}${k.dim(suffix)}`, 1);
|
||||
dumpTranscriptOnFailure(result.transcript);
|
||||
}
|
||||
return result;
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
/**
|
||||
* Parser/reader/writer for the advanced-config registry (setup-config.ts).
|
||||
*
|
||||
* readFromEnv() → values found in process.env
|
||||
* parseFlags() → values from argv, plus --help and any pass-through args
|
||||
* applyToEnv() → write resolved values back to process.env so existing
|
||||
* step code keeps reading env vars unchanged
|
||||
* printHelp() → render --help from the registry
|
||||
*
|
||||
* Flag parsing supports:
|
||||
* --key value space form
|
||||
* --key=value equals form
|
||||
* --key booleans only (sets true)
|
||||
* --no-key booleans only (sets false)
|
||||
*/
|
||||
import {
|
||||
CONFIG,
|
||||
envVarFor,
|
||||
flagFor,
|
||||
findByFlag,
|
||||
type Entry,
|
||||
} from './setup-config.js';
|
||||
|
||||
export type ConfigValues = Record<string, string | boolean | number>;
|
||||
|
||||
function coerce(e: Entry, raw: string): string | number | boolean | undefined {
|
||||
switch (e.type) {
|
||||
case 'boolean': {
|
||||
const v = raw.toLowerCase();
|
||||
if (['true', '1', 'yes'].includes(v)) return true;
|
||||
if (['false', '0', 'no'].includes(v)) return false;
|
||||
return undefined;
|
||||
}
|
||||
case 'integer': {
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
}
|
||||
default:
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
export function readFromEnv(env: NodeJS.ProcessEnv = process.env): ConfigValues {
|
||||
const out: ConfigValues = {};
|
||||
for (const e of CONFIG) {
|
||||
const raw = env[envVarFor(e)];
|
||||
if (raw === undefined || raw === '') continue;
|
||||
const v = coerce(e, raw);
|
||||
if (v !== undefined) out[e.key] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export type FlagParseResult = {
|
||||
values: ConfigValues;
|
||||
rest: string[];
|
||||
help: boolean;
|
||||
errors: string[];
|
||||
};
|
||||
|
||||
export function parseFlags(argv: string[]): FlagParseResult {
|
||||
const values: ConfigValues = {};
|
||||
const rest: string[] = [];
|
||||
const errors: string[] = [];
|
||||
let help = false;
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
|
||||
if (arg === '--help' || arg === '-h') {
|
||||
help = true;
|
||||
continue;
|
||||
}
|
||||
// POSIX end-of-options. pnpm passes a bare `--` through when invoked as
|
||||
// `pnpm run script --` with nothing after it; treat the rest as
|
||||
// pass-through positional args.
|
||||
if (arg === '--') {
|
||||
rest.push(...argv.slice(i + 1));
|
||||
break;
|
||||
}
|
||||
if (!arg.startsWith('--')) {
|
||||
rest.push(arg);
|
||||
continue;
|
||||
}
|
||||
|
||||
const eq = arg.indexOf('=');
|
||||
let name = eq === -1 ? arg : arg.slice(0, eq);
|
||||
const inline: string | undefined = eq === -1 ? undefined : arg.slice(eq + 1);
|
||||
|
||||
let negated = false;
|
||||
if (name.startsWith('--no-')) {
|
||||
negated = true;
|
||||
name = `--${name.slice(5)}`;
|
||||
}
|
||||
|
||||
const entry = findByFlag(name);
|
||||
if (!entry) {
|
||||
errors.push(`Unknown flag: ${arg}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.type === 'boolean') {
|
||||
if (negated) values[entry.key] = false;
|
||||
else if (inline !== undefined) {
|
||||
const v = coerce(entry, inline);
|
||||
if (v === undefined) errors.push(`Invalid boolean for ${name}: ${inline}`);
|
||||
else values[entry.key] = v;
|
||||
} else values[entry.key] = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const raw = inline !== undefined ? inline : argv[++i];
|
||||
if (raw === undefined) {
|
||||
errors.push(`Missing value for ${name}`);
|
||||
continue;
|
||||
}
|
||||
const v = coerce(entry, raw);
|
||||
if (v === undefined) {
|
||||
errors.push(`Invalid ${entry.type} for ${name}: ${raw}`);
|
||||
continue;
|
||||
}
|
||||
if (entry.type === 'string' || entry.type === 'url') {
|
||||
const err = entry.validate?.(raw);
|
||||
if (err) {
|
||||
errors.push(`${name}: ${err}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
values[entry.key] = v;
|
||||
}
|
||||
|
||||
return { values, rest, help, errors };
|
||||
}
|
||||
|
||||
export function applyToEnv(
|
||||
values: ConfigValues,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): void {
|
||||
for (const e of CONFIG) {
|
||||
if (!(e.key in values)) continue;
|
||||
const v = values[e.key];
|
||||
env[envVarFor(e)] =
|
||||
typeof v === 'boolean' ? (v ? 'true' : 'false') : String(v);
|
||||
}
|
||||
}
|
||||
|
||||
export function printHelp(stream: NodeJS.WritableStream = process.stdout): void {
|
||||
const lines: string[] = [];
|
||||
lines.push('Usage: bash nanoclaw.sh [flags...]');
|
||||
lines.push('');
|
||||
lines.push('Flags:');
|
||||
const width = Math.max(...CONFIG.map((e) => flagFor(e).length));
|
||||
for (const e of CONFIG) {
|
||||
const flag = flagFor(e).padEnd(width + 2);
|
||||
lines.push(` ${flag}${e.help}`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('Each flag also reads from its corresponding NANOCLAW_<KEY> env var.');
|
||||
lines.push('Run without flags for the default interactive flow.');
|
||||
stream.write(lines.join('\n') + '\n');
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
/**
|
||||
* Advanced-settings screen — menu of UI-visible entries from the config
|
||||
* registry. The user picks one entry, edits it, returns to the menu, and
|
||||
* exits via "Done". Returns a fresh values object; the caller passes it to
|
||||
* applyToEnv() so downstream step code reads them via env vars.
|
||||
*
|
||||
* Per-entry edit contract:
|
||||
* - Blank input on text/password/integer = leave current value unchanged.
|
||||
* - Enums get a synthetic "leave unchanged" first option.
|
||||
* - Booleans use confirm with the current value as initialValue.
|
||||
* - Secret entries mask the current value as bullets in hints/labels.
|
||||
*/
|
||||
import * as p from '@clack/prompts';
|
||||
|
||||
import { brightSelect } from './bright-select.js';
|
||||
import { ensureAnswer } from './runner.js';
|
||||
import { CONFIG, type Entry } from './setup-config.js';
|
||||
import type { ConfigValues } from './setup-config-parse.js';
|
||||
|
||||
const SKIP_SENTINEL = '__leave_unchanged__';
|
||||
const DONE_SENTINEL = '__done__';
|
||||
const MASK = '••••••••';
|
||||
|
||||
export async function runAdvancedScreen(
|
||||
initial: ConfigValues,
|
||||
): Promise<ConfigValues> {
|
||||
const result: ConfigValues = { ...initial };
|
||||
const visible = CONFIG.filter((e) => e.surface === 'flag+ui');
|
||||
|
||||
while (true) {
|
||||
const options = [
|
||||
...visible.map((e) => ({
|
||||
value: e.key,
|
||||
label: e.label,
|
||||
hint: hintFor(e, result),
|
||||
})),
|
||||
{ value: DONE_SENTINEL, label: 'Done — continue with setup' },
|
||||
];
|
||||
|
||||
const choice = ensureAnswer(
|
||||
await brightSelect<string>({
|
||||
message: 'Pick a setting to override',
|
||||
options,
|
||||
initialValue: DONE_SENTINEL,
|
||||
}),
|
||||
) as string;
|
||||
|
||||
if (choice === DONE_SENTINEL) return result;
|
||||
const entry = visible.find((e) => e.key === choice);
|
||||
if (entry) await promptOne(entry, result);
|
||||
}
|
||||
}
|
||||
|
||||
function hintFor(e: Entry, values: ConfigValues): string {
|
||||
const v = values[e.key];
|
||||
if (v === undefined) return 'not set';
|
||||
if (e.secret) return MASK;
|
||||
return String(v);
|
||||
}
|
||||
|
||||
async function promptOne(e: Entry, values: ConfigValues): Promise<void> {
|
||||
if (e.type === 'boolean') {
|
||||
const init =
|
||||
typeof values[e.key] === 'boolean'
|
||||
? (values[e.key] as boolean)
|
||||
: (e.default ?? false);
|
||||
const ans = ensureAnswer(
|
||||
await p.confirm({ message: e.label, initialValue: init }),
|
||||
);
|
||||
values[e.key] = ans as boolean;
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.type === 'enum') {
|
||||
const ans = ensureAnswer(
|
||||
await brightSelect<string>({
|
||||
message: e.label,
|
||||
options: [
|
||||
{ value: SKIP_SENTINEL, label: 'Leave unchanged' },
|
||||
...e.options,
|
||||
],
|
||||
initialValue: SKIP_SENTINEL,
|
||||
}),
|
||||
);
|
||||
if (ans !== SKIP_SENTINEL) values[e.key] = ans as string;
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.type === 'integer') {
|
||||
const ans = ensureAnswer(
|
||||
await p.text({
|
||||
message: e.label,
|
||||
placeholder: e.default !== undefined ? String(e.default) : undefined,
|
||||
validate: (v) => {
|
||||
const s = (v ?? '').trim();
|
||||
if (!s) return undefined;
|
||||
const n = Number(s);
|
||||
if (!Number.isFinite(n)) return 'Must be a number';
|
||||
if (e.min !== undefined && n < e.min) return `Must be ≥ ${e.min}`;
|
||||
if (e.max !== undefined && n > e.max) return `Must be ≤ ${e.max}`;
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
);
|
||||
const trimmed = ((ans as string) ?? '').trim();
|
||||
if (trimmed) values[e.key] = Number(trimmed);
|
||||
return;
|
||||
}
|
||||
|
||||
// string | url
|
||||
const validate = (v: string | undefined): string | undefined => {
|
||||
const s = (v ?? '').trim();
|
||||
if (!s) return undefined;
|
||||
return e.validate?.(s);
|
||||
};
|
||||
const ans = ensureAnswer(
|
||||
e.secret
|
||||
? await p.password({ message: e.label, validate })
|
||||
: await p.text({
|
||||
message: e.label,
|
||||
placeholder: e.placeholder ?? e.default,
|
||||
validate,
|
||||
}),
|
||||
);
|
||||
const trimmed = ((ans as string) ?? '').trim();
|
||||
if (trimmed) values[e.key] = trimmed;
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
/**
|
||||
* Setup-time advanced-config registry.
|
||||
*
|
||||
* One source of truth for: CLI flags, env-var names, the advanced-settings
|
||||
* screen, and `--help` output. The flag parser, env reader, and UI screen
|
||||
* all consume this list and write resolved values back to `process.env` so
|
||||
* existing step code keeps reading env vars unchanged.
|
||||
*
|
||||
* Default name conventions (overridable per entry):
|
||||
* key 'fooBar' → envVar 'NANOCLAW_FOO_BAR' → flag '--foo-bar'
|
||||
*
|
||||
* Surface levels:
|
||||
* 'flag' — CLI flag + env var only (debug/internal knobs)
|
||||
* 'flag+ui' — also shown in the advanced-settings screen
|
||||
*/
|
||||
|
||||
export type EntrySurface = 'flag' | 'flag+ui';
|
||||
|
||||
interface BaseEntry {
|
||||
/** Canonical camelCase key. */
|
||||
key: string;
|
||||
/** Override of the auto-derived NANOCLAW_<UPPER_SNAKE> env var. */
|
||||
envVar?: string;
|
||||
/** Override of the auto-derived --kebab-case flag. */
|
||||
flag?: string;
|
||||
label: string;
|
||||
help: string;
|
||||
surface: EntrySurface;
|
||||
/** UI section header. Entries without a group land in 'Other'. */
|
||||
group?: string;
|
||||
/** Mask in UI, redact in logs. */
|
||||
secret?: boolean;
|
||||
}
|
||||
|
||||
interface StringEntry extends BaseEntry {
|
||||
type: 'string' | 'url';
|
||||
default?: string;
|
||||
placeholder?: string;
|
||||
validate?: (v: string) => string | undefined;
|
||||
}
|
||||
|
||||
interface EnumEntry extends BaseEntry {
|
||||
type: 'enum';
|
||||
options: { value: string; label: string; hint?: string }[];
|
||||
default?: string;
|
||||
}
|
||||
|
||||
interface BoolEntry extends BaseEntry {
|
||||
type: 'boolean';
|
||||
default?: boolean;
|
||||
}
|
||||
|
||||
interface IntEntry extends BaseEntry {
|
||||
type: 'integer';
|
||||
default?: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
export type Entry = StringEntry | EnumEntry | BoolEntry | IntEntry;
|
||||
|
||||
const httpUrl = (v: string): string | undefined =>
|
||||
/^https?:\/\/\S+/.test(v) ? undefined : 'Must be http(s)://…';
|
||||
|
||||
export const CONFIG: Entry[] = [
|
||||
{
|
||||
key: 'onecliApiHost',
|
||||
label: 'OneCLI vault URL',
|
||||
help: 'Use a remote OneCLI vault instead of installing one locally.',
|
||||
surface: 'flag+ui',
|
||||
group: 'OneCLI',
|
||||
type: 'url',
|
||||
default: 'https://app.onecli.sh',
|
||||
placeholder: 'https://app.onecli.sh',
|
||||
validate: httpUrl,
|
||||
},
|
||||
{
|
||||
key: 'onecliApiToken',
|
||||
label: 'OneCLI access token',
|
||||
help: 'Bearer token for the remote vault. Required if --onecli-api-host is set.',
|
||||
surface: 'flag+ui',
|
||||
group: 'OneCLI',
|
||||
type: 'string',
|
||||
secret: true,
|
||||
placeholder: 'oc_…',
|
||||
validate: (v) => (v.startsWith('oc_') ? undefined : 'Must start with oc_'),
|
||||
},
|
||||
{
|
||||
key: 'anthropicBaseUrl',
|
||||
label: 'Anthropic API base URL',
|
||||
help: 'Use a proxy or alternative endpoint instead of api.anthropic.com.',
|
||||
surface: 'flag+ui',
|
||||
group: 'Anthropic',
|
||||
type: 'url',
|
||||
placeholder: 'https://api.anthropic.com',
|
||||
validate: httpUrl,
|
||||
},
|
||||
{
|
||||
key: 'anthropicAuthToken',
|
||||
label: 'Anthropic auth token',
|
||||
help: 'Bearer token for the custom Anthropic endpoint. Used together with --anthropic-base-url.',
|
||||
surface: 'flag+ui',
|
||||
group: 'Anthropic',
|
||||
type: 'string',
|
||||
secret: true,
|
||||
validate: (v) => (v.trim() ? undefined : 'Required'),
|
||||
},
|
||||
|
||||
// Existing env-var knobs — flag-only so they don't clutter the UI screen.
|
||||
{
|
||||
key: 'skip',
|
||||
envVar: 'NANOCLAW_SKIP',
|
||||
label: 'Skip steps',
|
||||
help: 'Comma-separated step names to skip (debugging only).',
|
||||
surface: 'flag',
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
key: 'displayName',
|
||||
envVar: 'NANOCLAW_DISPLAY_NAME',
|
||||
label: 'Display name',
|
||||
help: 'Skip the "what should your assistant call you?" prompt.',
|
||||
surface: 'flag',
|
||||
type: 'string',
|
||||
},
|
||||
];
|
||||
|
||||
// ─── name derivation ───────────────────────────────────────────────────
|
||||
|
||||
export function envVarFor(e: Entry): string {
|
||||
if (e.envVar) return e.envVar;
|
||||
return `NANOCLAW_${e.key.replace(/[A-Z]/g, (c) => `_${c}`).toUpperCase()}`;
|
||||
}
|
||||
|
||||
export function flagFor(e: Entry): string {
|
||||
if (e.flag) return e.flag;
|
||||
return `--${e.key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`;
|
||||
}
|
||||
|
||||
export function findByFlag(flag: string): Entry | null {
|
||||
return CONFIG.find((e) => flagFor(e) === flag) ?? null;
|
||||
}
|
||||
+9
-6
@@ -58,14 +58,17 @@ export function wrapForGutter(text: string, gutter: number): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap multi-line explanatory prose to the clack gutter. Previously
|
||||
* dimmed its output (hence the name) — that made body copy hard to read
|
||||
* against dark terminals. Dim is now reserved for preview/debug blocks
|
||||
* (failure transcript tails, claude-assist streams); prose renders at
|
||||
* the terminal's regular weight.
|
||||
* Wrap + dim together. Needed instead of `k.dim(wrapForGutter(...))`
|
||||
* because clack resets styling at each line break when rendering
|
||||
* multi-line log content — a single outer dim envelope only colors the
|
||||
* first line. Applying dim per-line gives each wrapped row its own
|
||||
* `\x1b[2m…\x1b[0m` envelope so the whole block reads as one block.
|
||||
*/
|
||||
export function dimWrap(text: string, gutter: number): string {
|
||||
return wrapForGutter(text, gutter);
|
||||
return wrapForGutter(text, gutter)
|
||||
.split('\n')
|
||||
.map((line) => k.dim(line))
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
||||
|
||||
+15
-192
@@ -86,161 +86,40 @@ function ensureShellProfilePath(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function writeEnvVar(name: string, value: string): void {
|
||||
function writeEnvOnecliUrl(url: string): void {
|
||||
const envFile = path.join(process.cwd(), '.env');
|
||||
let content = fs.existsSync(envFile) ? fs.readFileSync(envFile, 'utf-8') : '';
|
||||
const re = new RegExp(`^${name}=.*$`, 'm');
|
||||
if (re.test(content)) {
|
||||
content = content.replace(re, `${name}=${value}`);
|
||||
if (/^ONECLI_URL=/m.test(content)) {
|
||||
content = content.replace(/^ONECLI_URL=.*$/m, `ONECLI_URL=${url}`);
|
||||
} else {
|
||||
content = content.trimEnd() + (content ? '\n' : '') + `${name}=${value}\n`;
|
||||
content = content.trimEnd() + (content ? '\n' : '') + `ONECLI_URL=${url}\n`;
|
||||
}
|
||||
fs.writeFileSync(envFile, content);
|
||||
}
|
||||
|
||||
function writeEnvOnecliUrl(url: string): void {
|
||||
writeEnvVar('ONECLI_URL', url);
|
||||
}
|
||||
|
||||
// Last-known-good CLI release. Used only if BOTH the upstream installer
|
||||
// and the redirect-based version probe fail. Bump deliberately when a
|
||||
// new CLI release ships.
|
||||
const ONECLI_CLI_FALLBACK_VERSION = '1.3.0';
|
||||
const ONECLI_CLI_REPO = 'onecli/onecli-cli';
|
||||
|
||||
function installOnecliCliOnly(): { stdout: string; ok: boolean } {
|
||||
const upstream = runInstall('curl -fsSL onecli.sh/cli/install | sh');
|
||||
if (upstream.ok) return { stdout: upstream.stdout, ok: true };
|
||||
const fallback = installOnecliCliDirect();
|
||||
return { stdout: upstream.stdout + (upstream.stderr ?? '') + '\n' + fallback.stdout, ok: fallback.ok };
|
||||
}
|
||||
|
||||
function installOnecli(): { stdout: string; ok: boolean } {
|
||||
// OneCLI's own install script handles gateway + CLI + PATH.
|
||||
// We run the two canonical installers in sequence and capture stdout so
|
||||
// we can extract the printed URL as a fallback to `onecli config get`.
|
||||
let stdout = '';
|
||||
|
||||
// Gateway install (docker-compose based, no rate-limit concerns).
|
||||
const gw = runInstall('curl -fsSL onecli.sh/install | sh');
|
||||
stdout += gw.stdout;
|
||||
if (!gw.ok) {
|
||||
log.error('OneCLI gateway install failed', { stderr: gw.stderr });
|
||||
return { stdout: stdout + (gw.stderr ?? ''), ok: false };
|
||||
}
|
||||
|
||||
// CLI install. The upstream script calls the GitHub releases API
|
||||
// (api.github.com) to resolve the latest tag — which 403s anonymous
|
||||
// callers after 60 requests/hour per IP. Try upstream first; on failure
|
||||
// resolve the version ourselves (via HTTP redirect, which isn't
|
||||
// API-throttled) and download the release archive directly.
|
||||
const upstream = runInstall('curl -fsSL onecli.sh/cli/install | sh');
|
||||
stdout += upstream.stdout;
|
||||
if (upstream.ok) return { stdout, ok: true };
|
||||
|
||||
log.warn('Upstream CLI installer failed — falling back to direct download', {
|
||||
stderr: upstream.stderr,
|
||||
});
|
||||
stdout += (upstream.stderr ?? '') + '\n';
|
||||
|
||||
const fallback = installOnecliCliDirect();
|
||||
stdout += fallback.stdout;
|
||||
if (!fallback.ok) {
|
||||
log.error('OneCLI CLI install failed (both upstream and direct fallback)');
|
||||
return { stdout, ok: false };
|
||||
}
|
||||
return { stdout, ok: true };
|
||||
}
|
||||
|
||||
function runInstall(cmd: string): { stdout: string; stderr?: string; ok: boolean } {
|
||||
try {
|
||||
const stdout = execSync(cmd, {
|
||||
stdout += execSync('curl -fsSL onecli.sh/install | sh', {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
stdout += execSync('curl -fsSL onecli.sh/cli/install | sh', {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
return { stdout, ok: true };
|
||||
} catch (err) {
|
||||
const e = err as { stdout?: string; stderr?: string };
|
||||
return { stdout: e.stdout ?? '', stderr: e.stderr, ok: false };
|
||||
log.error('OneCLI install failed', { stderr: e.stderr });
|
||||
return { stdout: stdout + (e.stdout ?? '') + (e.stderr ?? ''), ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reinstate the OneCLI CLI install without hitting GitHub's rate-limited
|
||||
* releases API. Resolves the version via the HTTP redirect from
|
||||
* /releases/latest → /releases/tag/vX.Y.Z, then downloads the archive
|
||||
* directly. Falls back to ONECLI_CLI_FALLBACK_VERSION if the redirect
|
||||
* probe also fails.
|
||||
*/
|
||||
function installOnecliCliDirect(): { stdout: string; ok: boolean } {
|
||||
const lines: string[] = [];
|
||||
const append = (s: string): void => {
|
||||
lines.push(s);
|
||||
};
|
||||
|
||||
const osName = process.platform === 'darwin' ? 'darwin' : process.platform === 'linux' ? 'linux' : null;
|
||||
if (!osName) {
|
||||
append(`Unsupported platform: ${process.platform}`);
|
||||
return { stdout: lines.join('\n'), ok: false };
|
||||
}
|
||||
const arch = process.arch === 'x64' ? 'amd64' : process.arch === 'arm64' ? 'arm64' : null;
|
||||
if (!arch) {
|
||||
append(`Unsupported arch: ${process.arch}`);
|
||||
return { stdout: lines.join('\n'), ok: false };
|
||||
}
|
||||
|
||||
let version: string | null = null;
|
||||
try {
|
||||
const redirect = execSync(
|
||||
`curl -fsSL -o /dev/null -w '%{url_effective}' https://github.com/${ONECLI_CLI_REPO}/releases/latest`,
|
||||
{ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] },
|
||||
).trim();
|
||||
const m = redirect.match(/\/tag\/v?([^/]+)$/);
|
||||
if (m) version = m[1];
|
||||
} catch {
|
||||
// redirect probe failed — we'll pin the fallback
|
||||
}
|
||||
if (!version) {
|
||||
version = ONECLI_CLI_FALLBACK_VERSION;
|
||||
append(`Version probe failed; installing pinned fallback ${version}.`);
|
||||
} else {
|
||||
append(`Resolved onecli CLI ${version} via release redirect.`);
|
||||
}
|
||||
|
||||
const archive = `onecli_${version}_${osName}_${arch}.tar.gz`;
|
||||
const url = `https://github.com/${ONECLI_CLI_REPO}/releases/download/v${version}/${archive}`;
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'onecli-'));
|
||||
const archivePath = path.join(tmpDir, archive);
|
||||
|
||||
try {
|
||||
append(`Downloading ${url}`);
|
||||
execSync(`curl -fsSL -o ${JSON.stringify(archivePath)} ${JSON.stringify(url)}`, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
execSync(`tar -xzf ${JSON.stringify(archivePath)} -C ${JSON.stringify(tmpDir)}`, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let installDir = '/usr/local/bin';
|
||||
try {
|
||||
fs.accessSync(installDir, fs.constants.W_OK);
|
||||
} catch {
|
||||
installDir = LOCAL_BIN;
|
||||
fs.mkdirSync(installDir, { recursive: true });
|
||||
}
|
||||
const binSrc = path.join(tmpDir, 'onecli');
|
||||
const binDest = path.join(installDir, 'onecli');
|
||||
fs.copyFileSync(binSrc, binDest);
|
||||
fs.chmodSync(binDest, 0o755);
|
||||
append(`onecli ${version} installed to ${binDest}.`);
|
||||
return { stdout: lines.join('\n'), ok: true };
|
||||
} catch (err) {
|
||||
const e = err as { stdout?: string; stderr?: string; message?: string };
|
||||
append(`Direct install failed: ${e.stderr ?? e.message ?? String(err)}`);
|
||||
return { stdout: lines.join('\n'), ok: false };
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export async function pollHealth(url: string, timeoutMs: number): Promise<boolean> {
|
||||
async function pollHealth(url: string, timeoutMs: number): Promise<boolean> {
|
||||
// `/api/health` matches the path probe.sh uses — keep them aligned.
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
@@ -257,64 +136,8 @@ export async function pollHealth(url: string, timeoutMs: number): Promise<boolea
|
||||
|
||||
export async function run(args: string[]): Promise<void> {
|
||||
const reuse = args.includes('--reuse');
|
||||
const remoteUrlIdx = args.indexOf('--remote-url');
|
||||
const remoteUrl = remoteUrlIdx !== -1 ? args[remoteUrlIdx + 1] : null;
|
||||
ensureShellProfilePath();
|
||||
|
||||
if (remoteUrl) {
|
||||
// Remote-mode: install only the CLI, point it at the remote gateway, and
|
||||
// record the URL in .env. No local gateway is started.
|
||||
log.info('Installing OneCLI CLI for remote gateway', { remoteUrl });
|
||||
const res = installOnecliCliOnly();
|
||||
if (!res.ok || !onecliVersion()) {
|
||||
emitStatus('ONECLI', {
|
||||
INSTALLED: false,
|
||||
STATUS: 'failed',
|
||||
ERROR: 'cli_install_failed',
|
||||
HINT: 'CLI binary install failed. Make sure curl is installed and ~/.local/bin is writable.',
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
execFileSync('onecli', ['config', 'set', 'api-host', remoteUrl], {
|
||||
stdio: 'ignore',
|
||||
env: childEnv(),
|
||||
});
|
||||
} catch (err) {
|
||||
log.warn('onecli config set api-host failed', { err });
|
||||
}
|
||||
writeEnvOnecliUrl(remoteUrl);
|
||||
log.info('Wrote ONECLI_URL to .env', { url: remoteUrl });
|
||||
const remoteToken = process.env.NANOCLAW_ONECLI_API_TOKEN?.trim();
|
||||
if (remoteToken) {
|
||||
// Two auth surfaces: `onecli auth login` persists the key for CLI
|
||||
// calls during setup itself (e.g. detecting an existing Anthropic
|
||||
// secret via `onecli secrets list`), and ONECLI_API_KEY in .env is
|
||||
// read by the runtime SDK at request time. Both are needed.
|
||||
try {
|
||||
execFileSync('onecli', ['auth', 'login', '--api-key', remoteToken], {
|
||||
stdio: 'ignore',
|
||||
env: childEnv(),
|
||||
});
|
||||
} catch (err) {
|
||||
log.warn('onecli auth login failed', { err });
|
||||
}
|
||||
writeEnvVar('ONECLI_API_KEY', remoteToken);
|
||||
log.info('Wrote ONECLI_API_KEY to .env');
|
||||
}
|
||||
const healthy = await pollHealth(remoteUrl, 5000);
|
||||
emitStatus('ONECLI', {
|
||||
INSTALLED: true,
|
||||
REMOTE: true,
|
||||
ONECLI_URL: remoteUrl,
|
||||
HEALTHY: healthy,
|
||||
STATUS: 'success',
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (reuse) {
|
||||
// Reuse-mode: don't touch the running gateway at all. Just verify it
|
||||
// exists, read its api-host, write ONECLI_URL to .env, and move on.
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
/**
|
||||
* Detect and clean up unhealthy NanoClaw peer services.
|
||||
*
|
||||
* Runs as a setup preflight before we install our own service. A crash-looping
|
||||
* peer install (typically the legacy v1 `com.nanoclaw` plist) silently trashes
|
||||
* this install's containers on every respawn because its `cleanupOrphans()`
|
||||
* reaps anything matching `nanoclaw-`. We scope our reaper by label now, but
|
||||
* we still need to stop the peer from killing us on its way down.
|
||||
*
|
||||
* A peer is "unhealthy" when:
|
||||
* - launchd: `state != running` AND `runs > UNHEALTHY_RUNS_THRESHOLD`
|
||||
* - systemd: unit is in `failed` state, OR `activating` with many restarts
|
||||
*
|
||||
* Healthy peers are left alone — multiple installs can coexist fine now that
|
||||
* container-reaper is label-scoped.
|
||||
*/
|
||||
import { execFileSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js';
|
||||
import { log } from '../src/log.js';
|
||||
|
||||
const UNHEALTHY_RUNS_THRESHOLD = 10;
|
||||
|
||||
export interface PeerStatus {
|
||||
label: string;
|
||||
configPath: string;
|
||||
state: string;
|
||||
runs: number;
|
||||
unhealthy: boolean;
|
||||
}
|
||||
|
||||
export interface PeerCleanupResult {
|
||||
checked: PeerStatus[];
|
||||
unloaded: PeerStatus[];
|
||||
failures: Array<{ label: string; err: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan for peer NanoClaw services and unload any that are crash-looping.
|
||||
* Returns a summary suitable for emitStatus / setup-log reporting.
|
||||
*/
|
||||
export function cleanupUnhealthyPeers(projectRoot: string = process.cwd()): PeerCleanupResult {
|
||||
const platform = os.platform();
|
||||
if (platform === 'darwin') {
|
||||
return cleanupLaunchdPeers(projectRoot);
|
||||
}
|
||||
if (platform === 'linux') {
|
||||
return cleanupSystemdPeers(projectRoot);
|
||||
}
|
||||
return { checked: [], unloaded: [], failures: [] };
|
||||
}
|
||||
|
||||
// ---- launchd (macOS) --------------------------------------------------------
|
||||
|
||||
function cleanupLaunchdPeers(projectRoot: string): PeerCleanupResult {
|
||||
const ownLabel = getLaunchdLabel(projectRoot);
|
||||
const agentsDir = path.join(os.homedir(), 'Library', 'LaunchAgents');
|
||||
const result: PeerCleanupResult = { checked: [], unloaded: [], failures: [] };
|
||||
|
||||
let plists: string[];
|
||||
try {
|
||||
plists = fs
|
||||
.readdirSync(agentsDir)
|
||||
.filter((f) => /^com\.nanoclaw.*\.plist$/.test(f))
|
||||
.map((f) => path.join(agentsDir, f));
|
||||
} catch {
|
||||
return result;
|
||||
}
|
||||
|
||||
const uid = process.getuid?.() ?? 0;
|
||||
|
||||
for (const plistPath of plists) {
|
||||
const label = path.basename(plistPath, '.plist');
|
||||
if (label === ownLabel) continue;
|
||||
|
||||
const status = probeLaunchdPeer(label, plistPath, uid);
|
||||
if (!status) continue;
|
||||
result.checked.push(status);
|
||||
|
||||
if (!status.unhealthy) continue;
|
||||
|
||||
try {
|
||||
execFileSync('launchctl', ['unload', plistPath], { stdio: 'pipe' });
|
||||
log.info('Unloaded unhealthy peer launchd service', {
|
||||
label,
|
||||
state: status.state,
|
||||
runs: status.runs,
|
||||
plistPath,
|
||||
});
|
||||
result.unloaded.push(status);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
log.warn('Failed to unload peer launchd service', { label, err: message });
|
||||
result.failures.push({ label, err: message });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function probeLaunchdPeer(label: string, plistPath: string, uid: number): PeerStatus | null {
|
||||
let output: string;
|
||||
try {
|
||||
output = execFileSync('launchctl', ['print', `gui/${uid}/${label}`], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
} catch {
|
||||
// Not loaded → not currently a threat. Skip silently.
|
||||
return null;
|
||||
}
|
||||
|
||||
const state = /^\s*state\s*=\s*(.+?)\s*$/m.exec(output)?.[1] ?? 'unknown';
|
||||
const runsStr = /^\s*runs\s*=\s*(\d+)/m.exec(output)?.[1];
|
||||
const runs = runsStr ? parseInt(runsStr, 10) : 0;
|
||||
|
||||
const unhealthy = state !== 'running' && runs > UNHEALTHY_RUNS_THRESHOLD;
|
||||
return { label, configPath: plistPath, state, runs, unhealthy };
|
||||
}
|
||||
|
||||
// ---- systemd (Linux) --------------------------------------------------------
|
||||
|
||||
function cleanupSystemdPeers(projectRoot: string): PeerCleanupResult {
|
||||
const ownUnit = getSystemdUnit(projectRoot);
|
||||
const unitDir = path.join(os.homedir(), '.config', 'systemd', 'user');
|
||||
const result: PeerCleanupResult = { checked: [], unloaded: [], failures: [] };
|
||||
|
||||
let units: string[];
|
||||
try {
|
||||
units = fs
|
||||
.readdirSync(unitDir)
|
||||
.filter((f) => /^nanoclaw.*\.service$/.test(f))
|
||||
.map((f) => f.replace(/\.service$/, ''));
|
||||
} catch {
|
||||
return result;
|
||||
}
|
||||
|
||||
for (const unit of units) {
|
||||
if (unit === ownUnit) continue;
|
||||
|
||||
const status = probeSystemdPeer(unit);
|
||||
if (!status) continue;
|
||||
result.checked.push(status);
|
||||
|
||||
if (!status.unhealthy) continue;
|
||||
|
||||
try {
|
||||
execFileSync('systemctl', ['--user', 'disable', '--now', `${unit}.service`], { stdio: 'pipe' });
|
||||
log.info('Disabled unhealthy peer systemd unit', {
|
||||
unit,
|
||||
state: status.state,
|
||||
runs: status.runs,
|
||||
});
|
||||
result.unloaded.push(status);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
log.warn('Failed to disable peer systemd unit', { unit, err: message });
|
||||
result.failures.push({ label: unit, err: message });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function probeSystemdPeer(unit: string): PeerStatus | null {
|
||||
const unitPath = path.join(os.homedir(), '.config', 'systemd', 'user', `${unit}.service`);
|
||||
try {
|
||||
const output = execFileSync(
|
||||
'systemctl',
|
||||
['--user', 'show', '--property=ActiveState,NRestarts', `${unit}.service`],
|
||||
{ stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8' },
|
||||
);
|
||||
const activeState = /^ActiveState=(.+)$/m.exec(output)?.[1]?.trim() ?? 'unknown';
|
||||
const restartsStr = /^NRestarts=(\d+)/m.exec(output)?.[1];
|
||||
const runs = restartsStr ? parseInt(restartsStr, 10) : 0;
|
||||
|
||||
const unhealthy =
|
||||
activeState === 'failed' || (activeState !== 'active' && runs > UNHEALTHY_RUNS_THRESHOLD);
|
||||
return { label: unit, configPath: unitPath, state: activeState, runs, unhealthy };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
#!/bin/bash
|
||||
# Setup step: probe — single upfront parallel-ish scan that snapshots every
|
||||
# prerequisite and dependency for /setup's dynamic context injection.
|
||||
# prerequisite and dependency for /new-setup's dynamic context injection.
|
||||
# Rendered into the SKILL.md prompt via `!bash setup/probe.sh` so Claude sees
|
||||
# the current system state before generating its first response.
|
||||
#
|
||||
|
||||
+15
-17
@@ -20,7 +20,6 @@ import {
|
||||
import { isValidGroupFolder } from '../src/group-folder.js';
|
||||
import { initGroupFilesystem } from '../src/group-init.js';
|
||||
import { log } from '../src/log.js';
|
||||
import { namespacedPlatformId } from '../src/platform-id.js';
|
||||
import { resolveSession, writeSessionMessage } from '../src/session-manager.js';
|
||||
import { emitStatus } from './status.js';
|
||||
|
||||
@@ -113,10 +112,12 @@ export async function run(args: string[]): Promise<void> {
|
||||
process.exit(4);
|
||||
}
|
||||
|
||||
// Normalize platform_id to the same shape the adapter will emit at runtime,
|
||||
// so the router's (channel_type, platform_id) lookup matches what we store.
|
||||
// Chat SDK adapters prefix, native adapters (WhatsApp/iMessage/Signal) don't.
|
||||
parsed.platformId = namespacedPlatformId(parsed.channel, parsed.platformId);
|
||||
// Chat SDK adapters prefix platform IDs with the channel type
|
||||
// (e.g. "telegram:123", "discord:guild:channel"). Normalize here so
|
||||
// the stored ID always matches what the adapter sends at runtime.
|
||||
if (!parsed.platformId.startsWith(`${parsed.channel}:`)) {
|
||||
parsed.platformId = `${parsed.channel}:${parsed.platformId}`;
|
||||
}
|
||||
|
||||
log.info('Registering channel', parsed);
|
||||
|
||||
@@ -166,22 +167,19 @@ export async function run(args: string[]): Promise<void> {
|
||||
if (!existing) {
|
||||
newlyWired = true;
|
||||
const mgaId = generateId('mga');
|
||||
// Mirrors scripts/init-first-agent.ts:wireIfMissing so both setup paths
|
||||
// create rows with the same shape. Groups default to 'mention' (bot only
|
||||
// responds when addressed); DMs default to 'pattern'/'.' (respond to
|
||||
// every message). An explicit --trigger overrides the pattern regex.
|
||||
const isGroup = messagingGroup.is_group === 1;
|
||||
const engageMode: 'pattern' | 'mention' = isGroup && !parsed.trigger ? 'mention' : 'pattern';
|
||||
const engagePattern: string | null = engageMode === 'pattern' ? parsed.trigger || '.' : null;
|
||||
const triggerRules = parsed.trigger
|
||||
? JSON.stringify({
|
||||
pattern: parsed.trigger,
|
||||
requiresTrigger: parsed.requiresTrigger,
|
||||
})
|
||||
: null;
|
||||
createMessagingGroupAgent({
|
||||
id: mgaId,
|
||||
messaging_group_id: messagingGroup.id,
|
||||
agent_group_id: agentGroup.id,
|
||||
engage_mode: engageMode,
|
||||
engage_pattern: engagePattern,
|
||||
sender_scope: 'all',
|
||||
ignored_message_policy: 'drop',
|
||||
session_mode: parsed.sessionMode as 'shared' | 'per-thread' | 'agent-shared',
|
||||
trigger_rules: triggerRules,
|
||||
response_scope: 'all',
|
||||
session_mode: parsed.sessionMode,
|
||||
priority: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
@@ -11,7 +11,6 @@ import path from 'path';
|
||||
|
||||
import { log } from '../src/log.js';
|
||||
import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js';
|
||||
import { cleanupUnhealthyPeers } from './peer-cleanup.js';
|
||||
import {
|
||||
commandExists,
|
||||
getPlatform,
|
||||
@@ -54,19 +53,6 @@ export async function run(_args: string[]): Promise<void> {
|
||||
|
||||
fs.mkdirSync(path.join(projectRoot, 'logs'), { recursive: true });
|
||||
|
||||
// Peer preflight — a crash-looping peer install (most often the legacy v1
|
||||
// `com.nanoclaw` plist) will keep trashing this install's containers on
|
||||
// every respawn via its own cleanupOrphans. Detect and unload any peer
|
||||
// that's unhealthy before we install our service. Healthy peers are left
|
||||
// alone now that container reaping is install-label-scoped.
|
||||
const peerReport = cleanupUnhealthyPeers(projectRoot);
|
||||
if (peerReport.unloaded.length > 0) {
|
||||
log.warn('Unloaded unhealthy peer NanoClaw services', {
|
||||
count: peerReport.unloaded.length,
|
||||
labels: peerReport.unloaded.map((p) => p.label),
|
||||
});
|
||||
}
|
||||
|
||||
if (platform === 'macos') {
|
||||
setupLaunchd(projectRoot, nodePath, homeDir);
|
||||
} else if (platform === 'linux') {
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
/**
|
||||
* Step: signal-auth — link this host to an existing Signal account via
|
||||
* signal-cli's QR-code flow.
|
||||
*
|
||||
* signal-cli `link` opens a bi-directional handshake with the Signal
|
||||
* servers: it prints one line containing a linking URL (`sgnl://linkdevice?…`
|
||||
* or older `tsdevice://linkdevice?…`), then blocks until either the user
|
||||
* scans it from an existing Signal install, or the code expires. On
|
||||
* success, a secondary account is created under the user's signal-cli
|
||||
* data directory, associated with the phone number of the scanner.
|
||||
*
|
||||
* Methods:
|
||||
* (no args) Spawn signal-cli link, emit SIGNAL_AUTH_QR
|
||||
* with the URL, wait for completion.
|
||||
*
|
||||
* Block schema (parent parses these):
|
||||
* SIGNAL_AUTH_QR { QR: "<sgnl:// or tsdevice:// url>" } — one-shot
|
||||
* SIGNAL_AUTH { STATUS: success, ACCOUNT: +<digits> } — terminal
|
||||
* { STATUS: skipped, ACCOUNT, REASON: already-authenticated }
|
||||
* { STATUS: failed, ERROR: <reason> }
|
||||
*
|
||||
* STATUS values match the runner's vocabulary (success/skipped/failed) so
|
||||
* spawnStep recognises them and sets `ok` correctly; Signal-specific UI
|
||||
* lives in setup/channels/signal.ts.
|
||||
*
|
||||
* If one or more accounts are already linked (discovered via
|
||||
* `signal-cli -o json listAccounts`), the step emits SIGNAL_AUTH
|
||||
* STATUS=skipped with the first account so the driver can reuse it.
|
||||
* Selecting a different existing account is a driver concern.
|
||||
*/
|
||||
import { spawn, spawnSync } from 'child_process';
|
||||
|
||||
import { emitStatus } from './status.js';
|
||||
|
||||
const LINK_TIMEOUT_MS = 180_000;
|
||||
const DEFAULT_DEVICE_NAME = 'NanoClaw';
|
||||
|
||||
interface SignalAccount {
|
||||
account?: string;
|
||||
registered?: boolean;
|
||||
}
|
||||
|
||||
function cliPath(): string {
|
||||
return process.env.SIGNAL_CLI_PATH || 'signal-cli';
|
||||
}
|
||||
|
||||
/**
|
||||
* Query signal-cli for currently linked accounts. Empty array if none
|
||||
* configured, no binary, or the call fails for any other reason.
|
||||
*/
|
||||
function listAccounts(): string[] {
|
||||
const cli = cliPath();
|
||||
try {
|
||||
const res = spawnSync(cli, ['-o', 'json', 'listAccounts'], {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
if (res.status !== 0) return [];
|
||||
const parsed = JSON.parse(res.stdout || '[]') as SignalAccount[];
|
||||
return parsed
|
||||
.filter((a) => a.registered !== false)
|
||||
.map((a) => a.account ?? '')
|
||||
.filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function run(_args: string[]): Promise<void> {
|
||||
const cli = cliPath();
|
||||
|
||||
// Verify signal-cli exists before we commit to the long-running link.
|
||||
// The driver checks too, but this keeps the step honest when run alone.
|
||||
const probe = spawnSync(cli, ['--version'], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
if (probe.error || probe.status !== 0) {
|
||||
emitStatus('SIGNAL_AUTH', {
|
||||
STATUS: 'failed',
|
||||
ERROR: 'signal-cli not found. Install signal-cli first.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = listAccounts();
|
||||
if (existing.length > 0) {
|
||||
emitStatus('SIGNAL_AUTH', {
|
||||
STATUS: 'skipped',
|
||||
ACCOUNT: existing[0],
|
||||
REASON: 'already-authenticated',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
let settled = false;
|
||||
let qrEmitted = false;
|
||||
|
||||
const finish = (block: Record<string, string | number | boolean>, code: number): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
emitStatus('SIGNAL_AUTH', block);
|
||||
resolve();
|
||||
setTimeout(() => process.exit(code), 500);
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
try {
|
||||
child.kill('SIGTERM');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
finish({ STATUS: 'failed', ERROR: 'qr_timeout' }, 1);
|
||||
}, LINK_TIMEOUT_MS);
|
||||
|
||||
const child = spawn(cli, ['link', '--name', DEFAULT_DEVICE_NAME], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
// stdout carries the URL on the first line; subsequent lines may print
|
||||
// status like "Associated with: +1555…". We don't strictly need to parse
|
||||
// the number — listAccounts after exit is the source of truth — but the
|
||||
// URL match drives the QR emit, which is the whole point.
|
||||
let stdoutBuf = '';
|
||||
const handleStdout = (chunk: Buffer): void => {
|
||||
stdoutBuf += chunk.toString('utf-8');
|
||||
let idx: number;
|
||||
while ((idx = stdoutBuf.indexOf('\n')) !== -1) {
|
||||
const line = stdoutBuf.slice(0, idx).trim();
|
||||
stdoutBuf = stdoutBuf.slice(idx + 1);
|
||||
if (!line) continue;
|
||||
// Match both modern (sgnl://) and legacy (tsdevice://) schemes.
|
||||
if (/^(sgnl|tsdevice):\/\/linkdevice\?/.test(line) && !qrEmitted) {
|
||||
qrEmitted = true;
|
||||
emitStatus('SIGNAL_AUTH_QR', { QR: line });
|
||||
}
|
||||
}
|
||||
};
|
||||
child.stdout.on('data', handleStdout);
|
||||
|
||||
// Capture stderr for the transcript / log — signal-cli writes warnings
|
||||
// and errors there. We don't emit on partial stderr lines since a
|
||||
// successful link can still produce noise.
|
||||
let stderrBuf = '';
|
||||
child.stderr.on('data', (chunk: Buffer) => {
|
||||
stderrBuf += chunk.toString('utf-8');
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
finish({ STATUS: 'failed', ERROR: `spawn error: ${err.message}` }, 1);
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
// After a successful link, signal-cli exits 0 and the newly linked
|
||||
// account shows up in listAccounts. Use that as the source of truth
|
||||
// rather than scraping stdout — more robust across signal-cli versions.
|
||||
if (code === 0) {
|
||||
const post = listAccounts();
|
||||
if (post.length === 0) {
|
||||
finish(
|
||||
{ STATUS: 'failed', ERROR: 'link exited 0 but no account registered' },
|
||||
1,
|
||||
);
|
||||
return;
|
||||
}
|
||||
finish({ STATUS: 'success', ACCOUNT: post[0] }, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-zero exit. Surface the last non-empty stderr line for context;
|
||||
// signal-cli's own error messages are usually informative.
|
||||
const lastErr =
|
||||
stderrBuf
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)
|
||||
.slice(-1)[0] ?? `signal-cli link exited with code ${code}`;
|
||||
finish({ STATUS: 'failed', ERROR: lastErr }, 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { determineVerifyStatus } from './verify.js';
|
||||
|
||||
const healthyBase = {
|
||||
service: 'running' as const,
|
||||
credentials: 'configured',
|
||||
anyChannelConfigured: false,
|
||||
registeredGroups: 1,
|
||||
agentPing: 'ok' as const,
|
||||
};
|
||||
|
||||
describe('determineVerifyStatus', () => {
|
||||
it('accepts a working CLI-only install', () => {
|
||||
expect(determineVerifyStatus(healthyBase)).toBe('success');
|
||||
});
|
||||
|
||||
it('accepts a messaging-channel install when CLI ping is skipped', () => {
|
||||
expect(
|
||||
determineVerifyStatus({
|
||||
...healthyBase,
|
||||
anyChannelConfigured: true,
|
||||
agentPing: 'skipped',
|
||||
}),
|
||||
).toBe('success');
|
||||
});
|
||||
|
||||
it('fails when neither CLI nor messaging channels are usable', () => {
|
||||
expect(
|
||||
determineVerifyStatus({
|
||||
...healthyBase,
|
||||
agentPing: 'skipped',
|
||||
}),
|
||||
).toBe('failed');
|
||||
});
|
||||
|
||||
it('fails when the CLI agent does not respond', () => {
|
||||
expect(
|
||||
determineVerifyStatus({
|
||||
...healthyBase,
|
||||
anyChannelConfigured: true,
|
||||
agentPing: 'no_reply',
|
||||
}),
|
||||
).toBe('failed');
|
||||
});
|
||||
|
||||
it('fails when no agent groups are registered', () => {
|
||||
expect(
|
||||
determineVerifyStatus({
|
||||
...healthyBase,
|
||||
registeredGroups: 0,
|
||||
}),
|
||||
).toBe('failed');
|
||||
});
|
||||
});
|
||||
+11
-30
@@ -14,7 +14,7 @@ import Database from 'better-sqlite3';
|
||||
import { DATA_DIR } from '../src/config.js';
|
||||
import { readEnvFile } from '../src/env.js';
|
||||
import { log } from '../src/log.js';
|
||||
import { pingCliAgent, type PingResult } from './lib/agent-ping.js';
|
||||
import { pingCliAgent } from './lib/agent-ping.js';
|
||||
import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js';
|
||||
import {
|
||||
getPlatform,
|
||||
@@ -220,22 +220,22 @@ export async function run(_args: string[]): Promise<void> {
|
||||
|
||||
// 7. End-to-end: ping the CLI agent and confirm it replies. Only run if
|
||||
// everything upstream looks healthy, since a broken socket would just hang.
|
||||
let agentPing: 'ok' | 'no_reply' | 'socket_error' | 'auth_error' | 'skipped' = 'skipped';
|
||||
let agentPing: 'ok' | 'no_reply' | 'socket_error' | 'skipped' = 'skipped';
|
||||
if (service === 'running' && registeredGroups > 0) {
|
||||
log.info('Pinging CLI agent');
|
||||
agentPing = await pingCliAgent();
|
||||
log.info('Agent ping result', { agentPing });
|
||||
}
|
||||
|
||||
// Determine overall status. A CLI-only install is valid when the local
|
||||
// agent round-trip succeeds; messaging app credentials are optional.
|
||||
const status = determineVerifyStatus({
|
||||
service,
|
||||
credentials,
|
||||
anyChannelConfigured,
|
||||
registeredGroups,
|
||||
agentPing,
|
||||
});
|
||||
// Determine overall status
|
||||
const status =
|
||||
service === 'running' &&
|
||||
credentials !== 'missing' &&
|
||||
anyChannelConfigured &&
|
||||
registeredGroups > 0 &&
|
||||
(agentPing === 'ok' || agentPing === 'skipped')
|
||||
? 'success'
|
||||
: 'failed';
|
||||
|
||||
log.info('Verification complete', { status, channelAuth });
|
||||
|
||||
@@ -255,25 +255,6 @@ export async function run(_args: string[]): Promise<void> {
|
||||
if (status === 'failed') process.exit(1);
|
||||
}
|
||||
|
||||
export function determineVerifyStatus(input: {
|
||||
service: 'not_found' | 'stopped' | 'running' | 'running_other_checkout';
|
||||
credentials: string;
|
||||
anyChannelConfigured: boolean;
|
||||
registeredGroups: number;
|
||||
agentPing: PingResult | 'skipped';
|
||||
}): 'success' | 'failed' {
|
||||
const cliAgentResponds = input.agentPing === 'ok';
|
||||
const hasUsableChannel = input.anyChannelConfigured || cliAgentResponds;
|
||||
|
||||
return input.service === 'running' &&
|
||||
input.credentials !== 'missing' &&
|
||||
hasUsableChannel &&
|
||||
input.registeredGroups > 0 &&
|
||||
(cliAgentResponds || input.agentPing === 'skipped')
|
||||
? 'success'
|
||||
: 'failed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a PID, resolve the script path the process is executing (i.e. the
|
||||
* first `.js` / `.ts` / `.mjs` arg after `node`). Returns null on any
|
||||
|
||||
+25
-19
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Step: whatsapp-auth — standalone WhatsApp (Baileys v7) authentication.
|
||||
* Step: whatsapp-auth — standalone WhatsApp (Baileys) authentication.
|
||||
*
|
||||
* Forked from the channels-branch version so setup:auto's driver can render
|
||||
* the terminal UX itself (inside clack) instead of the step dumping a raw QR
|
||||
@@ -27,6 +27,7 @@
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { createRequire } from 'module';
|
||||
// Named import (not default) — pino's d.ts under NodeNext resolves the
|
||||
// default export to `typeof pino` (namespace), which isn't callable. The
|
||||
// named `pino` export resolves to the callable function.
|
||||
@@ -46,23 +47,26 @@ const AUTH_DIR = path.join(process.cwd(), 'store', 'auth');
|
||||
const PAIRING_CODE_FILE = path.join(process.cwd(), 'store', 'pairing-code.txt');
|
||||
const baileysLogger = pino({ level: 'silent' });
|
||||
|
||||
/** Fetch current WA Web version — wppconnect tracker, then Baileys sw.js scrape. */
|
||||
async function resolveWaWebVersion(): Promise<[number, number, number]> {
|
||||
try {
|
||||
const res = await fetch('https://wppconnect.io/whatsapp-versions/', {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (res.ok) {
|
||||
const html = await res.text();
|
||||
const match = html.match(/2\.3000\.(\d+)/);
|
||||
if (match) return [2, 3000, Number(match[1])];
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
try {
|
||||
const { version } = await fetchLatestWaWebVersion({});
|
||||
if (version) return version as [number, number, number];
|
||||
} catch { /* fall through */ }
|
||||
throw new Error('Could not fetch current WhatsApp Web version — cannot connect with stale version');
|
||||
// Baileys v6 bug: getPlatformId sends charCode (49) instead of enum value (1).
|
||||
// Fixed in Baileys 7.x but not backported. Without this patch pairing codes
|
||||
// fail with "couldn't link device" because WhatsApp receives an invalid
|
||||
// platform id. createRequire because proto is not a named ESM export.
|
||||
const _require = createRequire(import.meta.url);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { proto } = _require('@whiskeysockets/baileys') as { proto: any };
|
||||
try {
|
||||
const _generics = _require(
|
||||
'@whiskeysockets/baileys/lib/Utils/generics',
|
||||
) as Record<string, unknown>;
|
||||
_generics.getPlatformId = (browser: string): string => {
|
||||
const platformType =
|
||||
proto.DeviceProps.PlatformType[
|
||||
browser.toUpperCase() as keyof typeof proto.DeviceProps.PlatformType
|
||||
];
|
||||
return platformType ? platformType.toString() : '1';
|
||||
};
|
||||
} catch {
|
||||
// If CJS require fails, QR auth still works; only pairing code may be affected.
|
||||
}
|
||||
|
||||
type AuthMethod = 'qr' | 'pairing-code';
|
||||
@@ -135,7 +139,9 @@ export async function run(args: string[]): Promise<void> {
|
||||
|
||||
async function connectSocket(isReconnect = false): Promise<void> {
|
||||
const { state, saveCreds } = await useMultiFileAuthState(AUTH_DIR);
|
||||
const version = await resolveWaWebVersion();
|
||||
const { version } = await fetchLatestWaWebVersion({}).catch(() => ({
|
||||
version: undefined,
|
||||
}));
|
||||
|
||||
const sock = makeWASocket({
|
||||
version,
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Is `name` safe to use as the last segment of a path inside an
|
||||
* attachment-staging directory? Filenames originate from untrusted sources —
|
||||
* channel messages from any chat participant, agent-to-agent forwards from
|
||||
* a possibly-compromised peer agent — and land in `path.join(dir, name)`
|
||||
* sinks on the host. Without this guard, a `..`-laden name escapes the
|
||||
* inbox and writes anywhere the host process has filesystem permission.
|
||||
*
|
||||
* Rejects:
|
||||
* - non-string / empty
|
||||
* - `.` / `..` (traversal sentinels that path.basename returns as-is)
|
||||
* - anything containing a path separator (`/` or `\`) or NUL
|
||||
* - any value where `path.basename(name) !== name`, catching OS-specific
|
||||
* separators and covering drives/prefixes on Windows runtimes
|
||||
*/
|
||||
export function isSafeAttachmentName(name: string): boolean {
|
||||
if (typeof name !== 'string' || name.length === 0) return false;
|
||||
if (name === '.' || name === '..') return false;
|
||||
if (/[\\/\0]/.test(name)) return false;
|
||||
return path.basename(name) === name;
|
||||
}
|
||||
@@ -56,8 +56,6 @@ export interface InboundEvent {
|
||||
* See InboundMessage.isMention for the full explanation.
|
||||
*/
|
||||
isMention?: boolean;
|
||||
/** True when the source is a group/channel thread, false for DMs. */
|
||||
isGroup?: boolean;
|
||||
};
|
||||
replyTo?: DeliveryAddress;
|
||||
}
|
||||
@@ -83,8 +81,6 @@ export interface InboundMessage {
|
||||
* router falls back to text-match against agent_group_name.
|
||||
*/
|
||||
isMention?: boolean;
|
||||
/** True when the source is a group/channel thread, false for DMs. */
|
||||
isGroup?: boolean;
|
||||
}
|
||||
|
||||
/** A file attachment to deliver alongside a message. */
|
||||
|
||||
@@ -81,26 +81,6 @@ export interface ChatSdkBridgeConfig {
|
||||
* chunk boundary will render as two independent blocks on the receiving
|
||||
* platform, which is the same behavior as manually re-opening a fence.
|
||||
*/
|
||||
/**
|
||||
* Decode the actual option value from a button callback. Buttons are encoded
|
||||
* with an integer index (to keep under Telegram's 64-byte callback_data cap),
|
||||
* and the real value is looked up via `getAskQuestionRender(questionId)`.
|
||||
* Falls back to treating the tail as a literal value so old in-flight cards
|
||||
* (encoded before this shortening landed) still resolve.
|
||||
*/
|
||||
function resolveSelectedOption(
|
||||
render: { options: NormalizedOption[] } | undefined,
|
||||
eventValue: string | undefined,
|
||||
tail: string | undefined,
|
||||
): string {
|
||||
const candidate = eventValue ?? tail ?? '';
|
||||
if (render && /^\d+$/.test(candidate)) {
|
||||
const idx = Number(candidate);
|
||||
if (render.options[idx]) return render.options[idx].value;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
export function splitForLimit(text: string, limit: number): string[] {
|
||||
if (text.length <= limit) return [text];
|
||||
const chunks: string[] = [];
|
||||
@@ -125,11 +105,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
|
||||
let setupConfig: ChannelSetup;
|
||||
let gatewayAbort: AbortController | null = null;
|
||||
|
||||
async function messageToInbound(
|
||||
message: ChatMessage,
|
||||
isMention: boolean,
|
||||
isGroup?: boolean,
|
||||
): Promise<InboundMessage> {
|
||||
async function messageToInbound(message: ChatMessage, isMention: boolean): Promise<InboundMessage> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const serialized = message.toJSON() as Record<string, any>;
|
||||
|
||||
@@ -186,7 +162,6 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
|
||||
content: serialized,
|
||||
timestamp: message.metadata.dateSent.toISOString(),
|
||||
isMention,
|
||||
isGroup,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -220,17 +195,13 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
|
||||
// wirings still fire on in-thread mentions.
|
||||
chat.onSubscribedMessage(async (thread, message) => {
|
||||
const channelId = adapter.channelIdFromThreadId(thread.id);
|
||||
await setupConfig.onInbound(
|
||||
channelId,
|
||||
thread.id,
|
||||
await messageToInbound(message, message.isMention === true, true),
|
||||
);
|
||||
await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, message.isMention === true));
|
||||
});
|
||||
|
||||
// @mention in an unsubscribed thread — SDK-confirmed bot mention.
|
||||
chat.onNewMention(async (thread, message) => {
|
||||
const channelId = adapter.channelIdFromThreadId(thread.id);
|
||||
await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, true, true));
|
||||
await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, true));
|
||||
});
|
||||
|
||||
// DMs — by definition addressed to the bot. Thread id flows through
|
||||
@@ -245,7 +216,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
|
||||
sender: (message.author as any)?.fullName ?? (message.author as any)?.userId ?? 'unknown',
|
||||
threadId: thread.id,
|
||||
});
|
||||
await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, true, false));
|
||||
await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, true));
|
||||
});
|
||||
|
||||
// Plain messages in unsubscribed threads.
|
||||
@@ -260,7 +231,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
|
||||
// flood gate.
|
||||
chat.onNewMessage(/./, async (thread, message) => {
|
||||
const channelId = adapter.channelIdFromThreadId(thread.id);
|
||||
await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, false, true));
|
||||
await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, false));
|
||||
});
|
||||
|
||||
// Handle button clicks (ask_user_question)
|
||||
@@ -269,15 +240,11 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
|
||||
const parts = event.actionId.split(':');
|
||||
if (parts.length < 3) return;
|
||||
const questionId = parts[1];
|
||||
const tail = parts.slice(2).join(':');
|
||||
const selectedOption = event.value || '';
|
||||
const userId = event.user?.userId || '';
|
||||
|
||||
// Resolve render metadata BEFORE dispatching onAction (which deletes the row).
|
||||
const render = getAskQuestionRender(questionId);
|
||||
// New format: button id/value is an integer index into options (kept
|
||||
// short to fit Telegram's 64-byte callback_data cap). Old format:
|
||||
// the full value is embedded in actionId/value directly.
|
||||
const selectedOption = resolveSelectedOption(render, event.value, tail);
|
||||
const title = render?.title ?? '❓ Question';
|
||||
const matched = render?.options.find((o) => o.value === selectedOption);
|
||||
const selectedLabel = matched?.selectedLabel ?? selectedOption ?? '(clicked)';
|
||||
@@ -381,13 +348,8 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
|
||||
children: [
|
||||
CardText(question),
|
||||
Actions(
|
||||
// Encode button id/value with the option index rather than the
|
||||
// full value. Telegram caps callback_data at 64 bytes, and
|
||||
// long values (e.g. ISO datetimes, URLs) push the JSON payload
|
||||
// well past that. The onAction handlers resolve the index back
|
||||
// to the real value via getAskQuestionRender(questionId).
|
||||
options.map((opt, idx) =>
|
||||
Button({ id: `ncq:${questionId}:${idx}`, label: opt.label, value: String(idx) }),
|
||||
options.map((opt) =>
|
||||
Button({ id: `ncq:${questionId}:${opt.value}`, label: opt.label, value: opt.value }),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -539,21 +501,18 @@ async function handleForwardedEvent(
|
||||
// type 3 = MessageComponent (button/select)
|
||||
if (interaction.type === 3) {
|
||||
const customId = (interaction.data as Record<string, unknown>)?.custom_id as string;
|
||||
// In guilds the clicker is at interaction.member.user; in DMs it's interaction.user directly.
|
||||
const user =
|
||||
((interaction.member as Record<string, unknown>)?.user as Record<string, string> | undefined) ??
|
||||
(interaction.user as Record<string, string> | undefined);
|
||||
const user = (interaction.member as Record<string, unknown>)?.user as Record<string, string> | undefined;
|
||||
const interactionId = interaction.id as string;
|
||||
const interactionToken = interaction.token as string;
|
||||
|
||||
// Parse the selected option from custom_id
|
||||
let questionId: string | undefined;
|
||||
let tail: string | undefined;
|
||||
let selectedOption: string | undefined;
|
||||
if (customId?.startsWith('ncq:')) {
|
||||
const colonIdx = customId.indexOf(':', 4); // after "ncq:"
|
||||
if (colonIdx !== -1) {
|
||||
questionId = customId.slice(4, colonIdx);
|
||||
tail = customId.slice(colonIdx + 1);
|
||||
selectedOption = customId.slice(colonIdx + 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -562,9 +521,6 @@ async function handleForwardedEvent(
|
||||
((interaction.message as Record<string, unknown>)?.embeds as Array<Record<string, unknown>>) || [];
|
||||
const originalDescription = (originalEmbeds[0]?.description as string) || '';
|
||||
const render = questionId ? getAskQuestionRender(questionId) : undefined;
|
||||
// Discord custom_id mirrors the new index-based encoding (see Button
|
||||
// construction). Decode back to the real option value for downstream.
|
||||
const selectedOption = resolveSelectedOption(render, tail, tail);
|
||||
const cardTitle = render?.title ?? ((originalEmbeds[0]?.title as string) || '❓ Question');
|
||||
const matchedOpt = render?.options.find((o) => o.value === selectedOption);
|
||||
const selectedLabel = matchedOpt?.selectedLabel ?? selectedOption ?? customId;
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
/**
|
||||
* Integration test for the deltachat channel's single reach-in: the
|
||||
* self-registration import in the `src/channels/index.ts` barrel. Importing the
|
||||
* barrel runs deltachat.ts's top-level `registerChannelAdapter('deltachat', …)`;
|
||||
* without the import the channel is silently absent.
|
||||
*
|
||||
* Behavior, not structural: it imports the real barrel and asserts the registry
|
||||
* actually contains the channel. This reflects what happens at host boot — if the
|
||||
* `import './deltachat.js';` line is deleted, or the barrel fails to evaluate for
|
||||
* any reason (so the channel genuinely would not register), this goes red. A
|
||||
* structural check of the import line would falsely pass in that second case.
|
||||
*
|
||||
* Importing the barrel is safe: registration is a pure top-level call, and
|
||||
* deltachat.ts only instantiates DeltaChatOverJsonRpc inside setup() (run at host
|
||||
* startup), never at import — so nothing spawns here. It does require the adapter
|
||||
* package to be installed, which holds in a composed install: the skill's
|
||||
* `pnpm install` step runs before this test in the apply flow.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { getRegisteredChannelNames } from './channel-registry.js';
|
||||
import './index.js'; // the real barrel — triggers every channel's self-registration
|
||||
|
||||
describe('deltachat channel registration', () => {
|
||||
it('registers deltachat via the channel barrel', () => {
|
||||
expect(getRegisteredChannelNames()).toContain('deltachat');
|
||||
});
|
||||
});
|
||||
@@ -1,338 +0,0 @@
|
||||
/**
|
||||
* DeltaChat channel adapter.
|
||||
*
|
||||
* Bridges NanoClaw with DeltaChat via the @deltachat/stdio-rpc-server JSON-RPC
|
||||
* process. Each DeltaChat chat becomes a separate NanoClaw messaging group
|
||||
* (platformId = chatId string, e.g. "12"). No thread model — supportsThreads: false.
|
||||
*
|
||||
* Required env vars (.env): DC_EMAIL, DC_PASSWORD,
|
||||
* DC_IMAP_HOST, DC_IMAP_PORT,
|
||||
* DC_SMTP_HOST, DC_SMTP_PORT
|
||||
* Optional env vars (.env): DC_IMAP_SECURITY (default: "1" = SSL/TLS),
|
||||
* DC_SMTP_SECURITY (default: "2" = STARTTLS)
|
||||
* Security values: 1=SSL/TLS, 2=STARTTLS, 3=plain
|
||||
* Optional env vars (service unit): DC_ACCOUNT_DIR (default: "dc-account"),
|
||||
* DC_DISPLAY_NAME, DC_AVATAR_PATH
|
||||
*/
|
||||
import { existsSync, mkdtempSync, writeFileSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { basename, join, resolve } from 'path';
|
||||
|
||||
import { getDb, hasTable } from '../db/connection.js';
|
||||
import { readEnvFile } from '../env.js';
|
||||
import { log } from '../log.js';
|
||||
import type { ChannelAdapter, ChannelSetup, OutboundMessage } from './adapter.js';
|
||||
import { registerChannelAdapter } from './channel-registry.js';
|
||||
|
||||
import { DeltaChatOverJsonRpc } from '@deltachat/stdio-rpc-server';
|
||||
|
||||
const REQUIRED_ENV = [
|
||||
'DC_EMAIL',
|
||||
'DC_PASSWORD',
|
||||
'DC_IMAP_HOST',
|
||||
'DC_IMAP_PORT',
|
||||
'DC_SMTP_HOST',
|
||||
'DC_SMTP_PORT',
|
||||
] as const;
|
||||
|
||||
const OPTIONAL_ENV = ['DC_IMAP_SECURITY', 'DC_SMTP_SECURITY'] as const;
|
||||
|
||||
type DcEnv = { [K in (typeof REQUIRED_ENV)[number]]: string } & { [K in (typeof OPTIONAL_ENV)[number]]?: string };
|
||||
|
||||
function isDcAdmin(userId: string): boolean {
|
||||
try {
|
||||
const db = getDb();
|
||||
if (!hasTable(db, 'user_roles')) return true;
|
||||
return (
|
||||
db
|
||||
.prepare(
|
||||
`SELECT 1 FROM user_roles
|
||||
WHERE user_id = ?
|
||||
AND (role = 'owner' OR role = 'admin')
|
||||
AND agent_group_id IS NULL
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(userId) != null
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function createAdapter(env: DcEnv): ChannelAdapter {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let dc: any = null;
|
||||
let accountId = 0;
|
||||
let connectivity = 0;
|
||||
let lastImapIdleTs = Date.now();
|
||||
let consecutiveBadChecks = 0;
|
||||
let watchdogTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let networkTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
async function restartIo(reason: string): Promise<void> {
|
||||
log.warn('DeltaChat: restarting IO', { reason });
|
||||
try {
|
||||
await dc.rpc.stopIo(accountId);
|
||||
await dc.rpc.startIo(accountId);
|
||||
lastImapIdleTs = Date.now();
|
||||
consecutiveBadChecks = 0;
|
||||
} catch (err) {
|
||||
log.error('DeltaChat: IO restart failed', { err });
|
||||
}
|
||||
}
|
||||
|
||||
const adapter: ChannelAdapter = {
|
||||
name: 'deltachat',
|
||||
channelType: 'deltachat',
|
||||
supportsThreads: false,
|
||||
|
||||
async setup(config: ChannelSetup): Promise<void> {
|
||||
const accountDir = process.env.DC_ACCOUNT_DIR ?? 'dc-account';
|
||||
dc = new DeltaChatOverJsonRpc(accountDir, {});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
dc.on('Error', (_: any, event: any) => log.error('DeltaChat RPC error', { msg: event.msg ?? event }));
|
||||
|
||||
const accounts = await dc.rpc.getAllAccounts();
|
||||
accountId = accounts[0]?.id;
|
||||
if (!accountId) accountId = await dc.rpc.addAccount();
|
||||
|
||||
const imapSecurity = env.DC_IMAP_SECURITY ?? '1';
|
||||
const smtpSecurity = env.DC_SMTP_SECURITY ?? '2';
|
||||
|
||||
if (!(await dc.rpc.isConfigured(accountId))) {
|
||||
await dc.rpc.setConfig(accountId, 'addr', env.DC_EMAIL);
|
||||
await dc.rpc.setConfig(accountId, 'mail_pw', env.DC_PASSWORD);
|
||||
await dc.rpc.setConfig(accountId, 'mail_server', env.DC_IMAP_HOST);
|
||||
await dc.rpc.setConfig(accountId, 'mail_port', env.DC_IMAP_PORT);
|
||||
await dc.rpc.setConfig(accountId, 'send_server', env.DC_SMTP_HOST);
|
||||
await dc.rpc.setConfig(accountId, 'send_port', env.DC_SMTP_PORT);
|
||||
await dc.rpc.configure(accountId);
|
||||
log.info('DeltaChat: account configured', { email: env.DC_EMAIL });
|
||||
} else {
|
||||
log.info('DeltaChat: account ready', { email: env.DC_EMAIL });
|
||||
}
|
||||
|
||||
await dc.rpc.setConfig(accountId, 'mail_security', imapSecurity);
|
||||
await dc.rpc.setConfig(accountId, 'send_security', smtpSecurity);
|
||||
await dc.rpc.setConfig(accountId, 'displayname', process.env.DC_DISPLAY_NAME ?? 'NanoClaw');
|
||||
const avatarPath = process.env.DC_AVATAR_PATH;
|
||||
if (avatarPath && existsSync(avatarPath)) {
|
||||
await dc.rpc.setConfig(accountId, 'selfavatar', avatarPath);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
dc.on('IncomingMsg', async (contextId: number, event: any) => {
|
||||
if (contextId !== accountId) return;
|
||||
try {
|
||||
let msg = await dc.rpc.getMessage(accountId, event.msgId);
|
||||
if (msg.isInfo) return;
|
||||
|
||||
// Wait for large-message download to complete
|
||||
if (msg.downloadState !== 'Done') {
|
||||
await dc.rpc.downloadFullMessage(accountId, event.msgId);
|
||||
for (let i = 0; i < 30; i++) {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
msg = await dc.rpc.getMessage(accountId, event.msgId);
|
||||
if (msg.downloadState === 'Done') break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!msg.text && !msg.file) return;
|
||||
|
||||
const contact = await dc.rpc.getContact(accountId, msg.fromId);
|
||||
const chat = await dc.rpc.getBasicChatInfo(accountId, event.chatId);
|
||||
|
||||
if (/^\/set-avatar$/i.test((msg.text || '').trim()) && msg.file) {
|
||||
const userId = `deltachat:${contact.address}`;
|
||||
try {
|
||||
if (isDcAdmin(userId)) {
|
||||
const absPath = resolve(msg.file as string);
|
||||
await dc.rpc.setConfig(accountId, 'selfavatar', absPath);
|
||||
await dc.rpc.sendMsg(accountId, event.chatId, { text: 'Avatar updated.' });
|
||||
} else {
|
||||
await dc.rpc.sendMsg(accountId, event.chatId, { text: 'Permission denied.' });
|
||||
}
|
||||
} catch (avatarErr: unknown) {
|
||||
log.error('DeltaChat: failed to set avatar', {
|
||||
err: avatarErr instanceof Error ? avatarErr.message : JSON.stringify(avatarErr),
|
||||
});
|
||||
await dc.rpc.sendMsg(accountId, event.chatId, { text: 'Failed to set avatar.' }).catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const content: Record<string, unknown> = {
|
||||
text: msg.text || '',
|
||||
sender: contact.displayName || contact.address,
|
||||
senderId: contact.address,
|
||||
};
|
||||
if (msg.file) {
|
||||
content.attachments = [
|
||||
{
|
||||
name: basename(msg.file as string),
|
||||
type: 'file',
|
||||
localPath: msg.file,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const isGroup = chat?.isGroup ?? false;
|
||||
await config.onInbound(String(event.chatId), null, {
|
||||
id: String(event.msgId),
|
||||
kind: 'chat',
|
||||
content,
|
||||
timestamp: new Date().toISOString(),
|
||||
isGroup,
|
||||
isMention: !isGroup,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
log.error('DeltaChat: error handling incoming message', {
|
||||
err: err instanceof Error ? err.message : JSON.stringify(err),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
dc.on('ImapInboxIdle', (contextId: number) => {
|
||||
if (contextId === accountId) lastImapIdleTs = Date.now();
|
||||
});
|
||||
|
||||
dc.on('ConnectivityChanged', async (contextId: number) => {
|
||||
if (contextId !== accountId) return;
|
||||
try {
|
||||
connectivity = await dc.rpc.getConnectivity(accountId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
|
||||
await dc.rpc.startIo(accountId);
|
||||
try {
|
||||
connectivity = await dc.rpc.getConnectivity(accountId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
log.info('DeltaChat: IO started', { email: env.DC_EMAIL });
|
||||
|
||||
// Log invite link on every startup so the operator can bootstrap the first contact.
|
||||
// In DeltaChat, contacts can't simply be added by email — the user must open this
|
||||
// https://i.delta.chat/ invite URL in their DeltaChat app (or scan invite-qr.svg) to initiate contact.
|
||||
try {
|
||||
// null chatId → Setup-Contact invite (not group-specific)
|
||||
const [inviteUrl, svg] = await dc.rpc.getChatSecurejoinQrCodeSvg(accountId, null);
|
||||
const accountDir = resolve(process.env.DC_ACCOUNT_DIR ?? 'dc-account');
|
||||
const svgPath = join(accountDir, 'invite-qr.svg');
|
||||
writeFileSync(svgPath, svg);
|
||||
log.info('DeltaChat: invite link — open URL in DeltaChat app or scan ' + svgPath, { url: inviteUrl });
|
||||
} catch (err: unknown) {
|
||||
log.warn('DeltaChat: could not generate invite link', {
|
||||
err: err instanceof Error ? err.message : JSON.stringify(err),
|
||||
});
|
||||
}
|
||||
|
||||
// Connectivity watchdog: restart IO if IMAP goes quiet or connectivity drops
|
||||
watchdogTimer = setInterval(
|
||||
async () => {
|
||||
try {
|
||||
const conn = await dc.rpc.getConnectivity(accountId);
|
||||
connectivity = conn;
|
||||
if (conn < 3000) {
|
||||
consecutiveBadChecks++;
|
||||
if (consecutiveBadChecks >= 2) {
|
||||
await restartIo(`connectivity=${conn} for 2 consecutive checks`);
|
||||
}
|
||||
} else {
|
||||
consecutiveBadChecks = 0;
|
||||
}
|
||||
const idleAgeMin = (Date.now() - lastImapIdleTs) / 60000;
|
||||
if (idleAgeMin > 20) {
|
||||
await restartIo(`no IMAP IDLE in ${idleAgeMin.toFixed(0)}min`);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
log.warn('DeltaChat: watchdog error', {
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
},
|
||||
5 * 60 * 1000,
|
||||
);
|
||||
|
||||
// Nudge the network stack every 10 minutes (recovers from prolonged idle)
|
||||
networkTimer = setInterval(
|
||||
async () => {
|
||||
try {
|
||||
await dc.rpc.maybeNetwork();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
10 * 60 * 1000,
|
||||
);
|
||||
},
|
||||
|
||||
async teardown(): Promise<void> {
|
||||
if (watchdogTimer) clearInterval(watchdogTimer);
|
||||
if (networkTimer) clearInterval(networkTimer);
|
||||
try {
|
||||
await dc?.rpc.stopIo(accountId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
dc?.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
|
||||
isConnected(): boolean {
|
||||
// 4000 = fully connected (IMAP), 3000 = connecting; treat ≥3000 as live
|
||||
return connectivity >= 3000;
|
||||
},
|
||||
|
||||
async deliver(platformId: string, _threadId: string | null, message: OutboundMessage): Promise<string | undefined> {
|
||||
const chatId = parseInt(platformId, 10);
|
||||
if (isNaN(chatId)) {
|
||||
log.warn('DeltaChat: invalid platformId for delivery', { platformId });
|
||||
return undefined;
|
||||
}
|
||||
const content = message.content as Record<string, unknown>;
|
||||
const text = typeof content.text === 'string' ? content.text : '';
|
||||
|
||||
if (message.files && message.files.length > 0) {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'nanoclaw-dc-'));
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let firstId: any;
|
||||
for (let i = 0; i < message.files.length; i++) {
|
||||
const f = message.files[i];
|
||||
const tempPath = join(tempDir, f.filename);
|
||||
writeFileSync(tempPath, f.data);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const params: any = { file: tempPath };
|
||||
if (i === 0 && text) params.text = text;
|
||||
const sentId = await dc.rpc.sendMsg(accountId, chatId, params);
|
||||
if (i === 0) firstId = sentId;
|
||||
}
|
||||
return firstId != null ? String(firstId) : undefined;
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (!text) return undefined;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const sentId: any = await dc.rpc.sendMsg(accountId, chatId, { text });
|
||||
return sentId != null ? String(sentId) : undefined;
|
||||
},
|
||||
};
|
||||
|
||||
return adapter;
|
||||
}
|
||||
|
||||
registerChannelAdapter('deltachat', {
|
||||
factory: () => {
|
||||
const env = readEnvFile([...REQUIRED_ENV, ...OPTIONAL_ENV]);
|
||||
if (!env.DC_EMAIL || !env.DC_PASSWORD) return null;
|
||||
return createAdapter(env as DcEnv);
|
||||
},
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* Integration test for the discord channel's single reach-in: the self-registration
|
||||
* import in the `src/channels/index.ts` barrel. Importing the barrel runs discord.ts's
|
||||
* top-level `registerChannelAdapter('discord', …)`; without the import the channel is
|
||||
* silently absent.
|
||||
*
|
||||
* Behavior, not structural: it imports the real barrel and asserts the registry
|
||||
* actually contains the channel. This reflects what happens at host boot — if the
|
||||
* `import './discord.js';` line is deleted, or the barrel fails to evaluate for any
|
||||
* reason (so the channel genuinely would not register), this goes red. A structural
|
||||
* check of the import line would falsely pass in that second case.
|
||||
*
|
||||
* Importing the barrel is safe: registration is a pure top-level call, and discord.ts
|
||||
* builds the SDK adapter / bridge only inside its factory (invoked at host startup),
|
||||
* never at import. It does require the adapter package (`@chat-adapter/discord`) to be installed,
|
||||
* which holds in a composed install: the skill's `pnpm install` step runs before this
|
||||
* test — so this test also implicitly guards that dependency (an unmocked import throws
|
||||
* if the package is missing).
|
||||
*
|
||||
* discord is a Chat SDK channel: discord.ts also consumes a load-bearing *core* API —
|
||||
* `createChatSdkBridge(...)` from ./chat-sdk-bridge.js. That core-consumption is a
|
||||
* typed call, so the build/typecheck leg (`pnpm run build`) guards it against upstream
|
||||
* drift, not this test. Every Chat SDK channel follows this same shape.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { getRegisteredChannelNames } from './channel-registry.js';
|
||||
import './index.js'; // the real barrel — triggers every channel's self-registration
|
||||
|
||||
describe('discord channel registration', () => {
|
||||
it('registers discord via the channel barrel', () => {
|
||||
expect(getRegisteredChannelNames()).toContain('discord');
|
||||
});
|
||||
});
|
||||
@@ -1,38 +0,0 @@
|
||||
/**
|
||||
* Discord channel adapter (v2) — uses Chat SDK bridge.
|
||||
* Self-registers on import.
|
||||
*/
|
||||
import { createDiscordAdapter } from '@chat-adapter/discord';
|
||||
|
||||
import { readEnvFile } from '../env.js';
|
||||
import { createChatSdkBridge, type ReplyContext } from './chat-sdk-bridge.js';
|
||||
import { registerChannelAdapter } from './channel-registry.js';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function extractReplyContext(raw: Record<string, any>): ReplyContext | null {
|
||||
if (!raw.referenced_message) return null;
|
||||
const reply = raw.referenced_message;
|
||||
return {
|
||||
text: reply.content || '',
|
||||
sender: reply.author?.global_name || reply.author?.username || 'Unknown',
|
||||
};
|
||||
}
|
||||
|
||||
registerChannelAdapter('discord', {
|
||||
factory: () => {
|
||||
const env = readEnvFile(['DISCORD_BOT_TOKEN', 'DISCORD_PUBLIC_KEY', 'DISCORD_APPLICATION_ID']);
|
||||
if (!env.DISCORD_BOT_TOKEN) return null;
|
||||
const discordAdapter = createDiscordAdapter({
|
||||
botToken: env.DISCORD_BOT_TOKEN,
|
||||
publicKey: env.DISCORD_PUBLIC_KEY,
|
||||
applicationId: env.DISCORD_APPLICATION_ID,
|
||||
});
|
||||
return createChatSdkBridge({
|
||||
adapter: discordAdapter,
|
||||
concurrency: 'concurrent',
|
||||
botToken: env.DISCORD_BOT_TOKEN,
|
||||
extractReplyContext,
|
||||
supportsThreads: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
/**
|
||||
* Integration test for the emacs channel's single reach-in: the self-registration
|
||||
* import in the `src/channels/index.ts` barrel. Importing the barrel runs emacs.ts's
|
||||
* top-level `registerChannelAdapter('emacs', …)`; without the import the channel is
|
||||
* silently absent.
|
||||
*
|
||||
* Behavior, not structural: it imports the real barrel and asserts the registry
|
||||
* actually contains the channel. This reflects what happens at host boot — if the
|
||||
* `import './emacs.js';` line is deleted, or the barrel fails to evaluate for any
|
||||
* reason (so the channel genuinely would not register), this goes red. A structural
|
||||
* check of the import line would falsely pass in that second case.
|
||||
*
|
||||
* emacs is a native adapter with no npm dependency (it uses the Node http builtin); it talks to an Emacs HTTP client.
|
||||
* Importing the barrel is safe: registration is a pure top-level call and emacs.ts
|
||||
* opens connections / spawns subprocesses only inside setup() (run at host startup),
|
||||
* never at import. There is no adapter package to guard here — this test guards the
|
||||
* one barrel reach-in (red if `import './emacs.js';` is deleted or the barrel fails
|
||||
* to evaluate).
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { getRegisteredChannelNames } from './channel-registry.js';
|
||||
import './index.js'; // the real barrel — triggers every channel's self-registration
|
||||
|
||||
describe('emacs channel registration', () => {
|
||||
it('registers emacs via the channel barrel', () => {
|
||||
expect(getRegisteredChannelNames()).toContain('emacs');
|
||||
});
|
||||
});
|
||||
@@ -1,259 +0,0 @@
|
||||
/**
|
||||
* Tests for the v2 emacs channel adapter.
|
||||
*
|
||||
* Exercises the HTTP surface (POST /api/message, GET /api/messages) and
|
||||
* the ChannelAdapter lifecycle (setup / teardown / isConnected / deliver).
|
||||
*/
|
||||
import http from 'http';
|
||||
import type { AddressInfo } from 'net';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createEmacsAdapter } from './emacs.js';
|
||||
import type { ChannelAdapter, ChannelSetup } from './adapter.js';
|
||||
|
||||
vi.mock('../log.js', () => ({
|
||||
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
function makeSetup(overrides: Partial<ChannelSetup> = {}): ChannelSetup {
|
||||
return {
|
||||
onInbound: vi.fn(),
|
||||
onInboundEvent: vi.fn(),
|
||||
onMetadata: vi.fn(),
|
||||
onAction: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Ask the OS for a free port, then immediately release it. Small race window
|
||||
* before the adapter grabs it, but sufficient for local test use. */
|
||||
async function getFreePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const srv = http.createServer();
|
||||
srv.once('error', reject);
|
||||
srv.listen(0, '127.0.0.1', () => {
|
||||
const port = (srv.address() as AddressInfo).port;
|
||||
srv.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function req(
|
||||
port: number,
|
||||
method: string,
|
||||
path: string,
|
||||
body?: string,
|
||||
extraHeaders: Record<string, string> = {},
|
||||
): Promise<{ status: number; data: unknown }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json', ...extraHeaders };
|
||||
const request = http.request({ host: '127.0.0.1', port, method, path, headers }, (res) => {
|
||||
let raw = '';
|
||||
res.on('data', (chunk: Buffer) => (raw += chunk.toString()));
|
||||
res.on('end', () => {
|
||||
try {
|
||||
resolve({ status: res.statusCode!, data: JSON.parse(raw) });
|
||||
} catch {
|
||||
resolve({ status: res.statusCode!, data: raw });
|
||||
}
|
||||
});
|
||||
});
|
||||
request.on('error', reject);
|
||||
if (body) request.write(body);
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
describe('emacs adapter', () => {
|
||||
let adapter: ChannelAdapter;
|
||||
let port: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
port = await getFreePort();
|
||||
adapter = createEmacsAdapter({ port, authToken: null, platformId: 'default' });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter.isConnected()) await adapter.teardown();
|
||||
});
|
||||
|
||||
describe('lifecycle', () => {
|
||||
it('isConnected is false before setup', () => {
|
||||
expect(adapter.isConnected()).toBe(false);
|
||||
});
|
||||
|
||||
it('isConnected is true after setup', async () => {
|
||||
await adapter.setup(makeSetup());
|
||||
expect(adapter.isConnected()).toBe(true);
|
||||
});
|
||||
|
||||
it('isConnected is false after teardown', async () => {
|
||||
await adapter.setup(makeSetup());
|
||||
await adapter.teardown();
|
||||
expect(adapter.isConnected()).toBe(false);
|
||||
});
|
||||
|
||||
it('teardown is a no-op before setup', async () => {
|
||||
await expect(adapter.teardown()).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('calls onMetadata after setup with channel name', async () => {
|
||||
const onMetadata = vi.fn();
|
||||
await adapter.setup(makeSetup({ onMetadata }));
|
||||
expect(onMetadata).toHaveBeenCalledWith('default', 'Emacs', false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/message', () => {
|
||||
let onInbound: ChannelSetup['onInbound'] & { mock: { calls: unknown[][] } };
|
||||
|
||||
beforeEach(async () => {
|
||||
onInbound = vi.fn() as unknown as typeof onInbound;
|
||||
await adapter.setup(makeSetup({ onInbound }));
|
||||
});
|
||||
|
||||
it('fires onInbound with chat kind and sender metadata', async () => {
|
||||
const { status, data } = await req(port, 'POST', '/api/message', JSON.stringify({ text: 'hello' }));
|
||||
expect(status).toBe(200);
|
||||
expect((data as { messageId: string }).messageId).toMatch(/^emacs-/);
|
||||
expect(onInbound).toHaveBeenCalledOnce();
|
||||
const [platformId, threadId, msg] = onInbound.mock.calls[0] as [string, string | null, { content: unknown }];
|
||||
expect(platformId).toBe('default');
|
||||
expect(threadId).toBeNull();
|
||||
expect(msg).toMatchObject({
|
||||
kind: 'chat',
|
||||
content: { text: 'hello', sender: 'Emacs', senderId: 'emacs:default' },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns 400 for empty text', async () => {
|
||||
const { status } = await req(port, 'POST', '/api/message', JSON.stringify({ text: '' }));
|
||||
expect(status).toBe(400);
|
||||
expect(onInbound).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 400 for whitespace-only text', async () => {
|
||||
const { status } = await req(port, 'POST', '/api/message', JSON.stringify({ text: ' ' }));
|
||||
expect(status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 400 for invalid JSON', async () => {
|
||||
const { status } = await req(port, 'POST', '/api/message', 'not-json');
|
||||
expect(status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 404 for unknown paths', async () => {
|
||||
const { status } = await req(port, 'POST', '/api/unknown', JSON.stringify({ text: 'hi' }));
|
||||
expect(status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/messages + deliver', () => {
|
||||
beforeEach(async () => {
|
||||
await adapter.setup(makeSetup());
|
||||
});
|
||||
|
||||
it('returns empty buffer initially', async () => {
|
||||
const { status, data } = await req(port, 'GET', '/api/messages?since=0');
|
||||
expect(status).toBe(200);
|
||||
expect(data).toEqual({ messages: [] });
|
||||
});
|
||||
|
||||
it('deliver pushes text for the poll endpoint to return', async () => {
|
||||
await adapter.deliver('default', null, { kind: 'chat', content: { text: 'reply' } });
|
||||
const { data } = await req(port, 'GET', '/api/messages?since=0');
|
||||
const messages = (data as { messages: { text: string; timestamp: number }[] }).messages;
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0]?.text).toBe('reply');
|
||||
expect(typeof messages[0]?.timestamp).toBe('number');
|
||||
});
|
||||
|
||||
it('deliver accepts plain-string content', async () => {
|
||||
await adapter.deliver('default', null, { kind: 'chat', content: 'raw text' });
|
||||
const { data } = await req(port, 'GET', '/api/messages?since=0');
|
||||
expect((data as { messages: { text: string }[] }).messages[0]?.text).toBe('raw text');
|
||||
});
|
||||
|
||||
it('deliver skips empty text silently', async () => {
|
||||
await adapter.deliver('default', null, { kind: 'chat', content: { text: '' } });
|
||||
const { data } = await req(port, 'GET', '/api/messages?since=0');
|
||||
expect((data as { messages: unknown[] }).messages).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('deliver rejects unknown platformId', async () => {
|
||||
const result = await adapter.deliver('other', null, { kind: 'chat', content: { text: 'x' } });
|
||||
expect(result).toBeUndefined();
|
||||
const { data } = await req(port, 'GET', '/api/messages?since=0');
|
||||
expect((data as { messages: unknown[] }).messages).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('filters out messages at or before the since cutoff', async () => {
|
||||
await adapter.deliver('default', null, { kind: 'chat', content: { text: 'old' } });
|
||||
const since = Date.now();
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
await adapter.deliver('default', null, { kind: 'chat', content: { text: 'new' } });
|
||||
const { data } = await req(port, 'GET', `/api/messages?since=${since}`);
|
||||
const texts = (data as { messages: { text: string }[] }).messages.map((m) => m.text);
|
||||
expect(texts).not.toContain('old');
|
||||
expect(texts).toContain('new');
|
||||
});
|
||||
|
||||
it('caps buffer at 200 messages, evicting the oldest', async () => {
|
||||
for (let i = 0; i < 205; i++) {
|
||||
await adapter.deliver('default', null, { kind: 'chat', content: { text: `m-${i}` } });
|
||||
}
|
||||
const { data } = await req(port, 'GET', '/api/messages?since=0');
|
||||
const messages = (data as { messages: { text: string }[] }).messages;
|
||||
expect(messages).toHaveLength(200);
|
||||
expect(messages.map((m) => m.text)).not.toContain('m-0');
|
||||
expect(messages.map((m) => m.text)).toContain('m-5');
|
||||
expect(messages.map((m) => m.text)).toContain('m-204');
|
||||
});
|
||||
});
|
||||
|
||||
describe('auth', () => {
|
||||
let authAdapter: ChannelAdapter;
|
||||
let authPort: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
authPort = await getFreePort();
|
||||
authAdapter = createEmacsAdapter({ port: authPort, authToken: 'secret', platformId: 'default' });
|
||||
await authAdapter.setup(makeSetup());
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (authAdapter.isConnected()) await authAdapter.teardown();
|
||||
});
|
||||
|
||||
it('rejects POST without Authorization header', async () => {
|
||||
const { status } = await req(authPort, 'POST', '/api/message', JSON.stringify({ text: 'hi' }));
|
||||
expect(status).toBe(401);
|
||||
});
|
||||
|
||||
it('rejects POST with wrong token', async () => {
|
||||
const { status } = await req(authPort, 'POST', '/api/message', JSON.stringify({ text: 'hi' }), {
|
||||
Authorization: 'Bearer wrong',
|
||||
});
|
||||
expect(status).toBe(401);
|
||||
});
|
||||
|
||||
it('accepts POST with correct Bearer token', async () => {
|
||||
const { status } = await req(authPort, 'POST', '/api/message', JSON.stringify({ text: 'hi' }), {
|
||||
Authorization: 'Bearer secret',
|
||||
});
|
||||
expect(status).toBe(200);
|
||||
});
|
||||
|
||||
it('rejects GET without Authorization header', async () => {
|
||||
const { status } = await req(authPort, 'GET', '/api/messages?since=0');
|
||||
expect(status).toBe(401);
|
||||
});
|
||||
|
||||
it('accepts GET with correct Bearer token', async () => {
|
||||
const { status } = await req(authPort, 'GET', '/api/messages?since=0', undefined, {
|
||||
Authorization: 'Bearer secret',
|
||||
});
|
||||
expect(status).toBe(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,186 +0,0 @@
|
||||
/**
|
||||
* Emacs channel adapter (v2) — native HTTP bridge.
|
||||
*
|
||||
* Stands up a localhost HTTP server that the nanoclaw.el client talks to:
|
||||
* - POST /api/message — user typed a message in Emacs; fire onInbound
|
||||
* - GET /api/messages?since=<ms> — Emacs polls for agent replies
|
||||
*
|
||||
* Single-user, single-chat: one adapter instance = one messaging group with
|
||||
* `platform_id = "default"` (override with EMACS_PLATFORM_ID). No threads,
|
||||
* no cold DM. Self-registers on import.
|
||||
*/
|
||||
import http from 'http';
|
||||
|
||||
import { readEnvFile } from '../env.js';
|
||||
import { log } from '../log.js';
|
||||
import { registerChannelAdapter } from './channel-registry.js';
|
||||
import type { ChannelAdapter, ChannelSetup, InboundMessage, OutboundMessage } from './adapter.js';
|
||||
|
||||
const OUTBOUND_BUFFER_MAX = 200;
|
||||
|
||||
interface BufferedMessage {
|
||||
text: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface EmacsAdapterOptions {
|
||||
port: number;
|
||||
authToken: string | null;
|
||||
platformId: string;
|
||||
}
|
||||
|
||||
function createEmacsAdapter(opts: EmacsAdapterOptions): ChannelAdapter {
|
||||
let server: http.Server | null = null;
|
||||
let setupConfig: ChannelSetup | null = null;
|
||||
const outboundBuffer: BufferedMessage[] = [];
|
||||
|
||||
function checkAuth(req: http.IncomingMessage, res: http.ServerResponse): boolean {
|
||||
if (!opts.authToken) return true;
|
||||
if (req.headers['authorization'] === `Bearer ${opts.authToken}`) return true;
|
||||
res
|
||||
.writeHead(401, { 'Content-Type': 'application/json; charset=utf-8' })
|
||||
.end(JSON.stringify({ error: 'Unauthorized' }));
|
||||
return false;
|
||||
}
|
||||
|
||||
function handlePost(req: http.IncomingMessage, res: http.ServerResponse): void {
|
||||
let body = '';
|
||||
req.on('data', (chunk) => (body += chunk));
|
||||
req.on('end', () => {
|
||||
let text: string;
|
||||
try {
|
||||
const parsed = JSON.parse(body) as { text?: string };
|
||||
text = parsed.text ?? '';
|
||||
} catch {
|
||||
res
|
||||
.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' })
|
||||
.end(JSON.stringify({ error: 'Invalid JSON' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!text.trim()) {
|
||||
res
|
||||
.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' })
|
||||
.end(JSON.stringify({ error: 'text required' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
const id = `emacs-${Date.now()}`;
|
||||
|
||||
const inbound: InboundMessage = {
|
||||
id,
|
||||
kind: 'chat',
|
||||
content: {
|
||||
text,
|
||||
sender: 'Emacs',
|
||||
senderId: `emacs:${opts.platformId}`,
|
||||
},
|
||||
timestamp,
|
||||
};
|
||||
|
||||
try {
|
||||
setupConfig?.onInbound(opts.platformId, null, inbound);
|
||||
} catch (err) {
|
||||
log.error('Emacs onInbound failed', { err });
|
||||
}
|
||||
|
||||
res
|
||||
.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' })
|
||||
.end(JSON.stringify({ messageId: id, timestamp: Date.now() }));
|
||||
});
|
||||
}
|
||||
|
||||
function handlePoll(url: URL, res: http.ServerResponse): void {
|
||||
const since = parseInt(url.searchParams.get('since') ?? '0', 10);
|
||||
const messages = outboundBuffer.filter((m) => m.timestamp > since);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }).end(JSON.stringify({ messages }));
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'emacs',
|
||||
channelType: 'emacs',
|
||||
supportsThreads: false,
|
||||
|
||||
async setup(config: ChannelSetup): Promise<void> {
|
||||
setupConfig = config;
|
||||
|
||||
server = http.createServer((req, res) => {
|
||||
if (!checkAuth(req, res)) return;
|
||||
|
||||
const url = new URL(req.url ?? '/', `http://localhost:${opts.port}`);
|
||||
if (req.method === 'POST' && url.pathname === '/api/message') {
|
||||
handlePost(req, res);
|
||||
} else if (req.method === 'GET' && url.pathname === '/api/messages') {
|
||||
handlePoll(url, res);
|
||||
} else {
|
||||
res
|
||||
.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' })
|
||||
.end(JSON.stringify({ error: 'Not found' }));
|
||||
}
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server!.once('error', reject);
|
||||
server!.listen(opts.port, '127.0.0.1', () => {
|
||||
log.info('Emacs channel listening', { port: opts.port, platformId: opts.platformId });
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
// Stamp a human-readable name on the messaging_groups row on first boot.
|
||||
config.onMetadata(opts.platformId, 'Emacs', false);
|
||||
},
|
||||
|
||||
async teardown(): Promise<void> {
|
||||
if (!server) return;
|
||||
await new Promise<void>((resolve) => server!.close(() => resolve()));
|
||||
server = null;
|
||||
log.info('Emacs channel stopped');
|
||||
},
|
||||
|
||||
isConnected(): boolean {
|
||||
return server?.listening ?? false;
|
||||
},
|
||||
|
||||
async deliver(platformId: string, _threadId: string | null, message: OutboundMessage): Promise<string | undefined> {
|
||||
if (platformId !== opts.platformId) {
|
||||
log.warn('Emacs deliver called with unknown platformId', { platformId });
|
||||
return undefined;
|
||||
}
|
||||
const text = extractText(message.content);
|
||||
if (!text) return undefined;
|
||||
|
||||
const id = `emacs-out-${Date.now()}`;
|
||||
outboundBuffer.push({ text, timestamp: Date.now() });
|
||||
while (outboundBuffer.length > OUTBOUND_BUFFER_MAX) outboundBuffer.shift();
|
||||
return id;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function extractText(content: unknown): string {
|
||||
if (typeof content === 'string') return content;
|
||||
if (content && typeof content === 'object') {
|
||||
const c = content as { text?: unknown };
|
||||
if (typeof c.text === 'string') return c.text;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
registerChannelAdapter('emacs', {
|
||||
factory: () => {
|
||||
const env = readEnvFile(['EMACS_ENABLED', 'EMACS_CHANNEL_PORT', 'EMACS_AUTH_TOKEN', 'EMACS_PLATFORM_ID']);
|
||||
const enabled = process.env.EMACS_ENABLED || env.EMACS_ENABLED;
|
||||
if (!enabled || enabled === 'false') return null;
|
||||
|
||||
const portStr = process.env.EMACS_CHANNEL_PORT || env.EMACS_CHANNEL_PORT || '8766';
|
||||
const port = parseInt(portStr, 10);
|
||||
const authToken = process.env.EMACS_AUTH_TOKEN || env.EMACS_AUTH_TOKEN || null;
|
||||
const platformId = process.env.EMACS_PLATFORM_ID || env.EMACS_PLATFORM_ID || 'default';
|
||||
|
||||
return createEmacsAdapter({ port, authToken, platformId });
|
||||
},
|
||||
});
|
||||
|
||||
export { createEmacsAdapter };
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* Integration test for the gchat channel's single reach-in: the self-registration
|
||||
* import in the `src/channels/index.ts` barrel. Importing the barrel runs gchat.ts's
|
||||
* top-level `registerChannelAdapter('gchat', …)`; without the import the channel is
|
||||
* silently absent.
|
||||
*
|
||||
* Behavior, not structural: it imports the real barrel and asserts the registry
|
||||
* actually contains the channel. This reflects what happens at host boot — if the
|
||||
* `import './gchat.js';` line is deleted, or the barrel fails to evaluate for any
|
||||
* reason (so the channel genuinely would not register), this goes red. A structural
|
||||
* check of the import line would falsely pass in that second case.
|
||||
*
|
||||
* Importing the barrel is safe: registration is a pure top-level call, and gchat.ts
|
||||
* builds the SDK adapter / bridge only inside its factory (invoked at host startup),
|
||||
* never at import. It does require the adapter package (`@chat-adapter/gchat`) to be installed,
|
||||
* which holds in a composed install: the skill's `pnpm install` step runs before this
|
||||
* test — so this test also implicitly guards that dependency (an unmocked import throws
|
||||
* if the package is missing).
|
||||
*
|
||||
* gchat is a Chat SDK channel: gchat.ts also consumes a load-bearing *core* API —
|
||||
* `createChatSdkBridge(...)` from ./chat-sdk-bridge.js. That core-consumption is a
|
||||
* typed call, so the build/typecheck leg (`pnpm run build`) guards it against upstream
|
||||
* drift, not this test. Every Chat SDK channel follows this same shape.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { getRegisteredChannelNames } from './channel-registry.js';
|
||||
import './index.js'; // the real barrel — triggers every channel's self-registration
|
||||
|
||||
describe('gchat channel registration', () => {
|
||||
it('registers gchat via the channel barrel', () => {
|
||||
expect(getRegisteredChannelNames()).toContain('gchat');
|
||||
});
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
/**
|
||||
* Google Chat channel adapter (v2) — uses Chat SDK bridge.
|
||||
* Self-registers on import.
|
||||
*/
|
||||
import { createGoogleChatAdapter } from '@chat-adapter/gchat';
|
||||
|
||||
import { readEnvFile } from '../env.js';
|
||||
import { createChatSdkBridge } from './chat-sdk-bridge.js';
|
||||
import { registerChannelAdapter } from './channel-registry.js';
|
||||
|
||||
registerChannelAdapter('gchat', {
|
||||
factory: () => {
|
||||
const env = readEnvFile(['GCHAT_CREDENTIALS']);
|
||||
if (!env.GCHAT_CREDENTIALS) return null;
|
||||
const gchatAdapter = createGoogleChatAdapter({
|
||||
credentials: JSON.parse(env.GCHAT_CREDENTIALS),
|
||||
});
|
||||
return createChatSdkBridge({ adapter: gchatAdapter, concurrency: 'concurrent', supportsThreads: true });
|
||||
},
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* Integration test for the github channel's single reach-in: the self-registration
|
||||
* import in the `src/channels/index.ts` barrel. Importing the barrel runs github.ts's
|
||||
* top-level `registerChannelAdapter('github', …)`; without the import the channel is
|
||||
* silently absent.
|
||||
*
|
||||
* Behavior, not structural: it imports the real barrel and asserts the registry
|
||||
* actually contains the channel. This reflects what happens at host boot — if the
|
||||
* `import './github.js';` line is deleted, or the barrel fails to evaluate for any
|
||||
* reason (so the channel genuinely would not register), this goes red. A structural
|
||||
* check of the import line would falsely pass in that second case.
|
||||
*
|
||||
* Importing the barrel is safe: registration is a pure top-level call, and github.ts
|
||||
* builds the SDK adapter / bridge only inside its factory (invoked at host startup),
|
||||
* never at import. It does require the adapter package (`@chat-adapter/github`) to be installed,
|
||||
* which holds in a composed install: the skill's `pnpm install` step runs before this
|
||||
* test — so this test also implicitly guards that dependency (an unmocked import throws
|
||||
* if the package is missing).
|
||||
*
|
||||
* github is a Chat SDK channel: github.ts also consumes a load-bearing *core* API —
|
||||
* `createChatSdkBridge(...)` from ./chat-sdk-bridge.js. That core-consumption is a
|
||||
* typed call, so the build/typecheck leg (`pnpm run build`) guards it against upstream
|
||||
* drift, not this test. Every Chat SDK channel follows this same shape.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { getRegisteredChannelNames } from './channel-registry.js';
|
||||
import './index.js'; // the real barrel — triggers every channel's self-registration
|
||||
|
||||
describe('github channel registration', () => {
|
||||
it('registers github via the channel barrel', () => {
|
||||
expect(getRegisteredChannelNames()).toContain('github');
|
||||
});
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
/**
|
||||
* GitHub channel adapter (v2) — uses Chat SDK bridge.
|
||||
* PR comment threads as conversations.
|
||||
* Self-registers on import.
|
||||
*/
|
||||
import { createGitHubAdapter } from '@chat-adapter/github';
|
||||
|
||||
import { readEnvFile } from '../env.js';
|
||||
import { createChatSdkBridge } from './chat-sdk-bridge.js';
|
||||
import { registerChannelAdapter } from './channel-registry.js';
|
||||
|
||||
registerChannelAdapter('github', {
|
||||
factory: () => {
|
||||
const env = readEnvFile(['GITHUB_TOKEN', 'GITHUB_WEBHOOK_SECRET', 'GITHUB_BOT_USERNAME']);
|
||||
if (!env.GITHUB_TOKEN) return null;
|
||||
const githubAdapter = createGitHubAdapter({
|
||||
token: env.GITHUB_TOKEN,
|
||||
webhookSecret: env.GITHUB_WEBHOOK_SECRET,
|
||||
userName: env.GITHUB_BOT_USERNAME,
|
||||
});
|
||||
return createChatSdkBridge({ adapter: githubAdapter, concurrency: 'queue', supportsThreads: true });
|
||||
},
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* Integration test for the imessage channel's single reach-in: the self-registration
|
||||
* import in the `src/channels/index.ts` barrel. Importing the barrel runs imessage.ts's
|
||||
* top-level `registerChannelAdapter('imessage', …)`; without the import the channel is
|
||||
* silently absent.
|
||||
*
|
||||
* Behavior, not structural: it imports the real barrel and asserts the registry
|
||||
* actually contains the channel. This reflects what happens at host boot — if the
|
||||
* `import './imessage.js';` line is deleted, or the barrel fails to evaluate for any
|
||||
* reason (so the channel genuinely would not register), this goes red. A structural
|
||||
* check of the import line would falsely pass in that second case.
|
||||
*
|
||||
* Importing the barrel is safe: registration is a pure top-level call, and imessage.ts
|
||||
* builds the SDK adapter / bridge only inside its factory (invoked at host startup),
|
||||
* never at import. It does require the adapter package (`chat-adapter-imessage`) to be installed,
|
||||
* which holds in a composed install: the skill's `pnpm install` step runs before this
|
||||
* test — so this test also implicitly guards that dependency (an unmocked import throws
|
||||
* if the package is missing).
|
||||
*
|
||||
* imessage is a Chat SDK channel: imessage.ts also consumes a load-bearing *core* API —
|
||||
* `createChatSdkBridge(...)` from ./chat-sdk-bridge.js. That core-consumption is a
|
||||
* typed call, so the build/typecheck leg (`pnpm run build`) guards it against upstream
|
||||
* drift, not this test. Every Chat SDK channel follows this same shape.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { getRegisteredChannelNames } from './channel-registry.js';
|
||||
import './index.js'; // the real barrel — triggers every channel's self-registration
|
||||
|
||||
describe('imessage channel registration', () => {
|
||||
it('registers imessage via the channel barrel', () => {
|
||||
expect(getRegisteredChannelNames()).toContain('imessage');
|
||||
});
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
/**
|
||||
* iMessage channel adapter (v2) — uses Chat SDK bridge.
|
||||
* Supports local mode (macOS Full Disk Access) and remote mode (Photon API).
|
||||
* Self-registers on import.
|
||||
*/
|
||||
import { createiMessageAdapter } from 'chat-adapter-imessage';
|
||||
|
||||
import { readEnvFile } from '../env.js';
|
||||
import { createChatSdkBridge } from './chat-sdk-bridge.js';
|
||||
import { registerChannelAdapter } from './channel-registry.js';
|
||||
|
||||
registerChannelAdapter('imessage', {
|
||||
factory: () => {
|
||||
const env = readEnvFile(['IMESSAGE_ENABLED', 'IMESSAGE_LOCAL', 'IMESSAGE_SERVER_URL', 'IMESSAGE_API_KEY']);
|
||||
const isLocal = env.IMESSAGE_LOCAL !== 'false';
|
||||
if (isLocal && !env.IMESSAGE_ENABLED) return null;
|
||||
if (!isLocal && !env.IMESSAGE_SERVER_URL) return null;
|
||||
const rawAdapter = createiMessageAdapter({
|
||||
local: isLocal,
|
||||
serverUrl: env.IMESSAGE_SERVER_URL,
|
||||
apiKey: env.IMESSAGE_API_KEY,
|
||||
});
|
||||
// Polyfill channelIdFromThreadId (community adapter doesn't implement it)
|
||||
const imessageAdapter = Object.assign(rawAdapter, {
|
||||
channelIdFromThreadId: (threadId: string) => threadId,
|
||||
});
|
||||
return createChatSdkBridge({ adapter: imessageAdapter, concurrency: 'concurrent', supportsThreads: false });
|
||||
},
|
||||
});
|
||||
+4
-55
@@ -1,60 +1,9 @@
|
||||
// Channel self-registration barrel.
|
||||
// Each import triggers the channel module's registerChannelAdapter() call.
|
||||
//
|
||||
// The `channels` branch keeps this file fully populated — it's the
|
||||
// fully-loaded, runnable branch. Individual `/add-<channel>` skills pull
|
||||
// single files from this branch onto a user's install, appending their
|
||||
// own import lines to a leaner barrel on main.
|
||||
// Main ships with one default channel — `cli`, the always-on local-terminal
|
||||
// channel. Other channel skills (/add-slack, /add-discord, /add-whatsapp,
|
||||
// ...) copy their module from the `channels` branch and append a
|
||||
// self-registration import below.
|
||||
|
||||
// cli — default channel that ships with main (always on, no credentials).
|
||||
import './cli.js';
|
||||
|
||||
// discord
|
||||
import './discord.js';
|
||||
|
||||
// slack
|
||||
// import './slack.js';
|
||||
|
||||
// telegram
|
||||
import './telegram.js';
|
||||
|
||||
// github
|
||||
// import './github.js';
|
||||
|
||||
// linear
|
||||
import './linear.js';
|
||||
|
||||
// google chat
|
||||
// import './gchat.js';
|
||||
|
||||
// microsoft teams
|
||||
// import './teams.js';
|
||||
|
||||
// whatsapp cloud api
|
||||
// import './whatsapp-cloud.js';
|
||||
|
||||
// resend (email)
|
||||
// import './resend.js';
|
||||
|
||||
// matrix
|
||||
// import './matrix.js';
|
||||
|
||||
// webex
|
||||
// import './webex.js';
|
||||
|
||||
// imessage
|
||||
import './imessage.js';
|
||||
|
||||
// gmail (native, no Chat SDK)
|
||||
|
||||
// whatsapp (native, no Chat SDK)
|
||||
import './whatsapp.js';
|
||||
|
||||
// signal (native, no Chat SDK — signal-cli TCP JSON-RPC daemon)
|
||||
// import './signal.js';
|
||||
|
||||
// emacs (native HTTP bridge, no Chat SDK)
|
||||
// import './emacs.js';
|
||||
|
||||
// deltachat (native, no Chat SDK)
|
||||
// import './deltachat.js'
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* Integration test for the linear channel's single reach-in: the self-registration
|
||||
* import in the `src/channels/index.ts` barrel. Importing the barrel runs linear.ts's
|
||||
* top-level `registerChannelAdapter('linear', …)`; without the import the channel is
|
||||
* silently absent.
|
||||
*
|
||||
* Behavior, not structural: it imports the real barrel and asserts the registry
|
||||
* actually contains the channel. This reflects what happens at host boot — if the
|
||||
* `import './linear.js';` line is deleted, or the barrel fails to evaluate for any
|
||||
* reason (so the channel genuinely would not register), this goes red. A structural
|
||||
* check of the import line would falsely pass in that second case.
|
||||
*
|
||||
* Importing the barrel is safe: registration is a pure top-level call, and linear.ts
|
||||
* builds the SDK adapter / bridge only inside its factory (invoked at host startup),
|
||||
* never at import. It does require the adapter package (`@chat-adapter/linear`) to be installed,
|
||||
* which holds in a composed install: the skill's `pnpm install` step runs before this
|
||||
* test — so this test also implicitly guards that dependency (an unmocked import throws
|
||||
* if the package is missing).
|
||||
*
|
||||
* linear is a Chat SDK channel: linear.ts also consumes a load-bearing *core* API —
|
||||
* `createChatSdkBridge(...)` from ./chat-sdk-bridge.js. That core-consumption is a
|
||||
* typed call, so the build/typecheck leg (`pnpm run build`) guards it against upstream
|
||||
* drift, not this test. Every Chat SDK channel follows this same shape.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { getRegisteredChannelNames } from './channel-registry.js';
|
||||
import './index.js'; // the real barrel — triggers every channel's self-registration
|
||||
|
||||
describe('linear channel registration', () => {
|
||||
it('registers linear via the channel barrel', () => {
|
||||
expect(getRegisteredChannelNames()).toContain('linear');
|
||||
});
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
/**
|
||||
* Linear channel adapter (v2) — uses Chat SDK bridge.
|
||||
* Issue comment threads as conversations.
|
||||
* Self-registers on import.
|
||||
*
|
||||
* Linear OAuth apps can't be @-mentioned, so this adapter relies on the
|
||||
* bridge's default onNewMessage catch-all to forward every comment.
|
||||
*/
|
||||
import { createLinearAdapter } from '@chat-adapter/linear';
|
||||
|
||||
import { readEnvFile } from '../env.js';
|
||||
import { createChatSdkBridge } from './chat-sdk-bridge.js';
|
||||
import { registerChannelAdapter } from './channel-registry.js';
|
||||
|
||||
registerChannelAdapter('linear', {
|
||||
factory: () => {
|
||||
const env = readEnvFile([
|
||||
'LINEAR_API_KEY',
|
||||
'LINEAR_CLIENT_ID',
|
||||
'LINEAR_CLIENT_SECRET',
|
||||
'LINEAR_WEBHOOK_SECRET',
|
||||
'LINEAR_BOT_USERNAME',
|
||||
'LINEAR_TEAM_KEY',
|
||||
]);
|
||||
if (!env.LINEAR_API_KEY && !env.LINEAR_CLIENT_ID) return null;
|
||||
|
||||
const auth = env.LINEAR_CLIENT_ID
|
||||
? { clientId: env.LINEAR_CLIENT_ID, clientSecret: env.LINEAR_CLIENT_SECRET }
|
||||
: { apiKey: env.LINEAR_API_KEY };
|
||||
|
||||
const linearAdapter = createLinearAdapter({
|
||||
...auth,
|
||||
webhookSecret: env.LINEAR_WEBHOOK_SECRET,
|
||||
userName: env.LINEAR_BOT_USERNAME,
|
||||
});
|
||||
|
||||
// Override channelIdFromThreadId to return a team-based channel ID.
|
||||
// The upstream adapter returns per-issue UUIDs which creates a new
|
||||
// messaging group for every issue. We want one group per team.
|
||||
const teamKey = env.LINEAR_TEAM_KEY || 'default';
|
||||
linearAdapter.channelIdFromThreadId = () => `linear:${teamKey}`;
|
||||
|
||||
return createChatSdkBridge({ adapter: linearAdapter, concurrency: 'queue', supportsThreads: true });
|
||||
},
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* Integration test for the matrix channel's single reach-in: the self-registration
|
||||
* import in the `src/channels/index.ts` barrel. Importing the barrel runs matrix.ts's
|
||||
* top-level `registerChannelAdapter('matrix', …)`; without the import the channel is
|
||||
* silently absent.
|
||||
*
|
||||
* Behavior, not structural: it imports the real barrel and asserts the registry
|
||||
* actually contains the channel. This reflects what happens at host boot — if the
|
||||
* `import './matrix.js';` line is deleted, or the barrel fails to evaluate for any
|
||||
* reason (so the channel genuinely would not register), this goes red. A structural
|
||||
* check of the import line would falsely pass in that second case.
|
||||
*
|
||||
* Importing the barrel is safe: registration is a pure top-level call, and matrix.ts
|
||||
* builds the SDK adapter / bridge only inside its factory (invoked at host startup),
|
||||
* never at import. It does require the adapter package (`@beeper/chat-adapter-matrix`) to be installed,
|
||||
* which holds in a composed install: the skill's `pnpm install` step runs before this
|
||||
* test — so this test also implicitly guards that dependency (an unmocked import throws
|
||||
* if the package is missing).
|
||||
*
|
||||
* matrix is a Chat SDK channel: matrix.ts also consumes a load-bearing *core* API —
|
||||
* `createChatSdkBridge(...)` from ./chat-sdk-bridge.js. That core-consumption is a
|
||||
* typed call, so the build/typecheck leg (`pnpm run build`) guards it against upstream
|
||||
* drift, not this test. Every Chat SDK channel follows this same shape.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { getRegisteredChannelNames } from './channel-registry.js';
|
||||
import './index.js'; // the real barrel — triggers every channel's self-registration
|
||||
|
||||
describe('matrix channel registration', () => {
|
||||
it('registers matrix via the channel barrel', () => {
|
||||
expect(getRegisteredChannelNames()).toContain('matrix');
|
||||
});
|
||||
});
|
||||
@@ -1,206 +0,0 @@
|
||||
/**
|
||||
* Matrix channel adapter (v2) — uses Chat SDK bridge.
|
||||
* Self-registers on import.
|
||||
*
|
||||
* Supports two auth methods (resolved by the adapter from env):
|
||||
* - Access token: MATRIX_ACCESS_TOKEN + MATRIX_USER_ID
|
||||
* - Password: MATRIX_USERNAME + MATRIX_PASSWORD (+ optional MATRIX_USER_ID)
|
||||
*
|
||||
* Optional env vars:
|
||||
* MATRIX_BOT_USERNAME — display name for the bot (default: "bot")
|
||||
* MATRIX_INVITE_AUTOJOIN — "true" to auto-accept room invites
|
||||
* MATRIX_INVITE_AUTOJOIN_ALLOWLIST — comma-separated user IDs allowed to invite
|
||||
* MATRIX_RECOVERY_KEY — enable E2EE cross-signing
|
||||
* MATRIX_DEVICE_ID — stable device ID across restarts
|
||||
*/
|
||||
import { createMatrixAdapter } from '@beeper/chat-adapter-matrix';
|
||||
|
||||
import { log } from '../log.js';
|
||||
import { readEnvFile } from '../env.js';
|
||||
import { createChatSdkBridge } from './chat-sdk-bridge.js';
|
||||
import { registerChannelAdapter } from './channel-registry.js';
|
||||
|
||||
const ENV_KEYS = [
|
||||
'MATRIX_BASE_URL',
|
||||
'MATRIX_ACCESS_TOKEN',
|
||||
'MATRIX_USERNAME',
|
||||
'MATRIX_PASSWORD',
|
||||
'MATRIX_USER_ID',
|
||||
'MATRIX_BOT_USERNAME',
|
||||
'MATRIX_DEVICE_ID',
|
||||
'MATRIX_RECOVERY_KEY',
|
||||
'MATRIX_INVITE_AUTOJOIN',
|
||||
'MATRIX_INVITE_AUTOJOIN_ALLOWLIST',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Wrap the Matrix adapter so DM conversations are identified by user handle
|
||||
* across the whole system, not by ephemeral room IDs.
|
||||
*
|
||||
* Matrix DMs live in rooms (e.g. "!abc:server"), but NanoClaw identifies
|
||||
* channels by platform_id. Using a user handle as platform_id means both
|
||||
* the user and the messaging group reference the same stable identifier.
|
||||
*
|
||||
* Two directions to bridge:
|
||||
* - Outbound: delivery passes "matrix:@user:server" → resolve to room via openDM
|
||||
* - Inbound: adapter emits "matrix:!room:server" → rewrite to user handle
|
||||
* so the router finds the existing messaging group instead of creating
|
||||
* a new one.
|
||||
*
|
||||
* Both resolutions are cached for the process lifetime.
|
||||
*/
|
||||
function wrapWithDmResolution(adapter: ReturnType<typeof createMatrixAdapter>): typeof adapter {
|
||||
const origPostMessage = adapter.postMessage.bind(adapter);
|
||||
const origStartTyping = adapter.startTyping.bind(adapter);
|
||||
const origChannelIdFromThreadId = adapter.channelIdFromThreadId.bind(adapter);
|
||||
|
||||
// roomId → user handle, used to rewrite inbound channel IDs.
|
||||
const roomToUserCache = new Map<string, string>();
|
||||
|
||||
function isUserHandle(threadId: string): boolean {
|
||||
try {
|
||||
const { roomID } = adapter.decodeThreadId(threadId);
|
||||
return !roomID.startsWith('!');
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveThreadId(threadId: string): Promise<string> {
|
||||
if (!isUserHandle(threadId)) return threadId;
|
||||
|
||||
const userHandle = threadId.startsWith('matrix:') ? threadId.slice('matrix:'.length) : threadId;
|
||||
log.info('Matrix: resolving DM room for user handle', { userHandle });
|
||||
const resolved = await adapter.openDM(userHandle);
|
||||
|
||||
try {
|
||||
const { roomID } = adapter.decodeThreadId(resolved);
|
||||
roomToUserCache.set(roomID, userHandle);
|
||||
} catch {
|
||||
// decode failure is non-fatal — outbound still works
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// Rewrite inbound room-based channel IDs to user-handle form for DM rooms.
|
||||
// Non-DM rooms pass through unchanged.
|
||||
adapter.channelIdFromThreadId = (threadId: string): string => {
|
||||
try {
|
||||
const { roomID } = adapter.decodeThreadId(threadId);
|
||||
if (!roomID.startsWith('!')) return origChannelIdFromThreadId(threadId);
|
||||
|
||||
const cached = roomToUserCache.get(roomID);
|
||||
if (cached) return `matrix:${cached}`;
|
||||
|
||||
// Not cached — check if this is a DM by membership count
|
||||
const client = (adapter as any).client;
|
||||
const room = client?.getRoom(roomID);
|
||||
if (!room) return origChannelIdFromThreadId(threadId);
|
||||
if (room.getJoinedMemberCount() > 2) return origChannelIdFromThreadId(threadId);
|
||||
|
||||
const botId = (adapter as any).userID;
|
||||
const otherMember = room.getJoinedMembers().find((m: { userId: string }) => m.userId !== botId);
|
||||
if (!otherMember) return origChannelIdFromThreadId(threadId);
|
||||
|
||||
roomToUserCache.set(roomID, otherMember.userId);
|
||||
return `matrix:${otherMember.userId}`;
|
||||
} catch {
|
||||
return origChannelIdFromThreadId(threadId);
|
||||
}
|
||||
};
|
||||
|
||||
// The Chat SDK calls adapter.isDM(threadId) synchronously to decide whether
|
||||
// to dispatch to onDirectMessage handlers. The Matrix adapter doesn't expose
|
||||
// this method — it only has an async isDirectRoom(). We add a synchronous
|
||||
// isDM that checks room membership count: 2 members = DM.
|
||||
(adapter as any).isDM = (threadId: string): boolean => {
|
||||
try {
|
||||
const { roomID } = adapter.decodeThreadId(threadId);
|
||||
const client = (adapter as any).client;
|
||||
if (!client) return false;
|
||||
const room = client.getRoom(roomID);
|
||||
if (!room) return false;
|
||||
const members = room.getJoinedMemberCount();
|
||||
return members <= 2;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
adapter.postMessage = async (
|
||||
threadId: string,
|
||||
...args: Parameters<typeof origPostMessage> extends [string, ...infer R] ? R : never
|
||||
) => {
|
||||
const resolvedTid = await resolveThreadId(threadId);
|
||||
return origPostMessage(resolvedTid, ...args);
|
||||
};
|
||||
|
||||
adapter.startTyping = async (threadId: string) => {
|
||||
const resolvedTid = await resolveThreadId(threadId);
|
||||
return origStartTyping(resolvedTid);
|
||||
};
|
||||
|
||||
return adapter;
|
||||
}
|
||||
|
||||
registerChannelAdapter('matrix', {
|
||||
factory: () => {
|
||||
const env = readEnvFile([...ENV_KEYS]);
|
||||
if (!env.MATRIX_BASE_URL) return null;
|
||||
if (!env.MATRIX_ACCESS_TOKEN && !(env.MATRIX_USERNAME && env.MATRIX_PASSWORD)) return null;
|
||||
|
||||
for (const key of ENV_KEYS) {
|
||||
if (env[key]) process.env[key] = env[key];
|
||||
}
|
||||
|
||||
// Default: auto-join room invites so DMs work without manual acceptance
|
||||
if (!process.env.MATRIX_INVITE_AUTOJOIN) {
|
||||
process.env.MATRIX_INVITE_AUTOJOIN = 'true';
|
||||
}
|
||||
|
||||
const matrixAdapter = wrapWithDmResolution(createMatrixAdapter());
|
||||
const bridge = createChatSdkBridge({ adapter: matrixAdapter, concurrency: 'concurrent', supportsThreads: false });
|
||||
|
||||
// Matrix user IDs contain ":" (e.g. "@user:matrix.org") which the shared
|
||||
// permissions module interprets as already-prefixed. Wrap onInbound to
|
||||
// ensure senderId always carries the "matrix:" channel prefix so user
|
||||
// records match between init-first-agent and inbound routing.
|
||||
const origSetup = bridge.setup.bind(bridge);
|
||||
bridge.setup = async (hostConfig) => {
|
||||
const origOnInbound = hostConfig.onInbound.bind(hostConfig);
|
||||
await origSetup({
|
||||
...hostConfig,
|
||||
onInbound: (platformId, threadId, message) => {
|
||||
if (message.content && typeof message.content === 'object') {
|
||||
const content = message.content as Record<string, unknown>;
|
||||
if (typeof content.senderId === 'string' && !content.senderId.startsWith('matrix:')) {
|
||||
content.senderId = `matrix:${content.senderId}`;
|
||||
}
|
||||
}
|
||||
return origOnInbound(platformId, threadId, message);
|
||||
},
|
||||
});
|
||||
|
||||
// Wait for Matrix sync to reach PREPARED state before returning from setup.
|
||||
// Without this, the host's delivery poll and sweep timer start immediately
|
||||
// and can starve the SDK's sync generator microtask queue, blocking
|
||||
// incremental syncs so new inbound messages never get dispatched.
|
||||
await new Promise<void>((resolve) => {
|
||||
const check = setInterval(() => {
|
||||
if ((matrixAdapter as unknown as { liveSyncReady?: boolean }).liveSyncReady) {
|
||||
log.info('Matrix sync ready');
|
||||
clearInterval(check);
|
||||
resolve();
|
||||
}
|
||||
}, 500);
|
||||
setTimeout(() => {
|
||||
clearInterval(check);
|
||||
resolve();
|
||||
}, 30_000);
|
||||
});
|
||||
};
|
||||
|
||||
return bridge;
|
||||
},
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* Integration test for the resend channel's single reach-in: the self-registration
|
||||
* import in the `src/channels/index.ts` barrel. Importing the barrel runs resend.ts's
|
||||
* top-level `registerChannelAdapter('resend', …)`; without the import the channel is
|
||||
* silently absent.
|
||||
*
|
||||
* Behavior, not structural: it imports the real barrel and asserts the registry
|
||||
* actually contains the channel. This reflects what happens at host boot — if the
|
||||
* `import './resend.js';` line is deleted, or the barrel fails to evaluate for any
|
||||
* reason (so the channel genuinely would not register), this goes red. A structural
|
||||
* check of the import line would falsely pass in that second case.
|
||||
*
|
||||
* Importing the barrel is safe: registration is a pure top-level call, and resend.ts
|
||||
* builds the SDK adapter / bridge only inside its factory (invoked at host startup),
|
||||
* never at import. It does require the adapter package (`@resend/chat-sdk-adapter`) to be installed,
|
||||
* which holds in a composed install: the skill's `pnpm install` step runs before this
|
||||
* test — so this test also implicitly guards that dependency (an unmocked import throws
|
||||
* if the package is missing).
|
||||
*
|
||||
* resend is a Chat SDK channel: resend.ts also consumes a load-bearing *core* API —
|
||||
* `createChatSdkBridge(...)` from ./chat-sdk-bridge.js. That core-consumption is a
|
||||
* typed call, so the build/typecheck leg (`pnpm run build`) guards it against upstream
|
||||
* drift, not this test. Every Chat SDK channel follows this same shape.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { getRegisteredChannelNames } from './channel-registry.js';
|
||||
import './index.js'; // the real barrel — triggers every channel's self-registration
|
||||
|
||||
describe('resend channel registration', () => {
|
||||
it('registers resend via the channel barrel', () => {
|
||||
expect(getRegisteredChannelNames()).toContain('resend');
|
||||
});
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
/**
|
||||
* Resend (email) channel adapter (v2) — uses Chat SDK bridge.
|
||||
* Self-registers on import.
|
||||
*/
|
||||
import { createResendAdapter } from '@resend/chat-sdk-adapter';
|
||||
|
||||
import { readEnvFile } from '../env.js';
|
||||
import { createChatSdkBridge } from './chat-sdk-bridge.js';
|
||||
import { registerChannelAdapter } from './channel-registry.js';
|
||||
|
||||
registerChannelAdapter('resend', {
|
||||
factory: () => {
|
||||
const env = readEnvFile(['RESEND_API_KEY', 'RESEND_FROM_ADDRESS', 'RESEND_FROM_NAME', 'RESEND_WEBHOOK_SECRET']);
|
||||
if (!env.RESEND_API_KEY) return null;
|
||||
const resendAdapter = createResendAdapter({
|
||||
apiKey: env.RESEND_API_KEY,
|
||||
fromAddress: env.RESEND_FROM_ADDRESS,
|
||||
fromName: env.RESEND_FROM_NAME,
|
||||
webhookSecret: env.RESEND_WEBHOOK_SECRET,
|
||||
});
|
||||
return createChatSdkBridge({ adapter: resendAdapter, concurrency: 'queue', supportsThreads: false });
|
||||
},
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
/**
|
||||
* Integration test for the signal channel's single reach-in: the self-registration
|
||||
* import in the `src/channels/index.ts` barrel. Importing the barrel runs signal.ts's
|
||||
* top-level `registerChannelAdapter('signal', …)`; without the import the channel is
|
||||
* silently absent.
|
||||
*
|
||||
* Behavior, not structural: it imports the real barrel and asserts the registry
|
||||
* actually contains the channel. This reflects what happens at host boot — if the
|
||||
* `import './signal.js';` line is deleted, or the barrel fails to evaluate for any
|
||||
* reason (so the channel genuinely would not register), this goes red. A structural
|
||||
* check of the import line would falsely pass in that second case.
|
||||
*
|
||||
* signal is a native adapter with no npm dependency (it drives the external signal-cli binary over a local TCP socket); it talks to signal-cli.
|
||||
* Importing the barrel is safe: registration is a pure top-level call and signal.ts
|
||||
* opens connections / spawns subprocesses only inside setup() (run at host startup),
|
||||
* never at import. There is no adapter package to guard here — this test guards the
|
||||
* one barrel reach-in (red if `import './signal.js';` is deleted or the barrel fails
|
||||
* to evaluate).
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { getRegisteredChannelNames } from './channel-registry.js';
|
||||
import './index.js'; // the real barrel — triggers every channel's self-registration
|
||||
|
||||
describe('signal channel registration', () => {
|
||||
it('registers signal via the channel barrel', () => {
|
||||
expect(getRegisteredChannelNames()).toContain('signal');
|
||||
});
|
||||
});
|
||||
@@ -1,961 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
|
||||
// --- Mocks ---
|
||||
|
||||
vi.mock('./channel-registry.js', () => ({ registerChannelAdapter: vi.fn() }));
|
||||
vi.mock('../env.js', () => ({ readEnvFile: vi.fn(() => ({})) }));
|
||||
vi.mock('../log.js', () => ({
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
spawn: vi.fn(),
|
||||
execFileSync: vi.fn(),
|
||||
}));
|
||||
|
||||
// --- TCP socket mock ---
|
||||
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
const tcpRef = vi.hoisted(() => ({
|
||||
rpcResponses: new Map<string, unknown>(),
|
||||
fakeSocket: null as any,
|
||||
}));
|
||||
|
||||
function createFakeSocket(): EventEmitter & {
|
||||
write: ReturnType<typeof vi.fn>;
|
||||
destroy: ReturnType<typeof vi.fn>;
|
||||
destroyed: boolean;
|
||||
} {
|
||||
const sock = new EventEmitter() as any;
|
||||
sock.destroyed = false;
|
||||
sock.destroy = vi.fn(() => {
|
||||
sock.destroyed = true;
|
||||
sock.emit('close');
|
||||
});
|
||||
sock.write = vi.fn((data: string) => {
|
||||
try {
|
||||
const req = JSON.parse(data.trim());
|
||||
const result = tcpRef.rpcResponses.get(req.method) ?? { ok: true };
|
||||
const response = JSON.stringify({ jsonrpc: '2.0', id: req.id, result }) + '\n';
|
||||
setImmediate(() => sock.emit('data', Buffer.from(response)));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
return sock;
|
||||
}
|
||||
|
||||
vi.mock('node:net', () => ({
|
||||
createConnection: vi.fn((_port: number, _host: string, cb?: () => void) => {
|
||||
const sock = createFakeSocket();
|
||||
tcpRef.fakeSocket = sock;
|
||||
if (cb) setImmediate(cb);
|
||||
return sock;
|
||||
}),
|
||||
}));
|
||||
|
||||
import type { ChannelSetup } from './adapter.js';
|
||||
import { createSignalAdapter } from './signal.js';
|
||||
|
||||
// --- Test helpers ---
|
||||
|
||||
function createMockSetup() {
|
||||
return {
|
||||
onInbound: vi.fn() as unknown as ChannelSetup['onInbound'] & ReturnType<typeof vi.fn>,
|
||||
onInboundEvent: vi.fn() as unknown as ChannelSetup['onInboundEvent'] & ReturnType<typeof vi.fn>,
|
||||
onMetadata: vi.fn() as unknown as ChannelSetup['onMetadata'] & ReturnType<typeof vi.fn>,
|
||||
onAction: vi.fn() as unknown as ChannelSetup['onAction'] & ReturnType<typeof vi.fn>,
|
||||
};
|
||||
}
|
||||
|
||||
function createAdapter() {
|
||||
return createSignalAdapter({
|
||||
cliPath: 'signal-cli',
|
||||
account: '+15551234567',
|
||||
tcpHost: '127.0.0.1',
|
||||
tcpPort: 7583,
|
||||
manageDaemon: false,
|
||||
signalDataDir: '/tmp/signal-cli-test-data',
|
||||
});
|
||||
}
|
||||
|
||||
function getRpcCalls(): Array<{
|
||||
method: string;
|
||||
params: Record<string, unknown>;
|
||||
id: string;
|
||||
}> {
|
||||
if (!tcpRef.fakeSocket) return [];
|
||||
return tcpRef.fakeSocket.write.mock.calls
|
||||
.map((c: any[]) => {
|
||||
try {
|
||||
return JSON.parse(c[0].trim());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function getRpcCallsForMethod(method: string) {
|
||||
return getRpcCalls().filter((c) => c.method === method);
|
||||
}
|
||||
|
||||
function pushEvent(envelope: Record<string, unknown>) {
|
||||
if (!tcpRef.fakeSocket) throw new Error('TCP socket not connected');
|
||||
const notification =
|
||||
JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
method: 'receive',
|
||||
params: { envelope },
|
||||
}) + '\n';
|
||||
tcpRef.fakeSocket.emit('data', Buffer.from(notification));
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
describe('SignalAdapter', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
tcpRef.rpcResponses.clear();
|
||||
tcpRef.fakeSocket = null;
|
||||
tcpRef.rpcResponses.set('send', { timestamp: 1234567890 });
|
||||
tcpRef.rpcResponses.set('sendTyping', {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
tcpRef.fakeSocket?.destroy();
|
||||
} catch {
|
||||
// already closed
|
||||
}
|
||||
});
|
||||
|
||||
// --- Connection lifecycle ---
|
||||
|
||||
describe('connection lifecycle', () => {
|
||||
it('connects when daemon is reachable', async () => {
|
||||
const adapter = createAdapter();
|
||||
const cfg = createMockSetup();
|
||||
await adapter.setup(cfg);
|
||||
|
||||
expect(adapter.isConnected()).toBe(true);
|
||||
expect(tcpRef.fakeSocket).not.toBeNull();
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
|
||||
it('isConnected() returns false before setup', () => {
|
||||
const adapter = createAdapter();
|
||||
expect(adapter.isConnected()).toBe(false);
|
||||
});
|
||||
|
||||
it('disconnects cleanly', async () => {
|
||||
const adapter = createAdapter();
|
||||
await adapter.setup(createMockSetup());
|
||||
expect(adapter.isConnected()).toBe(true);
|
||||
|
||||
await adapter.teardown();
|
||||
expect(adapter.isConnected()).toBe(false);
|
||||
});
|
||||
|
||||
it('throws NetworkError if daemon is unreachable', async () => {
|
||||
const { createConnection } = await import('node:net');
|
||||
vi.mocked(createConnection).mockImplementationOnce((...args: any[]) => {
|
||||
const sock = createFakeSocket();
|
||||
setImmediate(() => sock.emit('error', new Error('Connection refused')));
|
||||
return sock as any;
|
||||
});
|
||||
|
||||
const adapter = createAdapter();
|
||||
await expect(adapter.setup(createMockSetup())).rejects.toThrow(/not reachable/);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Inbound message handling ---
|
||||
|
||||
describe('inbound message handling', () => {
|
||||
it('delivers DM via onInbound', async () => {
|
||||
const adapter = createAdapter();
|
||||
const cfg = createMockSetup();
|
||||
await adapter.setup(cfg);
|
||||
|
||||
pushEvent({
|
||||
sourceNumber: '+15555550123',
|
||||
sourceName: 'Alice',
|
||||
dataMessage: {
|
||||
timestamp: 1700000000000,
|
||||
message: 'Hello from Signal',
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
expect(cfg.onMetadata).toHaveBeenCalledWith('+15555550123', 'Alice', false);
|
||||
expect(cfg.onInbound).toHaveBeenCalledWith(
|
||||
'+15555550123',
|
||||
null,
|
||||
expect.objectContaining({
|
||||
id: '1700000000000',
|
||||
kind: 'chat',
|
||||
content: expect.objectContaining({
|
||||
text: 'Hello from Signal',
|
||||
sender: '+15555550123',
|
||||
senderName: 'Alice',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
|
||||
it('delivers group message with group platformId', async () => {
|
||||
const adapter = createAdapter();
|
||||
const cfg = createMockSetup();
|
||||
await adapter.setup(cfg);
|
||||
|
||||
pushEvent({
|
||||
sourceNumber: '+15555550999',
|
||||
sourceName: 'Bob',
|
||||
dataMessage: {
|
||||
timestamp: 1700000000000,
|
||||
message: 'Group hello',
|
||||
groupInfo: { groupId: 'abc123', groupName: 'Family' },
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
expect(cfg.onMetadata).toHaveBeenCalledWith('group:abc123', 'Family', true);
|
||||
expect(cfg.onInbound).toHaveBeenCalledWith(
|
||||
'group:abc123',
|
||||
null,
|
||||
expect.objectContaining({
|
||||
content: expect.objectContaining({
|
||||
text: 'Group hello',
|
||||
sender: '+15555550999',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
|
||||
it('skips sync messages (own outbound)', async () => {
|
||||
const adapter = createAdapter();
|
||||
const cfg = createMockSetup();
|
||||
await adapter.setup(cfg);
|
||||
|
||||
pushEvent({
|
||||
sourceNumber: '+15551234567',
|
||||
syncMessage: {
|
||||
sentMessage: {
|
||||
timestamp: 1700000000000,
|
||||
message: 'My own message',
|
||||
destination: '+15555550123',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(cfg.onInbound).not.toHaveBeenCalled();
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
|
||||
it('processes Note to Self sync messages as inbound', async () => {
|
||||
const adapter = createAdapter();
|
||||
const cfg = createMockSetup();
|
||||
await adapter.setup(cfg);
|
||||
|
||||
pushEvent({
|
||||
sourceNumber: '+15551234567',
|
||||
syncMessage: {
|
||||
sentMessage: {
|
||||
timestamp: 1700000000000,
|
||||
message: 'Hello Bee',
|
||||
destinationNumber: '+15551234567',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(cfg.onInbound).toHaveBeenCalledWith(
|
||||
'+15551234567',
|
||||
null,
|
||||
expect.objectContaining({
|
||||
content: expect.objectContaining({
|
||||
text: 'Hello Bee',
|
||||
senderName: 'Me',
|
||||
isFromMe: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
|
||||
it('skips empty messages', async () => {
|
||||
const adapter = createAdapter();
|
||||
const cfg = createMockSetup();
|
||||
await adapter.setup(cfg);
|
||||
|
||||
pushEvent({
|
||||
sourceNumber: '+15555550123',
|
||||
dataMessage: { timestamp: 1700000000000, message: ' ' },
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(cfg.onInbound).not.toHaveBeenCalled();
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
|
||||
it('skips echoed outbound messages', async () => {
|
||||
const adapter = createAdapter();
|
||||
const cfg = createMockSetup();
|
||||
await adapter.setup(cfg);
|
||||
|
||||
await adapter.deliver('+15555550123', null, {
|
||||
kind: 'text',
|
||||
content: { text: 'Echo test' },
|
||||
});
|
||||
|
||||
pushEvent({
|
||||
sourceNumber: '+15555550123',
|
||||
dataMessage: { timestamp: 1700000000000, message: 'Echo test' },
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(cfg.onInbound).not.toHaveBeenCalled();
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
|
||||
it('forwards image attachments as [Image: <path>] plus structured attachments array', async () => {
|
||||
const adapter = createAdapter();
|
||||
const cfg = createMockSetup();
|
||||
await adapter.setup(cfg);
|
||||
|
||||
pushEvent({
|
||||
sourceNumber: '+15555550123',
|
||||
sourceName: 'Alice',
|
||||
dataMessage: {
|
||||
timestamp: 1700000000000,
|
||||
attachments: [{ id: 'att123abc', contentType: 'image/jpeg', size: 50000 }],
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(cfg.onInbound).toHaveBeenCalledWith(
|
||||
'+15555550123',
|
||||
null,
|
||||
expect.objectContaining({
|
||||
content: expect.objectContaining({
|
||||
text: expect.stringMatching(/^\[Image: .+att123abc\]$/),
|
||||
attachments: [expect.objectContaining({ contentType: 'image/jpeg' })],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
});
|
||||
|
||||
// --- groupV2 ---
|
||||
|
||||
describe('group routing', () => {
|
||||
it('routes to groupV2.id when present, falling back to legacy groupInfo.groupId', async () => {
|
||||
const adapter = createAdapter();
|
||||
const cfg = createMockSetup();
|
||||
await adapter.setup(cfg);
|
||||
|
||||
pushEvent({
|
||||
sourceNumber: '+15555550123',
|
||||
sourceName: 'Alice',
|
||||
dataMessage: {
|
||||
timestamp: 1700000000000,
|
||||
message: 'hello v2',
|
||||
groupV2: { id: 'v2group=' },
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(cfg.onInbound).toHaveBeenCalledWith('group:v2group=', null, expect.anything());
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
});
|
||||
|
||||
// --- mention resolution ---
|
||||
|
||||
describe('mention resolution', () => {
|
||||
it('replaces inline mention placeholders with display names', async () => {
|
||||
const adapter = createAdapter();
|
||||
const cfg = createMockSetup();
|
||||
await adapter.setup(cfg);
|
||||
|
||||
pushEvent({
|
||||
sourceNumber: '+15555550123',
|
||||
sourceName: 'Alice',
|
||||
dataMessage: {
|
||||
timestamp: 1700000000000,
|
||||
message: 'hey  are you here?',
|
||||
mentions: [{ start: 4, length: 1, name: 'Bob', uuid: 'bob-uuid' }],
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(cfg.onInbound).toHaveBeenCalledWith(
|
||||
'+15555550123',
|
||||
null,
|
||||
expect.objectContaining({
|
||||
content: expect.objectContaining({ text: 'hey @Bob are you here?' }),
|
||||
}),
|
||||
);
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
});
|
||||
|
||||
// --- Quote context ---
|
||||
|
||||
describe('quote context', () => {
|
||||
it('emits a nested replyTo object matching the formatter contract', async () => {
|
||||
const adapter = createAdapter();
|
||||
const cfg = createMockSetup();
|
||||
await adapter.setup(cfg);
|
||||
|
||||
pushEvent({
|
||||
sourceNumber: '+15555550123',
|
||||
sourceName: 'Alice',
|
||||
dataMessage: {
|
||||
timestamp: 1700000000000,
|
||||
message: 'I disagree',
|
||||
quote: {
|
||||
id: 1699999999000,
|
||||
authorNumber: '+15555550888',
|
||||
authorName: 'Pineapple Pete',
|
||||
text: 'Pineapple belongs on pizza',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(cfg.onInbound).toHaveBeenCalledWith(
|
||||
'+15555550123',
|
||||
null,
|
||||
expect.objectContaining({
|
||||
content: expect.objectContaining({
|
||||
text: 'I disagree',
|
||||
replyTo: {
|
||||
id: '1699999999000',
|
||||
sender: 'Pineapple Pete',
|
||||
text: 'Pineapple belongs on pizza',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
});
|
||||
|
||||
// --- deliver ---
|
||||
|
||||
describe('deliver', () => {
|
||||
it('sends DM via TCP RPC', async () => {
|
||||
const adapter = createAdapter();
|
||||
await adapter.setup(createMockSetup());
|
||||
|
||||
await adapter.deliver('+15555550123', null, {
|
||||
kind: 'text',
|
||||
content: { text: 'Hello' },
|
||||
});
|
||||
|
||||
const sendCalls = getRpcCallsForMethod('send');
|
||||
expect(sendCalls.length).toBeGreaterThan(0);
|
||||
|
||||
const last = sendCalls[sendCalls.length - 1];
|
||||
expect(last.params).toEqual(
|
||||
expect.objectContaining({
|
||||
recipient: ['+15555550123'],
|
||||
message: 'Hello',
|
||||
account: '+15551234567',
|
||||
}),
|
||||
);
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
|
||||
it('sends group message via groupId', async () => {
|
||||
const adapter = createAdapter();
|
||||
await adapter.setup(createMockSetup());
|
||||
|
||||
await adapter.deliver('group:abc123', null, {
|
||||
kind: 'text',
|
||||
content: { text: 'Group msg' },
|
||||
});
|
||||
|
||||
const sendCalls = getRpcCallsForMethod('send');
|
||||
const last = sendCalls[sendCalls.length - 1];
|
||||
expect(last.params).toEqual(
|
||||
expect.objectContaining({
|
||||
groupId: 'abc123',
|
||||
message: 'Group msg',
|
||||
}),
|
||||
);
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
|
||||
it('chunks long messages', async () => {
|
||||
const adapter = createAdapter();
|
||||
await adapter.setup(createMockSetup());
|
||||
|
||||
const longText = 'x'.repeat(5000);
|
||||
await adapter.deliver('+15555550123', null, {
|
||||
kind: 'text',
|
||||
content: { text: longText },
|
||||
});
|
||||
|
||||
const sendCalls = getRpcCallsForMethod('send');
|
||||
expect(sendCalls.length).toBeGreaterThan(1);
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
|
||||
it('extracts text from string content', async () => {
|
||||
const adapter = createAdapter();
|
||||
await adapter.setup(createMockSetup());
|
||||
|
||||
await adapter.deliver('+15555550123', null, {
|
||||
kind: 'text',
|
||||
content: 'Plain string content',
|
||||
});
|
||||
|
||||
const sendCalls = getRpcCallsForMethod('send');
|
||||
expect(sendCalls.length).toBeGreaterThan(0);
|
||||
const last = sendCalls[sendCalls.length - 1];
|
||||
expect(last.params.message).toBe('Plain string content');
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
});
|
||||
|
||||
// --- Outbound attachments ---
|
||||
|
||||
describe('deliver — attachments', () => {
|
||||
// Real fs writes happen in tmpdir(); confirm the bytes round-trip and
|
||||
// are cleaned up after deliver returns.
|
||||
it('sends a single attachment via attachments[] param', async () => {
|
||||
const fs = await import('node:fs');
|
||||
const adapter = createAdapter();
|
||||
await adapter.setup(createMockSetup());
|
||||
tcpRef.fakeSocket.write.mockClear();
|
||||
|
||||
await adapter.deliver('+15555550123', null, {
|
||||
kind: 'file',
|
||||
content: {},
|
||||
files: [{ filename: 'report.md', data: Buffer.from('# Report\n\nbody') }],
|
||||
});
|
||||
|
||||
const sendCalls = getRpcCallsForMethod('send');
|
||||
expect(sendCalls.length).toBe(1);
|
||||
const params = sendCalls[0].params as Record<string, unknown>;
|
||||
expect(params.recipient).toEqual(['+15555550123']);
|
||||
expect(params.account).toBe('+15551234567');
|
||||
expect(params.message).toBeUndefined();
|
||||
const paths = params.attachments as string[];
|
||||
expect(paths).toHaveLength(1);
|
||||
expect(paths[0]).toMatch(/signal-out-\d+-[a-z0-9]+-report\.md$/);
|
||||
// Temp file should no longer exist — finally{} cleanup ran
|
||||
expect(fs.existsSync(paths[0])).toBe(false);
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
|
||||
it('sends text first, then attachment, when both are present', async () => {
|
||||
const adapter = createAdapter();
|
||||
await adapter.setup(createMockSetup());
|
||||
tcpRef.fakeSocket.write.mockClear();
|
||||
|
||||
await adapter.deliver('+15555550123', null, {
|
||||
kind: 'file',
|
||||
content: { text: 'Here is the digest' },
|
||||
files: [{ filename: 'digest.md', data: Buffer.from('content') }],
|
||||
});
|
||||
|
||||
const sendCalls = getRpcCallsForMethod('send');
|
||||
expect(sendCalls).toHaveLength(2);
|
||||
// First call: text message
|
||||
expect(sendCalls[0].params).toEqual(
|
||||
expect.objectContaining({ message: 'Here is the digest', recipient: ['+15555550123'] }),
|
||||
);
|
||||
expect((sendCalls[0].params as Record<string, unknown>).attachments).toBeUndefined();
|
||||
// Second call: attachment, no message
|
||||
expect(sendCalls[1].params).toEqual(
|
||||
expect.objectContaining({ recipient: ['+15555550123'] }),
|
||||
);
|
||||
const attachments = (sendCalls[1].params as Record<string, unknown>).attachments as string[];
|
||||
expect(attachments).toHaveLength(1);
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
|
||||
it('sends multiple attachments in a single send call', async () => {
|
||||
const adapter = createAdapter();
|
||||
await adapter.setup(createMockSetup());
|
||||
tcpRef.fakeSocket.write.mockClear();
|
||||
|
||||
await adapter.deliver('+15555550123', null, {
|
||||
kind: 'file',
|
||||
content: {},
|
||||
files: [
|
||||
{ filename: 'a.txt', data: Buffer.from('a') },
|
||||
{ filename: 'b.png', data: Buffer.from([0x89, 0x50, 0x4e, 0x47]) },
|
||||
],
|
||||
});
|
||||
|
||||
const sendCalls = getRpcCallsForMethod('send');
|
||||
expect(sendCalls).toHaveLength(1);
|
||||
const attachments = (sendCalls[0].params as Record<string, unknown>).attachments as string[];
|
||||
expect(attachments).toHaveLength(2);
|
||||
expect(attachments[0]).toMatch(/-a\.txt$/);
|
||||
expect(attachments[1]).toMatch(/-b\.png$/);
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
|
||||
it('uses groupId for group destinations', async () => {
|
||||
const adapter = createAdapter();
|
||||
await adapter.setup(createMockSetup());
|
||||
tcpRef.fakeSocket.write.mockClear();
|
||||
|
||||
await adapter.deliver('group:abc123', null, {
|
||||
kind: 'file',
|
||||
content: {},
|
||||
files: [{ filename: 'pic.jpg', data: Buffer.from('jpg') }],
|
||||
});
|
||||
|
||||
const sendCalls = getRpcCallsForMethod('send');
|
||||
expect(sendCalls).toHaveLength(1);
|
||||
const params = sendCalls[0].params as Record<string, unknown>;
|
||||
expect(params.groupId).toBe('abc123');
|
||||
expect(params.recipient).toBeUndefined();
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
|
||||
/**
|
||||
* Defensive test: `OutboundFile.filename` is operator-supplied data, so
|
||||
* the implementation must not let a filename containing path separators
|
||||
* escape the temp directory. We feed an attempt-to-traverse filename and
|
||||
* assert the resolved path stays strictly inside `tmpdir()`.
|
||||
*/
|
||||
it('keeps temp paths inside tmpdir even when filename contains path separators', async () => {
|
||||
const path = await import('node:path');
|
||||
const os = await import('node:os');
|
||||
const adapter = createAdapter();
|
||||
await adapter.setup(createMockSetup());
|
||||
tcpRef.fakeSocket.write.mockClear();
|
||||
|
||||
await adapter.deliver('+15555550123', null, {
|
||||
kind: 'file',
|
||||
content: {},
|
||||
files: [{ filename: '../sneaky.txt', data: Buffer.from('x') }],
|
||||
});
|
||||
|
||||
const sendCalls = getRpcCallsForMethod('send');
|
||||
const paths = (sendCalls[0].params as Record<string, unknown>).attachments as string[];
|
||||
const resolvedTmp = path.resolve(os.tmpdir());
|
||||
const resolvedResult = path.resolve(paths[0]);
|
||||
// path.resolve normalizes away any "../"; if sanitization failed, the
|
||||
// result would resolve to tmpdir's parent.
|
||||
expect(resolvedResult.startsWith(resolvedTmp + path.sep)).toBe(true);
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
});
|
||||
|
||||
// --- Text styles ---
|
||||
|
||||
describe('text styles', () => {
|
||||
it('sends bold text with textStyle parameter', async () => {
|
||||
const adapter = createAdapter();
|
||||
await adapter.setup(createMockSetup());
|
||||
tcpRef.fakeSocket.write.mockClear();
|
||||
|
||||
await adapter.deliver('+15555550123', null, {
|
||||
kind: 'text',
|
||||
content: { text: 'Hello **world**' },
|
||||
});
|
||||
|
||||
const sendCalls = getRpcCallsForMethod('send');
|
||||
expect(sendCalls.length).toBeGreaterThan(0);
|
||||
const last = sendCalls[sendCalls.length - 1];
|
||||
expect(last.params.message).toBe('Hello world');
|
||||
expect(last.params.textStyle).toEqual(['6:5:BOLD']);
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
|
||||
it('sends inline code with MONOSPACE style', async () => {
|
||||
const adapter = createAdapter();
|
||||
await adapter.setup(createMockSetup());
|
||||
tcpRef.fakeSocket.write.mockClear();
|
||||
|
||||
await adapter.deliver('+15555550123', null, {
|
||||
kind: 'text',
|
||||
content: { text: 'Run `npm test` now' },
|
||||
});
|
||||
|
||||
const sendCalls = getRpcCallsForMethod('send');
|
||||
const last = sendCalls[sendCalls.length - 1];
|
||||
expect(last.params.message).toBe('Run npm test now');
|
||||
expect(last.params.textStyle).toEqual(['4:8:MONOSPACE']);
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
|
||||
it('sends plain text without textStyle', async () => {
|
||||
const adapter = createAdapter();
|
||||
await adapter.setup(createMockSetup());
|
||||
tcpRef.fakeSocket.write.mockClear();
|
||||
|
||||
await adapter.deliver('+15555550123', null, {
|
||||
kind: 'text',
|
||||
content: { text: 'No formatting here' },
|
||||
});
|
||||
|
||||
const sendCalls = getRpcCallsForMethod('send');
|
||||
const last = sendCalls[sendCalls.length - 1];
|
||||
expect(last.params.message).toBe('No formatting here');
|
||||
expect(last.params.textStyle).toBeUndefined();
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
|
||||
it('falls back to original markup when textStyle is rejected', async () => {
|
||||
const adapter = createAdapter();
|
||||
await adapter.setup(createMockSetup());
|
||||
|
||||
let sendCount = 0;
|
||||
tcpRef.fakeSocket.write.mockImplementation((data: string) => {
|
||||
try {
|
||||
const req = JSON.parse(data.trim());
|
||||
if (req.method === 'send') {
|
||||
sendCount++;
|
||||
if (sendCount === 1) {
|
||||
const response =
|
||||
JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: req.id,
|
||||
error: { message: 'Unknown parameter: textStyle' },
|
||||
}) + '\n';
|
||||
setImmediate(() => tcpRef.fakeSocket.emit('data', Buffer.from(response)));
|
||||
return;
|
||||
}
|
||||
}
|
||||
const response =
|
||||
JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: req.id,
|
||||
result: { ok: true },
|
||||
}) + '\n';
|
||||
setImmediate(() => tcpRef.fakeSocket.emit('data', Buffer.from(response)));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
|
||||
await adapter.deliver('+15555550123', null, {
|
||||
kind: 'text',
|
||||
content: { text: 'Hello **world**' },
|
||||
});
|
||||
|
||||
const sendCalls = getRpcCallsForMethod('send');
|
||||
expect(sendCalls.length).toBe(2);
|
||||
expect(sendCalls[1].params.message).toBe('Hello **world**');
|
||||
expect(sendCalls[1].params.textStyle).toBeUndefined();
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
|
||||
it('tracks nested styles with correct offsets', async () => {
|
||||
const adapter = createAdapter();
|
||||
await adapter.setup(createMockSetup());
|
||||
tcpRef.fakeSocket.write.mockClear();
|
||||
|
||||
await adapter.deliver('+15555550123', null, {
|
||||
kind: 'text',
|
||||
content: { text: '**bold with `code` inside**' },
|
||||
});
|
||||
|
||||
const sendCalls = getRpcCallsForMethod('send');
|
||||
const last = sendCalls[sendCalls.length - 1];
|
||||
expect(last.params.message).toBe('bold with code inside');
|
||||
// BOLD covers the full inner span, MONOSPACE points at "code" in the
|
||||
// final plain text (offset 10, length 4) — not the intermediate text.
|
||||
const styles = (last.params.textStyle as string[]).slice().sort();
|
||||
expect(styles).toEqual(['0:21:BOLD', '10:4:MONOSPACE']);
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
|
||||
it('maps *single-asterisk* to ITALIC', async () => {
|
||||
const adapter = createAdapter();
|
||||
await adapter.setup(createMockSetup());
|
||||
tcpRef.fakeSocket.write.mockClear();
|
||||
|
||||
await adapter.deliver('+15555550123', null, {
|
||||
kind: 'text',
|
||||
content: { text: 'Hello *world*' },
|
||||
});
|
||||
|
||||
const sendCalls = getRpcCallsForMethod('send');
|
||||
const last = sendCalls[sendCalls.length - 1];
|
||||
expect(last.params.message).toBe('Hello world');
|
||||
expect(last.params.textStyle).toEqual(['6:5:ITALIC']);
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
|
||||
it('maps _underscore_ to ITALIC', async () => {
|
||||
const adapter = createAdapter();
|
||||
await adapter.setup(createMockSetup());
|
||||
tcpRef.fakeSocket.write.mockClear();
|
||||
|
||||
await adapter.deliver('+15555550123', null, {
|
||||
kind: 'text',
|
||||
content: { text: 'hey _there_' },
|
||||
});
|
||||
|
||||
const sendCalls = getRpcCallsForMethod('send');
|
||||
const last = sendCalls[sendCalls.length - 1];
|
||||
expect(last.params.message).toBe('hey there');
|
||||
expect(last.params.textStyle).toEqual(['4:5:ITALIC']);
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
});
|
||||
|
||||
// --- Echo cache ---
|
||||
|
||||
describe('echo cache', () => {
|
||||
it('does not drop same-text inbound from a different recipient', async () => {
|
||||
// Bot sends "Hello" to Alice. Immediately after, Bob sends "Hello" from
|
||||
// a different DM. Bob's message must still route — the earlier echo key
|
||||
// was scoped to Alice.
|
||||
const adapter = createAdapter();
|
||||
const cfg = createMockSetup();
|
||||
await adapter.setup(cfg);
|
||||
|
||||
await adapter.deliver('+15555550123', null, {
|
||||
kind: 'text',
|
||||
content: { text: 'Hello' },
|
||||
});
|
||||
|
||||
pushEvent({
|
||||
sourceNumber: '+15555550999',
|
||||
sourceName: 'Bob',
|
||||
dataMessage: { timestamp: 1700000000000, message: 'Hello' },
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(cfg.onInbound).toHaveBeenCalledWith(
|
||||
'+15555550999',
|
||||
null,
|
||||
expect.objectContaining({
|
||||
content: expect.objectContaining({ text: 'Hello', sender: '+15555550999' }),
|
||||
}),
|
||||
);
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
|
||||
it('still skips echo on the same recipient', async () => {
|
||||
const adapter = createAdapter();
|
||||
const cfg = createMockSetup();
|
||||
await adapter.setup(cfg);
|
||||
|
||||
await adapter.deliver('+15555550123', null, {
|
||||
kind: 'text',
|
||||
content: { text: 'Echo test' },
|
||||
});
|
||||
|
||||
pushEvent({
|
||||
sourceNumber: '+15555550123',
|
||||
dataMessage: { timestamp: 1700000000000, message: 'Echo test' },
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(cfg.onInbound).not.toHaveBeenCalled();
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
});
|
||||
|
||||
// --- Connection drop ---
|
||||
|
||||
describe('connection drop', () => {
|
||||
it('flips isConnected to false when the socket closes', async () => {
|
||||
const adapter = createAdapter();
|
||||
await adapter.setup(createMockSetup());
|
||||
expect(adapter.isConnected()).toBe(true);
|
||||
|
||||
// Simulate the daemon dropping the TCP connection.
|
||||
tcpRef.fakeSocket.destroy();
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
expect(adapter.isConnected()).toBe(false);
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
});
|
||||
|
||||
// --- setTyping ---
|
||||
|
||||
describe('setTyping', () => {
|
||||
it('sends typing indicator for DMs', async () => {
|
||||
const adapter = createAdapter();
|
||||
await adapter.setup(createMockSetup());
|
||||
|
||||
await adapter.setTyping!('+15555550123', null);
|
||||
|
||||
expect(getRpcCallsForMethod('sendTyping')).toHaveLength(1);
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
|
||||
it('skips typing for groups', async () => {
|
||||
const adapter = createAdapter();
|
||||
await adapter.setup(createMockSetup());
|
||||
|
||||
await adapter.setTyping!('group:abc123', null);
|
||||
|
||||
expect(getRpcCallsForMethod('sendTyping')).toHaveLength(0);
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
});
|
||||
|
||||
// --- Adapter properties ---
|
||||
|
||||
describe('adapter properties', () => {
|
||||
it('has channelType "signal"', () => {
|
||||
const adapter = createAdapter();
|
||||
expect(adapter.channelType).toBe('signal');
|
||||
});
|
||||
|
||||
it('does not support threads', () => {
|
||||
const adapter = createAdapter();
|
||||
expect(adapter.supportsThreads).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,983 +0,0 @@
|
||||
/**
|
||||
* Signal channel adapter for NanoClaw v2.
|
||||
*
|
||||
* Uses signal-cli's TCP JSON-RPC daemon for bidirectional messaging.
|
||||
* Requires signal-cli (https://github.com/AsamK/signal-cli) installed
|
||||
* and a linked account.
|
||||
*
|
||||
* Ported from v1 — see v1 source for commit history.
|
||||
*/
|
||||
import { execFileSync, execSync, spawn } from 'node:child_process';
|
||||
import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
import { createConnection, type Socket } from 'node:net';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import type { ChannelAdapter, ChannelSetup, InboundMessage, OutboundMessage } from './adapter.js';
|
||||
import { registerChannelAdapter } from './channel-registry.js';
|
||||
import { readEnvFile } from '../env.js';
|
||||
import { log } from '../log.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Signal CLI daemon management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface DaemonHandle {
|
||||
stop: () => void;
|
||||
exited: Promise<void>;
|
||||
isExited: () => boolean;
|
||||
}
|
||||
|
||||
function spawnSignalDaemon(cliPath: string, account: string, host: string, port: number): DaemonHandle {
|
||||
const args: string[] = [];
|
||||
if (account) args.push('-a', account);
|
||||
args.push('daemon', '--tcp', `${host}:${port}`, '--no-receive-stdout');
|
||||
args.push('--receive-mode', 'on-start');
|
||||
|
||||
const child = spawn(cliPath, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let exited = false;
|
||||
|
||||
const exitedPromise = new Promise<void>((resolve) => {
|
||||
child.once('exit', (code, signal) => {
|
||||
exited = true;
|
||||
if (code !== 0 && code !== null) {
|
||||
const reason = signal ? `signal ${signal}` : `code ${code}`;
|
||||
log.error('signal-cli daemon exited', { reason });
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
child.on('error', (err) => {
|
||||
exited = true;
|
||||
log.error('signal-cli spawn error', { err });
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
child.stdout?.on('data', (data: Buffer) => {
|
||||
for (const line of data.toString().split(/\r?\n/)) {
|
||||
if (line.trim()) log.debug('signal-cli stdout', { line: line.trim() });
|
||||
}
|
||||
});
|
||||
child.stderr?.on('data', (data: Buffer) => {
|
||||
for (const line of data.toString().split(/\r?\n/)) {
|
||||
if (!line.trim()) continue;
|
||||
if (/\b(ERROR|WARN|FAILED|SEVERE)\b/i.test(line)) {
|
||||
log.warn('signal-cli stderr', { line: line.trim() });
|
||||
} else {
|
||||
log.debug('signal-cli stderr', { line: line.trim() });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
stop: () => {
|
||||
if (!child.killed && !exited) child.kill('SIGTERM');
|
||||
},
|
||||
exited: exitedPromise,
|
||||
isExited: () => exited,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TCP JSON-RPC client for signal-cli daemon (--tcp mode)
|
||||
//
|
||||
// signal-cli 0.14.x --tcp exposes a newline-delimited JSON-RPC socket.
|
||||
// Requests are sent as JSON + newline; responses and push notifications
|
||||
// (inbound messages) arrive the same way.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const RPC_TIMEOUT_MS = 15_000;
|
||||
|
||||
class SignalTcpClient {
|
||||
private socket: Socket | null = null;
|
||||
private buffer = '';
|
||||
private pending = new Map<
|
||||
string,
|
||||
{
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (err: Error) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
>();
|
||||
private onNotification: ((method: string, params: unknown) => void) | null = null;
|
||||
private onClose: (() => void) | null = null;
|
||||
|
||||
constructor(
|
||||
private host: string,
|
||||
private port: number,
|
||||
) {}
|
||||
|
||||
connect(handlers?: {
|
||||
onNotification?: (method: string, params: unknown) => void;
|
||||
onClose?: () => void;
|
||||
}): Promise<void> {
|
||||
this.onNotification = handlers?.onNotification ?? null;
|
||||
this.onClose = handlers?.onClose ?? null;
|
||||
return new Promise((resolve, reject) => {
|
||||
const sock = createConnection(this.port, this.host, () => {
|
||||
this.socket = sock;
|
||||
resolve();
|
||||
});
|
||||
sock.on('error', (err) => {
|
||||
if (!this.socket) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
log.warn('Signal TCP socket error', { err });
|
||||
});
|
||||
sock.on('data', (chunk) => this.onData(chunk));
|
||||
sock.on('close', () => {
|
||||
const wasConnected = this.socket !== null;
|
||||
this.socket = null;
|
||||
for (const [, p] of this.pending) {
|
||||
clearTimeout(p.timer);
|
||||
p.reject(new Error('Signal TCP connection closed'));
|
||||
}
|
||||
this.pending.clear();
|
||||
if (wasConnected) this.onClose?.();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async rpc<T = unknown>(method: string, params?: Record<string, unknown>): Promise<T> {
|
||||
if (!this.socket) throw new Error('Signal TCP not connected');
|
||||
const id = Math.random().toString(36).slice(2);
|
||||
const msg = JSON.stringify({ jsonrpc: '2.0', method, params, id }) + '\n';
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(id);
|
||||
reject(new Error(`Signal RPC timeout: ${method}`));
|
||||
}, RPC_TIMEOUT_MS);
|
||||
|
||||
this.pending.set(id, {
|
||||
resolve: resolve as (v: unknown) => void,
|
||||
reject,
|
||||
timer,
|
||||
});
|
||||
this.socket!.write(msg);
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
this.socket?.destroy();
|
||||
this.socket = null;
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.socket !== null && !this.socket.destroyed;
|
||||
}
|
||||
|
||||
private onData(chunk: Buffer) {
|
||||
this.buffer += chunk.toString();
|
||||
let newlineIdx = this.buffer.indexOf('\n');
|
||||
while (newlineIdx !== -1) {
|
||||
const line = this.buffer.slice(0, newlineIdx).trim();
|
||||
this.buffer = this.buffer.slice(newlineIdx + 1);
|
||||
if (line) this.handleLine(line);
|
||||
newlineIdx = this.buffer.indexOf('\n');
|
||||
}
|
||||
}
|
||||
|
||||
private handleLine(line: string) {
|
||||
let parsed: any;
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
} catch {
|
||||
log.debug('Signal TCP: unparseable line', { line: line.slice(0, 200) });
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.id && this.pending.has(parsed.id)) {
|
||||
const p = this.pending.get(parsed.id)!;
|
||||
this.pending.delete(parsed.id);
|
||||
clearTimeout(p.timer);
|
||||
if (parsed.error) {
|
||||
p.reject(new Error(parsed.error.message ?? 'Signal RPC error'));
|
||||
} else {
|
||||
p.resolve(parsed.result);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.method && this.onNotification) {
|
||||
this.onNotification(parsed.method, parsed.params);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function signalTcpCheck(host: string, port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (result: boolean) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
sock.destroy();
|
||||
resolve(result);
|
||||
};
|
||||
const sock = createConnection(port, host, () => finish(true));
|
||||
sock.on('error', () => finish(false));
|
||||
const timer = setTimeout(() => finish(false), 5000);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Echo cache
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ECHO_TTL_MS = 10_000;
|
||||
|
||||
/**
|
||||
* Per-recipient dedup for messages we sent ourselves.
|
||||
*
|
||||
* signal-cli echoes our own outbound back via syncMessage (and, for Note to
|
||||
* Self, via sentMessage-with-self-destination). Without dedup, the agent sees
|
||||
* its own replies as new inbound and loops. We remember `(platformId, text)`
|
||||
* briefly after every send, and drop the first match within TTL.
|
||||
*
|
||||
* Keying on text alone is not enough: if we send "hi" to Alice and Bob then
|
||||
* sends "hi" from a different chat, Bob's real message gets silently dropped.
|
||||
*/
|
||||
class EchoCache {
|
||||
private entries = new Map<string, number>();
|
||||
|
||||
private keyFor(platformId: string, text: string): string {
|
||||
return `${platformId}\x00${text.trim()}`;
|
||||
}
|
||||
|
||||
remember(platformId: string, text: string): void {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return;
|
||||
this.entries.set(this.keyFor(platformId, trimmed), Date.now());
|
||||
this.cleanup();
|
||||
}
|
||||
|
||||
isEcho(platformId: string, text: string): boolean {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return false;
|
||||
const key = this.keyFor(platformId, trimmed);
|
||||
const ts = this.entries.get(key);
|
||||
if (!ts) return false;
|
||||
if (Date.now() - ts > ECHO_TTL_MS) {
|
||||
this.entries.delete(key);
|
||||
return false;
|
||||
}
|
||||
this.entries.delete(key);
|
||||
return true;
|
||||
}
|
||||
|
||||
private cleanup(): void {
|
||||
const now = Date.now();
|
||||
for (const [key, ts] of this.entries) {
|
||||
if (now - ts > ECHO_TTL_MS) this.entries.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Signal envelope types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SignalQuote {
|
||||
id?: number;
|
||||
author?: string;
|
||||
authorNumber?: string;
|
||||
authorUuid?: string;
|
||||
authorName?: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
interface SignalMention {
|
||||
start?: number;
|
||||
length?: number;
|
||||
uuid?: string;
|
||||
number?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
interface SignalDataMessage {
|
||||
timestamp?: number;
|
||||
message?: string;
|
||||
mentions?: SignalMention[];
|
||||
groupInfo?: { groupId?: string; groupName?: string; type?: string };
|
||||
groupV2?: { id?: string };
|
||||
quote?: SignalQuote;
|
||||
attachments?: Array<{
|
||||
id?: string;
|
||||
contentType?: string;
|
||||
filename?: string;
|
||||
size?: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface SignalEnvelope {
|
||||
source?: string;
|
||||
sourceName?: string;
|
||||
sourceNumber?: string;
|
||||
sourceUuid?: string;
|
||||
dataMessage?: SignalDataMessage;
|
||||
syncMessage?: {
|
||||
sentMessage?: SignalDataMessage & {
|
||||
destination?: string;
|
||||
destinationNumber?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Replace inline `@<placeholder>` mention markers with display names so the
|
||||
* agent sees `@Alice` instead of a raw UUID. Signal's protocol uses a single
|
||||
* placeholder character (typically U+FFFC) at each mention's `start` offset.
|
||||
*/
|
||||
function resolveMentions(text: string, mentions?: SignalMention[]): string {
|
||||
if (!mentions || mentions.length === 0) return text;
|
||||
const sorted = [...mentions].sort((a, b) => (a.start ?? 0) - (b.start ?? 0));
|
||||
let result = '';
|
||||
let cursor = 0;
|
||||
for (const m of sorted) {
|
||||
const start = m.start ?? 0;
|
||||
const length = m.length ?? 1;
|
||||
const name = m.name || m.number || (m.uuid ? m.uuid.slice(0, 8) : 'someone');
|
||||
if (start < cursor) continue;
|
||||
result += text.slice(cursor, start) + `@${name}`;
|
||||
cursor = start + length;
|
||||
}
|
||||
result += text.slice(cursor);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional voice-note transcription. Tries (in order):
|
||||
* 1. local whisper.cpp CLI when `WHISPER_BIN` is set
|
||||
* 2. OpenAI Whisper API when `OPENAI_API_KEY` is set
|
||||
* Returns null if neither path is configured or transcription fails — caller
|
||||
* falls back to a `[Voice Message]` placeholder.
|
||||
*
|
||||
* Signal voice notes are AAC/ADTS; whisper-cpp wants WAV. ffmpeg is invoked
|
||||
* if available to convert; if ffmpeg is missing the local path is skipped.
|
||||
*/
|
||||
async function transcribeAudioOptional(filePath: string): Promise<string | null> {
|
||||
const whisperBin = process.env.WHISPER_BIN;
|
||||
if (whisperBin) {
|
||||
try {
|
||||
const wavPath = `${filePath}.wav`;
|
||||
execSync(`ffmpeg -y -loglevel error -i "${filePath}" -ar 16000 -ac 1 "${wavPath}"`, { stdio: 'ignore' });
|
||||
const model = process.env.WHISPER_MODEL || `${homedir()}/.local/share/whisper/models/ggml-base.en.bin`;
|
||||
const out = execSync(`"${whisperBin}" -m "${model}" -f "${wavPath}" -nt -otxt -of "${wavPath}"`, {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
});
|
||||
try {
|
||||
unlinkSync(wavPath);
|
||||
unlinkSync(`${wavPath}.txt`);
|
||||
} catch {}
|
||||
const text = out.replace(/\[[^\]]*\]/g, '').trim();
|
||||
if (text) return text;
|
||||
} catch (err) {
|
||||
log.debug('Signal: local whisper transcription failed, trying OpenAI', { err });
|
||||
}
|
||||
}
|
||||
|
||||
const apiKey = process.env.OPENAI_API_KEY;
|
||||
if (apiKey) {
|
||||
try {
|
||||
const buf = readFileSync(filePath);
|
||||
const boundary = `----nanoclaw-${Date.now()}`;
|
||||
const body = Buffer.concat([
|
||||
Buffer.from(
|
||||
`--${boundary}\r\nContent-Disposition: form-data; name="model"\r\n\r\nwhisper-1\r\n--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="audio.aac"\r\nContent-Type: audio/aac\r\n\r\n`,
|
||||
),
|
||||
buf,
|
||||
Buffer.from(`\r\n--${boundary}--\r\n`),
|
||||
]);
|
||||
const res = await fetch('https://api.openai.com/v1/audio/transcriptions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
||||
},
|
||||
body,
|
||||
});
|
||||
if (res.ok) {
|
||||
const json = (await res.json()) as { text?: string };
|
||||
if (json.text) return json.text.trim();
|
||||
}
|
||||
} catch (err) {
|
||||
log.debug('Signal: OpenAI transcription failed', { err });
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function chunkText(text: string, limit: number): string[] {
|
||||
const chunks: string[] = [];
|
||||
let remaining = text;
|
||||
while (remaining.length > 0) {
|
||||
if (remaining.length <= limit) {
|
||||
chunks.push(remaining);
|
||||
break;
|
||||
}
|
||||
let splitAt = remaining.lastIndexOf('\n', limit);
|
||||
if (splitAt <= 0) splitAt = limit;
|
||||
chunks.push(remaining.slice(0, splitAt));
|
||||
remaining = remaining.slice(splitAt).replace(/^\n/, '');
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Signal text styles — convert Markdown to Signal's offset-based formatting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SignalTextStyle {
|
||||
style: 'BOLD' | 'ITALIC' | 'STRIKETHROUGH' | 'MONOSPACE' | 'SPOILER';
|
||||
start: number;
|
||||
length: number;
|
||||
}
|
||||
|
||||
interface StyledText {
|
||||
text: string;
|
||||
textStyles: SignalTextStyle[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Markdown-ish input to Signal's offset-based style ranges.
|
||||
*
|
||||
* Walks the input recursively: at each level we find the leftmost matching
|
||||
* pattern, descend into its captured inner text (so `**bold with \`code\`
|
||||
* inside**` stays bold-plus-monospace rather than leaking stripped markers),
|
||||
* then continue past the match. Style offsets are recorded against the
|
||||
* *output* text length as it's built, so nested styles always point at the
|
||||
* right span of the final plain text.
|
||||
*/
|
||||
function parseSignalStyles(input: string): StyledText {
|
||||
const styles: SignalTextStyle[] = [];
|
||||
|
||||
// Ordering matters: longer/greedier delimiters first so `` ``` `` beats
|
||||
// `` ` ``, `**` beats `*`. The italic-`*` pattern refuses to start on
|
||||
// whitespace so `*` isn't mistakenly opened on " * " in list-like text.
|
||||
const patterns: Array<{ regex: RegExp; style: SignalTextStyle['style'] }> = [
|
||||
{ regex: /```([\s\S]+?)```/, style: 'MONOSPACE' },
|
||||
{ regex: /`([^`]+)`/, style: 'MONOSPACE' },
|
||||
{ regex: /\*\*([^]+?)\*\*/, style: 'BOLD' },
|
||||
{ regex: /~~([^]+?)~~/, style: 'STRIKETHROUGH' },
|
||||
{ regex: /\|\|([^]+?)\|\|/, style: 'SPOILER' },
|
||||
{ regex: /\*([^*\s][^*]*?)\*/, style: 'ITALIC' },
|
||||
{ regex: /_([^_\s][^_]*?)_/, style: 'ITALIC' },
|
||||
];
|
||||
|
||||
function walk(segment: string, outputBase: number): string {
|
||||
let earliest: { start: number; match: RegExpExecArray; style: SignalTextStyle['style'] } | null = null;
|
||||
for (const { regex, style } of patterns) {
|
||||
const m = regex.exec(segment);
|
||||
if (!m) continue;
|
||||
if (earliest === null || m.index < earliest.start) {
|
||||
earliest = { start: m.index, match: m, style };
|
||||
}
|
||||
}
|
||||
if (!earliest) return segment;
|
||||
|
||||
const before = segment.slice(0, earliest.start);
|
||||
const fullMatch = earliest.match[0];
|
||||
const inner = earliest.match[1];
|
||||
const afterStart = earliest.start + fullMatch.length;
|
||||
const after = segment.slice(afterStart);
|
||||
|
||||
const innerOut = walk(inner, outputBase + before.length);
|
||||
styles.push({
|
||||
style: earliest.style,
|
||||
start: outputBase + before.length,
|
||||
length: innerOut.length,
|
||||
});
|
||||
const afterOut = walk(after, outputBase + before.length + innerOut.length);
|
||||
|
||||
return before + innerOut + afterOut;
|
||||
}
|
||||
|
||||
const text = walk(input, 0);
|
||||
return { text, textStyles: styles };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SignalAdapter — v2 ChannelAdapter implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Platform ID format:
|
||||
* DM: phone number or UUID (e.g. "+15555550123")
|
||||
* Group: "group:<groupId>" (e.g. "group:abc123")
|
||||
*
|
||||
* channelType is always "signal". The router combines channelType + platformId
|
||||
* to look up or create the messaging_group.
|
||||
*/
|
||||
export function createSignalAdapter(config: {
|
||||
cliPath: string;
|
||||
account: string;
|
||||
tcpHost: string;
|
||||
tcpPort: number;
|
||||
manageDaemon: boolean;
|
||||
signalDataDir: string;
|
||||
}): ChannelAdapter {
|
||||
let daemon: DaemonHandle | null = null;
|
||||
let tcp: SignalTcpClient | null = null;
|
||||
let connected = false;
|
||||
const echoCache = new EchoCache();
|
||||
let setup: ChannelSetup | null = null;
|
||||
|
||||
// -- inbound handling --
|
||||
|
||||
function handleNotification(method: string, params: unknown): void {
|
||||
if (method === 'receive') {
|
||||
const envelope = (params as any)?.envelope;
|
||||
if (envelope) {
|
||||
handleEnvelope(envelope).catch((err) => {
|
||||
log.error('Signal: error handling envelope', { err });
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEnvelope(envelope: SignalEnvelope): Promise<void> {
|
||||
if (!setup) return;
|
||||
|
||||
// Sync messages (sent from another device)
|
||||
const syncSent = envelope.syncMessage?.sentMessage;
|
||||
if (syncSent) {
|
||||
const dest = (syncSent.destinationNumber ?? syncSent.destination ?? '').trim();
|
||||
// "Note to Self" — destination is our own account
|
||||
if (dest === config.account) {
|
||||
const text = (syncSent.message ?? '').trim();
|
||||
if (!text) return;
|
||||
const platformId = config.account;
|
||||
if (echoCache.isEcho(platformId, text)) return;
|
||||
const timestamp = syncSent.timestamp ? new Date(syncSent.timestamp).toISOString() : new Date().toISOString();
|
||||
|
||||
setup.onMetadata(platformId, 'Note to Self', false);
|
||||
|
||||
const msg: InboundMessage = {
|
||||
id: String(syncSent.timestamp ?? Date.now()),
|
||||
kind: 'chat',
|
||||
content: {
|
||||
text,
|
||||
sender: config.account,
|
||||
senderId: `signal:${config.account}`,
|
||||
senderName: 'Me',
|
||||
isFromMe: true,
|
||||
...(syncSent.quote ? quoteToContent(syncSent.quote) : {}),
|
||||
},
|
||||
timestamp,
|
||||
};
|
||||
await setup.onInbound(platformId, null, msg);
|
||||
return;
|
||||
}
|
||||
// Other sync messages are our outbound — skip
|
||||
return;
|
||||
}
|
||||
|
||||
const dataMessage = envelope.dataMessage;
|
||||
if (!dataMessage) return;
|
||||
|
||||
const rawText = (dataMessage.message ?? '').trim();
|
||||
const text = rawText ? resolveMentions(rawText, dataMessage.mentions) : '';
|
||||
|
||||
const audioAttachment = dataMessage.attachments?.find((a) => a.contentType?.startsWith('audio/') && a.id);
|
||||
const imageAttachments = dataMessage.attachments?.filter((a) => a.contentType?.startsWith('image/') && a.id) ?? [];
|
||||
const hasVoice = !text && !!audioAttachment;
|
||||
|
||||
if (!text && !hasVoice && imageAttachments.length === 0) return;
|
||||
|
||||
const sender = (envelope.sourceNumber ?? envelope.sourceUuid ?? envelope.source ?? '').trim();
|
||||
if (!sender) return;
|
||||
|
||||
const senderName = (envelope.sourceName?.trim() || sender).trim();
|
||||
|
||||
// Modern Signal groups use groupV2; legacy groupInfo.groupId is the
|
||||
// pre-V2 fallback. Without the V2 read, V2-only groups appear as DMs
|
||||
// because `groupInfo` is undefined.
|
||||
const groupInfo = dataMessage.groupInfo;
|
||||
const groupId = dataMessage.groupV2?.id ?? groupInfo?.groupId;
|
||||
const isGroup = Boolean(groupId);
|
||||
|
||||
const platformId = isGroup ? `group:${groupId}` : sender;
|
||||
|
||||
if (text && echoCache.isEcho(platformId, text)) {
|
||||
log.debug('Signal: skipping echo', { platformId });
|
||||
return;
|
||||
}
|
||||
const timestamp = dataMessage.timestamp ? new Date(dataMessage.timestamp).toISOString() : new Date().toISOString();
|
||||
|
||||
const chatName = groupInfo?.groupName ?? (isGroup ? `Group ${groupId?.slice(0, 8)}` : senderName);
|
||||
|
||||
setup.onMetadata(platformId, chatName, isGroup);
|
||||
|
||||
let content = text;
|
||||
|
||||
// Voice attachment — try transcription if WHISPER_BIN or OPENAI_API_KEY
|
||||
// is configured; otherwise fall back to the original placeholder so
|
||||
// operators who don't want transcription get the same UX as before.
|
||||
if (hasVoice && audioAttachment?.id) {
|
||||
const attachmentPath = join(config.signalDataDir, 'attachments', audioAttachment.id);
|
||||
if (existsSync(attachmentPath)) {
|
||||
log.info('Signal: voice attachment received', {
|
||||
platformId,
|
||||
attachmentId: audioAttachment.id,
|
||||
path: attachmentPath,
|
||||
});
|
||||
const transcript = await transcribeAudioOptional(attachmentPath);
|
||||
if (transcript) {
|
||||
content = `[Voice: ${transcript}]`;
|
||||
log.info('Signal: voice transcribed', { platformId, length: transcript.length });
|
||||
} else {
|
||||
content = '[Voice Message]';
|
||||
}
|
||||
} else {
|
||||
log.warn('Signal: voice attachment file not found', {
|
||||
id: audioAttachment.id,
|
||||
path: attachmentPath,
|
||||
});
|
||||
content = '[Voice Message - file not found]';
|
||||
}
|
||||
}
|
||||
|
||||
// Image attachments — emit `[Image: <path>]` lines so the agent's Read
|
||||
// tool can pick them up, and surface the structured `attachments` array
|
||||
// for consumers that prefer that shape. Without this, vision-capable
|
||||
// models never see images sent over Signal.
|
||||
const attachmentRefs: Array<{ path: string; contentType: string }> = [];
|
||||
for (const img of imageAttachments) {
|
||||
const imagePath = join(config.signalDataDir, 'attachments', img.id!);
|
||||
const imageLine = `[Image: ${imagePath}]`;
|
||||
content = content ? `${content}\n${imageLine}` : imageLine;
|
||||
attachmentRefs.push({ path: imagePath, contentType: img.contentType || 'image/jpeg' });
|
||||
}
|
||||
|
||||
const msg: InboundMessage = {
|
||||
id: String(dataMessage.timestamp ?? Date.now()),
|
||||
kind: 'chat',
|
||||
content: {
|
||||
text: content,
|
||||
sender,
|
||||
senderId: `signal:${sender}`,
|
||||
senderName,
|
||||
...(attachmentRefs.length > 0 ? { attachments: attachmentRefs } : {}),
|
||||
...(dataMessage.quote ? quoteToContent(dataMessage.quote) : {}),
|
||||
},
|
||||
timestamp,
|
||||
};
|
||||
await setup.onInbound(platformId, null, msg);
|
||||
|
||||
log.info('Signal message received', { platformId, sender: senderName });
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `replyTo` object the agent-runner formatter expects (see
|
||||
* `container/agent-runner/src/formatter.ts:formatReplyContext`). The
|
||||
* formatter requires both `sender` and `text` to render the
|
||||
* `<quoted_message>` block; absent either, it omits the block entirely.
|
||||
*
|
||||
* The previous shape (`replyToSenderName` / `replyToMessageContent` /
|
||||
* `replyToMessageId` flat keys) did not match the formatter contract, so
|
||||
* quote-reply context was silently dropped end-to-end.
|
||||
*/
|
||||
function quoteToContent(quote: SignalQuote): Record<string, unknown> {
|
||||
const sender = quote.authorName || quote.authorNumber || quote.author || quote.authorUuid || 'someone';
|
||||
const text = quote.text || '';
|
||||
return {
|
||||
replyTo: {
|
||||
id: quote.id ? String(quote.id) : undefined,
|
||||
sender,
|
||||
text,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// -- send helpers --
|
||||
|
||||
async function sendText(platformId: string, text: string): Promise<void> {
|
||||
if (!connected || !tcp) return;
|
||||
|
||||
echoCache.remember(platformId, text);
|
||||
|
||||
const MAX_CHUNK = 4000;
|
||||
const chunks = text.length <= MAX_CHUNK ? [text] : chunkText(text, MAX_CHUNK);
|
||||
|
||||
for (const chunk of chunks) {
|
||||
try {
|
||||
const { text: plainText, textStyles } = parseSignalStyles(chunk);
|
||||
const params: Record<string, unknown> = { message: plainText };
|
||||
if (config.account) params.account = config.account;
|
||||
if (textStyles.length > 0) {
|
||||
params.textStyle = textStyles.map((s) => `${s.start}:${s.length}:${s.style}`);
|
||||
}
|
||||
|
||||
if (platformId.startsWith('group:')) {
|
||||
params.groupId = platformId.slice('group:'.length);
|
||||
} else {
|
||||
params.recipient = [platformId];
|
||||
}
|
||||
|
||||
try {
|
||||
await tcp.rpc('send', params);
|
||||
} catch (styledErr) {
|
||||
if (textStyles.length > 0) {
|
||||
log.debug('Signal: textStyle rejected, retrying with markup');
|
||||
delete params.textStyle;
|
||||
params.message = chunk;
|
||||
await tcp.rpc('send', params);
|
||||
} else {
|
||||
throw styledErr;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('Signal: send failed', { platformId, err });
|
||||
}
|
||||
}
|
||||
|
||||
log.info('Signal message sent', { platformId, length: text.length });
|
||||
}
|
||||
|
||||
/**
|
||||
* Send one or more file attachments via signal-cli's `send` JSON-RPC, which
|
||||
* accepts an `attachments` array of host filesystem paths. The OutboundFile
|
||||
* Buffer is materialized to an OS temp file so signal-cli can read it, then
|
||||
* removed in the finally block.
|
||||
*
|
||||
* Caption text, if any, is sent first via `sendText` (which handles chunking
|
||||
* + textStyles) — keeps this function single-purpose and avoids a long
|
||||
* caption colliding with signal-cli's per-message size limits.
|
||||
*/
|
||||
async function sendAttachments(platformId: string, files: { filename: string; data: Buffer }[]): Promise<void> {
|
||||
if (!connected || !tcp) return;
|
||||
if (files.length === 0) return;
|
||||
|
||||
const tempPaths: string[] = [];
|
||||
for (const file of files) {
|
||||
const safeName = file.filename.replace(/[/\\\0]/g, '_');
|
||||
const tempPath = join(tmpdir(), `signal-out-${Date.now()}-${Math.random().toString(36).slice(2, 8)}-${safeName}`);
|
||||
writeFileSync(tempPath, file.data);
|
||||
tempPaths.push(tempPath);
|
||||
}
|
||||
|
||||
try {
|
||||
const params: Record<string, unknown> = { attachments: tempPaths };
|
||||
if (config.account) params.account = config.account;
|
||||
if (platformId.startsWith('group:')) {
|
||||
params.groupId = platformId.slice('group:'.length);
|
||||
} else {
|
||||
params.recipient = [platformId];
|
||||
}
|
||||
await tcp.rpc('send', params);
|
||||
log.info('Signal attachments sent', { platformId, count: files.length, filenames: files.map((f) => f.filename) });
|
||||
} catch (err) {
|
||||
log.error('Signal: attachment send failed', { platformId, count: files.length, err });
|
||||
} finally {
|
||||
for (const p of tempPaths) {
|
||||
try {
|
||||
unlinkSync(p);
|
||||
} catch {
|
||||
/* best-effort cleanup */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForDaemon(): Promise<boolean> {
|
||||
const maxWait = 30_000;
|
||||
const pollInterval = 1000;
|
||||
const start = Date.now();
|
||||
|
||||
while (Date.now() - start < maxWait) {
|
||||
if (daemon?.isExited()) return false;
|
||||
const ok = await signalTcpCheck(config.tcpHost, config.tcpPort);
|
||||
if (ok) return true;
|
||||
await sleep(pollInterval);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// -- adapter --
|
||||
|
||||
const adapter: ChannelAdapter = {
|
||||
name: 'signal',
|
||||
channelType: 'signal',
|
||||
supportsThreads: false,
|
||||
|
||||
async setup(cfg: ChannelSetup): Promise<void> {
|
||||
setup = cfg;
|
||||
|
||||
if (config.manageDaemon) {
|
||||
daemon = spawnSignalDaemon(config.cliPath, config.account, config.tcpHost, config.tcpPort);
|
||||
const ready = await waitForDaemon();
|
||||
if (!ready) {
|
||||
daemon.stop();
|
||||
throw new Error('Signal daemon failed to start. Is signal-cli installed and your account linked?');
|
||||
}
|
||||
} else {
|
||||
const ok = await signalTcpCheck(config.tcpHost, config.tcpPort);
|
||||
if (!ok) {
|
||||
const err = new Error(
|
||||
`Signal daemon not reachable at ${config.tcpHost}:${config.tcpPort}. Start it manually or set SIGNAL_MANAGE_DAEMON=true`,
|
||||
);
|
||||
(err as any).name = 'NetworkError';
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
tcp = new SignalTcpClient(config.tcpHost, config.tcpPort);
|
||||
await tcp.connect({
|
||||
onNotification: handleNotification,
|
||||
// Signal the adapter that the daemon dropped us. No auto-reconnect yet
|
||||
// — subsequent deliver/setTyping calls short-circuit on `connected`
|
||||
// and log rather than throw into the retry loop. Operators see this in
|
||||
// logs/nanoclaw.log and can restart the service.
|
||||
onClose: () => {
|
||||
if (!connected) return;
|
||||
connected = false;
|
||||
log.warn('Signal channel lost TCP connection to signal-cli daemon', {
|
||||
account: config.account,
|
||||
host: config.tcpHost,
|
||||
port: config.tcpPort,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await tcp.rpc('updateProfile', {
|
||||
name: 'NanoClaw',
|
||||
account: config.account,
|
||||
});
|
||||
} catch {
|
||||
log.debug('Signal: could not set profile name');
|
||||
}
|
||||
|
||||
try {
|
||||
await tcp.rpc('updateConfiguration', {
|
||||
typingIndicators: true,
|
||||
account: config.account,
|
||||
});
|
||||
} catch {
|
||||
log.debug('Signal: could not enable typing indicators');
|
||||
}
|
||||
|
||||
connected = true;
|
||||
log.info('Signal channel connected', {
|
||||
account: config.account,
|
||||
host: config.tcpHost,
|
||||
port: config.tcpPort,
|
||||
});
|
||||
},
|
||||
|
||||
async teardown(): Promise<void> {
|
||||
connected = false;
|
||||
tcp?.close();
|
||||
tcp = null;
|
||||
if (daemon && config.manageDaemon) {
|
||||
daemon.stop();
|
||||
await daemon.exited;
|
||||
}
|
||||
daemon = null;
|
||||
log.info('Signal channel disconnected');
|
||||
},
|
||||
|
||||
isConnected(): boolean {
|
||||
return connected;
|
||||
},
|
||||
|
||||
async deliver(platformId: string, _threadId: string | null, message: OutboundMessage): Promise<string | undefined> {
|
||||
const content = message.content as Record<string, unknown> | string | undefined;
|
||||
let text: string | null = null;
|
||||
if (typeof content === 'string') {
|
||||
text = content;
|
||||
} else if (content && typeof content === 'object' && typeof content.text === 'string') {
|
||||
text = content.text;
|
||||
}
|
||||
|
||||
const files = message.files ?? [];
|
||||
|
||||
// Send accompanying text first so it lands above the attachment(s) in
|
||||
// the recipient's chat. Both branches no-op cleanly if their input is
|
||||
// empty, so any combination of (text, files) works.
|
||||
if (text) await sendText(platformId, text);
|
||||
if (files.length > 0) await sendAttachments(platformId, files);
|
||||
return undefined;
|
||||
},
|
||||
|
||||
async setTyping(platformId: string, _threadId: string | null): Promise<void> {
|
||||
if (!connected || !tcp) return;
|
||||
if (platformId.startsWith('group:')) return;
|
||||
|
||||
try {
|
||||
const params: Record<string, unknown> = { recipient: [platformId] };
|
||||
if (config.account) params.account = config.account;
|
||||
await tcp.rpc('sendTyping', params);
|
||||
} catch (err) {
|
||||
log.debug('Signal: typing indicator failed', { platformId, err });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return adapter;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Self-registration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DEFAULT_TCP_HOST = '127.0.0.1';
|
||||
const DEFAULT_TCP_PORT = 7583;
|
||||
|
||||
registerChannelAdapter('signal', {
|
||||
factory: () => {
|
||||
const envVars = readEnvFile([
|
||||
'SIGNAL_ACCOUNT',
|
||||
'SIGNAL_TCP_HOST',
|
||||
'SIGNAL_TCP_PORT',
|
||||
'SIGNAL_CLI_PATH',
|
||||
'SIGNAL_MANAGE_DAEMON',
|
||||
'SIGNAL_DATA_DIR',
|
||||
]);
|
||||
|
||||
const account = process.env.SIGNAL_ACCOUNT || envVars.SIGNAL_ACCOUNT || '';
|
||||
if (!account) {
|
||||
log.debug('Signal: SIGNAL_ACCOUNT not set, skipping channel');
|
||||
return null;
|
||||
}
|
||||
|
||||
const cliPath = process.env.SIGNAL_CLI_PATH || envVars.SIGNAL_CLI_PATH || 'signal-cli';
|
||||
const tcpHost = process.env.SIGNAL_TCP_HOST || envVars.SIGNAL_TCP_HOST || DEFAULT_TCP_HOST;
|
||||
const tcpPort = parseInt(process.env.SIGNAL_TCP_PORT || envVars.SIGNAL_TCP_PORT || String(DEFAULT_TCP_PORT), 10);
|
||||
const manageDaemon = (process.env.SIGNAL_MANAGE_DAEMON || envVars.SIGNAL_MANAGE_DAEMON || 'true') === 'true';
|
||||
|
||||
const signalDataDir =
|
||||
process.env.SIGNAL_DATA_DIR || envVars.SIGNAL_DATA_DIR || join(homedir(), '.local', 'share', 'signal-cli');
|
||||
|
||||
// Only check for `signal-cli` on PATH when the operator left cliPath at
|
||||
// the default AND asked us to manage the daemon. A custom absolute path
|
||||
// is treated as an explicit promise and spawn will surface its own ENOENT.
|
||||
if (manageDaemon && cliPath === 'signal-cli') {
|
||||
try {
|
||||
execFileSync('which', ['signal-cli'], { stdio: 'ignore' });
|
||||
} catch {
|
||||
log.debug('Signal: signal-cli binary not found, skipping channel');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return createSignalAdapter({
|
||||
cliPath,
|
||||
account,
|
||||
tcpHost,
|
||||
tcpPort,
|
||||
manageDaemon,
|
||||
signalDataDir,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* Integration test for the slack channel's single reach-in: the self-registration
|
||||
* import in the `src/channels/index.ts` barrel. Importing the barrel runs slack.ts's
|
||||
* top-level `registerChannelAdapter('slack', …)`; without the import the channel is
|
||||
* silently absent.
|
||||
*
|
||||
* Behavior, not structural: it imports the real barrel and asserts the registry
|
||||
* actually contains the channel. This reflects what happens at host boot — if the
|
||||
* `import './slack.js';` line is deleted, or the barrel fails to evaluate for any
|
||||
* reason (so the channel genuinely would not register), this goes red. A structural
|
||||
* check of the import line would falsely pass in that second case.
|
||||
*
|
||||
* Importing the barrel is safe: registration is a pure top-level call, and slack.ts
|
||||
* builds the SDK adapter / bridge only inside its factory (invoked at host startup),
|
||||
* never at import. It does require the adapter package to be installed, which holds
|
||||
* in a composed install: the skill's `pnpm install` step runs before this test.
|
||||
*
|
||||
* Note on the Chat SDK family: slack.ts also consumes a load-bearing *core* API —
|
||||
* `createChatSdkBridge(...)` from ./chat-sdk-bridge.js — with a specific options
|
||||
* shape. That core-consumption is a typed call, so the build/typecheck leg
|
||||
* (`pnpm run build`) guards it against upstream drift, not this test. Every Chat SDK
|
||||
* channel (discord, telegram, teams, gchat, webex, …) follows this same shape:
|
||||
* swap the channel name below and the adapter package in the build.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { getRegisteredChannelNames } from './channel-registry.js';
|
||||
import './index.js'; // the real barrel — triggers every channel's self-registration
|
||||
|
||||
describe('slack channel registration', () => {
|
||||
it('registers slack via the channel barrel', () => {
|
||||
expect(getRegisteredChannelNames()).toContain('slack');
|
||||
});
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
/**
|
||||
* Slack channel adapter (v2) — uses Chat SDK bridge.
|
||||
* Self-registers on import.
|
||||
*/
|
||||
import { createSlackAdapter } from '@chat-adapter/slack';
|
||||
|
||||
import { readEnvFile } from '../env.js';
|
||||
import { createChatSdkBridge } from './chat-sdk-bridge.js';
|
||||
import { registerChannelAdapter } from './channel-registry.js';
|
||||
|
||||
registerChannelAdapter('slack', {
|
||||
factory: () => {
|
||||
const env = readEnvFile(['SLACK_BOT_TOKEN', 'SLACK_SIGNING_SECRET']);
|
||||
if (!env.SLACK_BOT_TOKEN) return null;
|
||||
const slackAdapter = createSlackAdapter({
|
||||
botToken: env.SLACK_BOT_TOKEN,
|
||||
signingSecret: env.SLACK_SIGNING_SECRET,
|
||||
});
|
||||
const bridge = createChatSdkBridge({ adapter: slackAdapter, concurrency: 'concurrent', supportsThreads: true });
|
||||
bridge.resolveChannelName = async (platformId: string) => {
|
||||
try {
|
||||
const info = await slackAdapter.fetchThread(platformId);
|
||||
return (info as { channelName?: string }).channelName ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
return bridge;
|
||||
},
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* Integration test for the teams channel's single reach-in: the self-registration
|
||||
* import in the `src/channels/index.ts` barrel. Importing the barrel runs teams.ts's
|
||||
* top-level `registerChannelAdapter('teams', …)`; without the import the channel is
|
||||
* silently absent.
|
||||
*
|
||||
* Behavior, not structural: it imports the real barrel and asserts the registry
|
||||
* actually contains the channel. This reflects what happens at host boot — if the
|
||||
* `import './teams.js';` line is deleted, or the barrel fails to evaluate for any
|
||||
* reason (so the channel genuinely would not register), this goes red. A structural
|
||||
* check of the import line would falsely pass in that second case.
|
||||
*
|
||||
* Importing the barrel is safe: registration is a pure top-level call, and teams.ts
|
||||
* builds the SDK adapter / bridge only inside its factory (invoked at host startup),
|
||||
* never at import. It does require the adapter package (`@chat-adapter/teams`) to be installed,
|
||||
* which holds in a composed install: the skill's `pnpm install` step runs before this
|
||||
* test — so this test also implicitly guards that dependency (an unmocked import throws
|
||||
* if the package is missing).
|
||||
*
|
||||
* teams is a Chat SDK channel: teams.ts also consumes a load-bearing *core* API —
|
||||
* `createChatSdkBridge(...)` from ./chat-sdk-bridge.js. That core-consumption is a
|
||||
* typed call, so the build/typecheck leg (`pnpm run build`) guards it against upstream
|
||||
* drift, not this test. Every Chat SDK channel follows this same shape.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { getRegisteredChannelNames } from './channel-registry.js';
|
||||
import './index.js'; // the real barrel — triggers every channel's self-registration
|
||||
|
||||
describe('teams channel registration', () => {
|
||||
it('registers teams via the channel barrel', () => {
|
||||
expect(getRegisteredChannelNames()).toContain('teams');
|
||||
});
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
/**
|
||||
* Microsoft Teams channel adapter (v2) — uses Chat SDK bridge.
|
||||
* Self-registers on import.
|
||||
*/
|
||||
import { createTeamsAdapter } from '@chat-adapter/teams';
|
||||
|
||||
import { readEnvFile } from '../env.js';
|
||||
import { createChatSdkBridge } from './chat-sdk-bridge.js';
|
||||
import { registerChannelAdapter } from './channel-registry.js';
|
||||
|
||||
registerChannelAdapter('teams', {
|
||||
factory: () => {
|
||||
const env = readEnvFile(['TEAMS_APP_ID', 'TEAMS_APP_PASSWORD', 'TEAMS_APP_TENANT_ID', 'TEAMS_APP_TYPE']);
|
||||
if (!env.TEAMS_APP_ID) return null;
|
||||
const teamsAdapter = createTeamsAdapter({
|
||||
appId: env.TEAMS_APP_ID,
|
||||
appPassword: env.TEAMS_APP_PASSWORD,
|
||||
appType: (env.TEAMS_APP_TYPE as 'SingleTenant' | 'MultiTenant') || undefined,
|
||||
appTenantId: env.TEAMS_APP_TENANT_ID || undefined,
|
||||
});
|
||||
return createChatSdkBridge({ adapter: teamsAdapter, concurrency: 'concurrent', supportsThreads: true });
|
||||
},
|
||||
});
|
||||
@@ -1,78 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { sanitizeTelegramLegacyMarkdown } from './telegram-markdown-sanitize.js';
|
||||
|
||||
describe('sanitizeTelegramLegacyMarkdown', () => {
|
||||
it('downgrades CommonMark **bold** to legacy *bold*', () => {
|
||||
expect(sanitizeTelegramLegacyMarkdown('**Host path**')).toBe('*Host path*');
|
||||
});
|
||||
|
||||
it('downgrades CommonMark __bold__ to legacy _italic_', () => {
|
||||
expect(sanitizeTelegramLegacyMarkdown('__label__')).toBe('_label_');
|
||||
});
|
||||
|
||||
it('leaves balanced legacy *bold* and _italic_ alone', () => {
|
||||
expect(sanitizeTelegramLegacyMarkdown('a *b* c _d_ e')).toBe('a *b* c _d_ e');
|
||||
});
|
||||
|
||||
it('preserves inline code spans untouched', () => {
|
||||
const input = 'see `file_name.py` and `**not bold**` here';
|
||||
expect(sanitizeTelegramLegacyMarkdown(input)).toBe(input);
|
||||
});
|
||||
|
||||
it('preserves fenced code blocks untouched', () => {
|
||||
const input = '```\nfoo_bar **baz**\n```';
|
||||
expect(sanitizeTelegramLegacyMarkdown(input)).toBe(input);
|
||||
});
|
||||
|
||||
it('strips formatting chars on odd delimiter count (unbalanced *)', () => {
|
||||
expect(sanitizeTelegramLegacyMarkdown('a * b *c*')).toBe('a b c');
|
||||
});
|
||||
|
||||
it('strips formatting chars on odd delimiter count (unbalanced _)', () => {
|
||||
expect(sanitizeTelegramLegacyMarkdown('file_name has _one italic_')).toBe('filename has one italic');
|
||||
});
|
||||
|
||||
it('strips brackets when unbalanced', () => {
|
||||
expect(sanitizeTelegramLegacyMarkdown('see [docs here')).toBe('see docs here');
|
||||
});
|
||||
|
||||
it('leaves matched brackets (e.g. links) alone when counts balance', () => {
|
||||
const input = 'see [docs](https://example.com) for more';
|
||||
expect(sanitizeTelegramLegacyMarkdown(input)).toBe(input);
|
||||
});
|
||||
|
||||
it('fixes the real failing message', () => {
|
||||
const input =
|
||||
'Sure! What do you want to mount, and where should it appear inside the container?\n\n' +
|
||||
'- **Host path** (on your machine): e.g. `~/projects/webapp`\n' +
|
||||
'- **Container path**: e.g. `workspace/webapp`\n' +
|
||||
'- **Read-only or read-write?**';
|
||||
const out = sanitizeTelegramLegacyMarkdown(input);
|
||||
expect(out).not.toContain('**');
|
||||
expect(out).toContain('*Host path*');
|
||||
expect(out).toContain('`~/projects/webapp`');
|
||||
expect((out.match(/\*/g) ?? []).length % 2).toBe(0);
|
||||
});
|
||||
|
||||
it('is a no-op on empty string', () => {
|
||||
expect(sanitizeTelegramLegacyMarkdown('')).toBe('');
|
||||
});
|
||||
|
||||
it('replaces dash list bullets with • so the adapter does not re-emit `*` markers', () => {
|
||||
expect(sanitizeTelegramLegacyMarkdown('- one\n- two')).toBe('• one\n• two');
|
||||
});
|
||||
|
||||
it('preserves indented list structure', () => {
|
||||
expect(sanitizeTelegramLegacyMarkdown(' - nested')).toBe(' • nested');
|
||||
});
|
||||
|
||||
it('flattens Markdown horizontal rules (---, ***, ___)', () => {
|
||||
const input = 'before\n---\n***\n___\nafter';
|
||||
expect(sanitizeTelegramLegacyMarkdown(input)).toBe('before\n⎯⎯⎯\n⎯⎯⎯\n⎯⎯⎯\nafter');
|
||||
});
|
||||
|
||||
it('leaves horizontal rules inside code blocks alone', () => {
|
||||
const input = '```\n---\n```';
|
||||
expect(sanitizeTelegramLegacyMarkdown(input)).toBe(input);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user