mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-07-15 19:06:18 +08:00
Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ca19b744b3 | |||
| d2dd91b89f | |||
| 40b6a57527 | |||
| ed2e3b4c85 | |||
| c57076649a | |||
| d5dc9ef85c | |||
| 842f6ba565 | |||
| 780265225f | |||
| 9fa85ccf95 | |||
| dfd3ee31a9 | |||
| 47f8296e67 | |||
| dab93fc592 | |||
| 0ffe5582f0 | |||
| 4b5576faea | |||
| 904871aaa7 | |||
| 5e76b9d7e8 | |||
| b429ab37b8 | |||
| 09ddde33e1 | |||
| c0c46c14d6 | |||
| c993527e25 | |||
| 0367dbb6f0 | |||
| c7a7a709ed | |||
| 7c8d220115 | |||
| 2d2c3204bc | |||
| 4836cb59df | |||
| c20213a133 | |||
| d9ed98fd65 | |||
| 5ae1c33fff | |||
| af542adad5 | |||
| 894b154e41 | |||
| 831ef88f16 | |||
| d05923f274 | |||
| aba618215d | |||
| 138c277fae | |||
| 1e7cb8b8c8 | |||
| eb0055a0b0 | |||
| ef6ea87628 | |||
| b29df213ad | |||
| dd53875574 | |||
| a1ce73c376 | |||
| 48e4172899 |
@@ -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.
|
||||
@@ -72,41 +72,26 @@ pnpm run build
|
||||
### Event Subscriptions
|
||||
|
||||
8. Go to **Event Subscriptions** and toggle **Enable Events**
|
||||
9. **Webhook mode:** set the **Request URL** to `https://your-domain/webhook/slack` — Slack will send a verification challenge; it must pass before you can save. For **Socket Mode** (below), skip the Request URL.
|
||||
9. Set the **Request URL** to `https://your-domain/webhook/slack` — Slack will send a verification challenge; it must pass before you can save
|
||||
10. Under **Subscribe to bot events**, add:
|
||||
- `message.channels`, `message.groups`, `message.im`, `app_mention`
|
||||
11. Click **Save Changes**
|
||||
12. Slack will show a banner asking you to **reinstall the app** — click it to apply the new event subscriptions
|
||||
|
||||
### Socket Mode (optional — no public URL)
|
||||
|
||||
Socket Mode delivers events over an outbound WebSocket the bot opens to Slack, so the host needs **no public HTTPS endpoint** — ideal for local dev or a host behind NAT/a firewall. Setting `SLACK_APP_TOKEN` is what flips the adapter into Socket Mode; without it the adapter stays in webhook mode.
|
||||
|
||||
13. Go to **Basic Information** > **App-Level Tokens** > **Generate Token and Scopes**, add the `connections:write` scope, and copy the token (`xapp-...`)
|
||||
14. Go to **Socket Mode** and toggle **Enable Socket Mode** on
|
||||
15. Keep **Event Subscriptions** enabled with the bot events above — under Socket Mode no Request URL is required
|
||||
|
||||
### Configure environment
|
||||
|
||||
Add to `.env` — **webhook mode**:
|
||||
Add to `.env`:
|
||||
|
||||
```bash
|
||||
SLACK_BOT_TOKEN=xoxb-your-bot-token
|
||||
SLACK_SIGNING_SECRET=your-signing-secret
|
||||
```
|
||||
|
||||
…or **Socket Mode** (no public URL; signing secret optional):
|
||||
|
||||
```bash
|
||||
SLACK_BOT_TOKEN=xoxb-your-bot-token
|
||||
SLACK_APP_TOKEN=xapp-your-app-level-token
|
||||
```
|
||||
|
||||
Sync to container: `mkdir -p data/env && cp .env data/env/env`
|
||||
|
||||
### Webhook server (webhook mode only)
|
||||
### Webhook server
|
||||
|
||||
In **webhook mode** the Chat SDK bridge automatically starts a shared webhook server on port 3000 (configurable via `WEBHOOK_PORT` env var). The server handles `/webhook/slack` for Slack and other webhook-based adapters. This port must be publicly reachable from the internet for Slack to deliver events. **In Socket Mode this is not needed** — skip this section if you set `SLACK_APP_TOKEN`.
|
||||
The Chat SDK bridge automatically starts a shared webhook server on port 3000 (configurable via `WEBHOOK_PORT` env var). The server handles `/webhook/slack` for Slack and other webhook-based adapters. This port must be publicly reachable from the internet for Slack to deliver events.
|
||||
|
||||
If running locally, discuss options for exposing the server — e.g. ngrok (`ngrok http 3000`), Cloudflare Tunnel, or a reverse proxy on a VPS. The resulting public URL becomes the base for `https://your-domain/webhook/slack`.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
You are a NanoClaw agent. Your name, destinations, and message-sending rules are provided in the runtime system prompt at the top of each turn.
|
||||
|
||||
## Communication
|
||||
|
||||
Be concise. Prefer outcomes over play-by-play; when the work is done, the final message should be about the result.
|
||||
|
||||
When you produce a file for the user in the workspace — a document, export, or asset — deliver it with `send_file` in the same turn; announcing without sending is an unfinished reply.
|
||||
|
||||
## Workspace
|
||||
|
||||
Files you create are saved in `/workspace/agent/`. Use this for notes, research, artifacts, and anything that should persist across turns in this group.
|
||||
|
||||
## Conversation History
|
||||
|
||||
The `conversations/` folder holds searchable past conversation transcripts or exchange archives for this group. Use it to recall prior context when a request references something that happened before.
|
||||
@@ -7,6 +7,7 @@
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.116",
|
||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||
"@opencode-ai/sdk": "^1.4.3",
|
||||
"cron-parser": "^5.0.0",
|
||||
"zod": "^4.0.0",
|
||||
},
|
||||
@@ -44,6 +45,8 @@
|
||||
|
||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
|
||||
|
||||
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.4.11", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-EJxSfc7D/dda/vrw8zQe4g7yVTxERktvb5SvIBlGBnKYQJGOgo9RyA/1EL3l208rHeo6jm1sdrAF0E6o/k94ug=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="],
|
||||
|
||||
"@types/node": ["@types/node@22.19.17", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q=="],
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.116",
|
||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||
"@opencode-ai/sdk": "^1.4.3",
|
||||
"cron-parser": "^5.0.0",
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { describe, expect, it, afterEach } from 'bun:test';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import {
|
||||
type AppServer,
|
||||
attachCodexAutoApproval,
|
||||
buildCodexProcessEnv,
|
||||
tomlBasicString,
|
||||
writeCodexConfigToml,
|
||||
} from './codex-app-server.js';
|
||||
|
||||
let tmpHome: string | null = null;
|
||||
const originalHome = process.env.HOME;
|
||||
|
||||
afterEach(() => {
|
||||
process.env.HOME = originalHome;
|
||||
if (tmpHome) {
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
tmpHome = null;
|
||||
}
|
||||
});
|
||||
|
||||
describe('Codex config TOML', () => {
|
||||
it('escapes basic strings', () => {
|
||||
expect(tomlBasicString('a "quoted" \\\\ value')).toBe('"a \\"quoted\\" \\\\\\\\ value"');
|
||||
});
|
||||
|
||||
it('rejects newlines', () => {
|
||||
expect(() => tomlBasicString('bad\nvalue')).toThrow(/newline/);
|
||||
});
|
||||
|
||||
it('hardcodes danger-full-access + never and writes model, effort, and MCP servers', () => {
|
||||
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-home-'));
|
||||
process.env.HOME = tmpHome;
|
||||
|
||||
writeCodexConfigToml(
|
||||
{
|
||||
nanoclaw: {
|
||||
command: 'bun',
|
||||
args: ['run', '/app/src/mcp-tools/index.ts'],
|
||||
env: { FOO: 'bar' },
|
||||
},
|
||||
},
|
||||
{ model: 'gpt-5', effort: 'medium' },
|
||||
);
|
||||
|
||||
const content = fs.readFileSync(path.join(tmpHome, '.codex', 'config.toml'), 'utf-8');
|
||||
expect(content).toContain('sandbox_mode = "danger-full-access"');
|
||||
expect(content).toContain('approval_policy = "never"');
|
||||
expect(content).toContain('project_doc_max_bytes = 32768');
|
||||
expect(content).toContain('model = "gpt-5"');
|
||||
expect(content).toContain('model_reasoning_effort = "medium"');
|
||||
expect(content).not.toContain('[sandbox_workspace_write]');
|
||||
expect(content).not.toContain('writable_roots =');
|
||||
expect(content).toContain('[mcp_servers.nanoclaw]');
|
||||
expect(content).toContain('command = "bun"');
|
||||
expect(content).toContain('args = ["run", "/app/src/mcp-tools/index.ts"]');
|
||||
expect(content).toContain('[mcp_servers.nanoclaw.env]');
|
||||
expect(content).toContain('FOO = "bar"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Codex auto-approval', () => {
|
||||
// NanoClaw (container isolation + OneCLI) is the boundary, so the handler accepts
|
||||
// every request unconditionally — even paths/commands a sandbox policy would refuse.
|
||||
it('grants full filesystem + network for permission requests', () => {
|
||||
const { server, writes } = fakeServer();
|
||||
attachCodexAutoApproval(server);
|
||||
|
||||
server.serverRequestHandlers[0]({
|
||||
id: 1,
|
||||
method: 'item/permissions/requestApproval',
|
||||
params: { permissions: { fileSystem: { read: ['/workspace/agent'], write: ['/workspace/agent'] } } },
|
||||
});
|
||||
|
||||
const result = JSON.parse(writes[0]).result as {
|
||||
permissions: { fileSystem: { read: string[]; write: string[] }; network: { enabled: boolean } };
|
||||
scope: string;
|
||||
};
|
||||
expect(result.scope).toBe('turn');
|
||||
expect(result.permissions.fileSystem.read).toEqual(['/']);
|
||||
expect(result.permissions.fileSystem.write).toEqual(['/']);
|
||||
expect(result.permissions.network.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts file-change and command-exec approvals regardless of path', () => {
|
||||
const { server, writes } = fakeServer();
|
||||
attachCodexAutoApproval(server);
|
||||
|
||||
server.serverRequestHandlers[0]({ id: 2, method: 'item/fileChange/requestApproval', params: { grantRoot: '/etc' } });
|
||||
server.serverRequestHandlers[0]({
|
||||
id: 3,
|
||||
method: 'item/commandExecution/requestApproval',
|
||||
params: { command: 'rm -rf /', cwd: '/' },
|
||||
});
|
||||
|
||||
expect(JSON.parse(writes[0]).result).toEqual({ decision: 'accept' });
|
||||
expect(JSON.parse(writes[1]).result).toEqual({ decision: 'accept' });
|
||||
});
|
||||
|
||||
it('approves legacy patch and command-exec approvals regardless of path', () => {
|
||||
const { server, writes } = fakeServer();
|
||||
attachCodexAutoApproval(server);
|
||||
|
||||
server.serverRequestHandlers[0]({
|
||||
id: 4,
|
||||
method: 'applyPatchApproval',
|
||||
params: { fileChanges: { '/etc/passwd': {} } },
|
||||
});
|
||||
server.serverRequestHandlers[0]({ id: 5, method: 'execCommandApproval', params: { command: 'rm -rf /', cwd: '/' } });
|
||||
|
||||
expect(JSON.parse(writes[0]).result).toEqual({ decision: 'approved' });
|
||||
expect(JSON.parse(writes[1]).result).toEqual({ decision: 'approved' });
|
||||
});
|
||||
|
||||
it('fails closed for unknown server requests', () => {
|
||||
const { server, writes } = fakeServer();
|
||||
attachCodexAutoApproval(server);
|
||||
|
||||
server.serverRequestHandlers[0]({ id: 6, method: 'new/unknown/request' });
|
||||
|
||||
const response = JSON.parse(writes[0]);
|
||||
expect(response.error.message).toContain('Unhandled Codex app-server request');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Codex process env', () => {
|
||||
it('forwards proxy/runtime env without leaking secret-like host env', () => {
|
||||
const env = buildCodexProcessEnv({
|
||||
PATH: '/bin',
|
||||
HOME: '/home/node',
|
||||
CODEX_HOME: '/home/node/.codex',
|
||||
HTTPS_PROXY: 'http://proxy',
|
||||
OPENAI_API_KEY: 'sk-test',
|
||||
ONECLI_API_KEY: 'onecli-secret',
|
||||
SOME_TOKEN: 'token',
|
||||
});
|
||||
|
||||
expect(env.PATH).toBe('/bin');
|
||||
expect(env.HOME).toBe('/home/node');
|
||||
expect(env.CODEX_HOME).toBe('/home/node/.codex');
|
||||
expect(env.HTTPS_PROXY).toBe('http://proxy');
|
||||
expect(env.OPENAI_API_KEY).toBeUndefined();
|
||||
expect(env.ONECLI_API_KEY).toBeUndefined();
|
||||
expect(env.SOME_TOKEN).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
function fakeServer(): { server: AppServer; writes: string[] } {
|
||||
const writes: string[] = [];
|
||||
const server = {
|
||||
process: { stdin: { write: (line: string) => writes.push(line) } },
|
||||
readline: { close: () => {} },
|
||||
pending: new Map(),
|
||||
notificationHandlers: [],
|
||||
exitHandlers: [],
|
||||
serverRequestHandlers: [],
|
||||
} as unknown as AppServer;
|
||||
return { server, writes };
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { spawn, type ChildProcess } from 'child_process';
|
||||
import { createInterface, type Interface as ReadlineInterface } from 'readline';
|
||||
|
||||
// Cap Codex's project-doc loading (AGENTS.md). The host-side composer
|
||||
// (src/providers/codex-agents-md.ts) enforces the same cap at compose time —
|
||||
// host and container share no modules, so the constant lives in both.
|
||||
const CODEX_PROJECT_DOC_MAX_BYTES = 32 * 1024;
|
||||
|
||||
function log(msg: string): void {
|
||||
console.error(`[codex-app-server] ${msg}`);
|
||||
}
|
||||
|
||||
const INIT_TIMEOUT_MS = 30_000;
|
||||
|
||||
export const STALE_THREAD_RE = /thread\s+not\s+found|unknown\s+thread|thread[_\s]id|no such thread/i;
|
||||
|
||||
let nextRequestId = 1;
|
||||
|
||||
export interface JsonRpcResponse {
|
||||
id: number | string;
|
||||
result?: unknown;
|
||||
error?: { code: number; message: string; data?: unknown };
|
||||
}
|
||||
|
||||
export interface JsonRpcNotification {
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface JsonRpcServerRequest {
|
||||
id: number | string;
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
type JsonRpcMessage = JsonRpcResponse | JsonRpcNotification | JsonRpcServerRequest;
|
||||
|
||||
export interface AppServer {
|
||||
process: ChildProcess;
|
||||
readline: ReadlineInterface;
|
||||
pending: Map<number | string, { resolve: (r: JsonRpcResponse) => void; reject: (e: Error) => void }>;
|
||||
notificationHandlers: Array<(n: JsonRpcNotification) => void>;
|
||||
serverRequestHandlers: Array<(r: JsonRpcServerRequest) => void>;
|
||||
/**
|
||||
* Fired when the app-server process dies (exit or spawn error). Pending
|
||||
* request/response pairs are rejected separately via failPending — but a
|
||||
* turn in flight has NO pending request (turn/start already resolved); it
|
||||
* is parked on a notification waker that a dead process will never kick.
|
||||
* Without these handlers a mid-turn crash surfaces as a 10-minute turn
|
||||
* timeout instead of the real exit code, after the --rm container has
|
||||
* already taken the server's stderr with it.
|
||||
*/
|
||||
exitHandlers: Array<(err: Error) => void>;
|
||||
}
|
||||
|
||||
export interface CodexMcpServer {
|
||||
command: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
export type CodexReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
|
||||
|
||||
// Codex runs unrestricted inside the container. NanoClaw's container isolation and
|
||||
// the OneCLI allow-list are the security boundary — not Codex's own sandbox/approval
|
||||
// primitives (which can't run here anyway: workspace-write/read-only need user
|
||||
// namespaces, which the agent containers deny). Both are hardcoded as instance-level
|
||||
// defaults in config.toml; threads and turns inherit them, never override them.
|
||||
const CODEX_SANDBOX_MODE = 'danger-full-access';
|
||||
const CODEX_APPROVAL_POLICY = 'never';
|
||||
|
||||
const CODEX_ENV_ALLOWLIST = new Set([
|
||||
'ALL_PROXY',
|
||||
'CURL_CA_BUNDLE',
|
||||
'GIT_SSL_CAINFO',
|
||||
'HOME',
|
||||
'HTTP_PROXY',
|
||||
'HTTPS_PROXY',
|
||||
'LANG',
|
||||
'LC_ALL',
|
||||
'NODE_EXTRA_CA_CERTS',
|
||||
'NO_PROXY',
|
||||
'PATH',
|
||||
'PNPM_HOME',
|
||||
'REQUESTS_CA_BUNDLE',
|
||||
'SSL_CERT_DIR',
|
||||
'SSL_CERT_FILE',
|
||||
'TEMP',
|
||||
'TERM',
|
||||
'TMP',
|
||||
'TMPDIR',
|
||||
'TZ',
|
||||
'USER',
|
||||
'all_proxy',
|
||||
'http_proxy',
|
||||
'https_proxy',
|
||||
'no_proxy',
|
||||
'CODEX_HOME',
|
||||
]);
|
||||
|
||||
export interface ThreadParams {
|
||||
model?: string;
|
||||
cwd: string;
|
||||
baseInstructions?: string;
|
||||
developerInstructions?: string;
|
||||
}
|
||||
|
||||
export interface TurnParams {
|
||||
threadId: string;
|
||||
inputText: string;
|
||||
model?: string;
|
||||
effort?: string;
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
export function spawnCodexAppServer(): AppServer {
|
||||
const args = ['app-server', '--listen', 'stdio://'];
|
||||
log(`Spawning: codex ${args.join(' ')}`);
|
||||
|
||||
const proc = spawn('codex', args, {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: buildCodexProcessEnv(process.env),
|
||||
});
|
||||
const rl = createInterface({ input: proc.stdout! });
|
||||
|
||||
const server: AppServer = {
|
||||
process: proc,
|
||||
readline: rl,
|
||||
pending: new Map(),
|
||||
notificationHandlers: [],
|
||||
exitHandlers: [],
|
||||
serverRequestHandlers: [],
|
||||
};
|
||||
|
||||
proc.stderr?.on('data', (chunk: Buffer) => {
|
||||
const text = chunk.toString().trim();
|
||||
if (text) log(`[stderr] ${text}`);
|
||||
});
|
||||
|
||||
rl.on('line', (line: string) => {
|
||||
if (!line.trim()) return;
|
||||
let msg: JsonRpcMessage;
|
||||
try {
|
||||
msg = JSON.parse(line) as JsonRpcMessage;
|
||||
} catch {
|
||||
log(`[parse-error] ${line.slice(0, 200)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isResponse(msg)) {
|
||||
const handler = server.pending.get(msg.id);
|
||||
if (handler) {
|
||||
server.pending.delete(msg.id);
|
||||
handler.resolve(msg);
|
||||
}
|
||||
} else if (isServerRequest(msg)) {
|
||||
for (const h of server.serverRequestHandlers) h(msg);
|
||||
} else if ('method' in msg) {
|
||||
for (const h of server.notificationHandlers) h(msg as JsonRpcNotification);
|
||||
}
|
||||
});
|
||||
|
||||
const failPending = (err: Error): void => {
|
||||
for (const [, handler] of server.pending) handler.reject(err);
|
||||
server.pending.clear();
|
||||
};
|
||||
|
||||
proc.on('error', (err) => {
|
||||
log(`[process-error] ${err.message}`);
|
||||
failPending(err);
|
||||
for (const h of [...server.exitHandlers]) h(err);
|
||||
});
|
||||
|
||||
proc.on('exit', (code, signal) => {
|
||||
log(`[exit] code=${code} signal=${signal}`);
|
||||
const err = new Error(`Codex app-server exited: code=${code} signal=${signal}`);
|
||||
failPending(err);
|
||||
for (const h of [...server.exitHandlers]) h(err);
|
||||
});
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
export function sendCodexRequest(
|
||||
server: AppServer,
|
||||
method: string,
|
||||
params?: Record<string, unknown>,
|
||||
timeoutMs = 60_000,
|
||||
): Promise<JsonRpcResponse> {
|
||||
const id = nextRequestId++;
|
||||
const req = params === undefined ? { id, method } : { id, method, params };
|
||||
const line = JSON.stringify(req) + '\n';
|
||||
|
||||
return new Promise<JsonRpcResponse>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
server.pending.delete(id);
|
||||
reject(new Error(`Timeout waiting for ${method} response (${timeoutMs}ms)`));
|
||||
}, timeoutMs);
|
||||
|
||||
server.pending.set(id, {
|
||||
resolve: (r) => {
|
||||
clearTimeout(timer);
|
||||
resolve(r);
|
||||
},
|
||||
reject: (e) => {
|
||||
clearTimeout(timer);
|
||||
reject(e);
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
server.process.stdin!.write(line);
|
||||
} catch (err) {
|
||||
clearTimeout(timer);
|
||||
server.pending.delete(id);
|
||||
reject(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function sendCodexNotification(server: AppServer, method: string, params?: Record<string, unknown>): void {
|
||||
const line = JSON.stringify(params === undefined ? { method } : { method, params }) + '\n';
|
||||
server.process.stdin!.write(line);
|
||||
}
|
||||
|
||||
export function sendCodexResponse(server: AppServer, id: number | string, result: unknown): void {
|
||||
try {
|
||||
server.process.stdin!.write(JSON.stringify({ id, result }) + '\n');
|
||||
} catch (err) {
|
||||
log(`[send-error] response id=${id}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function killCodexAppServer(server: AppServer): void {
|
||||
try {
|
||||
server.readline.close();
|
||||
server.process.kill('SIGTERM');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export async function initializeCodexAppServer(server: AppServer): Promise<void> {
|
||||
const resp = await sendCodexRequest(
|
||||
server,
|
||||
'initialize',
|
||||
{
|
||||
clientInfo: { name: 'nanoclaw', title: 'NanoClaw', version: '2.0' },
|
||||
capabilities: { experimentalApi: true },
|
||||
},
|
||||
INIT_TIMEOUT_MS,
|
||||
);
|
||||
if (resp.error) throw new Error(`initialize failed: ${resp.error.message}`);
|
||||
sendCodexNotification(server, 'initialized');
|
||||
}
|
||||
|
||||
export async function startOrResumeCodexThread(
|
||||
server: AppServer,
|
||||
threadId: string | undefined,
|
||||
params: ThreadParams,
|
||||
): Promise<string> {
|
||||
const baseParams = {
|
||||
model: params.model,
|
||||
cwd: params.cwd,
|
||||
approvalPolicy: CODEX_APPROVAL_POLICY,
|
||||
sandbox: CODEX_SANDBOX_MODE,
|
||||
baseInstructions: params.baseInstructions,
|
||||
developerInstructions: params.developerInstructions,
|
||||
personality: 'friendly',
|
||||
sessionStartSource: 'startup',
|
||||
persistExtendedHistory: false,
|
||||
};
|
||||
|
||||
if (threadId) {
|
||||
const resp = await sendCodexRequest(server, 'thread/resume', {
|
||||
threadId,
|
||||
...baseParams,
|
||||
excludeTurns: true,
|
||||
});
|
||||
if (!resp.error) return threadId;
|
||||
if (!STALE_THREAD_RE.test(resp.error.message)) {
|
||||
throw new Error(`thread/resume failed: ${resp.error.message}`);
|
||||
}
|
||||
log(`Stale thread ${threadId}; starting fresh thread.`);
|
||||
}
|
||||
|
||||
const resp = await sendCodexRequest(server, 'thread/start', {
|
||||
...baseParams,
|
||||
experimentalRawEvents: false,
|
||||
});
|
||||
if (resp.error) throw new Error(`thread/start failed: ${resp.error.message}`);
|
||||
|
||||
const result = resp.result as { thread?: { id?: string } } | undefined;
|
||||
const newThreadId = result?.thread?.id;
|
||||
if (!newThreadId) throw new Error('thread/start response missing thread ID');
|
||||
return newThreadId;
|
||||
}
|
||||
|
||||
export async function startCodexTurn(server: AppServer, params: TurnParams): Promise<string> {
|
||||
const resp = await sendCodexRequest(server, 'turn/start', {
|
||||
threadId: params.threadId,
|
||||
input: [{ type: 'text', text: params.inputText, text_elements: [] }],
|
||||
model: params.model,
|
||||
effort: params.effort,
|
||||
cwd: params.cwd,
|
||||
});
|
||||
if (resp.error) throw new Error(`turn/start failed: ${resp.error.message}`);
|
||||
const result = resp.result as { turn?: { id?: string } } | undefined;
|
||||
const turnId = result?.turn?.id;
|
||||
if (!turnId) throw new Error('turn/start response missing turn ID');
|
||||
return turnId;
|
||||
}
|
||||
|
||||
export async function steerCodexTurn(
|
||||
server: AppServer,
|
||||
threadId: string,
|
||||
turnId: string,
|
||||
inputText: string,
|
||||
): Promise<void> {
|
||||
const resp = await sendCodexRequest(server, 'turn/steer', {
|
||||
threadId,
|
||||
expectedTurnId: turnId,
|
||||
input: [{ type: 'text', text: inputText, text_elements: [] }],
|
||||
});
|
||||
if (resp.error) throw new Error(`turn/steer failed: ${resp.error.message}`);
|
||||
}
|
||||
|
||||
export async function interruptCodexTurn(server: AppServer, threadId: string, turnId: string): Promise<void> {
|
||||
const resp = await sendCodexRequest(server, 'turn/interrupt', { threadId, turnId }, 10_000);
|
||||
if (resp.error) throw new Error(`turn/interrupt failed: ${resp.error.message}`);
|
||||
}
|
||||
|
||||
// With approval_policy=never the command/patch approval requests don't fire, but the
|
||||
// app-server still sends a few non-approval server→client requests (permission
|
||||
// negotiation, MCP elicitations, tool calls) that must be answered or the turn hangs.
|
||||
// NanoClaw is the boundary, so accept/grant everything.
|
||||
export function attachCodexAutoApproval(server: AppServer): void {
|
||||
server.serverRequestHandlers.push((req) => {
|
||||
switch (req.method) {
|
||||
case 'item/commandExecution/requestApproval':
|
||||
case 'item/fileChange/requestApproval':
|
||||
sendCodexResponse(server, req.id, { decision: 'accept' });
|
||||
break;
|
||||
case 'applyPatchApproval':
|
||||
case 'execCommandApproval':
|
||||
sendCodexResponse(server, req.id, { decision: 'approved' });
|
||||
break;
|
||||
case 'item/permissions/requestApproval':
|
||||
sendCodexResponse(server, req.id, {
|
||||
permissions: { fileSystem: { read: ['/'], write: ['/'] }, network: { enabled: true } },
|
||||
scope: 'turn',
|
||||
strictAutoReview: true,
|
||||
});
|
||||
break;
|
||||
case 'item/tool/requestUserInput':
|
||||
sendCodexResponse(server, req.id, { answers: {} });
|
||||
break;
|
||||
case 'mcpServer/elicitation/request':
|
||||
sendCodexResponse(server, req.id, { action: 'cancel', content: null, _meta: null });
|
||||
break;
|
||||
case 'item/tool/call':
|
||||
sendCodexResponse(server, req.id, { success: false, contentItems: [] });
|
||||
break;
|
||||
default:
|
||||
sendCodexError(server, req.id, `Unhandled Codex app-server request: ${req.method}`);
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function writeCodexConfigToml(
|
||||
servers: Record<string, CodexMcpServer>,
|
||||
opts: { model?: string; effort?: string } = {},
|
||||
): void {
|
||||
const codexConfigDir = path.join(process.env.HOME || '/home/node', '.codex');
|
||||
fs.mkdirSync(codexConfigDir, { recursive: true });
|
||||
const configTomlPath = path.join(codexConfigDir, 'config.toml');
|
||||
|
||||
// Instance-level defaults the app-server reads on startup; threads/turns inherit them.
|
||||
const lines: string[] = [
|
||||
`sandbox_mode = ${tomlBasicString(CODEX_SANDBOX_MODE)}`,
|
||||
`approval_policy = ${tomlBasicString(CODEX_APPROVAL_POLICY)}`,
|
||||
`project_doc_max_bytes = ${CODEX_PROJECT_DOC_MAX_BYTES}`,
|
||||
];
|
||||
if (opts.model) lines.push(`model = ${tomlBasicString(opts.model)}`);
|
||||
if (opts.effort) lines.push(`model_reasoning_effort = ${tomlBasicString(opts.effort)}`);
|
||||
lines.push('');
|
||||
|
||||
for (const [name, config] of Object.entries(servers)) {
|
||||
lines.push(`[mcp_servers.${name}]`);
|
||||
lines.push(`command = ${tomlBasicString(config.command)}`);
|
||||
if (config.args && config.args.length > 0) {
|
||||
lines.push(`args = [${config.args.map(tomlBasicString).join(', ')}]`);
|
||||
}
|
||||
if (config.env && Object.keys(config.env).length > 0) {
|
||||
lines.push(`[mcp_servers.${name}.env]`);
|
||||
for (const [key, value] of Object.entries(config.env)) {
|
||||
lines.push(`${key} = ${tomlBasicString(value)}`);
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
fs.writeFileSync(configTomlPath, lines.join('\n'));
|
||||
}
|
||||
|
||||
export function buildCodexProcessEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
const next: NodeJS.ProcessEnv = {};
|
||||
for (const key of CODEX_ENV_ALLOWLIST) {
|
||||
const value = env[key];
|
||||
if (value !== undefined) next[key] = value;
|
||||
}
|
||||
if (!next.CODEX_HOME) next.CODEX_HOME = next.HOME ? path.join(next.HOME, '.codex') : '/home/node/.codex';
|
||||
if (!next.HOME) next.HOME = '/home/node';
|
||||
return next;
|
||||
}
|
||||
|
||||
export function tomlBasicString(value: string): string {
|
||||
if (value.includes('\n') || value.includes('\r')) {
|
||||
throw new Error(`MCP config value contains newline: ${JSON.stringify(value.slice(0, 40))}`);
|
||||
}
|
||||
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
||||
}
|
||||
|
||||
function sendCodexError(server: AppServer, id: number | string, message: string, data?: unknown): void {
|
||||
try {
|
||||
server.process.stdin!.write(JSON.stringify({ id, error: { code: -32000, message, data } }) + '\n');
|
||||
} catch (err) {
|
||||
log(`[send-error] error id=${id}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function isResponse(msg: JsonRpcMessage): msg is JsonRpcResponse {
|
||||
return 'id' in msg && ('result' in msg || 'error' in msg) && !('method' in msg);
|
||||
}
|
||||
|
||||
function isServerRequest(msg: JsonRpcMessage): msg is JsonRpcServerRequest {
|
||||
return 'id' in msg && 'method' in msg;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Structural guard for the Codex CLI install in container/cli-tools.json.
|
||||
//
|
||||
// @openai/codex is a CLI *binary* installed from the global-CLI manifest (a
|
||||
// json-merge seam), not an importable package, so the barrel-driven
|
||||
// registration tests cannot see it. This test reads the real cli-tools.json
|
||||
// and asserts the @openai/codex entry is present and pinned to an exact
|
||||
// version. It goes red if the manifest entry is dropped or unpins.
|
||||
//
|
||||
// Runs under bun (same suite as the container registration test):
|
||||
// cd container/agent-runner && bun test src/providers/codex-cli-tools.test.ts
|
||||
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
|
||||
// container/agent-runner/src/providers/ -> container/cli-tools.json
|
||||
const MANIFEST = path.join(import.meta.dir, '..', '..', '..', 'cli-tools.json');
|
||||
const manifestPresent = existsSync(MANIFEST);
|
||||
|
||||
// Read lazily — `describe.skipIf` still runs the body to register tests, so the
|
||||
// read has to be guarded for the bare-branch (no manifest) case.
|
||||
const tools: Array<{ name: string; version: string }> = manifestPresent
|
||||
? JSON.parse(readFileSync(MANIFEST, 'utf8'))
|
||||
: [];
|
||||
const codex = tools.find((t) => t.name === '@openai/codex');
|
||||
|
||||
// cli-tools.json is a trunk file; on the bare providers branch it isn't present,
|
||||
// so skip there. In an installed tree (trunk + this payload) it must carry the
|
||||
// pinned @openai/codex entry.
|
||||
describe.skipIf(!manifestPresent)('container/cli-tools.json codex CLI install', () => {
|
||||
it('includes the @openai/codex entry', () => {
|
||||
expect(codex).toBeDefined();
|
||||
});
|
||||
|
||||
it('pins it to an exact semver (no latest, no ranges)', () => {
|
||||
expect(codex?.version).toMatch(/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Integration test for the codex provider's CONTAINER-side reach-in: the self-registration
|
||||
* import in container/agent-runner/src/providers/index.ts. Importing the barrel runs
|
||||
* codex.ts's top-level registerProvider('codex', …); without that import line
|
||||
* createProvider('codex') throws 'Unknown provider' at runtime.
|
||||
*
|
||||
* Behavior, not structural, and BARREL-ONLY: it imports the real barrel (./index.js),
|
||||
* never ./codex.js directly, then asserts listProviderNames() contains the provider. The
|
||||
* existing codex.factory.test.ts imports ./codex.js directly, so it self-registers and
|
||||
* stays GREEN when the barrel line is deleted — a unit test, not a registration guard.
|
||||
* This goes red if the barrel import is deleted/drifts or the barrel fails to evaluate. codex uses the @openai/codex CLI *binary* (not an importable package), so this test does not guard that dependency — the Dockerfile install line is guarded structurally + by the container build (see the skill validate step).
|
||||
*/
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
|
||||
import { listProviderNames } from './provider-registry.js';
|
||||
import './index.js'; // the real container provider barrel — triggers each provider's registerProvider()
|
||||
|
||||
describe('codex provider registration', () => {
|
||||
it('registers codex via the provider barrel', () => {
|
||||
expect(listProviderNames()).toContain('codex');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import { CodexProvider } from './codex.js';
|
||||
|
||||
describe('CodexProvider', () => {
|
||||
it('rejects unsupported reasoning effort values', () => {
|
||||
expect(() => new CodexProvider({ effort: 'max' })).toThrow(/Unsupported Codex reasoning effort/);
|
||||
});
|
||||
|
||||
it('normalizes supported reasoning effort values', () => {
|
||||
expect(new CodexProvider({ effort: 'HIGH' })).toBeInstanceOf(CodexProvider);
|
||||
});
|
||||
|
||||
it('accepts supported reasoning effort values', () => {
|
||||
expect(new CodexProvider({ effort: 'xhigh' })).toBeInstanceOf(CodexProvider);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,419 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { registerProvider } from './provider-registry.js';
|
||||
import type {
|
||||
AgentProvider,
|
||||
AgentQuery,
|
||||
McpServerConfig,
|
||||
ProviderEvent,
|
||||
ProviderExchange,
|
||||
ProviderOptions,
|
||||
QueryInput,
|
||||
} from './types.js';
|
||||
import { archiveProviderExchange } from './exchange-archive.js';
|
||||
import {
|
||||
type AppServer,
|
||||
type CodexReasoningEffort,
|
||||
type JsonRpcNotification,
|
||||
STALE_THREAD_RE,
|
||||
attachCodexAutoApproval,
|
||||
initializeCodexAppServer,
|
||||
interruptCodexTurn,
|
||||
killCodexAppServer,
|
||||
spawnCodexAppServer,
|
||||
startCodexTurn,
|
||||
startOrResumeCodexThread,
|
||||
steerCodexTurn,
|
||||
writeCodexConfigToml,
|
||||
} from './codex-app-server.js';
|
||||
|
||||
const TURN_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
const SUPPORTED_EFFORTS = new Set<CodexReasoningEffort>(['none', 'minimal', 'low', 'medium', 'high', 'xhigh']);
|
||||
|
||||
export interface CodexRuntimeDeps {
|
||||
writeCodexConfigToml: typeof writeCodexConfigToml;
|
||||
spawnCodexAppServer: typeof spawnCodexAppServer;
|
||||
attachCodexAutoApproval: typeof attachCodexAutoApproval;
|
||||
initializeCodexAppServer: typeof initializeCodexAppServer;
|
||||
startOrResumeCodexThread: typeof startOrResumeCodexThread;
|
||||
startCodexTurn: typeof startCodexTurn;
|
||||
steerCodexTurn: typeof steerCodexTurn;
|
||||
interruptCodexTurn: typeof interruptCodexTurn;
|
||||
killCodexAppServer: typeof killCodexAppServer;
|
||||
}
|
||||
|
||||
const defaultCodexRuntimeDeps: CodexRuntimeDeps = {
|
||||
writeCodexConfigToml,
|
||||
spawnCodexAppServer,
|
||||
attachCodexAutoApproval,
|
||||
initializeCodexAppServer,
|
||||
startOrResumeCodexThread,
|
||||
startCodexTurn,
|
||||
steerCodexTurn,
|
||||
interruptCodexTurn,
|
||||
killCodexAppServer,
|
||||
};
|
||||
|
||||
function classifyError(message: string): string | undefined {
|
||||
if (/auth|api key|unauthorized|login|credential/i.test(message)) return 'auth';
|
||||
if (/quota|rate limit|insufficient|billing|credit/i.test(message)) return 'quota';
|
||||
if (/sandbox|permission|denied/i.test(message)) return 'sandbox';
|
||||
if (/thread|conversation|session/i.test(message)) return 'stale-session';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeEffort(effort: string | undefined): CodexReasoningEffort | undefined {
|
||||
const normalized = effort?.trim().toLowerCase();
|
||||
if (!normalized) return undefined;
|
||||
if (!SUPPORTED_EFFORTS.has(normalized as CodexReasoningEffort)) {
|
||||
throw new Error(`Unsupported Codex reasoning effort: ${effort}`);
|
||||
}
|
||||
return normalized as CodexReasoningEffort;
|
||||
}
|
||||
|
||||
export class CodexProvider implements AgentProvider {
|
||||
readonly supportsNativeSlashCommands = false;
|
||||
// Codex has no native NanoClaw memory — opt in to the runner's persistent
|
||||
// memory/ scaffold (see memory-scaffold.ts).
|
||||
readonly usesMemoryScaffold = true;
|
||||
// The app-server keeps history server-side; there is no on-disk transcript,
|
||||
// so the provider persists each exchange itself into `conversations/`
|
||||
// (see exchange-archive.ts). The poll-loop reports exchanges through this
|
||||
// hook and does nothing else — archiving is payload code, not runner code.
|
||||
onExchangeComplete(exchange: ProviderExchange): void {
|
||||
archiveProviderExchange({
|
||||
provider: 'codex',
|
||||
prompt: exchange.prompt,
|
||||
result: exchange.result,
|
||||
continuation: exchange.continuation,
|
||||
status: exchange.status,
|
||||
});
|
||||
}
|
||||
|
||||
private readonly mcpServers: Record<string, McpServerConfig>;
|
||||
private readonly model?: string;
|
||||
private readonly effort?: CodexReasoningEffort;
|
||||
private readonly runtime: CodexRuntimeDeps;
|
||||
|
||||
constructor(options: ProviderOptions = {}, runtime: CodexRuntimeDeps = defaultCodexRuntimeDeps) {
|
||||
this.mcpServers = options.mcpServers ?? {};
|
||||
this.model = options.model;
|
||||
this.runtime = runtime;
|
||||
this.effort = normalizeEffort(options.effort);
|
||||
}
|
||||
|
||||
isSessionInvalid(err: unknown): boolean {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return STALE_THREAD_RE.test(msg);
|
||||
}
|
||||
|
||||
query(input: QueryInput): AgentQuery {
|
||||
const pending: string[] = [input.prompt];
|
||||
let waiting: (() => void) | null = null;
|
||||
let ended = false;
|
||||
let aborted = false;
|
||||
let activeServer: AppServer | null = null;
|
||||
let activeThreadId: string | null = null;
|
||||
let activeTurnId: string | null = null;
|
||||
let wakeActiveTurn: (() => void) | null = null;
|
||||
|
||||
const wake = (): void => {
|
||||
waiting?.();
|
||||
waiting = null;
|
||||
};
|
||||
|
||||
const pushOrSteer = (message: string): void => {
|
||||
if (activeServer && activeThreadId && activeTurnId) {
|
||||
void this.runtime.steerCodexTurn(activeServer, activeThreadId, activeTurnId, message).catch(() => {
|
||||
pending.push(message);
|
||||
wake();
|
||||
});
|
||||
return;
|
||||
}
|
||||
pending.push(message);
|
||||
wake();
|
||||
};
|
||||
|
||||
const self = this;
|
||||
|
||||
async function* gen(): AsyncGenerator<ProviderEvent> {
|
||||
self.runtime.writeCodexConfigToml(self.mcpServers, { model: self.model, effort: self.effort });
|
||||
const server = self.runtime.spawnCodexAppServer();
|
||||
activeServer = server;
|
||||
self.runtime.attachCodexAutoApproval(server);
|
||||
|
||||
let threadId: string | undefined = input.continuation;
|
||||
let initYielded = false;
|
||||
|
||||
try {
|
||||
await self.runtime.initializeCodexAppServer(server);
|
||||
threadId = await self.runtime.startOrResumeCodexThread(server, threadId, {
|
||||
model: self.model,
|
||||
cwd: input.cwd,
|
||||
baseInstructions: input.systemContext?.instructions,
|
||||
});
|
||||
activeThreadId = threadId;
|
||||
|
||||
while (!aborted) {
|
||||
while (pending.length === 0 && !ended && !aborted) {
|
||||
await new Promise<void>((resolve) => {
|
||||
waiting = resolve;
|
||||
});
|
||||
}
|
||||
if (aborted) return;
|
||||
if (pending.length === 0 && ended) return;
|
||||
|
||||
const text = pending.shift()!;
|
||||
yield* runOneTurn(
|
||||
server,
|
||||
threadId,
|
||||
text,
|
||||
self.model,
|
||||
self.effort,
|
||||
input.cwd,
|
||||
(turnId) => {
|
||||
activeTurnId = turnId;
|
||||
},
|
||||
() => {
|
||||
activeTurnId = null;
|
||||
},
|
||||
() => initYielded,
|
||||
() => {
|
||||
initYielded = true;
|
||||
},
|
||||
() => aborted,
|
||||
(waker) => {
|
||||
wakeActiveTurn = waker;
|
||||
},
|
||||
self.runtime.startCodexTurn,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
activeTurnId = null;
|
||||
activeThreadId = null;
|
||||
activeServer = null;
|
||||
wakeActiveTurn = null;
|
||||
self.runtime.killCodexAppServer(server);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
push: pushOrSteer,
|
||||
end: () => {
|
||||
ended = true;
|
||||
wake();
|
||||
},
|
||||
abort: () => {
|
||||
aborted = true;
|
||||
if (activeServer && activeThreadId && activeTurnId) {
|
||||
void this.runtime.interruptCodexTurn(activeServer, activeThreadId, activeTurnId).catch(() => {});
|
||||
}
|
||||
wakeActiveTurn?.();
|
||||
wake();
|
||||
},
|
||||
events: gen(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function* runOneTurn(
|
||||
server: AppServer,
|
||||
threadId: string,
|
||||
inputText: string,
|
||||
model: string | undefined,
|
||||
effort: string | undefined,
|
||||
cwd: string,
|
||||
setActiveTurn: (turnId: string) => void,
|
||||
clearActiveTurn: () => void,
|
||||
hasInit: () => boolean,
|
||||
markInit: () => void,
|
||||
isAborted: () => boolean,
|
||||
setAbortWaker: (waker: (() => void) | null) => void,
|
||||
startTurn: typeof startCodexTurn,
|
||||
): AsyncGenerator<ProviderEvent> {
|
||||
const state: { error: Error | null } = { error: null };
|
||||
let resultText = '';
|
||||
let turnDone = false;
|
||||
let turnId: string | null = null;
|
||||
|
||||
// A finished turn can no longer absorb steered input: codex's turn/steer
|
||||
// against a completed turn resolves as a no-op, so a follow-up routed there
|
||||
// is lost silently. Clear the active-turn marker the moment the turn ends —
|
||||
// before the generator drains and tears down in its `finally` — so
|
||||
// pushOrSteer queues any racing follow-up into a fresh turn instead.
|
||||
const finishTurn = (): void => {
|
||||
turnDone = true;
|
||||
clearActiveTurn();
|
||||
};
|
||||
|
||||
const buffer: ProviderEvent[] = [];
|
||||
let waker: (() => void) | null = null;
|
||||
const kick = (): void => {
|
||||
waker?.();
|
||||
waker = null;
|
||||
};
|
||||
setAbortWaker(kick);
|
||||
|
||||
const handler = (n: JsonRpcNotification): void => {
|
||||
const method = n.method;
|
||||
const params = n.params ?? {};
|
||||
buffer.push({ type: 'activity' });
|
||||
|
||||
switch (method) {
|
||||
case 'thread/started': {
|
||||
const thread = params.thread as { id?: string } | undefined;
|
||||
if (thread?.id && !hasInit()) {
|
||||
markInit();
|
||||
buffer.push({ type: 'init', continuation: thread.id });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'turn/started': {
|
||||
const turn = params.turn as { id?: string } | undefined;
|
||||
if (turn?.id) {
|
||||
turnId = turn.id;
|
||||
setActiveTurn(turn.id);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'item/agentMessage/delta': {
|
||||
const delta = params.delta as string | undefined;
|
||||
if (delta) resultText += delta;
|
||||
break;
|
||||
}
|
||||
case 'item/completed': {
|
||||
const item = params.item as { type?: string; text?: string } | undefined;
|
||||
if (item?.type === 'agentMessage' && item.text) resultText = item.text;
|
||||
break;
|
||||
}
|
||||
case 'thread/status/changed': {
|
||||
const status = params.status as string | undefined;
|
||||
if (status) buffer.push({ type: 'progress', message: `status: ${status}` });
|
||||
break;
|
||||
}
|
||||
case 'error': {
|
||||
const err = params.error as { message?: string; additionalDetails?: string | null } | undefined;
|
||||
const msg = [err?.message, err?.additionalDetails].filter(Boolean).join(': ') || 'Codex turn failed';
|
||||
state.error = new Error(msg);
|
||||
finishTurn();
|
||||
break;
|
||||
}
|
||||
case 'turn/completed': {
|
||||
const turn = params.turn as
|
||||
| { error?: { message?: string; additionalDetails?: string | null } | null; items?: unknown[] }
|
||||
| undefined;
|
||||
const agentMessage = turn?.items
|
||||
?.filter((item): item is { type: string; text?: string } => typeof item === 'object' && item !== null)
|
||||
.find((item) => item.type === 'agentMessage' && item.text);
|
||||
if (agentMessage?.text) resultText = agentMessage.text;
|
||||
if (turn?.error) {
|
||||
const msg =
|
||||
[turn.error.message, turn.error.additionalDetails].filter(Boolean).join(': ') || 'Codex turn failed';
|
||||
state.error = new Error(msg);
|
||||
}
|
||||
finishTurn();
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
kick();
|
||||
};
|
||||
|
||||
server.notificationHandlers.push(handler);
|
||||
|
||||
// A dead app-server can't send the notification this turn is parked on —
|
||||
// end the turn immediately with the real cause instead of the 10-min timeout.
|
||||
const onServerExit = (err: Error): void => {
|
||||
if (turnDone) return;
|
||||
state.error = err;
|
||||
finishTurn();
|
||||
kick();
|
||||
};
|
||||
server.exitHandlers.push(onServerExit);
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
state.error = new Error(`Turn timed out after ${TURN_TIMEOUT_MS}ms`);
|
||||
finishTurn();
|
||||
kick();
|
||||
}, TURN_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
if (!hasInit()) {
|
||||
markInit();
|
||||
buffer.push({ type: 'init', continuation: threadId });
|
||||
}
|
||||
|
||||
turnId = await startTurn(server, {
|
||||
threadId,
|
||||
inputText,
|
||||
model,
|
||||
effort,
|
||||
cwd,
|
||||
});
|
||||
setActiveTurn(turnId);
|
||||
const imagesBefore = listGeneratedImages(threadId);
|
||||
if (isAborted()) return;
|
||||
|
||||
while (true) {
|
||||
while (buffer.length > 0) {
|
||||
yield buffer.shift()!;
|
||||
}
|
||||
if (turnDone || isAborted()) break;
|
||||
await new Promise<void>((resolve) => {
|
||||
waker = resolve;
|
||||
});
|
||||
waker = null;
|
||||
}
|
||||
|
||||
while (buffer.length > 0) yield buffer.shift()!;
|
||||
|
||||
if (isAborted()) return;
|
||||
|
||||
if (state.error) {
|
||||
yield {
|
||||
type: 'error',
|
||||
message: state.error.message,
|
||||
retryable: false,
|
||||
classification: classifyError(state.error.message),
|
||||
};
|
||||
throw state.error;
|
||||
}
|
||||
|
||||
for (const imagePath of listGeneratedImages(threadId)) {
|
||||
if (!imagesBefore.has(imagePath)) {
|
||||
yield { type: 'file', path: imagePath };
|
||||
}
|
||||
}
|
||||
|
||||
yield { type: 'result', text: resultText || null };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
clearActiveTurn();
|
||||
setAbortWaker(null);
|
||||
const idx = server.notificationHandlers.indexOf(handler);
|
||||
if (idx >= 0) server.notificationHandlers.splice(idx, 1);
|
||||
const exitIdx = server.exitHandlers.indexOf(onServerExit);
|
||||
if (exitIdx >= 0) server.exitHandlers.splice(exitIdx, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Codex's built-in image generation saves into CODEX_HOME/generated_images/
|
||||
* <threadId>/ — its native client renders those to the user, so the model
|
||||
* believes delivery already happened and won't send_file them. The runner
|
||||
* must deliver them itself: snapshot the dir at turn start, emit a `file`
|
||||
* event for anything new at turn end.
|
||||
*/
|
||||
function listGeneratedImages(threadId: string): Set<string> {
|
||||
const dir = path.join(process.env.CODEX_HOME || '/home/node/.codex', 'generated_images', threadId);
|
||||
try {
|
||||
return new Set(fs.readdirSync(dir).map((f) => path.join(dir, f)));
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
registerProvider('codex', (opts) => new CodexProvider(opts));
|
||||
@@ -0,0 +1,267 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { CodexProvider, type CodexRuntimeDeps } from './codex.js';
|
||||
import type { AppServer, JsonRpcNotification, TurnParams } from './codex-app-server.js';
|
||||
import type { ProviderEvent } from './types.js';
|
||||
|
||||
describe('CodexProvider active turns', () => {
|
||||
it('steers follow-ups into the active turn and yields liveness activity', async () => {
|
||||
const fake = createFakeCodexRuntime();
|
||||
const provider = new CodexProvider({}, fake.runtime);
|
||||
const query = provider.query({ prompt: 'first prompt', cwd: '/workspace/agent' });
|
||||
const events: ProviderEvent[] = [];
|
||||
|
||||
const collect = collectEvents(query.events, events);
|
||||
|
||||
await waitFor(() => fake.startCalls.length === 1);
|
||||
query.push('follow-up prompt');
|
||||
await waitFor(() => fake.steerCalls.length === 1);
|
||||
query.end();
|
||||
fake.completeTurn('final answer');
|
||||
|
||||
await collect;
|
||||
|
||||
expect(fake.startCalls).toHaveLength(1);
|
||||
expect(fake.startCalls[0].inputText).toBe('first prompt');
|
||||
expect(fake.steerCalls).toEqual([{ threadId: 'thread-1', turnId: 'turn-1', inputText: 'follow-up prompt' }]);
|
||||
expect(events.filter((event) => event.type === 'activity').length).toBeGreaterThanOrEqual(2);
|
||||
expect(events.filter((event) => event.type === 'result')).toEqual([{ type: 'result', text: 'final answer' }]);
|
||||
expect(fake.killed).toBe(true);
|
||||
});
|
||||
|
||||
it('queues follow-ups for the next turn when steering is rejected', async () => {
|
||||
const fake = createFakeCodexRuntime({ rejectSteer: true });
|
||||
const provider = new CodexProvider({}, fake.runtime);
|
||||
const query = provider.query({ prompt: 'first prompt', cwd: '/workspace/agent' });
|
||||
const events: ProviderEvent[] = [];
|
||||
|
||||
const collect = collectEvents(query.events, events);
|
||||
|
||||
await waitFor(() => fake.startCalls.length === 1);
|
||||
query.push('queued follow-up');
|
||||
await waitFor(() => fake.steerCalls.length === 1);
|
||||
await sleep(0);
|
||||
|
||||
fake.completeTurn('first answer');
|
||||
await waitFor(() => fake.startCalls.length === 2);
|
||||
query.end();
|
||||
fake.completeTurn('second answer');
|
||||
|
||||
await collect;
|
||||
|
||||
expect(fake.startCalls.map((call) => call.inputText)).toEqual(['first prompt', 'queued follow-up']);
|
||||
expect(fake.steerCalls).toHaveLength(1);
|
||||
expect(events.filter((event) => event.type === 'result')).toEqual([
|
||||
{ type: 'result', text: 'first answer' },
|
||||
{ type: 'result', text: 'second answer' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('queues a follow-up that races turn completion into a new turn, never steering the finished turn', async () => {
|
||||
const fake = createFakeCodexRuntime();
|
||||
const provider = new CodexProvider({}, fake.runtime);
|
||||
const query = provider.query({ prompt: 'first prompt', cwd: '/workspace/agent' });
|
||||
const events: ProviderEvent[] = [];
|
||||
|
||||
const collect = collectEvents(query.events, events);
|
||||
|
||||
await waitFor(() => fake.startCalls.length === 1);
|
||||
|
||||
// The turn completes, then a follow-up lands in the same tick — before the
|
||||
// generator has drained and torn the turn down. codex's turn/steer no-ops
|
||||
// on a finished turn (resolves without error), so steering here would drop
|
||||
// the message silently. It must start a fresh turn instead.
|
||||
fake.completeTurn('first answer');
|
||||
query.push('racing follow-up');
|
||||
|
||||
await waitFor(() => fake.startCalls.length === 2);
|
||||
query.end();
|
||||
fake.completeTurn('second answer');
|
||||
|
||||
await collect;
|
||||
|
||||
expect(fake.steerCalls).toHaveLength(0);
|
||||
expect(fake.startCalls.map((call) => call.inputText)).toEqual(['first prompt', 'racing follow-up']);
|
||||
expect(events.filter((event) => event.type === 'result')).toEqual([
|
||||
{ type: 'result', text: 'first answer' },
|
||||
{ type: 'result', text: 'second answer' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('interrupts the active turn and closes the stream on abort', async () => {
|
||||
const fake = createFakeCodexRuntime();
|
||||
const provider = new CodexProvider({}, fake.runtime);
|
||||
const query = provider.query({ prompt: 'first prompt', cwd: '/workspace/agent' });
|
||||
const events: ProviderEvent[] = [];
|
||||
|
||||
const collect = collectEvents(query.events, events);
|
||||
|
||||
await waitFor(() => fake.startCalls.length === 1);
|
||||
query.abort();
|
||||
|
||||
await collect;
|
||||
|
||||
expect(fake.interruptCalls).toEqual([{ threadId: 'thread-1', turnId: 'turn-1' }]);
|
||||
expect(events.some((event) => event.type === 'result')).toBe(false);
|
||||
expect(fake.killed).toBe(true);
|
||||
});
|
||||
|
||||
it('threads the configured model and effort into the turn', async () => {
|
||||
const fake = createFakeCodexRuntime();
|
||||
const provider = new CodexProvider({ model: 'gpt-5.5', effort: 'high' }, fake.runtime);
|
||||
const query = provider.query({ prompt: 'first prompt', cwd: '/workspace/agent' });
|
||||
const events: ProviderEvent[] = [];
|
||||
|
||||
const collect = collectEvents(query.events, events);
|
||||
|
||||
await waitFor(() => fake.startCalls.length === 1);
|
||||
query.end();
|
||||
fake.completeTurn('final answer');
|
||||
|
||||
await collect;
|
||||
|
||||
expect(fake.startCalls[0].model).toBe('gpt-5.5');
|
||||
expect(fake.startCalls[0].effort).toBe('high');
|
||||
expect(events.filter((event) => event.type === 'result')).toEqual([{ type: 'result', text: 'final answer' }]);
|
||||
});
|
||||
|
||||
it('delivers harness-generated images as file events — the model never sends them itself', async () => {
|
||||
const codexHome = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-home-'));
|
||||
const prevHome = process.env.CODEX_HOME;
|
||||
process.env.CODEX_HOME = codexHome;
|
||||
try {
|
||||
const fake = createFakeCodexRuntime();
|
||||
const provider = new CodexProvider({}, fake.runtime);
|
||||
const query = provider.query({ prompt: 'make an image', cwd: '/workspace/agent' });
|
||||
const events: ProviderEvent[] = [];
|
||||
const collect = collectEvents(query.events, events);
|
||||
|
||||
await waitFor(() => fake.startCalls.length === 1);
|
||||
// Codex's built-in image_gen writes into CODEX_HOME mid-turn.
|
||||
const imagesDir = path.join(codexHome, 'generated_images', 'thread-1');
|
||||
fs.mkdirSync(imagesDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(imagesDir, 'ig_abc.png'), 'png-bytes');
|
||||
|
||||
query.end();
|
||||
fake.completeTurn('Here you go — created the image.');
|
||||
await collect;
|
||||
|
||||
const files = events.filter((event) => event.type === 'file') as Array<{ type: 'file'; path: string }>;
|
||||
expect(files).toHaveLength(1);
|
||||
expect(files[0].path).toBe(path.join(imagesDir, 'ig_abc.png'));
|
||||
// file events arrive before the result so delivery shares the turn.
|
||||
expect(events.findIndex((e) => e.type === 'file')).toBeLessThan(events.findIndex((e) => e.type === 'result'));
|
||||
} finally {
|
||||
if (prevHome === undefined) delete process.env.CODEX_HOME;
|
||||
else process.env.CODEX_HOME = prevHome;
|
||||
fs.rmSync(codexHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('ends the turn immediately with the real cause when the app-server dies mid-turn', async () => {
|
||||
const fake = createFakeCodexRuntime();
|
||||
const provider = new CodexProvider({}, fake.runtime);
|
||||
const query = provider.query({ prompt: 'prompt', cwd: '/workspace/agent' });
|
||||
const events: ProviderEvent[] = [];
|
||||
|
||||
const collect = collectEvents(query.events, events);
|
||||
await waitFor(() => fake.startCalls.length === 1);
|
||||
|
||||
// No pending request exists mid-turn (turn/start already resolved), so
|
||||
// only the exitHandlers seam can end the turn — without it this parks
|
||||
// on the waker until the 10-minute turn timeout.
|
||||
fake.crashServer(new Error('Codex app-server exited: code=1 signal=null'));
|
||||
|
||||
// The generator yields the error event, then rethrows to its consumer.
|
||||
await collect.catch(() => {});
|
||||
|
||||
const errors = events.filter((event) => event.type === 'error');
|
||||
expect(errors).toHaveLength(1);
|
||||
expect((errors[0] as { message: string }).message).toContain('app-server exited');
|
||||
});
|
||||
});
|
||||
|
||||
function createFakeCodexRuntime(opts: { rejectSteer?: boolean } = {}) {
|
||||
const server = fakeServer();
|
||||
const startCalls: TurnParams[] = [];
|
||||
const steerCalls: Array<{ threadId: string; turnId: string; inputText: string }> = [];
|
||||
const interruptCalls: Array<{ threadId: string; turnId: string }> = [];
|
||||
let killed = false;
|
||||
|
||||
const notify = (method: string, params?: Record<string, unknown>): void => {
|
||||
const notification: JsonRpcNotification = { method, params };
|
||||
for (const handler of [...server.notificationHandlers]) handler(notification);
|
||||
};
|
||||
|
||||
const runtime: CodexRuntimeDeps = {
|
||||
writeCodexConfigToml: () => {},
|
||||
spawnCodexAppServer: () => server,
|
||||
attachCodexAutoApproval: () => {},
|
||||
initializeCodexAppServer: async () => {},
|
||||
startOrResumeCodexThread: async (_server, threadId) => threadId ?? 'thread-1',
|
||||
startCodexTurn: async (_server, params) => {
|
||||
startCalls.push(params);
|
||||
const turnId = `turn-${startCalls.length}`;
|
||||
notify('turn/started', { turn: { id: turnId } });
|
||||
return turnId;
|
||||
},
|
||||
steerCodexTurn: async (_server, threadId, turnId, inputText) => {
|
||||
steerCalls.push({ threadId, turnId, inputText });
|
||||
if (opts.rejectSteer) throw new Error('steer rejected');
|
||||
},
|
||||
interruptCodexTurn: async (_server, threadId, turnId) => {
|
||||
interruptCalls.push({ threadId, turnId });
|
||||
},
|
||||
killCodexAppServer: () => {
|
||||
killed = true;
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
runtime,
|
||||
startCalls,
|
||||
steerCalls,
|
||||
interruptCalls,
|
||||
get killed() {
|
||||
return killed;
|
||||
},
|
||||
completeTurn(text: string) {
|
||||
notify('turn/completed', { turn: { items: [{ type: 'agentMessage', text }] } });
|
||||
},
|
||||
crashServer(err: Error) {
|
||||
for (const h of [...server.exitHandlers]) h(err);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function fakeServer(): AppServer {
|
||||
return {
|
||||
process: { stdin: { write: () => true }, kill: () => true },
|
||||
readline: { close: () => {} },
|
||||
pending: new Map(),
|
||||
notificationHandlers: [],
|
||||
exitHandlers: [],
|
||||
serverRequestHandlers: [],
|
||||
} as unknown as AppServer;
|
||||
}
|
||||
|
||||
async function collectEvents(events: AsyncIterable<ProviderEvent>, sink: ProviderEvent[]): Promise<void> {
|
||||
for await (const event of events) {
|
||||
sink.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitFor(condition: () => boolean, timeoutMs = 1000): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (!condition()) {
|
||||
if (Date.now() - start > timeoutMs) throw new Error('waitFor timeout');
|
||||
await sleep(10);
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { afterEach, describe, expect, it } from 'bun:test';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { archiveProviderExchange } from './exchange-archive.js';
|
||||
|
||||
let tmpDir: string | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (tmpDir) {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
tmpDir = null;
|
||||
}
|
||||
});
|
||||
|
||||
function makeTmpDir(): string {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-archive-'));
|
||||
return tmpDir;
|
||||
}
|
||||
|
||||
describe('provider exchange archive', () => {
|
||||
it('appends same-thread exchanges into one file with a single header', () => {
|
||||
const conversationsDir = makeTmpDir();
|
||||
const timestamp = new Date('2026-06-03T12:34:56.789Z');
|
||||
|
||||
const first = archiveProviderExchange({
|
||||
conversationsDir,
|
||||
provider: 'codex',
|
||||
prompt: 'hello',
|
||||
result: 'world',
|
||||
continuation: 'thread-123',
|
||||
status: 'completed',
|
||||
timestamp,
|
||||
});
|
||||
const second = archiveProviderExchange({
|
||||
conversationsDir,
|
||||
provider: 'codex',
|
||||
prompt: 'hello again',
|
||||
result: 'world again',
|
||||
continuation: 'thread-123',
|
||||
status: 'completed',
|
||||
timestamp,
|
||||
});
|
||||
|
||||
// Same thread → same date-prefixed, thread-stable file, not one per exchange.
|
||||
expect(first).toBe('2026-06-03-codex-thread-123.md');
|
||||
expect(second).toBe(first);
|
||||
expect(fs.readdirSync(conversationsDir)).toHaveLength(1);
|
||||
|
||||
const content = fs.readFileSync(path.join(conversationsDir, first!), 'utf-8');
|
||||
// Header (thread-level metadata) written exactly once.
|
||||
expect(content.match(/# Codex Conversation/g)).toHaveLength(1);
|
||||
expect(content).toContain('Provider: codex');
|
||||
expect(content).toContain('Continuation/thread id: thread-123');
|
||||
// Both exchanges present, each with its own status line.
|
||||
expect(content).toContain('**User**: hello');
|
||||
expect(content).toContain('**Assistant**: world');
|
||||
expect(content).toContain('**User**: hello again');
|
||||
expect(content).toContain('**Assistant**: world again');
|
||||
expect(content.match(/Status: completed/g)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('writes a separate file per thread', () => {
|
||||
const conversationsDir = makeTmpDir();
|
||||
const timestamp = new Date('2026-06-03T12:34:56.789Z');
|
||||
|
||||
const a = archiveProviderExchange({
|
||||
conversationsDir,
|
||||
provider: 'codex',
|
||||
prompt: 'p',
|
||||
result: 'r',
|
||||
continuation: 'thread-a',
|
||||
status: 'completed',
|
||||
timestamp,
|
||||
});
|
||||
const b = archiveProviderExchange({
|
||||
conversationsDir,
|
||||
provider: 'codex',
|
||||
prompt: 'p',
|
||||
result: 'r',
|
||||
continuation: 'thread-b',
|
||||
status: 'completed',
|
||||
timestamp,
|
||||
});
|
||||
|
||||
expect(a).toBe('2026-06-03-codex-thread-a.md');
|
||||
expect(b).toBe('2026-06-03-codex-thread-b.md');
|
||||
expect(fs.readdirSync(conversationsDir)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('keeps the creation-date prefix stable when later exchanges land on another day', () => {
|
||||
const conversationsDir = makeTmpDir();
|
||||
|
||||
const first = archiveProviderExchange({
|
||||
conversationsDir,
|
||||
provider: 'codex',
|
||||
prompt: 'a',
|
||||
result: 'b',
|
||||
continuation: 'thread-x',
|
||||
status: 'completed',
|
||||
timestamp: new Date('2026-06-03T10:00:00.000Z'),
|
||||
});
|
||||
// A later exchange on a different day must append to the same file, not
|
||||
// mint a new 2026-06-05-* one (the bug a naive date-from-timestamp scheme
|
||||
// would introduce).
|
||||
const second = archiveProviderExchange({
|
||||
conversationsDir,
|
||||
provider: 'codex',
|
||||
prompt: 'c',
|
||||
result: 'd',
|
||||
continuation: 'thread-x',
|
||||
status: 'completed',
|
||||
timestamp: new Date('2026-06-05T10:00:00.000Z'),
|
||||
});
|
||||
|
||||
expect(first).toBe('2026-06-03-codex-thread-x.md');
|
||||
expect(second).toBe(first);
|
||||
expect(fs.readdirSync(conversationsDir)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('skips empty result text', () => {
|
||||
const conversationsDir = makeTmpDir();
|
||||
const filename = archiveProviderExchange({
|
||||
conversationsDir,
|
||||
provider: 'codex',
|
||||
prompt: 'hello',
|
||||
result: ' ',
|
||||
continuation: 'thread-123',
|
||||
status: 'completed',
|
||||
});
|
||||
|
||||
expect(filename).toBeNull();
|
||||
expect(fs.readdirSync(conversationsDir)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { TIMEZONE, formatLocalStamp } from '../timezone.js';
|
||||
|
||||
/**
|
||||
* Per-thread conversation archive for providers with no on-disk transcript —
|
||||
* payload code, shipped with the provider that needs it. The provider's
|
||||
* `onExchangeComplete` hook (see types.ts) calls this with each completed
|
||||
* exchange; the runner never archives on a provider's behalf.
|
||||
*
|
||||
* One file per thread (keyed on the continuation id), named
|
||||
* `<date>-<provider>-<thread>.md` and appended to as exchanges complete —
|
||||
* mirroring the Claude path's one-file-per-session granularity and its
|
||||
* date-prefixed, name-sortable filenames, since the Codex app-server keeps
|
||||
* history server-side with no transcript to roll up at a compaction boundary.
|
||||
* The date is the thread's creation day and stays stable across later appends.
|
||||
*/
|
||||
|
||||
const DEFAULT_CONVERSATIONS_DIR = '/workspace/agent/conversations';
|
||||
|
||||
export interface ProviderExchangeArchiveOptions {
|
||||
provider: string;
|
||||
prompt: string;
|
||||
result: string | null | undefined;
|
||||
continuation?: string;
|
||||
status: string;
|
||||
timestamp?: Date;
|
||||
conversationsDir?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a single prompt/result exchange to its thread's conversation file,
|
||||
* writing the thread-level header once when the file is first created. Returns
|
||||
* the (thread-stable) filename, or null when there is nothing to archive
|
||||
* (empty result).
|
||||
*/
|
||||
export function archiveProviderExchange(options: ProviderExchangeArchiveOptions): string | null {
|
||||
const result = options.result?.trim();
|
||||
if (!result) return null;
|
||||
|
||||
const timestamp = options.timestamp ?? new Date();
|
||||
const conversationsDir =
|
||||
options.conversationsDir || process.env.NANOCLAW_CONVERSATIONS_DIR || DEFAULT_CONVERSATIONS_DIR;
|
||||
fs.mkdirSync(conversationsDir, { recursive: true });
|
||||
|
||||
const filename = threadArchiveFilename(conversationsDir, options.provider, options.continuation, timestamp);
|
||||
const filePath = path.join(conversationsDir, filename);
|
||||
|
||||
// Thread-level metadata (provider, thread id) belongs in the header, written
|
||||
// once. Per-exchange metadata (timestamp, status) rides in each appended
|
||||
// block. Each block leads with a blank line + `---` so the separator renders
|
||||
// as a thematic break, not a setext heading underline on the prior line.
|
||||
const parts: string[] = [];
|
||||
if (!fs.existsSync(filePath)) {
|
||||
parts.push(
|
||||
`# ${titleCase(options.provider)} Conversation`,
|
||||
'',
|
||||
`Provider: ${options.provider}`,
|
||||
`Continuation/thread id: ${options.continuation || '(none)'}`,
|
||||
);
|
||||
}
|
||||
parts.push(
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
`Archived: ${formatLocalStamp(timestamp, TIMEZONE)} · Status: ${options.status}`,
|
||||
'',
|
||||
`**User**: ${truncate(options.prompt)}`,
|
||||
'',
|
||||
`**Assistant**: ${truncate(result)}`,
|
||||
'',
|
||||
);
|
||||
fs.appendFileSync(filePath, parts.join('\n'));
|
||||
return filename;
|
||||
}
|
||||
|
||||
function threadArchiveFilename(
|
||||
dir: string,
|
||||
provider: string,
|
||||
continuation: string | undefined,
|
||||
timestamp: Date,
|
||||
): string {
|
||||
const thread = sanitizeSlug(continuation || 'no-thread').slice(0, 48) || 'no-thread';
|
||||
const suffix = `${sanitizeSlug(provider)}-${thread}.md`;
|
||||
// Reuse this thread's existing file whatever day it was created; only stamp a
|
||||
// new date when none exists. Match on the suffix after the date prefix.
|
||||
const dated = /^\d{4}-\d{2}-\d{2}-/;
|
||||
const existing = fs.readdirSync(dir).find((f) => dated.test(f) && f.replace(dated, '') === suffix);
|
||||
if (existing) return existing;
|
||||
// Local calendar day — the agent navigates conversations/ by these
|
||||
// date-sortable names, and evening sessions west of UTC would otherwise
|
||||
// land under tomorrow's date.
|
||||
return `${formatLocalStamp(timestamp, TIMEZONE).slice(0, 10)}-${suffix}`;
|
||||
}
|
||||
|
||||
function sanitizeSlug(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
function titleCase(value: string): string {
|
||||
return value ? value[0].toUpperCase() + value.slice(1) : 'Provider';
|
||||
}
|
||||
|
||||
function truncate(value: string): string {
|
||||
return value.length > 2000 ? value.slice(0, 2000) + '...' : value;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'bun:test';
|
||||
|
||||
import { createProvider, type ProviderName } from './factory.js';
|
||||
import { ClaudeProvider } from './claude.js';
|
||||
import { CodexProvider } from './codex.js';
|
||||
import { MockProvider } from './mock.js';
|
||||
|
||||
describe('createProvider', () => {
|
||||
@@ -9,6 +10,10 @@ describe('createProvider', () => {
|
||||
expect(createProvider('claude')).toBeInstanceOf(ClaudeProvider);
|
||||
});
|
||||
|
||||
it('returns CodexProvider for codex', () => {
|
||||
expect(createProvider('codex')).toBeInstanceOf(CodexProvider);
|
||||
});
|
||||
|
||||
it('returns MockProvider for mock', () => {
|
||||
expect(createProvider('mock')).toBeInstanceOf(MockProvider);
|
||||
});
|
||||
|
||||
@@ -3,4 +3,6 @@
|
||||
// level. Skills add a new provider by appending one import line below.
|
||||
|
||||
import './claude.js';
|
||||
import './codex.js';
|
||||
import './mock.js';
|
||||
import './opencode.js';
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
|
||||
import { mcpServersToOpenCodeConfig } from './mcp-to-opencode.js';
|
||||
|
||||
describe('mcpServersToOpenCodeConfig', () => {
|
||||
it('maps nanoclaw + extra server like v2 index.ts merge', () => {
|
||||
const servers = {
|
||||
nanoclaw: {
|
||||
command: 'node',
|
||||
args: ['/app/src/mcp-tools/index.js'],
|
||||
env: {
|
||||
SESSION_INBOUND_DB_PATH: '/workspace/inbound.db',
|
||||
SESSION_OUTBOUND_DB_PATH: '/workspace/outbound.db',
|
||||
SESSION_HEARTBEAT_PATH: '/workspace/.heartbeat',
|
||||
},
|
||||
},
|
||||
extra: {
|
||||
command: 'npx',
|
||||
args: ['-y', 'some-mcp'],
|
||||
env: { FOO: 'bar' },
|
||||
},
|
||||
};
|
||||
|
||||
const mcp = mcpServersToOpenCodeConfig(servers);
|
||||
|
||||
expect(mcp.nanoclaw).toEqual({
|
||||
type: 'local',
|
||||
command: ['node', '/app/src/mcp-tools/index.js'],
|
||||
environment: {
|
||||
SESSION_INBOUND_DB_PATH: '/workspace/inbound.db',
|
||||
SESSION_OUTBOUND_DB_PATH: '/workspace/outbound.db',
|
||||
SESSION_HEARTBEAT_PATH: '/workspace/.heartbeat',
|
||||
},
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
expect(mcp.extra).toEqual({
|
||||
type: 'local',
|
||||
command: ['npx', '-y', 'some-mcp'],
|
||||
environment: { FOO: 'bar' },
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('omits environment when env is empty', () => {
|
||||
const mcp = mcpServersToOpenCodeConfig({
|
||||
x: { command: 'true', args: [], env: {} },
|
||||
});
|
||||
expect(mcp.x).toEqual({
|
||||
type: 'local',
|
||||
command: ['true'],
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty record for undefined', () => {
|
||||
expect(mcpServersToOpenCodeConfig(undefined)).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { McpServerConfig } from './types.js';
|
||||
|
||||
/** OpenCode `mcp` entry shape (local stdio server). */
|
||||
export type OpenCodeMcpLocal = {
|
||||
type: 'local';
|
||||
command: string[];
|
||||
environment?: Record<string, string>;
|
||||
enabled: true;
|
||||
};
|
||||
|
||||
/** OpenCode `mcp` entry shape (remote HTTP server). */
|
||||
export type OpenCodeMcpRemote = {
|
||||
type: 'remote';
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
enabled: true;
|
||||
};
|
||||
|
||||
export type OpenCodeMcpEntry = OpenCodeMcpLocal | OpenCodeMcpRemote;
|
||||
|
||||
/**
|
||||
* Map NanoClaw v2 MCP definitions (same shape as Claude Agent SDK) into
|
||||
* OpenCode config `mcp` field. Stdio-only until `McpServerConfig` gains remote.
|
||||
*/
|
||||
export function mcpServersToOpenCodeConfig(
|
||||
servers: Record<string, McpServerConfig> | undefined,
|
||||
): Record<string, OpenCodeMcpEntry> {
|
||||
const out: Record<string, OpenCodeMcpEntry> = {};
|
||||
if (!servers) return out;
|
||||
for (const [name, cfg] of Object.entries(servers)) {
|
||||
out[name] = {
|
||||
type: 'local',
|
||||
command: [cfg.command, ...cfg.args],
|
||||
...(Object.keys(cfg.env).length > 0 ? { environment: cfg.env } : {}),
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Integration test for the opencode provider's CONTAINER-side reach-in: the self-registration
|
||||
* import in container/agent-runner/src/providers/index.ts. Importing the barrel runs
|
||||
* opencode.ts's top-level registerProvider('opencode', …); without that import line
|
||||
* createProvider('opencode') throws 'Unknown provider' at runtime.
|
||||
*
|
||||
* Behavior, not structural, and BARREL-ONLY: it imports the real barrel (./index.js),
|
||||
* never ./opencode.js directly, then asserts listProviderNames() contains the provider. The
|
||||
* existing opencode.factory.test.ts imports ./opencode.js directly, so it self-registers and
|
||||
* stays GREEN when the barrel line is deleted — a unit test, not a registration guard.
|
||||
* This goes red if the barrel import is deleted/drifts or the barrel fails to evaluate, or if @opencode-ai/sdk is not installed (the unmocked barrel import throws) — so it also implicitly guards that dependency.
|
||||
*/
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
|
||||
import { listProviderNames } from './provider-registry.js';
|
||||
import './index.js'; // the real container provider barrel — triggers each provider's registerProvider()
|
||||
|
||||
describe('opencode provider registration', () => {
|
||||
it('registers opencode via the provider barrel', () => {
|
||||
expect(listProviderNames()).toContain('opencode');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
|
||||
import { createProvider } from './factory.js';
|
||||
import { OpenCodeProvider } from './opencode.js';
|
||||
|
||||
describe('createProvider (opencode)', () => {
|
||||
it('returns OpenCodeProvider for opencode', () => {
|
||||
expect(createProvider('opencode')).toBeInstanceOf(OpenCodeProvider);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,423 @@
|
||||
import { spawn, type ChildProcess } from 'child_process';
|
||||
|
||||
import { createOpencodeClient, type OpencodeClient } from '@opencode-ai/sdk';
|
||||
|
||||
import { registerProvider } from './provider-registry.js';
|
||||
import type { AgentProvider, AgentQuery, ProviderEvent, ProviderOptions, QueryInput } from './types.js';
|
||||
import { mcpServersToOpenCodeConfig } from './mcp-to-opencode.js';
|
||||
|
||||
function log(msg: string): void {
|
||||
console.error(`[opencode-provider] ${msg}`);
|
||||
}
|
||||
|
||||
const SESSION_STATUS_RETRY_ERROR_AFTER = 3;
|
||||
|
||||
/** Stale / dead OpenCode session heuristics (complement Claude-centric host patterns). */
|
||||
const STALE_SESSION_RE =
|
||||
/no conversation found|ENOENT.*\.jsonl|session.*not found|NotFoundError|connection reset|ECONNRESET|404|event timeout/i;
|
||||
|
||||
function killProcessTree(proc: ChildProcess): void {
|
||||
if (!proc.pid) return;
|
||||
try {
|
||||
process.kill(-proc.pid, 'SIGKILL');
|
||||
} catch {
|
||||
try {
|
||||
proc.kill('SIGKILL');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function spawnOpencodeServer(config: Record<string, unknown>, timeoutMs = 10_000): Promise<{ url: string; proc: ChildProcess }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const hostname = '127.0.0.1';
|
||||
const port = 4096;
|
||||
const proc = spawn('opencode', ['serve', `--hostname=${hostname}`, `--port=${port}`], {
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCODE_CONFIG_CONTENT: JSON.stringify(config),
|
||||
},
|
||||
detached: true,
|
||||
});
|
||||
|
||||
const id = setTimeout(() => {
|
||||
killProcessTree(proc);
|
||||
reject(new Error(`Timeout waiting for OpenCode server to start after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
|
||||
let output = '';
|
||||
proc.stdout?.on('data', (chunk: Buffer) => {
|
||||
output += chunk.toString();
|
||||
for (const line of output.split('\n')) {
|
||||
if (line.startsWith('opencode server listening')) {
|
||||
const match = line.match(/on\s+(https?:\/\/[^\s]+)/);
|
||||
if (match) {
|
||||
clearTimeout(id);
|
||||
resolve({ url: match[1], proc });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
proc.stderr?.on('data', (chunk: Buffer) => {
|
||||
output += chunk.toString();
|
||||
});
|
||||
proc.on('exit', (code) => {
|
||||
clearTimeout(id);
|
||||
let msg = `OpenCode server exited with code ${code}`;
|
||||
if (output.trim()) msg += `\nServer output: ${output}`;
|
||||
reject(new Error(msg));
|
||||
});
|
||||
proc.on('error', (err) => {
|
||||
clearTimeout(id);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function wrapPromptWithContext(text: string, systemInstructions?: string): string {
|
||||
let out = text;
|
||||
if (systemInstructions) {
|
||||
out = `<system>\n${systemInstructions}\n</system>\n\n${out}`;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildOpenCodeConfig(options: ProviderOptions): Record<string, unknown> {
|
||||
const provider = process.env.OPENCODE_PROVIDER || 'anthropic';
|
||||
const model = process.env.OPENCODE_MODEL;
|
||||
const smallModel = process.env.OPENCODE_SMALL_MODEL;
|
||||
const proxyUrl = process.env.ANTHROPIC_BASE_URL;
|
||||
|
||||
const providerModelId = model ? model.replace(new RegExp(`^${provider}/`), '') : undefined;
|
||||
const providerSmallModelId = smallModel ? smallModel.replace(new RegExp(`^${provider}/`), '') : undefined;
|
||||
const modelsToRegister = [providerModelId, providerSmallModelId]
|
||||
.filter(Boolean)
|
||||
.filter((mid, i, a) => a.indexOf(mid as string) === i);
|
||||
|
||||
const providerOptions: Record<string, unknown> =
|
||||
provider === 'anthropic'
|
||||
? {}
|
||||
: {
|
||||
[provider]: {
|
||||
options: { apiKey: 'placeholder', baseURL: proxyUrl },
|
||||
...(modelsToRegister.length > 0
|
||||
? {
|
||||
models: Object.fromEntries(
|
||||
modelsToRegister.map((mid) => [mid, { id: mid, name: mid, tool_call: true }]),
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
|
||||
const mcp = mcpServersToOpenCodeConfig(options.mcpServers);
|
||||
|
||||
// Load shared base + per-group fragments + per-group memory through OpenCode's
|
||||
// native instructions pipeline (session/instruction.ts). Absolute paths with
|
||||
// globs are supported. Files are read raw — `@./...` includes are NOT expanded
|
||||
// by OpenCode, so point at the concrete files, not at composed CLAUDE.md.
|
||||
const instructions = [
|
||||
'/app/CLAUDE.md',
|
||||
'/workspace/agent/.claude-fragments/*.md',
|
||||
'/workspace/agent/CLAUDE.local.md',
|
||||
];
|
||||
|
||||
return {
|
||||
...(model ? { model } : {}),
|
||||
...(smallModel ? { small_model: smallModel } : {}),
|
||||
enabled_providers: [provider],
|
||||
permission: 'allow',
|
||||
autoupdate: false,
|
||||
snapshot: false,
|
||||
provider: providerOptions,
|
||||
instructions,
|
||||
mcp,
|
||||
};
|
||||
}
|
||||
|
||||
type SharedRuntime = {
|
||||
proc: ChildProcess;
|
||||
client: OpencodeClient;
|
||||
stream: AsyncGenerator<{ type: string; properties: Record<string, unknown> }, void, void>;
|
||||
streamRelease: () => void;
|
||||
};
|
||||
|
||||
let sharedRuntime: SharedRuntime | null = null;
|
||||
let sharedConfigKey: string | null = null;
|
||||
let sharedInit: Promise<SharedRuntime> | null = null;
|
||||
|
||||
function runtimeConfigKey(options: ProviderOptions): string {
|
||||
return JSON.stringify({
|
||||
mcp: mcpServersToOpenCodeConfig(options.mcpServers),
|
||||
model: process.env.OPENCODE_MODEL,
|
||||
small: process.env.OPENCODE_SMALL_MODEL,
|
||||
op: process.env.OPENCODE_PROVIDER,
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureSharedRuntime(options: ProviderOptions): Promise<SharedRuntime> {
|
||||
const key = runtimeConfigKey(options);
|
||||
if (sharedRuntime && sharedConfigKey === key) return sharedRuntime;
|
||||
|
||||
if (sharedInit) return sharedInit;
|
||||
|
||||
sharedInit = (async () => {
|
||||
if (sharedRuntime) {
|
||||
destroySharedRuntime();
|
||||
}
|
||||
const config = buildOpenCodeConfig(options);
|
||||
const { url, proc } = await spawnOpencodeServer(config);
|
||||
const client = createOpencodeClient({ baseUrl: url });
|
||||
const sub = await client.event.subscribe();
|
||||
const stream = sub.stream as AsyncGenerator<{ type: string; properties: Record<string, unknown> }, void, void>;
|
||||
sharedRuntime = {
|
||||
proc,
|
||||
client,
|
||||
stream,
|
||||
streamRelease: () => {
|
||||
void stream.return?.(undefined);
|
||||
},
|
||||
};
|
||||
sharedConfigKey = key;
|
||||
sharedInit = null;
|
||||
return sharedRuntime;
|
||||
})();
|
||||
|
||||
return sharedInit;
|
||||
}
|
||||
|
||||
export function destroySharedRuntime(): void {
|
||||
if (sharedRuntime) {
|
||||
try {
|
||||
sharedRuntime.streamRelease();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
killProcessTree(sharedRuntime.proc);
|
||||
sharedRuntime = null;
|
||||
sharedConfigKey = null;
|
||||
}
|
||||
sharedInit = null;
|
||||
}
|
||||
|
||||
function sessionErrorMessage(props: { error?: unknown }): string {
|
||||
const err = props.error as { data?: { message?: string } } | undefined;
|
||||
if (err && typeof err === 'object' && err.data && typeof err.data.message === 'string') {
|
||||
return err.data.message;
|
||||
}
|
||||
return JSON.stringify(props.error) || 'OpenCode session error';
|
||||
}
|
||||
|
||||
export class OpenCodeProvider implements AgentProvider {
|
||||
readonly supportsNativeSlashCommands = false;
|
||||
|
||||
private readonly options: ProviderOptions;
|
||||
private activeSessionId: string | undefined;
|
||||
|
||||
constructor(options: ProviderOptions = {}) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
isSessionInvalid(err: unknown): boolean {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return STALE_SESSION_RE.test(msg);
|
||||
}
|
||||
|
||||
query(input: QueryInput): AgentQuery {
|
||||
if (input.continuation) {
|
||||
this.activeSessionId = input.continuation;
|
||||
} else {
|
||||
this.activeSessionId = undefined;
|
||||
}
|
||||
|
||||
const pending: string[] = [];
|
||||
let waiting: (() => void) | null = null;
|
||||
let ended = false;
|
||||
let aborted = false;
|
||||
|
||||
const systemInstructions = input.systemContext?.instructions;
|
||||
pending.push(wrapPromptWithContext(input.prompt, systemInstructions));
|
||||
|
||||
const kick = (): void => {
|
||||
waiting?.();
|
||||
};
|
||||
|
||||
const self = this;
|
||||
const IDLE_TIMEOUT_MS = Number(process.env.OPENCODE_IDLE_TIMEOUT_MS) || 300_000;
|
||||
|
||||
async function* gen(): AsyncGenerator<ProviderEvent> {
|
||||
let initYielded = false;
|
||||
const rt = await ensureSharedRuntime(self.options);
|
||||
const { client, stream } = rt;
|
||||
|
||||
while (!aborted) {
|
||||
while (pending.length === 0 && !ended && !aborted) {
|
||||
await new Promise<void>((resolve) => {
|
||||
waiting = resolve;
|
||||
});
|
||||
waiting = null;
|
||||
}
|
||||
|
||||
if (aborted) return;
|
||||
if (pending.length === 0 && ended) return;
|
||||
|
||||
const text = pending.shift()!;
|
||||
let sessionId = self.activeSessionId;
|
||||
|
||||
if (!sessionId) {
|
||||
const created = await client.session.create();
|
||||
if (created.error) {
|
||||
throw new Error(`OpenCode: failed to create session: ${JSON.stringify(created.error)}`);
|
||||
}
|
||||
sessionId = created.data?.id;
|
||||
if (!sessionId) throw new Error('OpenCode: failed to create session (no id)');
|
||||
self.activeSessionId = sessionId;
|
||||
}
|
||||
|
||||
if (!initYielded) {
|
||||
yield { type: 'init', continuation: sessionId };
|
||||
initYielded = true;
|
||||
}
|
||||
|
||||
const promptRes = await client.session.promptAsync({
|
||||
path: { id: sessionId },
|
||||
body: { parts: [{ type: 'text', text }] },
|
||||
});
|
||||
if (promptRes.error) {
|
||||
self.activeSessionId = undefined;
|
||||
throw new Error(`OpenCode promptAsync: ${JSON.stringify(promptRes.error)}`);
|
||||
}
|
||||
|
||||
const partTextByMessageId = new Map<string, string>();
|
||||
const roleByMessageId = new Map<string, string>();
|
||||
let lastEventAt = Date.now();
|
||||
let eventTimedOut = false;
|
||||
const timeoutCheck = setInterval(() => {
|
||||
if (Date.now() - lastEventAt > IDLE_TIMEOUT_MS) {
|
||||
log(`OpenCode event timeout (${IDLE_TIMEOUT_MS}ms) — clearing session ${sessionId}`);
|
||||
eventTimedOut = true;
|
||||
self.activeSessionId = undefined;
|
||||
destroySharedRuntime();
|
||||
kick();
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
try {
|
||||
turn: while (true) {
|
||||
if (aborted) return;
|
||||
if (eventTimedOut) {
|
||||
throw new Error(`OpenCode event timeout (${IDLE_TIMEOUT_MS}ms)`);
|
||||
}
|
||||
|
||||
const { value: ev, done } = await stream.next();
|
||||
if (done) {
|
||||
throw new Error('OpenCode SSE stream ended unexpectedly');
|
||||
}
|
||||
|
||||
if (!ev?.type || ev.type === 'server.connected' || ev.type === 'server.heartbeat') continue;
|
||||
|
||||
lastEventAt = Date.now();
|
||||
yield { type: 'activity' };
|
||||
|
||||
switch (ev.type) {
|
||||
case 'message.updated': {
|
||||
const info = ev.properties.info as { id?: string; role?: string } | undefined;
|
||||
if (info?.id && info?.role) {
|
||||
roleByMessageId.set(info.id, info.role);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'message.part.updated': {
|
||||
const part = ev.properties.part as { type?: string; messageID?: string; text?: string } | undefined;
|
||||
if (part?.type === 'text' && part.messageID && part.text) {
|
||||
partTextByMessageId.set(part.messageID, part.text);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'permission.updated': {
|
||||
const perm = ev.properties as { id?: string; sessionID?: string };
|
||||
if (perm.sessionID === sessionId && perm.id) {
|
||||
try {
|
||||
await client.postSessionIdPermissionsPermissionId({
|
||||
path: { id: sessionId, permissionID: perm.id },
|
||||
body: { response: 'always' },
|
||||
});
|
||||
} catch (err) {
|
||||
log(`Failed to auto-reply permission: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'session.status': {
|
||||
const props = ev.properties as {
|
||||
sessionID?: string;
|
||||
status?: { type?: string; attempt?: number; message?: string };
|
||||
};
|
||||
if (props.sessionID !== sessionId) break;
|
||||
const st = props.status;
|
||||
if (
|
||||
st?.type === 'retry' &&
|
||||
typeof st.attempt === 'number' &&
|
||||
st.attempt >= SESSION_STATUS_RETRY_ERROR_AFTER &&
|
||||
st.message
|
||||
) {
|
||||
self.activeSessionId = undefined;
|
||||
throw new Error(`OpenCode retry limit (${st.attempt}): ${st.message}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'session.error': {
|
||||
const props = ev.properties as { sessionID?: string; error?: unknown };
|
||||
if (props.sessionID === sessionId || props.sessionID === undefined) {
|
||||
self.activeSessionId = undefined;
|
||||
throw new Error(sessionErrorMessage(props));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'session.idle': {
|
||||
const sid = (ev.properties as { sessionID?: string }).sessionID;
|
||||
if (sid === sessionId) {
|
||||
break turn;
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
clearInterval(timeoutCheck);
|
||||
}
|
||||
|
||||
let resultText = '';
|
||||
for (const [msgId, role] of roleByMessageId) {
|
||||
if (role === 'assistant') {
|
||||
resultText = partTextByMessageId.get(msgId) ?? resultText;
|
||||
}
|
||||
}
|
||||
yield { type: 'result', text: resultText || null };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
push: (message: string) => {
|
||||
pending.push(wrapPromptWithContext(message, systemInstructions));
|
||||
kick();
|
||||
},
|
||||
end: () => {
|
||||
ended = true;
|
||||
kick();
|
||||
},
|
||||
events: gen(),
|
||||
abort: () => {
|
||||
aborted = true;
|
||||
this.activeSessionId = undefined;
|
||||
kick();
|
||||
destroySharedRuntime();
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
registerProvider('opencode', (opts) => new OpenCodeProvider(opts));
|
||||
@@ -48,6 +48,22 @@ export function formatLocalTime(utcIso: string, timezone: string): string {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact sortable local stamp for log lines: "YYYY-MM-DD HH:mm" in `timezone`.
|
||||
* (sv-SE is the one locale whose default rendering is this exact shape.)
|
||||
*/
|
||||
export function formatLocalStamp(date: Date, timezone: string): string {
|
||||
return date.toLocaleString('sv-SE', {
|
||||
timeZone: resolveTimezone(timezone),
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveContainerTimezone(): string {
|
||||
const candidates = [process.env.TZ, Intl.DateTimeFormat().resolvedOptions().timeZone];
|
||||
for (const tz of candidates) {
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
---
|
||||
name: whatsapp-formatting
|
||||
description: Format messages for WhatsApp, including mentions that render as real WhatsApp tags. Use when responding in a WhatsApp conversation (platform_id / chatJid ends with @s.whatsapp.net or @g.us).
|
||||
---
|
||||
|
||||
# WhatsApp Message Formatting
|
||||
|
||||
WhatsApp uses its own lightweight markup and a phone-number-based mention syntax. The host's WhatsApp adapter (Baileys) handles markdown conversion automatically, but **mentions are only protocol-level mentions if you use the right syntax** — otherwise they render as plain text and don't notify the recipient.
|
||||
|
||||
## How to detect WhatsApp context
|
||||
|
||||
You're in a WhatsApp conversation when any of these are true:
|
||||
- The chat JID / platform id ends with `@s.whatsapp.net` (1-on-1 DM)
|
||||
- The chat JID / platform id ends with `@g.us` (group)
|
||||
- Your inbound message metadata has `chatJid` matching the above
|
||||
|
||||
## Mentions — the important part
|
||||
|
||||
To tag a user so their name appears **bold and clickable** in WhatsApp and they get a push notification, write the `@` followed by their phone number digits (no `+`, no spaces, no display name):
|
||||
|
||||
```
|
||||
@15551234567 can you confirm?
|
||||
```
|
||||
|
||||
The adapter scans your outgoing text for `@<digits>` (5–15 digits, optional leading `+` is stripped) and tells WhatsApp to render them as real mention tags.
|
||||
|
||||
**The sender's phone JID is always in your inbound message metadata.** When a user writes to you, inbound `content.sender` looks like `15551234567@s.whatsapp.net`. The part before the `@` is exactly what you put after `@` when tagging them back.
|
||||
|
||||
### Wrong vs right
|
||||
|
||||
| You write | What recipients see |
|
||||
|-----------|---------------------|
|
||||
| `@Adam can you...` | Plain text `@Adam`. No tag, no notification. |
|
||||
| `@15551234567 can you...` | Bold/blue **@Adam** (or whatever name they're saved as), notification fires. |
|
||||
| `@+15551234567 ...` | Same as above — adapter strips the `+`. |
|
||||
|
||||
### Picking who to tag
|
||||
|
||||
- In a DM, there's no real need to tag the recipient (they already see every message), but tagging still works if you want emphasis.
|
||||
- In a group, look at the `participants` / inbound `content.sender` to find the JID of the person you mean. Don't guess from display names — pushNames can collide and are not reliable.
|
||||
- If you don't know the JID, just refer to the person by name in plain prose. Don't write `@<name>` — it won't tag and it will look like a tag that failed.
|
||||
|
||||
## Text styles
|
||||
|
||||
WhatsApp uses single-character delimiters, *not* doubled like standard Markdown.
|
||||
|
||||
| Style | Syntax | Renders as |
|
||||
|-------|--------|------------|
|
||||
| Bold | `*bold*` | **bold** |
|
||||
| Italic | `_italic_` | *italic* |
|
||||
| Strikethrough | `~strike~` | ~strike~ |
|
||||
| Monospace | `` `code` `` | `code` |
|
||||
| Block monospace | ```` ```block``` ```` | preformatted block |
|
||||
|
||||
The adapter converts standard Markdown (`**bold**`, `[link](url)`, `# heading`) to the WhatsApp-native form automatically, so you don't have to think about it — but be aware that single asterisks become italics, not bold.
|
||||
|
||||
## What not to do
|
||||
|
||||
- Don't write `<@U123>` (that's Slack), `<@!123>` (Discord), or any other channel's mention syntax.
|
||||
- Don't paste a full JID like `@15551234567@s.whatsapp.net` in the text — only the digits before the JID's `@` go after your `@`.
|
||||
- Don't try to tag display names. WhatsApp has no display-name-based mention API.
|
||||
@@ -1,19 +0,0 @@
|
||||
## WhatsApp mentions — always use phone digits
|
||||
|
||||
When you are replying in a WhatsApp conversation (the inbound message's `chatJid` ends with `@s.whatsapp.net` for a DM or `@g.us` for a group), and you want to tag a person so their name appears **bold and clickable** with a push notification, write `@` followed by their phone-number digits — never the display name.
|
||||
|
||||
**The sender's phone JID is in your inbound message metadata** at `content.sender` (e.g. `15551234567@s.whatsapp.net`). The part before the `@` is exactly what you put after `@` when tagging them.
|
||||
|
||||
| You write | What recipients see |
|
||||
|-----------|---------------------|
|
||||
| `@Adam, can you...` | Plain text. No tag, no notification. |
|
||||
| `@15551234567, can you...` | Bold/blue **@Adam** (whatever name they're saved as), notification fires. |
|
||||
| `@+15551234567 ...` | Same as above — the adapter strips the `+` automatically. |
|
||||
|
||||
The host adapter scans your outbound text for `@<5–15 digits>` (with optional leading `+`) and tells WhatsApp to render those as real mention tags. If the digits aren't in the text, the tag doesn't render — no exceptions.
|
||||
|
||||
### In groups
|
||||
|
||||
Tag the person you're addressing using their JID from inbound metadata (look at the most recent message from them). Don't guess — pushNames collide and aren't reliable.
|
||||
|
||||
If you don't know someone's JID, refer to them by name in plain prose. Do not write `@<displayname>` hoping it works.
|
||||
@@ -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
|
||||
+1
-19
@@ -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.29.0",
|
||||
"@chat-adapter/gchat": "4.29.0",
|
||||
"@chat-adapter/github": "4.29.0",
|
||||
"@chat-adapter/linear": "4.29.0",
|
||||
"@chat-adapter/slack": "4.29.0",
|
||||
"@chat-adapter/state-memory": "4.29.0",
|
||||
"@chat-adapter/teams": "4.29.0",
|
||||
"@chat-adapter/telegram": "4.29.0",
|
||||
"@chat-adapter/whatsapp": "4.29.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.29.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
+4
-3916
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
+5
-13
@@ -1,11 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Install the Slack adapter, persist SLACK_BOT_TOKEN plus the mode-specific
|
||||
# secret (SLACK_APP_TOKEN for Socket Mode, SLACK_SIGNING_SECRET for webhook) to
|
||||
# Install the Slack adapter, persist SLACK_BOT_TOKEN + SLACK_SIGNING_SECRET to
|
||||
# .env + data/env/env, and restart the service. Non-interactive — the
|
||||
# operator-facing app creation walkthrough + credential paste live in
|
||||
# setup/channels/slack.ts. Credentials come in via env vars:
|
||||
# SLACK_BOT_TOKEN, and SLACK_APP_TOKEN and/or SLACK_SIGNING_SECRET.
|
||||
# SLACK_BOT_TOKEN, SLACK_SIGNING_SECRET.
|
||||
#
|
||||
# Emits exactly one status block on stdout (ADD_SLACK) at the end. All chatty
|
||||
# progress messages go to stderr so setup:auto's raw-log capture sees the full
|
||||
@@ -42,10 +41,8 @@ if [ -z "${SLACK_BOT_TOKEN:-}" ]; then
|
||||
emit_status failed "SLACK_BOT_TOKEN env var not set"
|
||||
exit 1
|
||||
fi
|
||||
# Socket Mode authenticates with SLACK_APP_TOKEN; webhook mode with
|
||||
# SLACK_SIGNING_SECRET. Require at least one.
|
||||
if [ -z "${SLACK_APP_TOKEN:-}" ] && [ -z "${SLACK_SIGNING_SECRET:-}" ]; then
|
||||
emit_status failed "Set SLACK_APP_TOKEN (Socket Mode) or SLACK_SIGNING_SECRET (webhook)"
|
||||
if [ -z "${SLACK_SIGNING_SECRET:-}" ]; then
|
||||
emit_status failed "SLACK_SIGNING_SECRET env var not set"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -101,12 +98,7 @@ upsert_env() {
|
||||
fi
|
||||
}
|
||||
upsert_env SLACK_BOT_TOKEN "$SLACK_BOT_TOKEN"
|
||||
if [ -n "${SLACK_APP_TOKEN:-}" ]; then
|
||||
upsert_env SLACK_APP_TOKEN "$SLACK_APP_TOKEN"
|
||||
fi
|
||||
if [ -n "${SLACK_SIGNING_SECRET:-}" ]; then
|
||||
upsert_env SLACK_SIGNING_SECRET "$SLACK_SIGNING_SECRET"
|
||||
fi
|
||||
upsert_env SLACK_SIGNING_SECRET "$SLACK_SIGNING_SECRET"
|
||||
|
||||
# Container reads from data/env/env (the host mounts it).
|
||||
mkdir -p data/env
|
||||
|
||||
@@ -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);
|
||||
|
||||
+48
-116
@@ -4,23 +4,21 @@
|
||||
* `runSlackChannel(displayName)` walks the operator from a bare Slack
|
||||
* workspace through a running bot, then stops before wiring an agent:
|
||||
*
|
||||
* 1. Ask the delivery mode: Socket Mode (outbound WebSocket, no public
|
||||
* URL) or a public webhook
|
||||
* 2. Walk through creating a Slack app (api.slack.com/apps) — scopes,
|
||||
* events, and the mode-specific credential (app-level token for
|
||||
* Socket Mode, signing secret for webhook)
|
||||
* 3. Paste the bot token + that credential (clack password prompts)
|
||||
* 4. Validate via auth.test → resolves workspace + bot identity
|
||||
* 5. Install the adapter (setup/add-slack.sh, non-interactive)
|
||||
* 6. Print the post-install checklist (Socket Mode: just DM the bot;
|
||||
* webhook: set the public Request URL in Event Subscriptions), then
|
||||
* `/manage-channels` to wire an agent.
|
||||
* 1. Walk through creating a Slack app (api.slack.com/apps) — scopes,
|
||||
* event subscriptions, and signing secret
|
||||
* 2. Paste the bot token + signing secret (clack password prompts)
|
||||
* 3. Validate via auth.test → resolves workspace + bot identity
|
||||
* 4. Install the adapter (setup/add-slack.sh, non-interactive)
|
||||
* 5. Print the post-install checklist: set the public webhook URL in
|
||||
* Slack's Event Subscriptions, DM the bot to bootstrap the channel,
|
||||
* then `/manage-channels` to wire an agent.
|
||||
*
|
||||
* Why no welcome DM here: opening an unsolicited DM would need `im:write`
|
||||
* scope we don't force the SKILL.md to require — and in webhook mode inbound
|
||||
* events don't flow until the public Event Subscriptions URL is configured.
|
||||
* Shipping an honest "here's what's left" note is better than a welcome DM
|
||||
* the user won't receive until they finish wiring Slack up.
|
||||
* Why no welcome DM here: unlike Discord/Telegram (gateway / long-poll),
|
||||
* Slack needs a public Event Subscriptions URL for inbound events, and
|
||||
* opening an unsolicited DM would need `im:write` scope we don't force
|
||||
* the SKILL.md to require. Shipping a honest "here's what's left" note
|
||||
* is better than a welcome DM the user won't receive until they
|
||||
* configure the webhook anyway.
|
||||
*
|
||||
* All output obeys the three-level contract. See docs/setup-flow.md.
|
||||
*/
|
||||
@@ -28,7 +26,6 @@ import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import * as setupLog from '../logs.js';
|
||||
import { brightSelect } from '../lib/bright-select.js';
|
||||
import { confirmThenOpen } from '../lib/browser.js';
|
||||
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
|
||||
import { wrapForGutter } from '../lib/theme.js';
|
||||
@@ -43,28 +40,16 @@ interface WorkspaceInfo {
|
||||
botUserId: string;
|
||||
}
|
||||
|
||||
// Socket Mode (SLACK_APP_TOKEN, xapp-…) needs no public URL; webhook mode
|
||||
// (SLACK_SIGNING_SECRET) needs a public Request URL. The adapter picks the mode
|
||||
// purely from SLACK_APP_TOKEN's presence — this choice just decides which
|
||||
// credential to collect and which post-install guidance to show.
|
||||
type SlackMode = 'socket' | 'webhook';
|
||||
|
||||
// displayName is reserved for when we start wiring the first agent here.
|
||||
// Kept to match the `run<X>Channel(displayName)` signature every other
|
||||
// channel driver uses, so auto.ts can dispatch without a branch.
|
||||
export async function runSlackChannel(_displayName: string): Promise<void> {
|
||||
const mode = await askSlackMode();
|
||||
await walkThroughAppCreation(mode);
|
||||
await walkThroughAppCreation();
|
||||
|
||||
const token = await collectBotToken();
|
||||
const appToken = mode === 'socket' ? await collectAppToken() : undefined;
|
||||
const signingSecret = mode === 'webhook' ? await collectSigningSecret() : undefined;
|
||||
const signingSecret = await collectSigningSecret();
|
||||
const info = await validateSlackToken(token);
|
||||
|
||||
const env: Record<string, string> = { SLACK_BOT_TOKEN: token };
|
||||
if (appToken) env.SLACK_APP_TOKEN = appToken;
|
||||
if (signingSecret) env.SLACK_SIGNING_SECRET = signingSecret;
|
||||
|
||||
const install = await runQuietChild(
|
||||
'slack-install',
|
||||
'bash',
|
||||
@@ -74,9 +59,11 @@ export async function runSlackChannel(_displayName: string): Promise<void> {
|
||||
done: 'Slack adapter installed.',
|
||||
},
|
||||
{
|
||||
env,
|
||||
env: {
|
||||
SLACK_BOT_TOKEN: token,
|
||||
SLACK_SIGNING_SECRET: signingSecret,
|
||||
},
|
||||
extraFields: {
|
||||
MODE: mode,
|
||||
BOT_NAME: info.botName,
|
||||
TEAM_NAME: info.teamName,
|
||||
TEAM_ID: info.teamId,
|
||||
@@ -84,52 +71,21 @@ export async function runSlackChannel(_displayName: string): Promise<void> {
|
||||
},
|
||||
);
|
||||
if (!install.ok) {
|
||||
await fail('slack-install', "Couldn't connect Slack.", 'See logs/setup-steps/ for details, then retry setup.');
|
||||
await fail(
|
||||
'slack-install',
|
||||
"Couldn't connect Slack.",
|
||||
'See logs/setup-steps/ for details, then retry setup.',
|
||||
);
|
||||
}
|
||||
|
||||
showPostInstallChecklist(info, mode);
|
||||
showPostInstallChecklist(info);
|
||||
}
|
||||
|
||||
async function askSlackMode(): Promise<SlackMode> {
|
||||
const choice = ensureAnswer(
|
||||
await brightSelect<SlackMode>({
|
||||
message: 'How should Slack deliver events to NanoClaw?',
|
||||
initialValue: 'socket',
|
||||
options: [
|
||||
{
|
||||
value: 'socket',
|
||||
label: 'Socket Mode',
|
||||
hint: 'no public URL — recommended for local or behind NAT',
|
||||
},
|
||||
{
|
||||
value: 'webhook',
|
||||
label: 'Public webhook',
|
||||
hint: 'needs a public HTTPS Request URL',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
setupLog.userInput('slack_mode', String(choice));
|
||||
return choice;
|
||||
}
|
||||
|
||||
async function walkThroughAppCreation(mode: SlackMode): Promise<void> {
|
||||
const credSteps =
|
||||
mode === 'socket'
|
||||
? [
|
||||
' 4. Basic Information → App-Level Tokens → "Generate Token and',
|
||||
' Scopes" → add the connections:write scope → copy it (xapp-…)',
|
||||
' 5. Socket Mode → toggle "Enable Socket Mode" on',
|
||||
' 6. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)',
|
||||
]
|
||||
: [
|
||||
' 4. Basic Information → copy the "Signing Secret"',
|
||||
' 5. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)',
|
||||
];
|
||||
async function walkThroughAppCreation(): Promise<void> {
|
||||
p.note(
|
||||
[
|
||||
"You'll create a Slack app that the assistant talks through.",
|
||||
'Free and stays inside the workspaces you pick.',
|
||||
"Free and stays inside the workspaces you pick.",
|
||||
'',
|
||||
' 1. Create a new app "From scratch", name it, pick a workspace',
|
||||
' 2. OAuth & Permissions → add Bot Token Scopes:',
|
||||
@@ -137,7 +93,8 @@ async function walkThroughAppCreation(mode: SlackMode): Promise<void> {
|
||||
' channels:read, groups:read, users:read, reactions:write',
|
||||
' 3. App Home → enable "Messages Tab" and "Allow users to send',
|
||||
' slash commands and messages from the messages tab"',
|
||||
...credSteps,
|
||||
' 4. Basic Information → copy the "Signing Secret"',
|
||||
' 5. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)',
|
||||
'',
|
||||
k.dim(SLACK_APPS_URL),
|
||||
].join('\n'),
|
||||
@@ -147,7 +104,7 @@ async function walkThroughAppCreation(mode: SlackMode): Promise<void> {
|
||||
|
||||
ensureAnswer(
|
||||
await p.confirm({
|
||||
message: mode === 'socket' ? 'Got your bot token and app-level token?' : 'Got your bot token and signing secret?',
|
||||
message: 'Got your bot token and signing secret?',
|
||||
initialValue: true,
|
||||
}),
|
||||
);
|
||||
@@ -167,7 +124,10 @@ async function collectBotToken(): Promise<string> {
|
||||
}),
|
||||
);
|
||||
const token = (answer as string).trim();
|
||||
setupLog.userInput('slack_bot_token', `${token.slice(0, 10)}…${token.slice(-4)}`);
|
||||
setupLog.userInput(
|
||||
'slack_bot_token',
|
||||
`${token.slice(0, 10)}…${token.slice(-4)}`,
|
||||
);
|
||||
return token;
|
||||
}
|
||||
|
||||
@@ -188,26 +148,11 @@ async function collectSigningSecret(): Promise<string> {
|
||||
}),
|
||||
);
|
||||
const secret = (answer as string).trim();
|
||||
setupLog.userInput('slack_signing_secret', `${secret.slice(0, 4)}…${secret.slice(-4)}`);
|
||||
return secret;
|
||||
}
|
||||
|
||||
async function collectAppToken(): Promise<string> {
|
||||
const answer = ensureAnswer(
|
||||
await p.password({
|
||||
message: 'Paste your Slack app-level token (Socket Mode)',
|
||||
validate: (v) => {
|
||||
const t = (v ?? '').trim();
|
||||
if (!t) return 'App-level token is required for Socket Mode';
|
||||
if (!t.startsWith('xapp-')) return 'App-level tokens start with xapp-';
|
||||
if (t.length < 24) return "That's shorter than a real Slack app-level token";
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
setupLog.userInput(
|
||||
'slack_signing_secret',
|
||||
`${secret.slice(0, 4)}…${secret.slice(-4)}`,
|
||||
);
|
||||
const token = (answer as string).trim();
|
||||
setupLog.userInput('slack_app_token', `${token.slice(0, 10)}…${token.slice(-4)}`);
|
||||
return token;
|
||||
return secret;
|
||||
}
|
||||
|
||||
async function validateSlackToken(token: string): Promise<WorkspaceInfo> {
|
||||
@@ -232,7 +177,9 @@ async function validateSlackToken(token: string): Promise<WorkspaceInfo> {
|
||||
};
|
||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
||||
if (data.ok && data.team && data.user) {
|
||||
s.stop(`Connected to ${data.team} as @${data.user}. ${k.dim(`(${elapsedS}s)`)}`);
|
||||
s.stop(
|
||||
`Connected to ${data.team} as @${data.user}. ${k.dim(`(${elapsedS}s)`)}`,
|
||||
);
|
||||
const info: WorkspaceInfo = {
|
||||
teamName: data.team,
|
||||
teamId: data.team_id ?? '',
|
||||
@@ -266,30 +213,15 @@ async function validateSlackToken(token: string): Promise<WorkspaceInfo> {
|
||||
setupLog.step('slack-validate', 'failed', Date.now() - start, {
|
||||
ERROR: message,
|
||||
});
|
||||
await fail('slack-validate', "Couldn't reach Slack.", 'Check your internet connection and retry setup.');
|
||||
await fail(
|
||||
'slack-validate',
|
||||
"Couldn't reach Slack.",
|
||||
'Check your internet connection and retry setup.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function showPostInstallChecklist(info: WorkspaceInfo, mode: SlackMode): void {
|
||||
if (mode === 'socket') {
|
||||
p.note(
|
||||
wrapForGutter(
|
||||
[
|
||||
`The Slack adapter is installed in Socket Mode and your creds are saved. No public URL needed — ${info.teamName} reaches NanoClaw over an outbound WebSocket.`,
|
||||
'',
|
||||
` 1. DM @${info.botName} from Slack once — that bootstraps the`,
|
||||
' messaging group. Then run `/manage-channels` in `claude` to',
|
||||
' wire an agent to it.',
|
||||
'',
|
||||
' Note: keep the NanoClaw host running to hold the socket open —',
|
||||
' Slack does not retry delivery while it is down.',
|
||||
].join('\n'),
|
||||
6,
|
||||
),
|
||||
'Finish setting up Slack',
|
||||
);
|
||||
return;
|
||||
}
|
||||
function showPostInstallChecklist(info: WorkspaceInfo): void {
|
||||
p.note(
|
||||
wrapForGutter(
|
||||
[
|
||||
|
||||
+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:
|
||||
|
||||
-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
-2
@@ -13,9 +13,8 @@ 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'),
|
||||
|
||||
@@ -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
-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.
|
||||
#
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Setup-side registration guard for the codex provider (the third barrel of
|
||||
* the multi-point archetype): imports the REAL setup/providers barrel and
|
||||
* asserts the registry carries codex with its auth + install check. Red if
|
||||
* the barrel line is deleted, the barrel fails to evaluate, or the payload
|
||||
* module breaks. (Importing ./codex.js directly would self-register and stay
|
||||
* green when the barrel line is deleted.)
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getSetupProvider } from './registry.js';
|
||||
import './index.js'; // the real setup provider barrel
|
||||
|
||||
describe('codex setup registration', () => {
|
||||
it('registers codex with auth + install check via the barrel', () => {
|
||||
const codex = getSetupProvider('codex');
|
||||
expect(codex).toBeDefined();
|
||||
expect(typeof codex!.runAuth).toBe('function');
|
||||
expect(typeof codex!.runInstallCheck).toBe('function');
|
||||
expect(typeof codex!.offerFailureAssist).toBe('function');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Mock child_process so runCodexLoginAuth never spawns a real codex CLI; the
|
||||
// spawn stand-in plays `codex login` writing auth.json into whatever
|
||||
// CODEX_HOME it was handed.
|
||||
const mockSpawn = vi.fn();
|
||||
const mockSpawnSync = vi.fn();
|
||||
const mockExecFileSync = vi.fn();
|
||||
vi.mock('child_process', () => ({
|
||||
spawn: (...args: unknown[]) => mockSpawn(...args),
|
||||
spawnSync: (...args: unknown[]) => mockSpawnSync(...args),
|
||||
execFileSync: (...args: unknown[]) => mockExecFileSync(...args),
|
||||
}));
|
||||
|
||||
// Keep the auth flow's structured logging out of logs/setup.log.
|
||||
vi.mock('../logs.js', () => ({ step: vi.fn(), userInput: vi.fn() }));
|
||||
|
||||
import { buildCodexFailurePrompt, runCodexLoginAuth, verifyCodexInstall } from './codex.js';
|
||||
|
||||
// Structural guard for the codex payload wiring: provider files, both barrel
|
||||
// imports, and the pinned Dockerfile install. Goes red if any of them is
|
||||
// removed without going through the /add-codex (or its REMOVE.md) path.
|
||||
describe('verifyCodexInstall', () => {
|
||||
it('passes on a tree with the codex payload wired', () => {
|
||||
const { ok, problems } = verifyCodexInstall();
|
||||
expect(problems).toEqual([]);
|
||||
expect(ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// Pure prompt builder for the failure-assist hook — no spawning involved.
|
||||
describe('buildCodexFailurePrompt', () => {
|
||||
it('carries the failure context and the de-duped reference list', () => {
|
||||
const projectRoot = '/repo';
|
||||
const prompt = buildCodexFailurePrompt(
|
||||
{
|
||||
stepName: 'verify',
|
||||
msg: 'first-chat ping timed out',
|
||||
hint: 'check the container logs',
|
||||
rawLogPath: '/repo/logs/setup-steps/verify.log',
|
||||
},
|
||||
projectRoot,
|
||||
);
|
||||
|
||||
expect(prompt).toContain('Failed step: verify');
|
||||
expect(prompt).toContain('Error: first-chat ping timed out');
|
||||
expect(prompt).toContain('Hint: check the container logs');
|
||||
expect(prompt).toContain('README.md'); // BIG_PICTURE_FILES
|
||||
expect(prompt).toContain('setup/verify.ts'); // STEP_FILES['verify']
|
||||
expect(prompt).toContain('logs/setup.log');
|
||||
expect(prompt).toContain('logs/setup-steps/verify.log'); // relativized rawLogPath
|
||||
});
|
||||
|
||||
it('falls back to the step-log directory when no raw log path is given', () => {
|
||||
const prompt = buildCodexFailurePrompt({ stepName: 'verify', msg: 'boom' }, '/repo');
|
||||
expect(prompt).toContain('logs/setup-steps/');
|
||||
expect(prompt).not.toContain('Hint:');
|
||||
});
|
||||
});
|
||||
|
||||
// Session-isolation invariant: the ChatGPT session vaulted for the gateway
|
||||
// must never be the user's personal ~/.codex session — sharing one OAuth
|
||||
// session across two consumers gets the whole family invalidated server-side
|
||||
// when refresh tokens rotate (see the header of codex.ts).
|
||||
describe('runCodexLoginAuth', () => {
|
||||
it('logs in under an isolated CODEX_HOME, vaults from it, and deletes it', async () => {
|
||||
mockSpawnSync.mockReturnValue({ status: 0, stdout: '', stderr: '' });
|
||||
mockExecFileSync.mockReturnValue('');
|
||||
|
||||
let loginEnv: NodeJS.ProcessEnv | undefined;
|
||||
mockSpawn.mockImplementation((...args: unknown[]) => {
|
||||
const opts = args[2] as { env?: NodeJS.ProcessEnv };
|
||||
loginEnv = opts.env;
|
||||
fs.writeFileSync(path.join(opts.env!.CODEX_HOME!, 'auth.json'), '{"tokens":{}}');
|
||||
const child = new EventEmitter();
|
||||
setImmediate(() => child.emit('close', 0));
|
||||
return child;
|
||||
});
|
||||
|
||||
await runCodexLoginAuth('browser');
|
||||
|
||||
// The login spawn ran under a CODEX_HOME that is not the personal one.
|
||||
const codexHome = loginEnv?.CODEX_HOME;
|
||||
expect(codexHome).toBeDefined();
|
||||
expect(codexHome).not.toBe(path.join(os.homedir(), '.codex'));
|
||||
|
||||
// The vault snapshot was read from the isolated dir, not ~/.codex.
|
||||
const vaultCall = mockExecFileSync.mock.calls.find((c) => c[0] === 'onecli');
|
||||
expect(vaultCall).toBeDefined();
|
||||
const vaultArgs = vaultCall![1] as string[];
|
||||
expect(vaultArgs[vaultArgs.indexOf('--file') + 1]).toBe(path.join(codexHome!, 'auth.json'));
|
||||
|
||||
// The isolated dir holds a live credential — gone once vaulted.
|
||||
expect(fs.existsSync(codexHome!)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,449 @@
|
||||
/**
|
||||
* Codex provider setup — auth walk-through + install verification.
|
||||
*
|
||||
* Codex-owned payload code: when the codex provider moves to the `providers`
|
||||
* branch, this file travels with it and `/add-codex` copies it back in. The
|
||||
* only trunk reach-in is one import + one picker entry in setup/auto.ts.
|
||||
*
|
||||
* Auth honors the v2 credential invariant — everything lands in the OneCLI
|
||||
* vault, nothing in .env, nothing in the container:
|
||||
* - ChatGPT subscription (the common case): `codex login` (browser) or
|
||||
* `codex login --device-auth` (URL + pairing code) runs with CODEX_HOME
|
||||
* pointed at a throwaway dir; the auth.json written there is stored
|
||||
* WHOLE in the vault (`--file … --host-pattern chatgpt.com`) and the dir
|
||||
* is deleted. The gateway injects it in flight; the container only ever
|
||||
* sees the `onecli-managed` placeholder.
|
||||
* - API key: pasted once, stored as an `openai` secret for api.openai.com.
|
||||
*
|
||||
* Session-isolation invariant: the vaulted ChatGPT session must be DEDICATED
|
||||
* to the gateway. Never vault a copy of the user's live ~/.codex/auth.json.
|
||||
* OpenAI rotates refresh tokens, so two consumers sharing one OAuth session
|
||||
* strand each other on refresh, and replaying the stale token trips reuse
|
||||
* detection — which invalidates the whole session family server-side
|
||||
* (`token_invalidated`) for the gateway AND the user's personal Codex CLI.
|
||||
*/
|
||||
import { execFileSync, spawn, spawnSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import { brightSelect } from '../lib/bright-select.js';
|
||||
import { type AssistContext, BIG_PICTURE_FILES, STEP_FILES } from '../lib/claude-assist.js';
|
||||
import { brandBody, note } from '../lib/theme.js';
|
||||
import * as setupLog from '../logs.js';
|
||||
import { type FailureAssistResult, registerSetupProvider } from './registry.js';
|
||||
|
||||
// ─── OneCLI vault helpers ────────────────────────────────────────────────
|
||||
|
||||
interface OnecliSecret {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
hostPattern: string | null;
|
||||
}
|
||||
|
||||
function listSecrets(): OnecliSecret[] {
|
||||
const out = execFileSync('onecli', ['secrets', 'list'], { encoding: 'utf-8' });
|
||||
const parsed = JSON.parse(out) as { data?: unknown };
|
||||
return Array.isArray(parsed.data) ? (parsed.data as OnecliSecret[]) : [];
|
||||
}
|
||||
|
||||
function findOpenAISecret(secrets: OnecliSecret[]): OnecliSecret | undefined {
|
||||
return secrets.find((s) => {
|
||||
const name = s.name.toLowerCase();
|
||||
const type = s.type.toLowerCase();
|
||||
const hostPattern = (s.hostPattern ?? '').toLowerCase();
|
||||
return (
|
||||
name === 'codex' ||
|
||||
name === 'openai' ||
|
||||
type === 'openai' ||
|
||||
hostPattern.includes('api.openai.com') ||
|
||||
hostPattern.includes('chatgpt.com')
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function openAISecretExists(): boolean {
|
||||
try {
|
||||
return findOpenAISecret(listSecrets()) !== undefined;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── auth step ───────────────────────────────────────────────────────────
|
||||
|
||||
function ensureAnswer<T>(value: T | symbol): T {
|
||||
if (p.isCancel(value)) {
|
||||
p.cancel('Setup cancelled.');
|
||||
process.exit(1);
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
|
||||
export async function runCodexAuthStep(): Promise<void> {
|
||||
if (openAISecretExists()) {
|
||||
p.log.success(brandBody('Your OpenAI account is already connected.'));
|
||||
setupLog.step('auth', 'skipped', 0, { REASON: 'openai-secret-already-present', PROVIDER: 'codex' });
|
||||
return;
|
||||
}
|
||||
|
||||
const method = ensureAnswer(
|
||||
await brightSelect<'browser' | 'device' | 'api' | 'skip'>({
|
||||
message: 'How would you like to connect Codex?',
|
||||
options: [
|
||||
{
|
||||
value: 'browser',
|
||||
label: 'Sign in with my ChatGPT subscription',
|
||||
hint: 'recommended if you have Plus or Pro — opens a browser',
|
||||
},
|
||||
{
|
||||
value: 'device',
|
||||
label: 'ChatGPT device pairing',
|
||||
hint: 'no browser handoff — shows a URL and a code',
|
||||
},
|
||||
{
|
||||
value: 'api',
|
||||
label: 'Paste an OpenAI API key',
|
||||
hint: 'pay-per-use; stored in OneCLI, never copied into the container',
|
||||
},
|
||||
{
|
||||
value: 'skip',
|
||||
label: "Skip — I'll connect later",
|
||||
hint: 'Codex groups will start, but model calls will fail auth',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
setupLog.userInput('codex_auth_method', method);
|
||||
|
||||
if (method === 'skip') {
|
||||
const confirmed = ensureAnswer(
|
||||
await p.confirm({
|
||||
message: "Skip Codex sign-in? Codex won't be able to answer until you connect an OpenAI account.",
|
||||
initialValue: false,
|
||||
}),
|
||||
);
|
||||
if (!confirmed) return runCodexAuthStep();
|
||||
setupLog.step('auth', 'skipped', 0, { REASON: 'user-skipped', PROVIDER: 'codex' });
|
||||
p.log.warn(brandBody('Codex sign-in skipped. Add an OpenAI account to OneCLI before using Codex groups.'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === 'api') {
|
||||
await runCodexApiKeyAuth();
|
||||
return;
|
||||
}
|
||||
|
||||
await runCodexLoginAuth(method);
|
||||
}
|
||||
|
||||
async function runCodexApiKeyAuth(): Promise<void> {
|
||||
const key = ensureAnswer(
|
||||
await p.password({
|
||||
message: 'Paste your OpenAI API key (sk-…)',
|
||||
validate: (v) => (v && v.trim().startsWith('sk-') ? undefined : 'That does not look like an OpenAI API key.'),
|
||||
}),
|
||||
) as string;
|
||||
|
||||
try {
|
||||
execFileSync(
|
||||
'onecli',
|
||||
[
|
||||
'secrets',
|
||||
'create',
|
||||
'--name',
|
||||
'Codex',
|
||||
'--type',
|
||||
'openai',
|
||||
'--value',
|
||||
key.trim(),
|
||||
'--host-pattern',
|
||||
'api.openai.com',
|
||||
],
|
||||
{ stdio: ['ignore', 'pipe', 'pipe'] },
|
||||
);
|
||||
} catch (err) {
|
||||
setupLog.step('auth', 'failed', 0, { PROVIDER: 'codex', METHOD: 'api', ERROR: String(err) });
|
||||
p.log.error(
|
||||
brandBody(
|
||||
"Couldn't save your OpenAI key to the vault. Make sure OneCLI is running (`onecli version`), then retry.",
|
||||
),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
setupLog.step('auth', 'success', 0, { PROVIDER: 'codex', METHOD: 'api' });
|
||||
p.log.success(brandBody('OpenAI account connected.'));
|
||||
}
|
||||
|
||||
export async function runCodexLoginAuth(method: 'browser' | 'device'): Promise<void> {
|
||||
const codexCheck = spawnSync('codex', ['--version'], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
if (codexCheck.status !== 0) {
|
||||
p.log.error(
|
||||
brandBody(
|
||||
'The Codex CLI is not installed on this machine. Install it with `npm install -g @openai/codex`, then re-run setup — or choose the API key option instead.',
|
||||
),
|
||||
);
|
||||
setupLog.step('auth', 'failed', 0, { PROVIDER: 'codex', METHOD: method, ERROR: 'codex_cli_missing' });
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (method === 'browser') {
|
||||
p.log.step(brandBody('Opening the Codex sign-in flow…'));
|
||||
console.log(k.dim(' (a browser will open for sign-in; this part is interactive)'));
|
||||
} else {
|
||||
p.log.step(brandBody('Starting Codex device-code pairing…'));
|
||||
console.log(k.dim(' (a URL and code will appear below — open the URL and enter the code)'));
|
||||
}
|
||||
console.log();
|
||||
|
||||
// Session-isolation invariant (see file header): the login runs under a
|
||||
// throwaway CODEX_HOME so the vaulted session is dedicated to the gateway
|
||||
// and never shared with the user's personal ~/.codex.
|
||||
const loginHome = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-vault-login-'));
|
||||
// Holds a live credential after login — must go on every exit path. The
|
||||
// failure branches call process.exit, which skips finally blocks, so each
|
||||
// removes it explicitly.
|
||||
const removeLoginHome = (): void => fs.rmSync(loginHome, { recursive: true, force: true });
|
||||
|
||||
const args = method === 'device' ? ['login', '--device-auth'] : ['login'];
|
||||
const start = Date.now();
|
||||
const code = await runInherit('codex', args, { CODEX_HOME: loginHome });
|
||||
const durationMs = Date.now() - start;
|
||||
console.log();
|
||||
|
||||
if (code !== 0) {
|
||||
removeLoginHome();
|
||||
setupLog.step('auth', 'failed', durationMs, { PROVIDER: 'codex', METHOD: method, EXIT_CODE: String(code) });
|
||||
p.log.error(
|
||||
brandBody(
|
||||
"Couldn't complete the Codex sign-in. Re-run setup and try again, or choose the API key option instead.",
|
||||
),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const authJsonPath = path.join(loginHome, 'auth.json');
|
||||
if (!fs.existsSync(authJsonPath)) {
|
||||
removeLoginHome();
|
||||
setupLog.step('auth', 'failed', durationMs, { PROVIDER: 'codex', METHOD: method, ERROR: 'auth_json_not_found' });
|
||||
p.log.error(
|
||||
brandBody('Codex login succeeded but no auth.json was written. Try again, or paste an API key instead.'),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
execFileSync(
|
||||
'onecli',
|
||||
[
|
||||
'secrets',
|
||||
'create',
|
||||
'--name',
|
||||
'Codex',
|
||||
'--type',
|
||||
'openai',
|
||||
'--file',
|
||||
authJsonPath,
|
||||
'--host-pattern',
|
||||
'chatgpt.com',
|
||||
],
|
||||
{ stdio: ['ignore', 'pipe', 'pipe'] },
|
||||
);
|
||||
} catch (err) {
|
||||
removeLoginHome();
|
||||
setupLog.step('auth', 'failed', durationMs, { PROVIDER: 'codex', METHOD: method, ERROR: String(err) });
|
||||
p.log.error(
|
||||
brandBody(
|
||||
"Couldn't save your Codex credentials to the vault. Make sure OneCLI is running (`onecli version`), then retry.",
|
||||
),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
removeLoginHome();
|
||||
setupLog.step('auth', 'success', durationMs, { PROVIDER: 'codex', METHOD: method });
|
||||
p.log.success(brandBody('OpenAI account connected — credentials live in your OneCLI vault, never in the container.'));
|
||||
}
|
||||
|
||||
function runInherit(cmd: string, args: string[], extraEnv?: Record<string, string>): Promise<number> {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(cmd, args, {
|
||||
stdio: 'inherit',
|
||||
env: extraEnv ? { ...process.env, ...extraEnv } : process.env,
|
||||
});
|
||||
child.on('close', (code) => resolve(code ?? 1));
|
||||
child.on('error', () => resolve(1));
|
||||
});
|
||||
}
|
||||
|
||||
// ─── failure assist ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The Codex CLI can debug a setup failure only if the binary runs AND
|
||||
* ~/.codex/auth.json exists (API-key-only installs keep the key in the
|
||||
* OneCLI vault, so the host-side CLI has nothing to authenticate with).
|
||||
*/
|
||||
export function isCodexCliUsable(): boolean {
|
||||
const codexCheck = spawnSync('codex', ['--version'], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
if (codexCheck.status !== 0) return false;
|
||||
return fs.existsSync(path.join(os.homedir(), '.codex', 'auth.json'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Failure prompt handed to the interactive Codex session — same content as
|
||||
* the dispatcher's Claude system prompt: what failed, the job ("diagnose and
|
||||
* fix, be concise, exit when done"), and a de-duped file reference list.
|
||||
*/
|
||||
export function buildCodexFailurePrompt(ctx: AssistContext, projectRoot: string): string {
|
||||
const stepRefs = STEP_FILES[ctx.stepName] ?? [];
|
||||
const references = [
|
||||
...BIG_PICTURE_FILES,
|
||||
...stepRefs,
|
||||
'logs/setup.log',
|
||||
ctx.rawLogPath ? path.relative(projectRoot, ctx.rawLogPath) : 'logs/setup-steps/',
|
||||
].filter((v, i, a) => a.indexOf(v) === i);
|
||||
|
||||
const lines: string[] = [
|
||||
"The user is running NanoClaw's interactive setup flow and hit a failure.",
|
||||
'',
|
||||
`Failed step: ${ctx.stepName}`,
|
||||
`Error: ${ctx.msg}`,
|
||||
];
|
||||
|
||||
if (ctx.hint) lines.push(`Hint: ${ctx.hint}`);
|
||||
|
||||
lines.push(
|
||||
'',
|
||||
'Your job: help them diagnose and fix this issue. Read the referenced files',
|
||||
'and logs to understand what went wrong, then help them fix it. You can read',
|
||||
'files, run commands, check logs, and explain what happened. Be concise.',
|
||||
"When they're ready to resume setup, tell them to exit Codex.",
|
||||
'',
|
||||
'Relevant files (read as needed):',
|
||||
);
|
||||
for (const f of references) lines.push(` - ${f}`);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry hook: offer to debug a setup failure with the Codex CLI. Returns
|
||||
* 'unavailable' when the CLI can't run here so the dispatcher can fall back
|
||||
* to its guarded Claude offer.
|
||||
*/
|
||||
export async function offerCodexFailureAssist(ctx: AssistContext, projectRoot: string): Promise<FailureAssistResult> {
|
||||
if (!isCodexCliUsable()) return 'unavailable';
|
||||
|
||||
const want = ensureAnswer(
|
||||
await p.confirm({
|
||||
message: 'Want to debug this with Codex?',
|
||||
initialValue: true,
|
||||
}),
|
||||
);
|
||||
if (!want) return 'declined';
|
||||
|
||||
const prompt = buildCodexFailurePrompt(ctx, projectRoot);
|
||||
|
||||
note(
|
||||
[
|
||||
'Launching Codex to help debug this failure.',
|
||||
'It has the context of what went wrong.',
|
||||
'',
|
||||
k.dim("Exit Codex (Ctrl-C or /quit) when you're ready to come back to setup."),
|
||||
].join('\n'),
|
||||
'Handing off to Codex',
|
||||
);
|
||||
|
||||
return new Promise<FailureAssistResult>((resolve) => {
|
||||
// codex accepts a positional initial prompt for the interactive TUI.
|
||||
const child = spawn('codex', [prompt], { cwd: projectRoot, stdio: 'inherit' });
|
||||
child.on('close', () => {
|
||||
p.log.success(brandBody("Back from Codex. Let's continue."));
|
||||
resolve('launched');
|
||||
});
|
||||
child.on('error', () => {
|
||||
p.log.error("Couldn't launch Codex.");
|
||||
resolve('unavailable');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─── install verification ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Verify the codex provider payload is fully wired — the same pre-flight the
|
||||
* /add-codex skill checks. While codex ships in trunk these always pass; once
|
||||
* the payload moves to the providers branch, a failed check means the install
|
||||
* step should run (or the user finishes via /add-codex).
|
||||
*/
|
||||
export function verifyCodexInstall(): { ok: boolean; problems: string[] } {
|
||||
const problems: string[] = [];
|
||||
const root = process.cwd();
|
||||
|
||||
const requiredFiles = [
|
||||
'src/providers/codex.ts',
|
||||
'src/providers/codex-agents-md.ts',
|
||||
'container/agent-runner/src/providers/codex.ts',
|
||||
'container/agent-runner/src/providers/codex-app-server.ts',
|
||||
];
|
||||
for (const file of requiredFiles) {
|
||||
if (!fs.existsSync(path.join(root, file))) problems.push(`missing file: ${file}`);
|
||||
}
|
||||
|
||||
for (const barrel of ['src/providers/index.ts', 'container/agent-runner/src/providers/index.ts']) {
|
||||
const barrelPath = path.join(root, barrel);
|
||||
if (!fs.existsSync(barrelPath) || !fs.readFileSync(barrelPath, 'utf-8').includes("import './codex.js';")) {
|
||||
problems.push(`missing barrel import in ${barrel}`);
|
||||
}
|
||||
}
|
||||
|
||||
const manifestPath = path.join(root, 'container', 'cli-tools.json');
|
||||
let hasCodexCli = false;
|
||||
if (fs.existsSync(manifestPath)) {
|
||||
try {
|
||||
const tools = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as Array<{ name?: string }>;
|
||||
hasCodexCli = Array.isArray(tools) && tools.some((t) => t.name === '@openai/codex');
|
||||
} catch {
|
||||
hasCodexCli = false;
|
||||
}
|
||||
}
|
||||
if (!hasCodexCli) {
|
||||
problems.push('container/cli-tools.json missing the @openai/codex CLI entry');
|
||||
}
|
||||
|
||||
return { ok: problems.length === 0, problems };
|
||||
}
|
||||
|
||||
export async function runCodexInstallCheck(): Promise<void> {
|
||||
p.log.step(brandBody('Checking the Codex provider install…'));
|
||||
const { ok, problems } = verifyCodexInstall();
|
||||
if (ok) {
|
||||
setupLog.step('codex-install', 'success', 0, {});
|
||||
p.log.success(brandBody('Codex installed properly.'));
|
||||
return;
|
||||
}
|
||||
|
||||
setupLog.step('codex-install', 'failed', 0, { PROBLEMS: problems.join('; ') });
|
||||
p.log.warn(brandBody('The Codex provider is not fully installed:'));
|
||||
for (const problem of problems) console.log(k.dim(` • ${problem}`));
|
||||
p.log.warn(
|
||||
brandBody(
|
||||
'Finish it with your coding agent of choice: open Codex CLI or Claude Code in this repo and run the /add-codex skill. Setup will continue — Codex groups will work once the install completes.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Self-registration: the setup picker and the standalone `provider-auth` step
|
||||
// render from the registry — this call is codex's only reach-in to the setup
|
||||
// flow (guarded by the barrel-driven registration test).
|
||||
registerSetupProvider({
|
||||
value: 'codex',
|
||||
label: 'Codex',
|
||||
hint: 'OpenAI — ChatGPT subscription or API key',
|
||||
runAuth: runCodexAuthStep,
|
||||
runInstallCheck: runCodexInstallCheck,
|
||||
offerFailureAssist: offerCodexFailureAssist,
|
||||
});
|
||||
+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
-80
@@ -80,8 +80,7 @@ export interface InboundMessage {
|
||||
* display name (e.g. `@Andy`).
|
||||
*
|
||||
* Adapters that don't set it (native / legacy) leave it undefined — the
|
||||
* router treats undefined as "not a mention" (`isMention === true` check,
|
||||
* src/router.ts). There is no text-match fallback.
|
||||
* router falls back to text-match against agent_group_name.
|
||||
*/
|
||||
isMention?: boolean;
|
||||
/** True when the source is a group/channel thread, false for DMs. */
|
||||
@@ -108,71 +107,11 @@ export interface ConversationInfo {
|
||||
isGroup: boolean;
|
||||
}
|
||||
|
||||
/** Wiring/mg defaults for one conversation context (DM vs group/channel). */
|
||||
export interface ChannelContextDefaults {
|
||||
/** Default engage_mode for wirings created in this context. */
|
||||
engageMode: 'pattern' | 'mention' | 'mention-sticky';
|
||||
/**
|
||||
* Default engage_pattern when engageMode === 'pattern'. May contain the
|
||||
* literal token `{name}`: creation helpers replace it with the regex-escaped
|
||||
* agent_group name (for platforms with no group-mention metadata, e.g.
|
||||
* iMessage/DeltaChat groups, WhatsApp shared-number mode). Required iff
|
||||
* engageMode === 'pattern'.
|
||||
*/
|
||||
engagePattern?: string;
|
||||
/**
|
||||
* Whether thread ids are honored in this context by default.
|
||||
* true — inbound thread ids flow into messages_in and (in groups) force
|
||||
* per-thread session identity; replies, typing, and cards land
|
||||
* in-thread.
|
||||
* false — thread ids are nulled per-wiring at router fanout; sessions
|
||||
* collapse; replies land top-level.
|
||||
* MUST be false when `supportsThreads` is false (capability bound; the
|
||||
* router treats supportsThreads=false as a hard pre-strip regardless).
|
||||
* Per-wiring override: messaging_group_agents.threads (NULL = inherit).
|
||||
*/
|
||||
threads: boolean;
|
||||
/**
|
||||
* unknown_sender_policy stamped on messaging_groups rows auto-created by
|
||||
* the router or created by wizard/CLI paths in this context.
|
||||
*/
|
||||
unknownSenderPolicy: 'strict' | 'request_approval' | 'public';
|
||||
}
|
||||
|
||||
/**
|
||||
* Static per-channel declaration of wiring-time defaults. Exactly two levels
|
||||
* exist: this declaration, and the per-wiring/per-mg values chosen at
|
||||
* creation. Install-wide changes = edit the adapter copy (skill-installed,
|
||||
* user-owned). Never persisted to the central DB.
|
||||
*/
|
||||
export interface ChannelDefaults {
|
||||
dm: ChannelContextDefaults;
|
||||
group: ChannelContextDefaults;
|
||||
/**
|
||||
* Which mention signal the adapter emits (InboundMessage.isMention):
|
||||
* 'platform' — platform-confirmed mentions in groups; DMs flagged too.
|
||||
* 'dm-only' — only DMs flagged (no group mention metadata).
|
||||
* 'never' — isMention never set: auto-create/registration card never
|
||||
* fires; 'mention'/'mention-sticky' wirings never engage.
|
||||
* Creation surfaces must reject/warn on mention modes that can never fire.
|
||||
*/
|
||||
mentions: 'platform' | 'dm-only' | 'never';
|
||||
}
|
||||
|
||||
/** The v2 channel adapter contract. */
|
||||
export interface ChannelAdapter {
|
||||
name: string;
|
||||
channelType: string;
|
||||
|
||||
/**
|
||||
* Adapter-instance name — distinguishes N adapters of one platform
|
||||
* (e.g. three Slack apps in one workspace). Defaults to channelType.
|
||||
* channelType stays the SEMANTIC platform key (user ids '<channelType>:<handle>',
|
||||
* formatting, container config); instance is a host-side routing key only.
|
||||
* Must be unique across active adapters and URL-safe (no '/', '?', ':').
|
||||
*/
|
||||
instance?: string;
|
||||
|
||||
/**
|
||||
* Whether this adapter models conversations as threads.
|
||||
*
|
||||
@@ -224,16 +163,6 @@ export interface ChannelAdapter {
|
||||
* Returning the same platform_id on repeated calls is expected.
|
||||
*/
|
||||
openDM?(userHandle: string): Promise<string>;
|
||||
resolveChannelName?: (platformId: string) => Promise<string | null>;
|
||||
|
||||
/**
|
||||
* Declared wiring-time defaults for this channel. Optional for backward
|
||||
* compatibility with stale adapter copies; absent → core fallback
|
||||
* (fallbackChannelDefaults(supportsThreads), see channel-registry.ts).
|
||||
* May be computed from adapter-internal env at module load (e.g. WhatsApp
|
||||
* shared-number mode), but is immutable for the process lifetime.
|
||||
*/
|
||||
defaults?: ChannelDefaults;
|
||||
}
|
||||
|
||||
/** Factory function that creates a channel adapter (returns null if credentials missing). */
|
||||
@@ -242,14 +171,6 @@ export type ChannelAdapterFactory = () => ChannelAdapter | Promise<ChannelAdapte
|
||||
/** Registration entry for a channel adapter. */
|
||||
export interface ChannelRegistration {
|
||||
factory: ChannelAdapterFactory;
|
||||
/**
|
||||
* Same declaration as ChannelAdapter.defaults, resolvable WITHOUT
|
||||
* instantiating the adapter — offline creation paths (setup/register.ts,
|
||||
* scripts/init-first-agent.ts, ncl against a host where the factory
|
||||
* returned null for missing creds) read it from the registry. Channel
|
||||
* modules pass the same const here and to the adapter/bridge.
|
||||
*/
|
||||
defaults?: ChannelDefaults;
|
||||
containerConfig?: {
|
||||
mounts?: Array<{ hostPath: string; containerPath: string; readonly: boolean }>;
|
||||
env?: Record<string, string>;
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
/**
|
||||
* Guards the ChannelDefaults declarations every channel module registers.
|
||||
*
|
||||
* Each module is imported directly (not via the barrel — many imports there
|
||||
* are commented out until the corresponding /add-<channel> skill installs
|
||||
* them). Importing runs the top-level registerChannelAdapter(name, { …,
|
||||
* defaults }) call; factories are never invoked, so getChannelDefaults
|
||||
* resolves from the registration tier.
|
||||
*
|
||||
* Two exclusions, both import-time-only (typecheck still covers them):
|
||||
* - deltachat: its runtime dep (@deltachat/stdio-rpc-server) is
|
||||
* skill-installed and absent from this branch's package.json.
|
||||
* - matrix: @beeper/chat-adapter-matrix's dist has an extensionless ESM
|
||||
* import (matrix-js-sdk/lib/http-api/errors) that Node/vitest can't
|
||||
* resolve, so the module can't be evaluated in this environment.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import type { ChannelDefaults } from './adapter.js';
|
||||
import { getChannelDefaults } from './channel-registry.js';
|
||||
|
||||
import './cli.js';
|
||||
import './discord.js';
|
||||
import './slack.js';
|
||||
import './telegram.js';
|
||||
import './github.js';
|
||||
import './linear.js';
|
||||
import './gchat.js';
|
||||
import './teams.js';
|
||||
import './whatsapp-cloud.js';
|
||||
import './resend.js';
|
||||
import './webex.js';
|
||||
import './imessage.js';
|
||||
import './whatsapp.js';
|
||||
import './signal.js';
|
||||
import './emacs.js';
|
||||
import './wechat.js';
|
||||
|
||||
/** channel → key facts of its declaration (the parts that differ per channel). */
|
||||
const EXPECTED: Record<
|
||||
string,
|
||||
{ groupMode: ChannelDefaults['group']['engageMode']; groupThreads: boolean; mentions: ChannelDefaults['mentions'] }
|
||||
> = {
|
||||
cli: { groupMode: 'pattern', groupThreads: false, mentions: 'never' },
|
||||
discord: { groupMode: 'mention-sticky', groupThreads: true, mentions: 'platform' },
|
||||
slack: { groupMode: 'mention-sticky', groupThreads: true, mentions: 'platform' },
|
||||
telegram: { groupMode: 'mention', groupThreads: false, mentions: 'platform' },
|
||||
github: { groupMode: 'mention', groupThreads: true, mentions: 'platform' },
|
||||
linear: { groupMode: 'pattern', groupThreads: true, mentions: 'never' },
|
||||
gchat: { groupMode: 'mention', groupThreads: true, mentions: 'platform' },
|
||||
teams: { groupMode: 'mention', groupThreads: true, mentions: 'platform' },
|
||||
'whatsapp-cloud': { groupMode: 'mention', groupThreads: false, mentions: 'platform' },
|
||||
resend: { groupMode: 'pattern', groupThreads: false, mentions: 'dm-only' },
|
||||
webex: { groupMode: 'mention', groupThreads: true, mentions: 'platform' },
|
||||
imessage: { groupMode: 'pattern', groupThreads: false, mentions: 'dm-only' },
|
||||
// whatsapp is env-computed; the test env has no ASSISTANT_HAS_OWN_NUMBER=true
|
||||
// so the shared-number declaration applies. (Dedicated mode is covered by
|
||||
// the adapter's own tests once PR8 lands the behavior split.)
|
||||
whatsapp: { groupMode: 'pattern', groupThreads: false, mentions: 'never' },
|
||||
// signal emits top-level isGroup/isMention (DM→true, group→account tagged);
|
||||
// non-threaded, so group mode is 'mention', never sticky.
|
||||
signal: { groupMode: 'mention', groupThreads: false, mentions: 'platform' },
|
||||
emacs: { groupMode: 'pattern', groupThreads: false, mentions: 'never' },
|
||||
// wechat emits isMention only for DMs (shared account, no group-mention
|
||||
// metadata), so mention wirings are dm-only and groups stay name-pattern.
|
||||
wechat: { groupMode: 'pattern', groupThreads: false, mentions: 'dm-only' },
|
||||
};
|
||||
|
||||
describe('channel default declarations', () => {
|
||||
for (const [channel, expected] of Object.entries(EXPECTED)) {
|
||||
describe(channel, () => {
|
||||
const decl = getChannelDefaults(channel);
|
||||
|
||||
it('declares the expected group mode, threads, and mention capability', () => {
|
||||
expect(decl.group.engageMode).toBe(expected.groupMode);
|
||||
expect(decl.group.threads).toBe(expected.groupThreads);
|
||||
expect(decl.mentions).toBe(expected.mentions);
|
||||
});
|
||||
|
||||
it('declares a valid pattern default in every pattern-mode context', () => {
|
||||
for (const ctx of [decl.dm, decl.group]) {
|
||||
if (ctx.engageMode === 'pattern') {
|
||||
expect(ctx.engagePattern).toBeTruthy();
|
||||
// Must compile once {name} is substituted (resolveWiringDefaults
|
||||
// regex-escapes the name, so any literal stands in).
|
||||
expect(() => new RegExp(ctx.engagePattern!.replaceAll('{name}', 'Agent'))).not.toThrow();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('never declares a mention mode the channel says can never fire', () => {
|
||||
if (expected.mentions === 'never') {
|
||||
expect(decl.dm.engageMode).toBe('pattern');
|
||||
expect(decl.group.engageMode).toBe('pattern');
|
||||
}
|
||||
});
|
||||
|
||||
it('DMs engage on every message', () => {
|
||||
expect(decl.dm.engageMode).toBe('pattern');
|
||||
expect(decl.dm.engagePattern).toBe('.');
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,273 +0,0 @@
|
||||
/**
|
||||
* Tests for channel default declarations: getChannelDefaults tiered lookup,
|
||||
* the behavior-faithful fallback, and the wiring-creation helpers.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
import type { ChannelAdapter, ChannelDefaults, ChannelSetup } from './adapter.js';
|
||||
|
||||
function makeDefaults(marker: string, threads = true): ChannelDefaults {
|
||||
return {
|
||||
dm: { engageMode: 'pattern', engagePattern: marker, threads, unknownSenderPolicy: 'public' },
|
||||
group: { engageMode: 'mention', threads, unknownSenderPolicy: 'strict' },
|
||||
mentions: 'platform',
|
||||
};
|
||||
}
|
||||
|
||||
function makeAdapter(
|
||||
channelType: string,
|
||||
opts: { instance?: string; supportsThreads?: boolean; defaults?: ChannelDefaults } = {},
|
||||
): ChannelAdapter {
|
||||
return {
|
||||
name: opts.instance ?? channelType,
|
||||
channelType,
|
||||
instance: opts.instance,
|
||||
supportsThreads: opts.supportsThreads ?? false,
|
||||
defaults: opts.defaults,
|
||||
async setup(_config: ChannelSetup) {},
|
||||
async teardown() {},
|
||||
isConnected: () => true,
|
||||
async deliver() {
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const mockSetup = () => ({
|
||||
onInbound: () => {},
|
||||
onInboundEvent: () => {},
|
||||
onMetadata: () => {},
|
||||
onAction: () => {},
|
||||
});
|
||||
|
||||
describe('getChannelDefaults — tiered lookup', () => {
|
||||
// The registry and activeAdapters maps are module-level; fresh module per
|
||||
// test so registrations don't leak across arms.
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const { teardownChannelAdapters } = await import('./channel-registry.js');
|
||||
await teardownChannelAdapters();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('live adapter declaration wins over the registration declaration', async () => {
|
||||
const reg = await import('./channel-registry.js');
|
||||
const liveDecl = makeDefaults('live');
|
||||
const regDecl = makeDefaults('registration');
|
||||
reg.registerChannelAdapter('mock', {
|
||||
factory: () => makeAdapter('mock', { defaults: liveDecl }),
|
||||
defaults: regDecl,
|
||||
});
|
||||
await reg.initChannelAdapters(mockSetup);
|
||||
|
||||
expect(reg.getChannelDefaults('mock')).toBe(liveDecl);
|
||||
});
|
||||
|
||||
it('falls through a live channelType scan for a channelType key (live tier instance→channelType)', async () => {
|
||||
const reg = await import('./channel-registry.js');
|
||||
const decl = makeDefaults('named-instance');
|
||||
reg.registerChannelAdapter('slack-tester', {
|
||||
factory: () => makeAdapter('slack', { instance: 'slack-tester', defaults: decl }),
|
||||
});
|
||||
await reg.initChannelAdapters(mockSetup);
|
||||
|
||||
// Key is the bare channelType; only a named instance is live.
|
||||
expect(reg.getChannelDefaults('slack')).toBe(decl);
|
||||
});
|
||||
|
||||
it('falls through to the registration entry when the factory returned null', async () => {
|
||||
const reg = await import('./channel-registry.js');
|
||||
const decl = makeDefaults('registration');
|
||||
reg.registerChannelAdapter('mock', { factory: () => null, defaults: decl });
|
||||
await reg.initChannelAdapters(mockSetup);
|
||||
|
||||
expect(reg.getChannelDefaults('mock')).toBe(decl);
|
||||
});
|
||||
|
||||
it('resolves a stale live instance through its channelType registration (registration tier instance→channelType)', async () => {
|
||||
const reg = await import('./channel-registry.js');
|
||||
const decl = makeDefaults('platform-registration');
|
||||
// Stale adapter copy: live under a named-instance key with NO declaration;
|
||||
// the platform's registration (keyed by channelType) carries one.
|
||||
reg.registerChannelAdapter('slack-tester', {
|
||||
factory: () => makeAdapter('slack', { instance: 'slack-tester' }),
|
||||
});
|
||||
reg.registerChannelAdapter('slack', { factory: () => null, defaults: decl });
|
||||
await reg.initChannelAdapters(mockSetup);
|
||||
|
||||
expect(reg.getChannelDefaults('slack-tester')).toBe(decl);
|
||||
});
|
||||
|
||||
it('resolves a dead named instance through the channelType hint (registration tier instance→channelType)', async () => {
|
||||
const reg = await import('./channel-registry.js');
|
||||
const decl = makeDefaults('platform-registration');
|
||||
// Nothing live at all: the named instance's factory returned null and its
|
||||
// registration has no declaration — only mg.channel_type can bridge.
|
||||
reg.registerChannelAdapter('slack-tester', { factory: () => null });
|
||||
reg.registerChannelAdapter('slack', { factory: () => null, defaults: decl });
|
||||
await reg.initChannelAdapters(mockSetup);
|
||||
|
||||
expect(reg.getChannelDefaults('slack-tester', 'slack')).toBe(decl);
|
||||
// Without the hint there is no instance→channelType mapping in the registry.
|
||||
expect(reg.getChannelDefaults('slack-tester')).toEqual(reg.fallbackChannelDefaults(false));
|
||||
});
|
||||
|
||||
it('uses the live adapter supportsThreads for the fallback tier', async () => {
|
||||
const reg = await import('./channel-registry.js');
|
||||
reg.registerChannelAdapter('mock', {
|
||||
factory: () => makeAdapter('mock', { supportsThreads: true }),
|
||||
});
|
||||
await reg.initChannelAdapters(mockSetup);
|
||||
|
||||
expect(reg.getChannelDefaults('mock')).toEqual(reg.fallbackChannelDefaults(true));
|
||||
});
|
||||
|
||||
it('unknown channel type resolves the conservative fallback', async () => {
|
||||
const reg = await import('./channel-registry.js');
|
||||
expect(reg.getChannelDefaults('no-such-channel')).toEqual(reg.fallbackChannelDefaults(false));
|
||||
});
|
||||
});
|
||||
|
||||
describe('fallbackChannelDefaults — behavior-faithful values', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('reproduces trunk behavior for undeclared adapters', async () => {
|
||||
const { fallbackChannelDefaults } = await import('./channel-registry.js');
|
||||
expect(fallbackChannelDefaults(true)).toEqual({
|
||||
dm: { engageMode: 'pattern', engagePattern: '.', threads: true, unknownSenderPolicy: 'request_approval' },
|
||||
group: { engageMode: 'mention-sticky', threads: true, unknownSenderPolicy: 'request_approval' },
|
||||
mentions: 'platform',
|
||||
});
|
||||
// threads track the raw capability in BOTH contexts so NULL-inherit
|
||||
// wirings behave exactly like today's supportsThreads-derived routing.
|
||||
const nonThreaded = fallbackChannelDefaults(false);
|
||||
expect(nonThreaded.dm.threads).toBe(false);
|
||||
expect(nonThreaded.group.threads).toBe(false);
|
||||
expect(nonThreaded.group.engageMode).toBe('mention-sticky');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveWiringDefaults', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const { teardownChannelAdapters } = await import('./channel-registry.js');
|
||||
await teardownChannelAdapters();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
async function withDeclaration(defaults: ChannelDefaults) {
|
||||
const reg = await import('./channel-registry.js');
|
||||
reg.registerChannelAdapter('mock', { factory: () => null, defaults });
|
||||
return import('./channel-defaults.js');
|
||||
}
|
||||
|
||||
it('substitutes {name} with the regex-escaped agent group name', async () => {
|
||||
const { resolveWiringDefaults } = await withDeclaration({
|
||||
dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'public' },
|
||||
group: { engageMode: 'pattern', engagePattern: '\\b{name}\\b', threads: false, unknownSenderPolicy: 'strict' },
|
||||
mentions: 'dm-only',
|
||||
});
|
||||
|
||||
expect(resolveWiringDefaults('mock', true, 'C-3PO (dev)')).toEqual({
|
||||
engage_mode: 'pattern',
|
||||
engage_pattern: '\\bC-3PO \\(dev\\)\\b',
|
||||
});
|
||||
// DM context: no token, pattern passes through untouched.
|
||||
expect(resolveWiringDefaults('mock', false, 'C-3PO (dev)')).toEqual({
|
||||
engage_mode: 'pattern',
|
||||
engage_pattern: '.',
|
||||
});
|
||||
});
|
||||
|
||||
it('coerces mention-sticky to mention when the context threads=false', async () => {
|
||||
const { resolveWiringDefaults } = await withDeclaration({
|
||||
dm: { engageMode: 'mention', threads: false, unknownSenderPolicy: 'strict' },
|
||||
group: { engageMode: 'mention-sticky', threads: false, unknownSenderPolicy: 'strict' },
|
||||
mentions: 'platform',
|
||||
});
|
||||
|
||||
expect(resolveWiringDefaults('mock', true, 'Andy')).toEqual({
|
||||
engage_mode: 'mention',
|
||||
engage_pattern: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps mention-sticky when the context threads=true', async () => {
|
||||
const { resolveWiringDefaults } = await withDeclaration({
|
||||
dm: { engageMode: 'mention', threads: true, unknownSenderPolicy: 'strict' },
|
||||
group: { engageMode: 'mention-sticky', threads: true, unknownSenderPolicy: 'strict' },
|
||||
mentions: 'platform',
|
||||
});
|
||||
|
||||
expect(resolveWiringDefaults('mock', true, 'Andy')).toEqual({
|
||||
engage_mode: 'mention-sticky',
|
||||
engage_pattern: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws on a pattern-mode declaration without a pattern', async () => {
|
||||
const { resolveWiringDefaults } = await withDeclaration({
|
||||
dm: { engageMode: 'pattern', threads: false, unknownSenderPolicy: 'public' },
|
||||
group: { engageMode: 'mention', threads: false, unknownSenderPolicy: 'strict' },
|
||||
mentions: 'platform',
|
||||
});
|
||||
|
||||
expect(() => resolveWiringDefaults('mock', false, 'Andy')).toThrow(/without an engagePattern/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveUnknownSenderPolicy', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('selects the context policy from the declaration', async () => {
|
||||
const reg = await import('./channel-registry.js');
|
||||
reg.registerChannelAdapter('mock', {
|
||||
factory: () => null,
|
||||
defaults: {
|
||||
dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'public' },
|
||||
group: { engageMode: 'mention', threads: false, unknownSenderPolicy: 'strict' },
|
||||
mentions: 'platform',
|
||||
},
|
||||
});
|
||||
const { resolveUnknownSenderPolicy } = await import('./channel-defaults.js');
|
||||
|
||||
expect(resolveUnknownSenderPolicy('mock', false)).toBe('public');
|
||||
expect(resolveUnknownSenderPolicy('mock', true)).toBe('strict');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveThreadPolicy', () => {
|
||||
it('ANDs the resolved value with the raw capability', async () => {
|
||||
vi.resetModules();
|
||||
const { resolveThreadPolicy } = await import('./channel-defaults.js');
|
||||
const decl: ChannelDefaults = {
|
||||
dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'strict' },
|
||||
group: { engageMode: 'mention-sticky', threads: true, unknownSenderPolicy: 'strict' },
|
||||
mentions: 'platform',
|
||||
};
|
||||
|
||||
// NULL = inherit the declaration for the context.
|
||||
expect(resolveThreadPolicy(null, decl, true, true)).toBe(true);
|
||||
expect(resolveThreadPolicy(null, decl, false, true)).toBe(false);
|
||||
// Explicit wiring value beats the declaration…
|
||||
expect(resolveThreadPolicy(1, decl, false, true)).toBe(true);
|
||||
expect(resolveThreadPolicy(0, decl, true, true)).toBe(false);
|
||||
// …but never the capability: no opt-in on a non-threaded platform.
|
||||
expect(resolveThreadPolicy(1, decl, true, false)).toBe(false);
|
||||
expect(resolveThreadPolicy(null, decl, true, false)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,86 +0,0 @@
|
||||
/**
|
||||
* Wiring-creation helpers over channel default declarations.
|
||||
*
|
||||
* Every path that creates a messaging_group_agents row (ncl, setup wizard,
|
||||
* card-approval flow, bootstrap scripts) resolves its engage defaults through
|
||||
* resolveWiringDefaults; every path that auto-creates a messaging_groups row
|
||||
* resolves its policy through resolveUnknownSenderPolicy. The router's fanout
|
||||
* consults resolveThreadPolicy at runtime — threading is the one per-wiring
|
||||
* setting that stays live (NULL = inherit the declaration) rather than being
|
||||
* snapshotted at creation.
|
||||
*
|
||||
* Context selection everywhere: isGroup = event.message.isGroup ??
|
||||
* (mg.is_group === 1) — NEVER `threadId !== null` (DM sub-threads exist on
|
||||
* Slack/Discord, and non-threaded group platforms have null threadIds).
|
||||
*/
|
||||
import type { ChannelDefaults } from './adapter.js';
|
||||
import { getChannelDefaults } from './channel-registry.js';
|
||||
|
||||
function escapeRegex(text: string): string {
|
||||
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the engage defaults a new wiring should be created with.
|
||||
*
|
||||
* @param channelKey mg.instance ?? mg.channel_type (getChannelAdapter key discipline)
|
||||
* @param isGroup event.message.isGroup ?? mg.is_group === 1 — never derived from threadId
|
||||
* @param agentGroupName substituted (regex-escaped) for the `{name}` token in declared patterns
|
||||
*
|
||||
* mention-sticky is downgraded to mention when the context's declared threads
|
||||
* value is false: sticky engagement is keyed on per-thread session existence,
|
||||
* so without thread ids it could engage once and never disengage.
|
||||
*/
|
||||
export function resolveWiringDefaults(
|
||||
channelKey: string,
|
||||
isGroup: boolean,
|
||||
agentGroupName: string,
|
||||
): { engage_mode: 'pattern' | 'mention' | 'mention-sticky'; engage_pattern: string | null } {
|
||||
const decl = getChannelDefaults(channelKey);
|
||||
const ctx = isGroup ? decl.group : decl.dm;
|
||||
|
||||
let mode = ctx.engageMode;
|
||||
if (mode === 'mention-sticky' && !ctx.threads) mode = 'mention';
|
||||
|
||||
if (mode !== 'pattern') return { engage_mode: mode, engage_pattern: null };
|
||||
|
||||
if (!ctx.engagePattern) {
|
||||
throw new Error(
|
||||
`Channel '${channelKey}' declares engageMode 'pattern' without an engagePattern (${isGroup ? 'group' : 'dm'} context)`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
engage_mode: 'pattern',
|
||||
engage_pattern: ctx.engagePattern.replaceAll('{name}', escapeRegex(agentGroupName)),
|
||||
};
|
||||
}
|
||||
|
||||
/** unknown_sender_policy for a messaging_groups row created in this context. */
|
||||
export function resolveUnknownSenderPolicy(
|
||||
channelKey: string,
|
||||
isGroup: boolean,
|
||||
): 'strict' | 'request_approval' | 'public' {
|
||||
const decl = getChannelDefaults(channelKey);
|
||||
return (isGroup ? decl.group : decl.dm).unknownSenderPolicy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime thread policy for one wiring: does its event-derived address keep
|
||||
* thread ids? wiring.threads (0/1, NULL = inherit the declaration) hard-ANDed
|
||||
* with the adapter's raw capability — a wiring can opt out of threads on a
|
||||
* threaded platform, never opt in on a non-threaded one.
|
||||
*
|
||||
* Applies ONLY to event-derived addresses. `event.replyTo` is operator intent
|
||||
* from the CLI admin transport (src/channels/adapter.ts) and must never be
|
||||
* nulled through this policy.
|
||||
*/
|
||||
export function resolveThreadPolicy(
|
||||
wiringThreads: number | null,
|
||||
decl: ChannelDefaults,
|
||||
isGroup: boolean,
|
||||
supportsThreads: boolean,
|
||||
): boolean {
|
||||
const inherited = (isGroup ? decl.group : decl.dm).threads;
|
||||
const wanted = wiringThreads === null ? inherited : wiringThreads !== 0;
|
||||
return wanted && supportsThreads;
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
* Channels self-register on import. The host calls initChannelAdapters() at startup
|
||||
* to instantiate and set up all registered adapters.
|
||||
*/
|
||||
import type { ChannelAdapter, ChannelDefaults, ChannelRegistration, ChannelSetup } from './adapter.js';
|
||||
import type { ChannelAdapter, ChannelRegistration, ChannelSetup } from './adapter.js';
|
||||
import { log } from '../log.js';
|
||||
|
||||
const SETUP_RETRY_DELAYS_MS = [2000, 5000, 10000];
|
||||
@@ -31,76 +31,6 @@ export function getChannelAdapter(channelType: string): ChannelAdapter | undefin
|
||||
return activeAdapters.get(channelType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Behavior-faithful fallback for adapters with no `defaults` declaration
|
||||
* (stale skill-installed copies, unknown channel types). Values reproduce
|
||||
* what trunk did before declarations existed, so a trunk update alone
|
||||
* changes nothing for undeclared adapters:
|
||||
* - dm: pattern '.' (every DM message engages), router auto-create policy
|
||||
* 'request_approval' (src/router.ts auto-create branch).
|
||||
* - group: mention-sticky (what the card-approval flow stamped on group
|
||||
* channels), same 'request_approval' policy.
|
||||
* - threads follow the raw capability in BOTH contexts — a NULL (inherit)
|
||||
* wiring resolved through this fallback behaves exactly like today's
|
||||
* supportsThreads-derived routing.
|
||||
* - mentions 'platform': never blocks a mention wiring at creation time.
|
||||
*/
|
||||
export function fallbackChannelDefaults(supportsThreads: boolean): ChannelDefaults {
|
||||
return {
|
||||
dm: {
|
||||
engageMode: 'pattern',
|
||||
engagePattern: '.',
|
||||
threads: supportsThreads,
|
||||
unknownSenderPolicy: 'request_approval',
|
||||
},
|
||||
group: {
|
||||
engageMode: 'mention-sticky',
|
||||
threads: supportsThreads,
|
||||
unknownSenderPolicy: 'request_approval',
|
||||
},
|
||||
mentions: 'platform',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a channel's declared wiring defaults. Never returns undefined.
|
||||
*
|
||||
* `key` follows the same discipline as getChannelAdapter: mg.instance ??
|
||||
* mg.channel_type. Tiers, first hit wins:
|
||||
* 1. live adapter, instance-exact — lets an instance carry env-computed
|
||||
* declarations (e.g. WhatsApp shared-number mode);
|
||||
* 2. live adapter of that channelType (mirrors getChannelAdapter's scan);
|
||||
* 3. registration entry under the key — covers offline scripts and
|
||||
* factories that returned null for missing creds;
|
||||
* 4. registration entry under the channelType — resolved from the live
|
||||
* adapter found in tiers 1-2 (a stale adapter copy without a declaration
|
||||
* whose registration has one), else from the optional `channelType`
|
||||
* hint, which callers holding a named-instance mg row should pass so a
|
||||
* dead instance still resolves its platform's declaration;
|
||||
* 5. fallbackChannelDefaults on the live adapter's capability (false when
|
||||
* no adapter is live — conservative, reachable only from manual creation
|
||||
* surfaces since the router never sees events for unregistered channels).
|
||||
*/
|
||||
export function getChannelDefaults(key: string, channelType?: string): ChannelDefaults {
|
||||
let live = activeAdapters.get(key);
|
||||
if (!live) {
|
||||
for (const adapter of activeAdapters.values()) {
|
||||
if (adapter.channelType === key) {
|
||||
live = adapter;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (live?.defaults) return live.defaults;
|
||||
|
||||
const typeKey = live?.channelType ?? channelType;
|
||||
const registered =
|
||||
registry.get(key)?.defaults ?? (typeKey !== undefined ? registry.get(typeKey)?.defaults : undefined);
|
||||
if (registered) return registered;
|
||||
|
||||
return fallbackChannelDefaults(live?.supportsThreads ?? false);
|
||||
}
|
||||
|
||||
/** Get all active adapters. */
|
||||
export function getActiveAdapters(): ChannelAdapter[] {
|
||||
return [...activeAdapters.values()];
|
||||
@@ -155,16 +85,8 @@ export async function initChannelAdapters(setupFn: (adapter: ChannelAdapter) =>
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
// Adapters key by instance (default instance = channelType), so N
|
||||
// instances of one platform coexist. Duplicate keys warn instead of
|
||||
// throwing — boot stays resilient, matching the historical silent
|
||||
// last-write-wins, but now visibly.
|
||||
const key = adapter.instance ?? adapter.channelType;
|
||||
if (activeAdapters.has(key)) {
|
||||
log.warn('Duplicate adapter instance key — overwriting previous adapter', { key, channel: name });
|
||||
}
|
||||
activeAdapters.set(key, adapter);
|
||||
log.info('Channel adapter started', { channel: name, type: adapter.channelType, instance: key });
|
||||
activeAdapters.set(adapter.channelType, adapter);
|
||||
log.info('Channel adapter started', { channel: name, type: adapter.channelType });
|
||||
} catch (err) {
|
||||
log.error('Failed to start channel adapter', { channel: name, err });
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import { SqliteStateAdapter } from '../state-sqlite.js';
|
||||
import { registerWebhookAdapter } from '../webhook-server.js';
|
||||
import { getAskQuestionRender } from '../db/sessions.js';
|
||||
import { normalizeOptions, type NormalizedOption } from './ask-question.js';
|
||||
import type { ChannelAdapter, ChannelDefaults, ChannelSetup, InboundMessage } from './adapter.js';
|
||||
import type { ChannelAdapter, ChannelSetup, InboundMessage } from './adapter.js';
|
||||
|
||||
/** Adapter with optional gateway support (e.g., Discord). */
|
||||
interface GatewayAdapter extends Adapter {
|
||||
@@ -57,12 +57,6 @@ export interface ChatSdkBridgeConfig {
|
||||
* way and the default depends on installation style.
|
||||
*/
|
||||
supportsThreads: boolean;
|
||||
/**
|
||||
* Declared wiring-time defaults for this channel. Copied verbatim onto the
|
||||
* returned ChannelAdapter, exactly like supportsThreads. See
|
||||
* `ChannelAdapter.defaults`.
|
||||
*/
|
||||
defaults?: ChannelDefaults;
|
||||
/**
|
||||
* Optional transform applied to outbound text/markdown before it reaches the
|
||||
* adapter. Used by channels that need to sanitize for a platform-specific
|
||||
@@ -200,7 +194,6 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
|
||||
name: adapter.name,
|
||||
channelType: adapter.name,
|
||||
supportsThreads: config.supportsThreads,
|
||||
defaults: config.defaults,
|
||||
|
||||
async setup(hostConfig: ChannelSetup) {
|
||||
setupConfig = hostConfig;
|
||||
@@ -241,11 +234,9 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
|
||||
});
|
||||
|
||||
// DMs — by definition addressed to the bot. Thread id flows through
|
||||
// unmodified (Slack users can open sub-threads inside a DM); whether it
|
||||
// is honored is policy, not transport: the channel's declared
|
||||
// dm.threads default (ChannelDefaults) or a per-wiring threads override
|
||||
// decides at router fanout whether replies land in-thread or all DM
|
||||
// sub-threads collapse into the one DM session.
|
||||
// so sub-thread context reaches delivery (Slack users can open threads
|
||||
// inside a DM). Router collapses DM sub-threads to one session via
|
||||
// is_group=0 short-circuit.
|
||||
chat.onDirectMessage(async (thread, message) => {
|
||||
const channelId = adapter.channelIdFromThreadId(thread.id);
|
||||
log.info('Inbound DM received', {
|
||||
|
||||
+2
-22
@@ -39,30 +39,11 @@ import path from 'path';
|
||||
|
||||
import { DATA_DIR } from '../config.js';
|
||||
import { log } from '../log.js';
|
||||
import type {
|
||||
ChannelAdapter,
|
||||
ChannelDefaults,
|
||||
ChannelSetup,
|
||||
DeliveryAddress,
|
||||
InboundEvent,
|
||||
OutboundMessage,
|
||||
} from './adapter.js';
|
||||
import type { ChannelAdapter, ChannelSetup, DeliveryAddress, InboundEvent, OutboundMessage } from './adapter.js';
|
||||
import { registerChannelAdapter } from './channel-registry.js';
|
||||
|
||||
const PLATFORM_ID = 'local';
|
||||
|
||||
/**
|
||||
* Terminal transport: every line the operator types is for the agent
|
||||
* (pattern '.'), the socket is owner-only so senders are trusted ('public'),
|
||||
* there is no thread or mention concept. Matches what
|
||||
* scripts/init-cli-agent.ts has always created.
|
||||
*/
|
||||
const CLI_DEFAULTS: ChannelDefaults = {
|
||||
dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'public' },
|
||||
group: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'public' },
|
||||
mentions: 'never',
|
||||
};
|
||||
|
||||
function socketPath(): string {
|
||||
return path.join(DATA_DIR, 'cli.sock');
|
||||
}
|
||||
@@ -75,7 +56,6 @@ function createAdapter(): ChannelAdapter {
|
||||
name: 'cli',
|
||||
channelType: 'cli',
|
||||
supportsThreads: false,
|
||||
defaults: CLI_DEFAULTS,
|
||||
|
||||
async setup(config: ChannelSetup): Promise<void> {
|
||||
const sock = socketPath();
|
||||
@@ -293,4 +273,4 @@ function extractText(message: OutboundMessage): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
registerChannelAdapter('cli', { factory: createAdapter, defaults: CLI_DEFAULTS });
|
||||
registerChannelAdapter('cli', { factory: createAdapter });
|
||||
|
||||
@@ -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,356 +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, ChannelDefaults, 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,
|
||||
defaults: DELTACHAT_DEFAULTS,
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dedicated email identity (DC_EMAIL), so request_approval is sound. Email
|
||||
* carries no mention metadata ('dm-only'; the adapter flags DMs only), so
|
||||
* group wirings default to a name-pattern trigger.
|
||||
*/
|
||||
const DELTACHAT_DEFAULTS: ChannelDefaults = {
|
||||
dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'request_approval' },
|
||||
group: {
|
||||
engageMode: 'pattern',
|
||||
engagePattern: '\\b{name}\\b',
|
||||
threads: false,
|
||||
unknownSenderPolicy: 'request_approval',
|
||||
},
|
||||
mentions: 'dm-only',
|
||||
};
|
||||
|
||||
registerChannelAdapter('deltachat', {
|
||||
factory: () => {
|
||||
const env = readEnvFile([...REQUIRED_ENV, ...OPTIONAL_ENV]);
|
||||
if (!env.DC_EMAIL || !env.DC_PASSWORD) return null;
|
||||
return createAdapter(env as DcEnv);
|
||||
},
|
||||
defaults: DELTACHAT_DEFAULTS,
|
||||
});
|
||||
@@ -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,76 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { unwrapForwardedSnapshot } from './discord.js';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function forwardPayload(snapshotMessage: Record<string, any> | null, overrides: Record<string, any> = {}) {
|
||||
return {
|
||||
id: '123',
|
||||
content: '',
|
||||
attachments: [],
|
||||
message_reference: { type: 1, channel_id: 'c1', message_id: 'm1' },
|
||||
...(snapshotMessage ? { message_snapshots: [{ message: snapshotMessage }] } : {}),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('unwrapForwardedSnapshot', () => {
|
||||
it('unwraps forwarded text into content with a label', () => {
|
||||
const data = forwardPayload({ content: 'hello from the past', attachments: [] });
|
||||
unwrapForwardedSnapshot(data);
|
||||
expect(data.content).toBe('[Forwarded message]\nhello from the past');
|
||||
});
|
||||
|
||||
it('unwraps attachment-only forwards: label + merged attachments', () => {
|
||||
const att = { filename: 'photo.png', content_type: 'image/png', size: 1234, url: 'https://cdn.example/photo.png' };
|
||||
const data = forwardPayload({ content: '', attachments: [att] });
|
||||
unwrapForwardedSnapshot(data);
|
||||
expect(data.content).toBe('[Forwarded message]');
|
||||
expect(data.attachments).toEqual([att]);
|
||||
});
|
||||
|
||||
it('merges snapshot attachments after existing ones', () => {
|
||||
const existing = { filename: 'own.txt', content_type: 'text/plain', size: 1, url: 'https://cdn.example/own.txt' };
|
||||
const fwd = { filename: 'fwd.jpg', content_type: 'image/jpeg', size: 2, url: 'https://cdn.example/fwd.jpg' };
|
||||
const data = forwardPayload({ content: 'look', attachments: [fwd] }, { attachments: [existing] });
|
||||
unwrapForwardedSnapshot(data);
|
||||
expect(data.attachments).toEqual([existing, fwd]);
|
||||
expect(data.content).toBe('[Forwarded message]\nlook');
|
||||
});
|
||||
|
||||
it('leaves plain messages untouched', () => {
|
||||
const data = { id: '1', content: 'hi', attachments: [] };
|
||||
unwrapForwardedSnapshot(data);
|
||||
expect(data).toEqual({ id: '1', content: 'hi', attachments: [] });
|
||||
});
|
||||
|
||||
it('leaves normal replies (type 0) untouched', () => {
|
||||
const data = {
|
||||
id: '1',
|
||||
content: 'a reply',
|
||||
attachments: [],
|
||||
message_reference: { type: 0, message_id: 'm0' },
|
||||
referenced_message: { content: 'original', author: { username: 'alice' } },
|
||||
};
|
||||
unwrapForwardedSnapshot(data);
|
||||
expect(data.content).toBe('a reply');
|
||||
});
|
||||
|
||||
it('is a no-op when a forward has no snapshots', () => {
|
||||
const data = forwardPayload(null);
|
||||
unwrapForwardedSnapshot(data);
|
||||
expect(data.content).toBe('');
|
||||
expect(data.attachments).toEqual([]);
|
||||
});
|
||||
|
||||
it('joins multiple snapshots', () => {
|
||||
const data = forwardPayload(null, {
|
||||
message_snapshots: [
|
||||
{ message: { content: 'one', attachments: [] } },
|
||||
{ message: { content: 'two', attachments: [] } },
|
||||
],
|
||||
});
|
||||
unwrapForwardedSnapshot(data);
|
||||
expect(data.content).toBe('[Forwarded message]\none\ntwo');
|
||||
});
|
||||
});
|
||||
@@ -1,98 +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 type { ChannelDefaults } from './adapter.js';
|
||||
import { createChatSdkBridge, type ReplyContext } from './chat-sdk-bridge.js';
|
||||
import { registerChannelAdapter } from './channel-registry.js';
|
||||
|
||||
/**
|
||||
* Dedicated bot app on a threaded platform. group threads:true matches the
|
||||
* declared supportsThreads (the skill-installed install-style knob) so
|
||||
* mention-sticky engagement stays bounded per-thread. dm.threads:false —
|
||||
* DM replies land top-level, one session per DM.
|
||||
*/
|
||||
const DISCORD_DEFAULTS: ChannelDefaults = {
|
||||
dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'request_approval' },
|
||||
group: { engageMode: 'mention-sticky', threads: true, unknownSenderPolicy: 'request_approval' },
|
||||
mentions: 'platform',
|
||||
};
|
||||
|
||||
// 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',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Discord message forwards carry their content in `message_snapshots`, not
|
||||
* `content` (`message_reference.type === 1` means FORWARD; 0 is a normal
|
||||
* reply). The adapter only reads `content`/`attachments`, so without this the
|
||||
* agent sees an empty message. Unwrap the snapshot back into the payload so
|
||||
* text, attachment download, and formatting all ride the existing path.
|
||||
* Note: snapshots contain no author, so the original sender is unavailable.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function unwrapForwardedSnapshot(data: Record<string, any>): void {
|
||||
if (data.message_reference?.type !== 1) return;
|
||||
const snaps = (data.message_snapshots ?? [])
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.map((s: any) => s?.message)
|
||||
.filter(Boolean);
|
||||
if (snaps.length === 0) return;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const text = snaps
|
||||
.map((m: any) => m.content)
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
const label = '[Forwarded message]';
|
||||
data.content = text ? `${label}\n${text}` : data.content || label;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const fwdAttachments = snaps.flatMap((m: any) => m.attachments ?? []);
|
||||
if (fwdAttachments.length > 0) {
|
||||
data.attachments = [...(data.attachments ?? []), ...fwdAttachments];
|
||||
}
|
||||
}
|
||||
|
||||
function unwrapForwards(adapter: ReturnType<typeof createDiscordAdapter>): void {
|
||||
const a = adapter as unknown as {
|
||||
handleForwardedMessage: (data: Record<string, unknown>, options?: unknown) => Promise<void>;
|
||||
};
|
||||
const orig = a.handleForwardedMessage.bind(adapter);
|
||||
a.handleForwardedMessage = async (data, options) => {
|
||||
unwrapForwardedSnapshot(data);
|
||||
return orig(data, options);
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
unwrapForwards(discordAdapter);
|
||||
return createChatSdkBridge({
|
||||
adapter: discordAdapter,
|
||||
concurrency: 'concurrent',
|
||||
botToken: env.DISCORD_BOT_TOKEN,
|
||||
extractReplyContext,
|
||||
supportsThreads: true,
|
||||
defaults: DISCORD_DEFAULTS,
|
||||
// Discord rejects messages over 2000 chars; without this the bridge
|
||||
// would let long agent replies fail instead of splitting them.
|
||||
maxTextLength: 2000,
|
||||
});
|
||||
},
|
||||
defaults: DISCORD_DEFAULTS,
|
||||
});
|
||||
@@ -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,199 +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, ChannelDefaults, ChannelSetup, InboundMessage, OutboundMessage } from './adapter.js';
|
||||
|
||||
const OUTBOUND_BUFFER_MAX = 200;
|
||||
|
||||
/**
|
||||
* Single-operator localhost transport, wired manually: every line is for the
|
||||
* agent, senders are whoever can reach the local port ('strict' keeps
|
||||
* auto-create off), no thread or mention concept.
|
||||
*/
|
||||
const EMACS_DEFAULTS: ChannelDefaults = {
|
||||
dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'strict' },
|
||||
group: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'strict' },
|
||||
mentions: 'never',
|
||||
};
|
||||
|
||||
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,
|
||||
defaults: EMACS_DEFAULTS,
|
||||
|
||||
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 });
|
||||
},
|
||||
defaults: EMACS_DEFAULTS,
|
||||
});
|
||||
|
||||
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,37 +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 type { ChannelDefaults } from './adapter.js';
|
||||
import { createChatSdkBridge } from './chat-sdk-bridge.js';
|
||||
import { registerChannelAdapter } from './channel-registry.js';
|
||||
|
||||
/**
|
||||
* Dedicated bot app on a threaded platform. 'mention' (not sticky) is the
|
||||
* conservative group default; operators upgrade per wiring.
|
||||
*/
|
||||
const GCHAT_DEFAULTS: ChannelDefaults = {
|
||||
dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'request_approval' },
|
||||
group: { engageMode: 'mention', threads: true, unknownSenderPolicy: 'request_approval' },
|
||||
mentions: 'platform',
|
||||
};
|
||||
|
||||
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,
|
||||
defaults: GCHAT_DEFAULTS,
|
||||
});
|
||||
},
|
||||
defaults: GCHAT_DEFAULTS,
|
||||
});
|
||||
@@ -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,41 +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 type { ChannelDefaults } from './adapter.js';
|
||||
import { createChatSdkBridge } from './chat-sdk-bridge.js';
|
||||
import { registerChannelAdapter } from './channel-registry.js';
|
||||
|
||||
/**
|
||||
* Dedicated bot identity. group threads:true — every issue/PR comment thread
|
||||
* is its own conversation and session (the long-standing GitHub behavior,
|
||||
* now declared instead of forced by supportsThreads alone).
|
||||
*/
|
||||
const GITHUB_DEFAULTS: ChannelDefaults = {
|
||||
dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'request_approval' },
|
||||
group: { engageMode: 'mention', threads: true, unknownSenderPolicy: 'request_approval' },
|
||||
mentions: 'platform',
|
||||
};
|
||||
|
||||
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,
|
||||
defaults: GITHUB_DEFAULTS,
|
||||
});
|
||||
},
|
||||
defaults: GITHUB_DEFAULTS,
|
||||
});
|
||||
@@ -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,48 +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 type { ChannelDefaults } from './adapter.js';
|
||||
import { createChatSdkBridge } from './chat-sdk-bridge.js';
|
||||
import { registerChannelAdapter } from './channel-registry.js';
|
||||
|
||||
/**
|
||||
* The operator's personal Apple ID is a shared identity — strangers DMing it
|
||||
* reach the human, not the bot, so auto-create stays 'strict'. iMessage
|
||||
* exposes no group-mention metadata ('dm-only'); group wirings default to a
|
||||
* name-pattern trigger instead ({name} = agent group name).
|
||||
*/
|
||||
const IMESSAGE_DEFAULTS: ChannelDefaults = {
|
||||
dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'strict' },
|
||||
group: { engageMode: 'pattern', engagePattern: '\\b{name}\\b', threads: false, unknownSenderPolicy: 'strict' },
|
||||
mentions: 'dm-only',
|
||||
};
|
||||
|
||||
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,
|
||||
defaults: IMESSAGE_DEFAULTS,
|
||||
});
|
||||
},
|
||||
defaults: IMESSAGE_DEFAULTS,
|
||||
});
|
||||
+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,65 +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 type { ChannelDefaults } from './adapter.js';
|
||||
import { createChatSdkBridge } from './chat-sdk-bridge.js';
|
||||
import { registerChannelAdapter } from './channel-registry.js';
|
||||
|
||||
/**
|
||||
* Linear OAuth apps can't be @-mentioned (see module doc), so mention modes
|
||||
* can never fire — creation surfaces must refuse them ('never'). Group
|
||||
* wirings default to pattern '.' (every comment engages) with per-issue
|
||||
* threads. Auto-create can't fire without isMention, so unknownSenderPolicy
|
||||
* is declared for uniformity only.
|
||||
*/
|
||||
const LINEAR_DEFAULTS: ChannelDefaults = {
|
||||
dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'request_approval' },
|
||||
group: { engageMode: 'pattern', engagePattern: '.', threads: true, unknownSenderPolicy: 'request_approval' },
|
||||
mentions: 'never',
|
||||
};
|
||||
|
||||
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,
|
||||
defaults: LINEAR_DEFAULTS,
|
||||
});
|
||||
},
|
||||
defaults: LINEAR_DEFAULTS,
|
||||
});
|
||||
@@ -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,225 +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 type { ChannelDefaults } from './adapter.js';
|
||||
import { createChatSdkBridge } from './chat-sdk-bridge.js';
|
||||
import { registerChannelAdapter } from './channel-registry.js';
|
||||
|
||||
/**
|
||||
* Assumes a dedicated bot account on a homeserver (the common install).
|
||||
* Non-threaded at the bridge level, so group engagement is 'mention', never
|
||||
* sticky. Personal-account installs should edit their copy to dm 'strict' —
|
||||
* install-wide changes live in this declaration by design.
|
||||
*/
|
||||
const MATRIX_DEFAULTS: ChannelDefaults = {
|
||||
dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'request_approval' },
|
||||
group: { engageMode: 'mention', threads: false, unknownSenderPolicy: 'request_approval' },
|
||||
mentions: 'platform',
|
||||
};
|
||||
|
||||
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,
|
||||
defaults: MATRIX_DEFAULTS,
|
||||
});
|
||||
|
||||
// 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;
|
||||
},
|
||||
defaults: MATRIX_DEFAULTS,
|
||||
});
|
||||
@@ -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,41 +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 type { ChannelDefaults } from './adapter.js';
|
||||
import { createChatSdkBridge } from './chat-sdk-bridge.js';
|
||||
import { registerChannelAdapter } from './channel-registry.js';
|
||||
|
||||
/**
|
||||
* Email: every conversation is effectively a DM addressed to the bot's
|
||||
* address — the group branch is inert but required by the type. 'dm-only'
|
||||
* because email has no mention metadata.
|
||||
*/
|
||||
const RESEND_DEFAULTS: ChannelDefaults = {
|
||||
dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'request_approval' },
|
||||
group: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'request_approval' },
|
||||
mentions: 'dm-only',
|
||||
};
|
||||
|
||||
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,
|
||||
defaults: RESEND_DEFAULTS,
|
||||
});
|
||||
},
|
||||
defaults: RESEND_DEFAULTS,
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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,62 +0,0 @@
|
||||
/**
|
||||
* Slack channel adapter (v2) — uses Chat SDK bridge.
|
||||
* Self-registers on import.
|
||||
*
|
||||
* Socket Mode opt-in: set SLACK_APP_TOKEN (xapp-…) to receive events over an
|
||||
* outbound WebSocket instead of an inbound HTTPS webhook.
|
||||
*/
|
||||
import { createSlackAdapter } from '@chat-adapter/slack';
|
||||
|
||||
import { readEnvFile } from '../env.js';
|
||||
import type { ChannelDefaults } from './adapter.js';
|
||||
import { createChatSdkBridge } from './chat-sdk-bridge.js';
|
||||
import { registerChannelAdapter } from './channel-registry.js';
|
||||
|
||||
/**
|
||||
* Dedicated bot app on a threaded platform. group threads:true keeps
|
||||
* mention-sticky bounded — engagement sticks per-thread, not forever.
|
||||
* dm.threads:false is a deliberate policy choice, not a capability limit:
|
||||
* Slack users can open sub-threads inside a DM, but by default the agent
|
||||
* replies top-level and all DM sub-threads collapse into the one DM session.
|
||||
* This declaration owns that judgment (it used to be hardcoded router
|
||||
* behavior); operators who want in-thread DM replies override per wiring
|
||||
* with `--threads true`.
|
||||
*/
|
||||
const SLACK_DEFAULTS: ChannelDefaults = {
|
||||
dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'request_approval' },
|
||||
group: { engageMode: 'mention-sticky', threads: true, unknownSenderPolicy: 'request_approval' },
|
||||
mentions: 'platform',
|
||||
};
|
||||
|
||||
registerChannelAdapter('slack', {
|
||||
factory: () => {
|
||||
const env = readEnvFile(['SLACK_BOT_TOKEN', 'SLACK_SIGNING_SECRET', 'SLACK_APP_TOKEN']);
|
||||
if (!env.SLACK_BOT_TOKEN) return null;
|
||||
// SLACK_APP_TOKEN (xapp-…) enables Socket Mode: events arrive over an
|
||||
// outbound WebSocket, so no public HTTPS endpoint is required. When set,
|
||||
// the signing secret is optional (Slack signs socket frames separately).
|
||||
const useSocketMode = Boolean(env.SLACK_APP_TOKEN);
|
||||
const slackAdapter = createSlackAdapter({
|
||||
botToken: env.SLACK_BOT_TOKEN,
|
||||
signingSecret: env.SLACK_SIGNING_SECRET,
|
||||
appToken: env.SLACK_APP_TOKEN,
|
||||
mode: useSocketMode ? 'socket' : 'webhook',
|
||||
});
|
||||
const bridge = createChatSdkBridge({
|
||||
adapter: slackAdapter,
|
||||
concurrency: 'concurrent',
|
||||
supportsThreads: true,
|
||||
defaults: SLACK_DEFAULTS,
|
||||
});
|
||||
bridge.resolveChannelName = async (platformId: string) => {
|
||||
try {
|
||||
const info = await slackAdapter.fetchThread(platformId);
|
||||
return (info as { channelName?: string }).channelName ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
return bridge;
|
||||
},
|
||||
defaults: SLACK_DEFAULTS,
|
||||
});
|
||||
@@ -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,40 +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 type { ChannelDefaults } from './adapter.js';
|
||||
import { createChatSdkBridge } from './chat-sdk-bridge.js';
|
||||
import { registerChannelAdapter } from './channel-registry.js';
|
||||
|
||||
/**
|
||||
* Dedicated bot app on a threaded platform. 'mention' (not sticky) is the
|
||||
* conservative group default; operators upgrade per wiring.
|
||||
*/
|
||||
const TEAMS_DEFAULTS: ChannelDefaults = {
|
||||
dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'request_approval' },
|
||||
group: { engageMode: 'mention', threads: true, unknownSenderPolicy: 'request_approval' },
|
||||
mentions: 'platform',
|
||||
};
|
||||
|
||||
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,
|
||||
defaults: TEAMS_DEFAULTS,
|
||||
});
|
||||
},
|
||||
defaults: TEAMS_DEFAULTS,
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
/**
|
||||
* Sanitize outbound text for Telegram's legacy `Markdown` parse mode.
|
||||
*
|
||||
* WORKAROUND: The @chat-adapter/telegram adapter hardcodes parse_mode=Markdown
|
||||
* (legacy) but its converter emits CommonMark. Messages with `**bold**`, odd
|
||||
* delimiter counts, or malformed links are rejected by Telegram and dropped
|
||||
* after retries. Remove this once upstream ships real mode-aware conversion
|
||||
* (vercel/chat PR #367 adds the knob; a follow-up is needed for the converter).
|
||||
*/
|
||||
|
||||
const CODE_PATTERN = /```[\s\S]*?```|`[^`\n]*`/g;
|
||||
const PLACEHOLDER_PREFIX = '\x00CODE';
|
||||
const PLACEHOLDER_SUFFIX = '\x00';
|
||||
|
||||
export function sanitizeTelegramLegacyMarkdown(input: string): string {
|
||||
if (!input) return input;
|
||||
|
||||
const codeSegments: string[] = [];
|
||||
let text = input.replace(CODE_PATTERN, (m) => {
|
||||
codeSegments.push(m);
|
||||
return `${PLACEHOLDER_PREFIX}${codeSegments.length - 1}${PLACEHOLDER_SUFFIX}`;
|
||||
});
|
||||
|
||||
// The adapter re-parses and re-stringifies markdown before sending, which
|
||||
// rewrites `- item` list bullets into `* item` — injecting unbalanced
|
||||
// asterisks that Telegram's legacy Markdown parser then rejects. Replace
|
||||
// list bullets with a plain Unicode bullet so the adapter treats the line
|
||||
// as prose.
|
||||
text = text.replace(/^(\s*)[-+]\s+/gm, '$1• ');
|
||||
|
||||
// Flatten Markdown horizontal rules (bare --- / *** / ___ lines) to a
|
||||
// plain Unicode divider. The parser doesn't understand HR syntax and the
|
||||
// `*` / `_` characters would otherwise unbalance the delimiter counts below.
|
||||
text = text.replace(/^[ \t]*[-_*]{3,}[ \t]*$/gm, '⎯⎯⎯');
|
||||
|
||||
text = text.replace(/\*\*([^*\n]+?)\*\*/g, '*$1*');
|
||||
text = text.replace(/__([^_\n]+?)__/g, '_$1_');
|
||||
|
||||
const starCount = (text.match(/\*/g) ?? []).length;
|
||||
const underCount = (text.match(/_/g) ?? []).length;
|
||||
if (starCount % 2 !== 0 || underCount % 2 !== 0) {
|
||||
text = text.replace(/[*_]/g, '');
|
||||
}
|
||||
|
||||
const openBrackets = (text.match(/\[/g) ?? []).length;
|
||||
const closeBrackets = (text.match(/\]/g) ?? []).length;
|
||||
if (openBrackets !== closeBrackets) {
|
||||
text = text.replace(/[[\]]/g, '');
|
||||
}
|
||||
|
||||
return text.replace(
|
||||
new RegExp(`${PLACEHOLDER_PREFIX}(\\d+)${PLACEHOLDER_SUFFIX}`, 'g'),
|
||||
(_, i) => codeSegments[Number(i)],
|
||||
);
|
||||
}
|
||||
@@ -1,248 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
vi.mock('../log.js', () => ({ log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } }));
|
||||
|
||||
import {
|
||||
createPairing,
|
||||
tryConsume,
|
||||
getStatus,
|
||||
getPairing,
|
||||
waitForPairing,
|
||||
extractCode,
|
||||
extractAddressedText,
|
||||
_setStorePathForTest,
|
||||
_resetForTest,
|
||||
} from './telegram-pairing.js';
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tg-pair-'));
|
||||
_setStorePathForTest(path.join(tmpDir, 'pairings.json'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
_resetForTest();
|
||||
_setStorePathForTest(null);
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('extractAddressedText', () => {
|
||||
it('strips @botname prefix', () => {
|
||||
expect(extractAddressedText('@nanobot 1234', 'nanobot')).toBe('1234');
|
||||
});
|
||||
it('is case-insensitive', () => {
|
||||
expect(extractAddressedText('@NanoBot hello', 'nanobot')).toBe('hello');
|
||||
});
|
||||
it('returns null when not addressed', () => {
|
||||
expect(extractAddressedText('hello 1234', 'nanobot')).toBeNull();
|
||||
});
|
||||
it('returns null when address is mid-text', () => {
|
||||
expect(extractAddressedText('hi @nanobot 1234', 'nanobot')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractCode', () => {
|
||||
it('accepts a bare 4-digit code', () => {
|
||||
expect(extractCode('0349', 'nanobot')).toBe('0349');
|
||||
});
|
||||
it('accepts 4-digit code after @botname', () => {
|
||||
expect(extractCode('@nanobot 0042', 'nanobot')).toBe('0042');
|
||||
});
|
||||
it('rejects non-4-digit numbers', () => {
|
||||
expect(extractCode('@nanobot 12345', 'nanobot')).toBeNull();
|
||||
expect(extractCode('@nanobot 12', 'nanobot')).toBeNull();
|
||||
expect(extractCode('12345', 'nanobot')).toBeNull();
|
||||
});
|
||||
it('rejects loose matches with surrounding text', () => {
|
||||
expect(extractCode('my pin is 0349', 'nanobot')).toBeNull();
|
||||
expect(extractCode('0349 thanks', 'nanobot')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPairing', () => {
|
||||
it('generates a 4-digit code', async () => {
|
||||
const r = await createPairing('main');
|
||||
expect(r.code).toMatch(/^\d{4}$/);
|
||||
expect(r.status).toBe('pending');
|
||||
});
|
||||
|
||||
it('does not collide with active codes', async () => {
|
||||
const codes = new Set<string>();
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const r = await createPairing('main');
|
||||
expect(codes.has(r.code)).toBe(false);
|
||||
codes.add(r.code);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('tryConsume', () => {
|
||||
it('matches and marks consumed', async () => {
|
||||
const r = await createPairing('main');
|
||||
const consumed = await tryConsume({
|
||||
text: `@nanobot ${r.code}`,
|
||||
botUsername: 'nanobot',
|
||||
platformId: 'telegram:123',
|
||||
isGroup: false,
|
||||
adminUserId: 'u1',
|
||||
});
|
||||
expect(consumed).not.toBeNull();
|
||||
expect(consumed!.status).toBe('consumed');
|
||||
expect(consumed!.consumed?.platformId).toBe('telegram:123');
|
||||
expect(consumed!.consumed?.adminUserId).toBe('u1');
|
||||
expect(getStatus(r.code)).toBe('consumed');
|
||||
});
|
||||
|
||||
it('returns null on no match (silent drop)', async () => {
|
||||
await createPairing('main');
|
||||
const out = await tryConsume({
|
||||
text: '@nanobot 9999',
|
||||
botUsername: 'nanobot',
|
||||
platformId: 'x',
|
||||
isGroup: false,
|
||||
});
|
||||
expect(out).toBeNull();
|
||||
});
|
||||
|
||||
it('matches a bare code without @botname addressing', async () => {
|
||||
const r = await createPairing('main');
|
||||
const out = await tryConsume({
|
||||
text: r.code,
|
||||
botUsername: 'nanobot',
|
||||
platformId: 'x',
|
||||
isGroup: false,
|
||||
});
|
||||
expect(out).not.toBeNull();
|
||||
expect(out!.status).toBe('consumed');
|
||||
});
|
||||
|
||||
it('cannot be consumed twice', async () => {
|
||||
const r = await createPairing('main');
|
||||
await tryConsume({ text: `@b ${r.code}`, botUsername: 'b', platformId: 'p', isGroup: false });
|
||||
const second = await tryConsume({ text: `@b ${r.code}`, botUsername: 'b', platformId: 'p', isGroup: false });
|
||||
expect(second).toBeNull();
|
||||
});
|
||||
|
||||
it('cannot consume an invalidated pairing', async () => {
|
||||
const r = await createPairing('main');
|
||||
// Invalidate by sending a wrong code
|
||||
await tryConsume({ text: '9999', botUsername: 'b', platformId: 'p', isGroup: false });
|
||||
const out = await tryConsume({ text: `@b ${r.code}`, botUsername: 'b', platformId: 'p', isGroup: false });
|
||||
expect(out).toBeNull();
|
||||
expect(getStatus(r.code)).toBe('invalidated');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStatus', () => {
|
||||
it('returns unknown for missing codes', () => {
|
||||
expect(getStatus('0000')).toBe('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('waitForPairing', () => {
|
||||
it('resolves when consumed', async () => {
|
||||
const r = await createPairing('main');
|
||||
const p = waitForPairing(r.code, { pollMs: 50 });
|
||||
setTimeout(() => {
|
||||
tryConsume({ text: `@b ${r.code}`, botUsername: 'b', platformId: 'tg:1', isGroup: true, name: 'Group' });
|
||||
}, 100);
|
||||
const consumed = await p;
|
||||
expect(consumed.status).toBe('consumed');
|
||||
expect(consumed.consumed?.name).toBe('Group');
|
||||
});
|
||||
|
||||
it('rejects on invalidation', async () => {
|
||||
const r = await createPairing('main');
|
||||
const waiter = waitForPairing(r.code, { pollMs: 30 });
|
||||
setTimeout(() => {
|
||||
tryConsume({ text: '0000', botUsername: 'b', platformId: 'tg:1', isGroup: false });
|
||||
}, 60);
|
||||
await expect(waiter).rejects.toThrow(/invalidated/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('replace-by-default', () => {
|
||||
it('supersedes an existing pending pairing with the same intent', async () => {
|
||||
const first = await createPairing('main');
|
||||
const second = await createPairing('main');
|
||||
expect(getStatus(first.code)).toBe('invalidated');
|
||||
expect(getStatus(second.code)).toBe('pending');
|
||||
});
|
||||
|
||||
it('does not supersede pairings with a different intent', async () => {
|
||||
const a = await createPairing({ kind: 'wire-to', folder: 'work' });
|
||||
const b = await createPairing({ kind: 'wire-to', folder: 'side' });
|
||||
expect(getStatus(a.code)).toBe('pending');
|
||||
expect(getStatus(b.code)).toBe('pending');
|
||||
});
|
||||
|
||||
it('causes waitForPairing on the old code to reject as invalidated', async () => {
|
||||
const first = await createPairing('main');
|
||||
const waiter = waitForPairing(first.code, { pollMs: 30 });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
await createPairing('main');
|
||||
await expect(waiter).rejects.toThrow(/invalidated/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('attempt tracking', () => {
|
||||
it('fires onAttempt for a wrong code, invalidates the pairing, and rejects the waiter', async () => {
|
||||
const r = await createPairing('main');
|
||||
const attempts: string[] = [];
|
||||
const waiter = waitForPairing(r.code, {
|
||||
pollMs: 30,
|
||||
onAttempt: (a) => attempts.push(a.candidate),
|
||||
});
|
||||
setTimeout(() => {
|
||||
tryConsume({ text: '9999', botUsername: 'b', platformId: 'tg:1', isGroup: false });
|
||||
}, 60);
|
||||
await expect(waiter).rejects.toThrow(/invalidated by wrong code \(9999\)/);
|
||||
expect(attempts).toEqual(['9999']);
|
||||
expect(getStatus(r.code)).toBe('invalidated');
|
||||
});
|
||||
|
||||
it('a correct code consumes without firing onAttempt', async () => {
|
||||
const r = await createPairing('main');
|
||||
const attempts: string[] = [];
|
||||
const waiter = waitForPairing(r.code, {
|
||||
pollMs: 30,
|
||||
onAttempt: (a) => attempts.push(a.candidate),
|
||||
});
|
||||
setTimeout(() => {
|
||||
tryConsume({ text: r.code, botUsername: 'b', platformId: 'tg:1', isGroup: false });
|
||||
}, 60);
|
||||
const consumed = await waiter;
|
||||
expect(consumed.status).toBe('consumed');
|
||||
expect(attempts).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores non-code messages and keeps the pairing pending', async () => {
|
||||
const r = await createPairing('main');
|
||||
await tryConsume({ text: 'hello there', botUsername: 'b', platformId: 'p', isGroup: false });
|
||||
const after = getPairing(r.code);
|
||||
expect(after?.status).toBe('pending');
|
||||
expect(after?.attempts ?? []).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('a second code attempt after invalidation does not match', async () => {
|
||||
const r = await createPairing('main');
|
||||
await tryConsume({ text: '9999', botUsername: 'b', platformId: 'p', isGroup: false });
|
||||
const retry = await tryConsume({ text: r.code, botUsername: 'b', platformId: 'p', isGroup: false });
|
||||
expect(retry).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('intent passthrough', () => {
|
||||
it('preserves wire-to and new-agent intents', async () => {
|
||||
const a = await createPairing({ kind: 'wire-to', folder: 'work' });
|
||||
const b = await createPairing({ kind: 'new-agent', folder: 'side' });
|
||||
const ca = await tryConsume({ text: `@b ${a.code}`, botUsername: 'b', platformId: 'p1', isGroup: true });
|
||||
const cb = await tryConsume({ text: `@b ${b.code}`, botUsername: 'b', platformId: 'p2', isGroup: true });
|
||||
expect(ca!.intent).toEqual({ kind: 'wire-to', folder: 'work' });
|
||||
expect(cb!.intent).toEqual({ kind: 'new-agent', folder: 'side' });
|
||||
});
|
||||
});
|
||||
@@ -1,339 +0,0 @@
|
||||
/**
|
||||
* Telegram pairing — proves the operator owns the chat they're registering.
|
||||
*
|
||||
* BotFather hands out tokens with no user binding, so anyone who guesses the
|
||||
* bot's username can DM it. Pairing closes that gap: setup creates a one-time
|
||||
* 4-digit code and the operator echoes it back from the chat they want to
|
||||
* register. The message must be exactly the 4 digits (optionally prefixed by
|
||||
* `@botname ` for groups with privacy ON) — arbitrary messages that happen to
|
||||
* contain a 4-digit number do NOT match. The inbound interceptor in
|
||||
* telegram.ts matches the code, records the chat, upserts the paired user,
|
||||
* and (if no owner exists yet) promotes them to owner — all before the
|
||||
* message ever reaches the router.
|
||||
*
|
||||
* Storage is a JSON file at data/telegram-pairings.json — single-process,
|
||||
* read-modify-write under an in-process mutex.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { DATA_DIR } from '../config.js';
|
||||
import { log } from '../log.js';
|
||||
|
||||
export type PairingIntent = 'main' | { kind: 'wire-to'; folder: string } | { kind: 'new-agent'; folder: string };
|
||||
export type PairingStatus = 'pending' | 'consumed' | 'invalidated' | 'unknown';
|
||||
|
||||
export interface ConsumedDetails {
|
||||
platformId: string;
|
||||
isGroup: boolean;
|
||||
name: string | null;
|
||||
adminUserId: string | null;
|
||||
consumedAt: string;
|
||||
}
|
||||
|
||||
export interface PairingAttempt {
|
||||
candidate: string;
|
||||
platformId: string;
|
||||
at: string;
|
||||
matched: boolean;
|
||||
}
|
||||
|
||||
export interface PairingRecord {
|
||||
code: string;
|
||||
intent: PairingIntent;
|
||||
createdAt: string;
|
||||
status: Exclude<PairingStatus, 'unknown'>;
|
||||
consumed?: ConsumedDetails;
|
||||
/** Recent pairing attempts observed while this record was pending. Capped. */
|
||||
attempts?: PairingAttempt[];
|
||||
}
|
||||
|
||||
const MAX_ATTEMPTS_PER_RECORD = 10;
|
||||
|
||||
function intentEquals(a: PairingIntent, b: PairingIntent): boolean {
|
||||
if (a === 'main' || b === 'main') return a === b;
|
||||
return a.kind === b.kind && a.folder === b.folder;
|
||||
}
|
||||
|
||||
interface Store {
|
||||
pairings: PairingRecord[];
|
||||
}
|
||||
|
||||
/** Pairing codes do not expire — they are consumed on match or invalidated by wrong guesses. */
|
||||
const FILE_NAME = 'telegram-pairings.json';
|
||||
|
||||
let storePathOverride: string | null = null;
|
||||
export function _setStorePathForTest(p: string | null): void {
|
||||
storePathOverride = p;
|
||||
}
|
||||
|
||||
function storePath(): string {
|
||||
return storePathOverride ?? path.join(DATA_DIR, FILE_NAME);
|
||||
}
|
||||
|
||||
let mutex: Promise<unknown> = Promise.resolve();
|
||||
function withLock<T>(fn: () => Promise<T> | T): Promise<T> {
|
||||
const next = mutex.then(() => fn());
|
||||
mutex = next.catch(() => {});
|
||||
return next;
|
||||
}
|
||||
|
||||
function readStore(): Store {
|
||||
try {
|
||||
const raw = fs.readFileSync(storePath(), 'utf8');
|
||||
const parsed = JSON.parse(raw) as Store;
|
||||
if (!Array.isArray(parsed.pairings)) return { pairings: [] };
|
||||
return parsed;
|
||||
} catch {
|
||||
return { pairings: [] };
|
||||
}
|
||||
}
|
||||
|
||||
function writeStore(store: Store): void {
|
||||
const p = storePath();
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||
const tmp = `${p}.tmp`;
|
||||
fs.writeFileSync(tmp, JSON.stringify(store, null, 2));
|
||||
fs.renameSync(tmp, p);
|
||||
}
|
||||
|
||||
/** Clean up old consumed/invalidated records (keep last 50). */
|
||||
function sweep(store: Store): boolean {
|
||||
if (store.pairings.length <= 50) return false;
|
||||
store.pairings = store.pairings.slice(-50);
|
||||
return true;
|
||||
}
|
||||
|
||||
function generateCode(active: Set<string>): string {
|
||||
// 4-digit numeric, zero-padded. 10k space, fine for one-at-a-time intents.
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const code = Math.floor(Math.random() * 10000)
|
||||
.toString()
|
||||
.padStart(4, '0');
|
||||
if (!active.has(code)) return code;
|
||||
}
|
||||
throw new Error('Could not allocate a free pairing code (too many active).');
|
||||
}
|
||||
|
||||
export async function createPairing(intent: PairingIntent): Promise<PairingRecord> {
|
||||
return withLock(() => {
|
||||
const store = readStore();
|
||||
sweep(store);
|
||||
// Replace-by-default: a new pairing for an intent supersedes any existing
|
||||
// pending pairing for the same intent. Old waitForPairing calls observe
|
||||
// `invalidated` and exit on their own.
|
||||
for (const r of store.pairings) {
|
||||
if (r.status === 'pending' && intentEquals(r.intent, intent)) {
|
||||
r.status = 'invalidated';
|
||||
log.info('Pairing superseded by new request', { code: r.code, intent });
|
||||
}
|
||||
}
|
||||
const active = new Set(store.pairings.filter((r) => r.status === 'pending').map((r) => r.code));
|
||||
const record: PairingRecord = {
|
||||
code: generateCode(active),
|
||||
intent,
|
||||
createdAt: new Date().toISOString(),
|
||||
status: 'pending',
|
||||
};
|
||||
store.pairings.push(record);
|
||||
writeStore(store);
|
||||
log.info('Pairing created', { code: record.code, intent });
|
||||
return record;
|
||||
});
|
||||
}
|
||||
|
||||
export interface ConsumeInput {
|
||||
text: string;
|
||||
botUsername: string;
|
||||
platformId: string;
|
||||
isGroup: boolean;
|
||||
name?: string | null;
|
||||
adminUserId?: string | null;
|
||||
}
|
||||
|
||||
/** Strip leading @botname and return the trimmed remainder, or null if not addressed. */
|
||||
export function extractAddressedText(text: string, botUsername: string): string | null {
|
||||
const trimmed = text.trim();
|
||||
const re = new RegExp(`^@${botUsername.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')}\\b`, 'i');
|
||||
const m = trimmed.match(re);
|
||||
if (!m) return null;
|
||||
return trimmed.slice(m[0].length).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a pairing code from an inbound message. The message must be exactly
|
||||
* 4 digits (optionally prefixed by `@botname `) — loose matches like
|
||||
* "my pin is 1234" are rejected to avoid false positives from chatter.
|
||||
*/
|
||||
export function extractCode(text: string, botUsername: string): string | null {
|
||||
const addressed = extractAddressedText(text, botUsername);
|
||||
const candidate = (addressed !== null ? addressed : text).trim();
|
||||
const m = candidate.match(/^(\d{4})$/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to match an inbound message against a pending pairing. On match,
|
||||
* marks the pairing consumed atomically and returns the record. Returns
|
||||
* null on no match or expiry (silent drop).
|
||||
*/
|
||||
export async function tryConsume(input: ConsumeInput): Promise<PairingRecord | null> {
|
||||
const code = extractCode(input.text, input.botUsername);
|
||||
if (!code) return null;
|
||||
return withLock(() => {
|
||||
const store = readStore();
|
||||
const now = Date.now();
|
||||
sweep(store);
|
||||
const record = store.pairings.find((r) => r.code === code && r.status === 'pending');
|
||||
if (!record) {
|
||||
// Miss: record the attempt on every currently-pending record so each
|
||||
// waitForPairing caller can surface it as user feedback.
|
||||
const attempt: PairingAttempt = {
|
||||
candidate: code,
|
||||
platformId: input.platformId,
|
||||
at: new Date(now).toISOString(),
|
||||
matched: false,
|
||||
};
|
||||
let recorded = false;
|
||||
for (const r of store.pairings) {
|
||||
if (r.status !== 'pending') continue;
|
||||
r.attempts = [...(r.attempts ?? []), attempt].slice(-MAX_ATTEMPTS_PER_RECORD);
|
||||
// One attempt per code. A wrong guess invalidates the pairing
|
||||
// immediately — pair-telegram observes the `invalidated` signal and
|
||||
// auto-issues a fresh code (up to a retry cap).
|
||||
r.status = 'invalidated';
|
||||
recorded = true;
|
||||
}
|
||||
writeStore(store);
|
||||
if (recorded) {
|
||||
log.info('Pairing invalidated by wrong attempt', { candidate: code, platformId: input.platformId });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
record.status = 'consumed';
|
||||
record.consumed = {
|
||||
platformId: input.platformId,
|
||||
isGroup: input.isGroup,
|
||||
name: input.name ?? null,
|
||||
adminUserId: input.adminUserId ?? null,
|
||||
consumedAt: new Date(now).toISOString(),
|
||||
};
|
||||
record.attempts = [
|
||||
...(record.attempts ?? []),
|
||||
{ candidate: code, platformId: input.platformId, at: new Date(now).toISOString(), matched: true },
|
||||
].slice(-MAX_ATTEMPTS_PER_RECORD);
|
||||
writeStore(store);
|
||||
log.info('Pairing consumed', { code, platformId: input.platformId, intent: record.intent });
|
||||
return record;
|
||||
});
|
||||
}
|
||||
|
||||
export function getStatus(code: string): PairingStatus {
|
||||
const store = readStore();
|
||||
sweep(store);
|
||||
const r = store.pairings.find((p) => p.code === code);
|
||||
if (!r) return 'unknown';
|
||||
return r.status;
|
||||
}
|
||||
|
||||
export function getPairing(code: string): PairingRecord | null {
|
||||
const store = readStore();
|
||||
sweep(store);
|
||||
return store.pairings.find((p) => p.code === code) ?? null;
|
||||
}
|
||||
|
||||
export interface WaitForPairingOptions {
|
||||
/** Polling interval as a fallback when fs.watch misses an event. */
|
||||
pollMs?: number;
|
||||
/** Fires once per new attempt recorded against this pairing (misses only). */
|
||||
onAttempt?: (attempt: PairingAttempt) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve when the pairing is consumed; reject when it is invalidated
|
||||
* (wrong code guess). Waits indefinitely — codes do not expire.
|
||||
* Uses fs.watch as the primary signal with a slow poll fallback.
|
||||
*/
|
||||
export async function waitForPairing(code: string, opts: WaitForPairingOptions = {}): Promise<PairingRecord> {
|
||||
const pollMs = opts.pollMs ?? 1000;
|
||||
const initial = getPairing(code);
|
||||
if (!initial) throw new Error(`Unknown pairing code: ${code}`);
|
||||
|
||||
return new Promise<PairingRecord>((resolve, reject) => {
|
||||
let watcher: fs.FSWatcher | null = null;
|
||||
let interval: NodeJS.Timeout | null = null;
|
||||
let settled = false;
|
||||
|
||||
const cleanup = () => {
|
||||
settled = true;
|
||||
if (watcher)
|
||||
try {
|
||||
watcher.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (interval) clearInterval(interval);
|
||||
};
|
||||
|
||||
let seenAttempts = 0;
|
||||
const check = () => {
|
||||
if (settled) return;
|
||||
const r = getPairing(code);
|
||||
if (!r) {
|
||||
cleanup();
|
||||
reject(new Error(`Pairing ${code} disappeared`));
|
||||
return;
|
||||
}
|
||||
// Surface any new miss attempts since the last tick. Only fire for
|
||||
// misses — matches are signaled by `status === 'consumed'` below.
|
||||
if (opts.onAttempt && r.attempts) {
|
||||
for (let i = seenAttempts; i < r.attempts.length; i++) {
|
||||
const a = r.attempts[i];
|
||||
if (!a.matched) {
|
||||
try {
|
||||
opts.onAttempt(a);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
seenAttempts = r.attempts.length;
|
||||
}
|
||||
if (r.status === 'consumed') {
|
||||
cleanup();
|
||||
resolve(r);
|
||||
return;
|
||||
}
|
||||
if (r.status === 'invalidated') {
|
||||
cleanup();
|
||||
const lastMiss = r.attempts
|
||||
?.slice()
|
||||
.reverse()
|
||||
.find((a) => !a.matched);
|
||||
reject(new Error(`Pairing ${code} invalidated by wrong code${lastMiss ? ` (${lastMiss.candidate})` : ''}`));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const dir = path.dirname(storePath());
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
watcher = fs.watch(dir, (_event, fname) => {
|
||||
if (!fname || fname.toString().startsWith(path.basename(storePath()))) check();
|
||||
});
|
||||
} catch {
|
||||
// fs.watch unsupported — poll-only is fine
|
||||
}
|
||||
interval = setInterval(check, pollMs);
|
||||
check();
|
||||
});
|
||||
}
|
||||
|
||||
/** Test helper — wipe the store. */
|
||||
export function _resetForTest(): void {
|
||||
try {
|
||||
fs.unlinkSync(storePath());
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* Integration test for the telegram channel's single reach-in: the self-registration
|
||||
* import in the `src/channels/index.ts` barrel. Importing the barrel runs telegram.ts's
|
||||
* top-level `registerChannelAdapter('telegram', …)`; 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 './telegram.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 telegram.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/telegram`) 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).
|
||||
*
|
||||
* telegram is a Chat SDK channel: telegram.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('telegram channel registration', () => {
|
||||
it('registers telegram via the channel barrel', () => {
|
||||
expect(getRegisteredChannelNames()).toContain('telegram');
|
||||
});
|
||||
});
|
||||
@@ -1,261 +0,0 @@
|
||||
/**
|
||||
* Telegram channel adapter (v2) — uses Chat SDK bridge, with a pairing
|
||||
* interceptor wrapped around onInbound to verify chat ownership before
|
||||
* registration. See telegram-pairing.ts for the why.
|
||||
*/
|
||||
import { createTelegramAdapter } from '@chat-adapter/telegram';
|
||||
|
||||
import { readEnvFile } from '../env.js';
|
||||
import { log } from '../log.js';
|
||||
import { createMessagingGroup, getMessagingGroupByPlatform, updateMessagingGroup } from '../db/messaging-groups.js';
|
||||
import { grantRole, hasAnyOwner } from '../modules/permissions/db/user-roles.js';
|
||||
import { upsertUser } from '../modules/permissions/db/users.js';
|
||||
import { createChatSdkBridge, type ReplyContext } from './chat-sdk-bridge.js';
|
||||
import { sanitizeTelegramLegacyMarkdown } from './telegram-markdown-sanitize.js';
|
||||
import { registerChannelAdapter } from './channel-registry.js';
|
||||
import type { ChannelAdapter, ChannelDefaults, ChannelSetup, InboundMessage } from './adapter.js';
|
||||
import { tryConsume } from './telegram-pairing.js';
|
||||
|
||||
/**
|
||||
* Dedicated bot identity, non-threaded platform (supportsThreads:false), so
|
||||
* group engagement can never be sticky-per-thread — 'mention' keeps a group
|
||||
* wiring from staying engaged forever in the single shared session.
|
||||
*/
|
||||
const TELEGRAM_DEFAULTS: ChannelDefaults = {
|
||||
dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'request_approval' },
|
||||
group: { engageMode: 'mention', threads: false, unknownSenderPolicy: 'request_approval' },
|
||||
mentions: 'platform',
|
||||
};
|
||||
|
||||
/**
|
||||
* Retry a one-shot operation that can fail on transient network errors at
|
||||
* cold-start (DNS hiccups, brief upstream outages). Exponential backoff capped
|
||||
* at 5 attempts — if the network is truly down we surface it instead of
|
||||
* hanging the service indefinitely.
|
||||
*/
|
||||
async function withRetry<T>(fn: () => Promise<T>, label: string, maxAttempts = 5): Promise<T> {
|
||||
let lastErr: unknown;
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
if (attempt === maxAttempts) break;
|
||||
const delay = Math.min(16000, 1000 * 2 ** (attempt - 1));
|
||||
log.warn('Telegram setup failed, retrying', { label, attempt, delayMs: delay, err });
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
}
|
||||
}
|
||||
throw lastErr;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function extractReplyContext(raw: Record<string, any>): ReplyContext | null {
|
||||
if (!raw.reply_to_message) return null;
|
||||
const reply = raw.reply_to_message;
|
||||
return {
|
||||
text: reply.text || reply.caption || '',
|
||||
sender: reply.from?.first_name || reply.from?.username || 'Unknown',
|
||||
};
|
||||
}
|
||||
|
||||
/** Look up the bot username via Telegram getMe. Cached after first call. */
|
||||
async function fetchBotUsername(token: string): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetch(`https://api.telegram.org/bot${token}/getMe`);
|
||||
const json = (await res.json()) as { ok: boolean; result?: { username?: string } };
|
||||
return json.ok ? (json.result?.username ?? null) : null;
|
||||
} catch (err) {
|
||||
log.warn('Telegram getMe failed', { err });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isGroupPlatformId(platformId: string): boolean {
|
||||
// platformId is "telegram:<chatId>". Negative chat IDs are groups/channels.
|
||||
const id = platformId.split(':').pop() ?? '';
|
||||
return id.startsWith('-');
|
||||
}
|
||||
|
||||
interface InboundFields {
|
||||
text: string;
|
||||
authorUserId: string | null;
|
||||
}
|
||||
|
||||
function readInboundFields(message: InboundMessage): InboundFields {
|
||||
if (message.kind !== 'chat-sdk' || !message.content || typeof message.content !== 'object') {
|
||||
return { text: '', authorUserId: null };
|
||||
}
|
||||
const c = message.content as { text?: string; author?: { userId?: string } };
|
||||
return { text: c.text ?? '', authorUserId: c.author?.userId ?? null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an onInbound interceptor that consumes pairing codes before they
|
||||
* reach the router. On match: records the chat + its paired user, promotes
|
||||
* the user to owner if the instance has no owner yet, and short-circuits.
|
||||
* On miss: forwards to the host.
|
||||
*/
|
||||
/**
|
||||
* Send a one-shot confirmation back to the paired chat. Best-effort — failures
|
||||
* are logged but never propagated, so a Telegram outage can't undo a successful
|
||||
* pairing or trigger the interceptor's fail-open path.
|
||||
*/
|
||||
async function sendPairingConfirmation(token: string, platformId: string): Promise<void> {
|
||||
const chatId = platformId.split(':').slice(1).join(':');
|
||||
if (!chatId) return;
|
||||
try {
|
||||
const res = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chat_id: chatId,
|
||||
text: 'Pairing success! Head back to the NanoClaw installer to finish setup.',
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
log.warn('Telegram pairing confirmation non-OK', { status: res.status });
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('Telegram pairing confirmation failed', { err });
|
||||
}
|
||||
}
|
||||
|
||||
function createPairingInterceptor(
|
||||
botUsernamePromise: Promise<string | null>,
|
||||
hostOnInbound: ChannelSetup['onInbound'],
|
||||
token: string,
|
||||
): ChannelSetup['onInbound'] {
|
||||
return async (platformId, threadId, message) => {
|
||||
try {
|
||||
const botUsername = await botUsernamePromise;
|
||||
if (!botUsername) {
|
||||
hostOnInbound(platformId, threadId, message);
|
||||
return;
|
||||
}
|
||||
const { text, authorUserId } = readInboundFields(message);
|
||||
if (!text) {
|
||||
hostOnInbound(platformId, threadId, message);
|
||||
return;
|
||||
}
|
||||
const consumed = await tryConsume({
|
||||
text,
|
||||
botUsername,
|
||||
platformId,
|
||||
isGroup: isGroupPlatformId(platformId),
|
||||
adminUserId: authorUserId,
|
||||
});
|
||||
if (!consumed) {
|
||||
hostOnInbound(platformId, threadId, message);
|
||||
return;
|
||||
}
|
||||
// Pairing matched — record the chat and short-circuit so the
|
||||
// code-bearing message never reaches an agent. Privilege is now a
|
||||
// property of the paired user, not the chat: upsert the user, and if
|
||||
// this instance has no owner yet, promote them to owner.
|
||||
const existing = getMessagingGroupByPlatform('telegram', platformId);
|
||||
if (existing) {
|
||||
updateMessagingGroup(existing.id, {
|
||||
is_group: consumed.consumed!.isGroup ? 1 : 0,
|
||||
});
|
||||
} else {
|
||||
createMessagingGroup({
|
||||
id: `mg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
channel_type: 'telegram',
|
||||
platform_id: platformId,
|
||||
name: consumed.consumed!.name,
|
||||
is_group: consumed.consumed!.isGroup ? 1 : 0,
|
||||
// Same context-appropriate default as router auto-create, so a
|
||||
// paired chat behaves like any other telegram messaging group.
|
||||
unknown_sender_policy: (consumed.consumed!.isGroup ? TELEGRAM_DEFAULTS.group : TELEGRAM_DEFAULTS.dm)
|
||||
.unknownSenderPolicy,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
const pairedUserId = `telegram:${consumed.consumed!.adminUserId}`;
|
||||
upsertUser({
|
||||
id: pairedUserId,
|
||||
kind: 'telegram',
|
||||
display_name: null,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
let promotedToOwner = false;
|
||||
if (!hasAnyOwner()) {
|
||||
grantRole({
|
||||
user_id: pairedUserId,
|
||||
role: 'owner',
|
||||
agent_group_id: null,
|
||||
granted_by: null,
|
||||
granted_at: new Date().toISOString(),
|
||||
});
|
||||
promotedToOwner = true;
|
||||
}
|
||||
|
||||
log.info('Telegram pairing accepted — chat registered', {
|
||||
platformId,
|
||||
pairedUser: pairedUserId,
|
||||
promotedToOwner,
|
||||
intent: consumed.intent,
|
||||
});
|
||||
|
||||
await sendPairingConfirmation(token, platformId);
|
||||
} catch (err) {
|
||||
log.error('Telegram pairing interceptor error', { err });
|
||||
// Fail open: pass through so a pairing bug doesn't break normal traffic.
|
||||
hostOnInbound(platformId, threadId, message);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
registerChannelAdapter('telegram', {
|
||||
factory: () => {
|
||||
const env = readEnvFile(['TELEGRAM_BOT_TOKEN']);
|
||||
if (!env.TELEGRAM_BOT_TOKEN) return null;
|
||||
const token = env.TELEGRAM_BOT_TOKEN;
|
||||
const telegramAdapter = createTelegramAdapter({
|
||||
botToken: token,
|
||||
mode: 'polling',
|
||||
});
|
||||
const bridge = createChatSdkBridge({
|
||||
adapter: telegramAdapter,
|
||||
concurrency: 'concurrent',
|
||||
extractReplyContext,
|
||||
supportsThreads: false,
|
||||
defaults: TELEGRAM_DEFAULTS,
|
||||
transformOutboundText: sanitizeTelegramLegacyMarkdown,
|
||||
maxTextLength: 4000,
|
||||
});
|
||||
|
||||
const botUsernamePromise = fetchBotUsername(token);
|
||||
|
||||
const wrapped: ChannelAdapter = {
|
||||
...bridge,
|
||||
resolveChannelName: async (platformId: string) => {
|
||||
const chatId = platformId.split(':').slice(1).join(':');
|
||||
if (!chatId) return null;
|
||||
try {
|
||||
const res = await fetch(`https://api.telegram.org/bot${token}/getChat`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ chat_id: chatId }),
|
||||
});
|
||||
const data = (await res.json()) as { ok?: boolean; result?: { title?: string } };
|
||||
return data.ok ? (data.result?.title ?? null) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
async setup(hostConfig: ChannelSetup) {
|
||||
const intercepted: ChannelSetup = {
|
||||
...hostConfig,
|
||||
onInbound: createPairingInterceptor(botUsernamePromise, hostConfig.onInbound, token),
|
||||
};
|
||||
return withRetry(() => bridge.setup(intercepted), 'bridge.setup');
|
||||
},
|
||||
};
|
||||
return wrapped;
|
||||
},
|
||||
defaults: TELEGRAM_DEFAULTS,
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user