feat(ndr): add NDR channel plugin for nostr-double-ratchet
Adds NDR (Nostr Double Ratchet) as a channel extension providing forward-secure E2E encrypted messaging over Nostr relays. Features: - Full channel implementation with send/receive/react - Onboarding flow with invite URL acceptance and hello message - Auto-generated identity if not configured - Configurable relays (defaults to popular Nostr relays) - Owner pubkey authentication for message filtering Requires external `ndr` CLI (cargo install ndr). Control surface: chat.iris.to 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
f4004054ab
commit
e954a55f1f
181
extensions/ndr/README.md
Normal file
181
extensions/ndr/README.md
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
# @clawdbot/ndr
|
||||||
|
|
||||||
|
Clawdbot channel plugin for [nostr-double-ratchet](https://files.iris.to/#/npub1xndmdgymsf4a34rzr7346vp8qcptxf75pjqweh8naa8rklgxpfqqmfjtce/nostr-double-ratchet) - forward-secure end-to-end encrypted messaging over Nostr.
|
||||||
|
|
||||||
|
Compatible with [chat.iris.to](https://chat.iris.to).
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Forward secrecy** - Past messages remain secure even if keys are compromised
|
||||||
|
- **Double ratchet encryption** - Based on Signal's proven protocol
|
||||||
|
- **Nostr transport** - Messages sent via Nostr relays
|
||||||
|
- **CLI integration** - Uses the `ndr` CLI for encryption/decryption
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
Install Rust and the required CLIs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sSf https://sh.rustup.rs | sh && cargo install ndr hashtree-cli
|
||||||
|
```
|
||||||
|
|
||||||
|
- **ndr** - Required for double ratchet encryption
|
||||||
|
- **hashtree-cli** - Optional, for encrypted media uploads via [hashtree](https://github.com/mmalmi/hashtree)
|
||||||
|
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot plugins install @clawdbot/ndr
|
||||||
|
```
|
||||||
|
|
||||||
|
Or link for development:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot plugins install -l ./extensions/ndr
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Add to your `~/.clawdbot/clawdbot.json`:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
ndr: {
|
||||||
|
// Owner's pubkey - only messages from this npub are handled as commands
|
||||||
|
ownerPubkey: "npub1...",
|
||||||
|
|
||||||
|
// Optional: Nostr relays (defaults shown below)
|
||||||
|
relays: [
|
||||||
|
"wss://temp.iris.to",
|
||||||
|
"wss://relay.snort.social",
|
||||||
|
"wss://relay.primal.net",
|
||||||
|
"wss://relay.damus.io",
|
||||||
|
"wss://offchain.pub"
|
||||||
|
],
|
||||||
|
|
||||||
|
// Optional: Path to ndr CLI (default: "ndr" in PATH)
|
||||||
|
ndrPath: "/path/to/ndr",
|
||||||
|
|
||||||
|
// Optional: Custom data directory for ndr
|
||||||
|
dataDir: "~/.ndr-clawdbot",
|
||||||
|
|
||||||
|
// Optional: Private key (hex or nsec). If not provided, ndr auto-generates one.
|
||||||
|
// privateKey: "nsec1...",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Authorization:**
|
||||||
|
- Only messages from `ownerPubkey` are handled as agent commands
|
||||||
|
- Messages from other pubkeys are logged but ignored (for now)
|
||||||
|
- If `ownerPubkey` is not set, all messages are handled (legacy behavior)
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
The ndr CLI auto-generates an identity on first use.
|
||||||
|
|
||||||
|
### 1. Check your identity
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ndr whoami
|
||||||
|
```
|
||||||
|
|
||||||
|
This shows your npub. Share this with people who want to message you.
|
||||||
|
|
||||||
|
### 2. Create an invite (to let others connect to you)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ndr invite create --label "clawdbot"
|
||||||
|
```
|
||||||
|
|
||||||
|
Share the invite URL with the person you want to chat with. They accept it with `ndr chat join <url>`.
|
||||||
|
|
||||||
|
### 3. Join someone else's invite
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ndr chat join <invite_url>
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates a chat session. You can now send/receive messages.
|
||||||
|
|
||||||
|
### 4. Configure your owner pubkey
|
||||||
|
|
||||||
|
Add your npub to the config so only you can control the agent:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
ndr: {
|
||||||
|
ownerPubkey: "npub1..." // your npub from step 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Start the gateway
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot gateway run
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Check channel status
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot channels status --channel ndr
|
||||||
|
```
|
||||||
|
|
||||||
|
### List active chats
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ndr chat list
|
||||||
|
```
|
||||||
|
|
||||||
|
### Send a message
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot message send --channel ndr --to <chat_id> --message "Hello!"
|
||||||
|
```
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
1. **Initialization** - ndr auto-generates an identity if not logged in
|
||||||
|
2. **Listening** - Runs `ndr listen` to receive incoming messages
|
||||||
|
3. **Receiving** - Decrypts messages using the double ratchet session
|
||||||
|
4. **Sending** - Uses `ndr send` to encrypt and publish messages
|
||||||
|
5. **Session management** - ndr handles key rotation automatically
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- **No key exposure** - Private keys are only passed to the ndr CLI
|
||||||
|
- **Forward secrecy** - Each message uses a unique encryption key
|
||||||
|
- **Session isolation** - Each chat has its own ratchet state
|
||||||
|
|
||||||
|
## Comparison with Nostr NIP-04
|
||||||
|
|
||||||
|
| Feature | NDR (Double Ratchet) | Nostr NIP-04 |
|
||||||
|
|---------|---------------------|--------------|
|
||||||
|
| Forward secrecy | Yes | No |
|
||||||
|
| Key rotation | Automatic | None |
|
||||||
|
| Session state | Required | Stateless |
|
||||||
|
| Complexity | Higher | Lower |
|
||||||
|
|
||||||
|
Use NDR for high-security conversations where forward secrecy matters.
|
||||||
|
Use NIP-04 for simpler use cases where stateless encryption is acceptable.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "ndr: command not found"
|
||||||
|
|
||||||
|
Install ndr CLI: `cargo install ndr`
|
||||||
|
|
||||||
|
### "Failed to send message"
|
||||||
|
|
||||||
|
Check that:
|
||||||
|
1. You have an active chat session with the recipient
|
||||||
|
2. The relay is reachable
|
||||||
|
3. ndr CLI is working: `ndr chat list`
|
||||||
11
extensions/ndr/clawdbot.plugin.json
Normal file
11
extensions/ndr/clawdbot.plugin.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"id": "ndr",
|
||||||
|
"channels": [
|
||||||
|
"ndr"
|
||||||
|
],
|
||||||
|
"configSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
extensions/ndr/index.ts
Normal file
18
extensions/ndr/index.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||||
|
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
import { ndrPlugin } from "./src/channel.js";
|
||||||
|
import { setNdrRuntime } from "./src/runtime.js";
|
||||||
|
|
||||||
|
const plugin = {
|
||||||
|
id: "ndr",
|
||||||
|
name: "NDR",
|
||||||
|
description: "Forward-secure E2E encryption via nostr-double-ratchet",
|
||||||
|
configSchema: emptyPluginConfigSchema(),
|
||||||
|
register(api: ClawdbotPluginApi) {
|
||||||
|
setNdrRuntime(api.runtime);
|
||||||
|
api.registerChannel({ plugin: ndrPlugin });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default plugin;
|
||||||
28
extensions/ndr/package.json
Normal file
28
extensions/ndr/package.json
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "ndr",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"description": "Clawdbot channel plugin for nostr-double-ratchet (forward-secure E2E encryption)",
|
||||||
|
"keywords": ["clawdbot", "nostr", "double-ratchet", "ndr", "e2e", "encryption", "chat"],
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/mmalmi/nostr-double-ratchet"
|
||||||
|
},
|
||||||
|
"license": "MIT",
|
||||||
|
"clawdbot": {
|
||||||
|
"id": "ndr",
|
||||||
|
"extensions": [
|
||||||
|
"./index.ts"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"nostr-tools": "^2.20.0",
|
||||||
|
"zod": "^4.3.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"clawdbot": "workspace:*"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"clawdbot": "*"
|
||||||
|
}
|
||||||
|
}
|
||||||
360
extensions/ndr/src/channel.ts
Normal file
360
extensions/ndr/src/channel.ts
Normal file
@ -0,0 +1,360 @@
|
|||||||
|
import {
|
||||||
|
buildChannelConfigSchema,
|
||||||
|
DEFAULT_ACCOUNT_ID,
|
||||||
|
type ChannelPlugin,
|
||||||
|
createReplyPrefixContext,
|
||||||
|
} from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
import { NdrConfigSchema } from "./config-schema.js";
|
||||||
|
import { getNdrRuntime } from "./runtime.js";
|
||||||
|
import {
|
||||||
|
listNdrAccountIds,
|
||||||
|
resolveDefaultNdrAccountId,
|
||||||
|
resolveNdrAccount,
|
||||||
|
type ResolvedNdrAccount,
|
||||||
|
} from "./types.js";
|
||||||
|
import { startNdrBus, type NdrBusHandle } from "./ndr-bus.js";
|
||||||
|
import { ndrOnboardingAdapter } from "./onboarding.js";
|
||||||
|
|
||||||
|
// Store active bus handles per account
|
||||||
|
const activeBuses = new Map<string, NdrBusHandle>();
|
||||||
|
|
||||||
|
export const ndrPlugin: ChannelPlugin<ResolvedNdrAccount> = {
|
||||||
|
id: "ndr",
|
||||||
|
meta: {
|
||||||
|
id: "ndr",
|
||||||
|
label: "NDR",
|
||||||
|
selectionLabel: "NDR (Nostr Double Ratchet)",
|
||||||
|
docsPath: "/channels/ndr",
|
||||||
|
docsLabel: "ndr",
|
||||||
|
blurb: "Forward-secure E2E encryption via double ratchet over Nostr (chat.iris.to).",
|
||||||
|
order: 56,
|
||||||
|
selectionExtras: ["https://chat.iris.to"],
|
||||||
|
quickstartAllowFrom: true,
|
||||||
|
},
|
||||||
|
capabilities: {
|
||||||
|
chatTypes: ["direct"], // DMs only
|
||||||
|
media: false, // No media support yet
|
||||||
|
},
|
||||||
|
reload: { configPrefixes: ["channels.ndr"] },
|
||||||
|
configSchema: buildChannelConfigSchema(NdrConfigSchema),
|
||||||
|
onboarding: ndrOnboardingAdapter,
|
||||||
|
|
||||||
|
config: {
|
||||||
|
listAccountIds: (cfg) => listNdrAccountIds(cfg),
|
||||||
|
resolveAccount: (cfg, accountId) => resolveNdrAccount({ cfg, accountId }),
|
||||||
|
defaultAccountId: (cfg) => resolveDefaultNdrAccountId(cfg),
|
||||||
|
isConfigured: (account) => account.configured,
|
||||||
|
describeAccount: (account) => ({
|
||||||
|
accountId: account.accountId,
|
||||||
|
name: account.name,
|
||||||
|
enabled: account.enabled,
|
||||||
|
configured: account.configured,
|
||||||
|
publicKey: account.publicKey,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
// Authorization is handled by NDR's invite/accept flow.
|
||||||
|
// Only users with an established double ratchet session can message.
|
||||||
|
// No pairing/allowFrom config needed - the invite exchange IS the authorization.
|
||||||
|
|
||||||
|
messaging: {
|
||||||
|
normalizeTarget: (target) => {
|
||||||
|
// NDR uses chat IDs, not pubkeys directly
|
||||||
|
return target.trim();
|
||||||
|
},
|
||||||
|
targetResolver: {
|
||||||
|
looksLikeId: (input) => {
|
||||||
|
const trimmed = input.trim();
|
||||||
|
// Chat IDs are short hex strings
|
||||||
|
return /^[0-9a-fA-F]{8}$/.test(trimmed) || trimmed.startsWith("npub1");
|
||||||
|
},
|
||||||
|
hint: "<chat_id|npub>",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
outbound: {
|
||||||
|
deliveryMode: "direct",
|
||||||
|
textChunkLimit: 4000,
|
||||||
|
sendText: async ({ to, text, accountId }) => {
|
||||||
|
const core = getNdrRuntime();
|
||||||
|
const aid = accountId ?? DEFAULT_ACCOUNT_ID;
|
||||||
|
const bus = activeBuses.get(aid);
|
||||||
|
if (!bus) {
|
||||||
|
throw new Error(`NDR bus not running for account ${aid}`);
|
||||||
|
}
|
||||||
|
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
||||||
|
cfg: core.config.loadConfig(),
|
||||||
|
channel: "ndr",
|
||||||
|
accountId: aid,
|
||||||
|
});
|
||||||
|
const message = core.channel.text.convertMarkdownTables(text ?? "", tableMode);
|
||||||
|
await bus.sendMessage(to, message);
|
||||||
|
return { channel: "ndr", to };
|
||||||
|
},
|
||||||
|
sendMedia: async ({ to, media, accountId }) => {
|
||||||
|
const core = getNdrRuntime();
|
||||||
|
const aid = accountId ?? DEFAULT_ACCOUNT_ID;
|
||||||
|
const bus = activeBuses.get(aid);
|
||||||
|
if (!bus) {
|
||||||
|
throw new Error(`NDR bus not running for account ${aid}`);
|
||||||
|
}
|
||||||
|
const caption = media.caption ? `${media.caption}\n` : "";
|
||||||
|
|
||||||
|
// Try to upload via htree if we have a local file path
|
||||||
|
let mediaLink = media.url ?? "[media attachment]";
|
||||||
|
if (media.path) {
|
||||||
|
try {
|
||||||
|
const { execSync } = await import("child_process");
|
||||||
|
// Properly escape the file path for shell
|
||||||
|
const escapedPath = media.path.replace(/'/g, "'\\''");
|
||||||
|
const output = execSync(`htree add '${escapedPath}'`, {
|
||||||
|
encoding: "utf-8",
|
||||||
|
timeout: 60000,
|
||||||
|
});
|
||||||
|
// Parse "url: nhash1.../filename" from output
|
||||||
|
const urlMatch = output.match(/url:\s+(nhash1[^\s]+)/);
|
||||||
|
if (urlMatch) {
|
||||||
|
mediaLink = urlMatch[1];
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// htree not available or failed - fall back to URL or placeholder
|
||||||
|
mediaLink = media.url ?? "[media: upload failed]";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = `${caption}${mediaLink}`;
|
||||||
|
await bus.sendMessage(to, message);
|
||||||
|
return { channel: "ndr", to };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
status: {
|
||||||
|
defaultRuntime: {
|
||||||
|
accountId: DEFAULT_ACCOUNT_ID,
|
||||||
|
running: false,
|
||||||
|
lastStartAt: null,
|
||||||
|
lastStopAt: null,
|
||||||
|
lastError: null,
|
||||||
|
},
|
||||||
|
collectStatusIssues: (accounts) =>
|
||||||
|
accounts.flatMap((account) => {
|
||||||
|
const lastError = typeof account.lastError === "string" ? account.lastError.trim() : "";
|
||||||
|
if (!lastError) return [];
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
channel: "ndr",
|
||||||
|
accountId: account.accountId,
|
||||||
|
kind: "runtime" as const,
|
||||||
|
message: `Channel error: ${lastError}`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
buildChannelSummary: ({ snapshot }) => ({
|
||||||
|
configured: snapshot.configured ?? false,
|
||||||
|
publicKey: snapshot.publicKey ?? null,
|
||||||
|
running: snapshot.running ?? false,
|
||||||
|
lastStartAt: snapshot.lastStartAt ?? null,
|
||||||
|
lastStopAt: snapshot.lastStopAt ?? null,
|
||||||
|
lastError: snapshot.lastError ?? null,
|
||||||
|
}),
|
||||||
|
buildAccountSnapshot: ({ account, runtime }) => ({
|
||||||
|
accountId: account.accountId,
|
||||||
|
name: account.name,
|
||||||
|
enabled: account.enabled,
|
||||||
|
configured: account.configured,
|
||||||
|
publicKey: account.publicKey,
|
||||||
|
running: runtime?.running ?? false,
|
||||||
|
lastStartAt: runtime?.lastStartAt ?? null,
|
||||||
|
lastStopAt: runtime?.lastStopAt ?? null,
|
||||||
|
lastError: runtime?.lastError ?? null,
|
||||||
|
lastInboundAt: runtime?.lastInboundAt ?? null,
|
||||||
|
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
gateway: {
|
||||||
|
startAccount: async (ctx) => {
|
||||||
|
const account = ctx.account;
|
||||||
|
ctx.setStatus({
|
||||||
|
accountId: account.accountId,
|
||||||
|
publicKey: account.publicKey,
|
||||||
|
});
|
||||||
|
ctx.log?.info(`[${account.accountId}] starting NDR provider (pubkey: ${account.publicKey})`);
|
||||||
|
|
||||||
|
// Note: account.configured is always true since ndr auto-generates identity
|
||||||
|
|
||||||
|
const runtime = getNdrRuntime();
|
||||||
|
|
||||||
|
const bus = await startNdrBus({
|
||||||
|
accountId: account.accountId,
|
||||||
|
privateKey: account.privateKey,
|
||||||
|
relays: account.relays,
|
||||||
|
ndrPath: account.ndrPath,
|
||||||
|
dataDir: account.dataDir,
|
||||||
|
onMessage: async (chatId, messageId, senderPubkey, text, replyFn) => {
|
||||||
|
ctx.log?.debug(`[${account.accountId}] Message from ${senderPubkey} in chat ${chatId}: ${text.slice(0, 50)}...`);
|
||||||
|
|
||||||
|
// React with "eyes" emoji to indicate we're processing (like WhatsApp "typing" indicator)
|
||||||
|
if (messageId) {
|
||||||
|
try {
|
||||||
|
await bus.react(chatId, messageId, "👀");
|
||||||
|
} catch {
|
||||||
|
// Reaction failed, continue anyway
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if sender is the owner
|
||||||
|
// Note: senderPubkey is the ephemeral key used in the message, not the identity key.
|
||||||
|
// We need to look up the chat's their_pubkey (identity) to compare with ownerPubkey.
|
||||||
|
let identityPubkey = senderPubkey; // fallback
|
||||||
|
try {
|
||||||
|
const chats = await bus.listChats();
|
||||||
|
const chat = chats.find((c) => c.id === chatId);
|
||||||
|
if (chat) {
|
||||||
|
identityPubkey = chat.their_pubkey;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// If lookup fails, fall back to senderPubkey
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOwner = account.ownerPubkey && identityPubkey === account.ownerPubkey;
|
||||||
|
|
||||||
|
if (!isOwner && account.ownerPubkey) {
|
||||||
|
// Non-owner message - log and ignore
|
||||||
|
ctx.log?.info(`[${account.accountId}] Ignoring message from non-owner ${identityPubkey}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the message through clawdbot's reply pipeline
|
||||||
|
const cfg = runtime.config.loadConfig();
|
||||||
|
const ndrTo = `ndr:${chatId}`;
|
||||||
|
|
||||||
|
// Resolve agent route for this chat
|
||||||
|
const route = runtime.channel.routing.resolveAgentRoute({
|
||||||
|
cfg,
|
||||||
|
channel: "ndr",
|
||||||
|
accountId: account.accountId,
|
||||||
|
peer: { kind: "dm", id: chatId },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build the envelope for the message
|
||||||
|
const envelopeOptions = runtime.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
||||||
|
const body = runtime.channel.reply.formatInboundEnvelope({
|
||||||
|
channel: "NDR",
|
||||||
|
from: identityPubkey.slice(0, 16) + "...",
|
||||||
|
body: text,
|
||||||
|
chatType: "direct",
|
||||||
|
sender: { name: identityPubkey.slice(0, 8), id: identityPubkey },
|
||||||
|
envelope: envelopeOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Finalize the inbound context
|
||||||
|
const ctxPayload = runtime.channel.reply.finalizeInboundContext({
|
||||||
|
Body: body,
|
||||||
|
RawBody: text,
|
||||||
|
CommandBody: text,
|
||||||
|
From: `ndr:${identityPubkey}`,
|
||||||
|
To: ndrTo,
|
||||||
|
SessionKey: route.sessionKey,
|
||||||
|
AccountId: route.accountId,
|
||||||
|
ChatType: "direct" as const,
|
||||||
|
ConversationLabel: `NDR chat ${chatId}`,
|
||||||
|
SenderName: identityPubkey.slice(0, 8),
|
||||||
|
SenderId: identityPubkey,
|
||||||
|
Provider: "ndr" as const,
|
||||||
|
Surface: "ndr" as const,
|
||||||
|
MessageSid: `${chatId}-${Date.now()}`,
|
||||||
|
CommandAuthorized: true, // Owner is always authorized
|
||||||
|
OriginatingChannel: "ndr" as const,
|
||||||
|
OriginatingTo: ndrTo,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Record the session
|
||||||
|
const storePath = runtime.channel.session.resolveStorePath(cfg.session?.store, {
|
||||||
|
agentId: route.agentId,
|
||||||
|
});
|
||||||
|
await runtime.channel.session.recordInboundSession({
|
||||||
|
storePath,
|
||||||
|
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||||
|
ctx: ctxPayload,
|
||||||
|
updateLastRoute: {
|
||||||
|
sessionKey: route.mainSessionKey,
|
||||||
|
channel: "ndr",
|
||||||
|
to: chatId,
|
||||||
|
accountId: route.accountId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create reply prefix context
|
||||||
|
const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId });
|
||||||
|
|
||||||
|
// Create dispatcher with typing (simplified - no typing indicator for NDR)
|
||||||
|
const { dispatcher, replyOptions, markDispatchIdle } = runtime.channel.reply.createReplyDispatcherWithTyping({
|
||||||
|
responsePrefix: prefixContext.responsePrefix,
|
||||||
|
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
|
||||||
|
humanDelay: runtime.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
|
||||||
|
deliver: async (payload) => {
|
||||||
|
ctx.log?.info(`[${account.accountId}] NDR deliver called with payload: ${JSON.stringify(payload).slice(0, 200)}`);
|
||||||
|
const responseText = payload.text ?? "";
|
||||||
|
if (responseText) {
|
||||||
|
ctx.log?.info(`[${account.accountId}] NDR sending reply: ${responseText.slice(0, 100)}...`);
|
||||||
|
await replyFn(responseText);
|
||||||
|
ctx.log?.info(`[${account.accountId}] NDR reply sent successfully`);
|
||||||
|
} else {
|
||||||
|
ctx.log?.warn(`[${account.accountId}] NDR deliver called but no text in payload`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (err, info) => {
|
||||||
|
ctx.log?.error(`[${account.accountId}] NDR reply failed (${info.kind}): ${String(err)}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dispatch the message
|
||||||
|
await runtime.channel.reply.dispatchReplyFromConfig({
|
||||||
|
ctx: ctxPayload,
|
||||||
|
cfg,
|
||||||
|
dispatcher,
|
||||||
|
replyOptions: {
|
||||||
|
...replyOptions,
|
||||||
|
onModelSelected: (modelCtx) => {
|
||||||
|
prefixContext.onModelSelected(modelCtx);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
markDispatchIdle();
|
||||||
|
},
|
||||||
|
onError: (error, context) => {
|
||||||
|
ctx.log?.error(`[${account.accountId}] NDR error (${context}): ${error.message}`);
|
||||||
|
},
|
||||||
|
onConnect: () => {
|
||||||
|
ctx.log?.info(`[${account.accountId}] NDR listener started`);
|
||||||
|
},
|
||||||
|
onDisconnect: () => {
|
||||||
|
ctx.log?.warn(`[${account.accountId}] NDR listener disconnected`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store the bus handle
|
||||||
|
activeBuses.set(account.accountId, bus);
|
||||||
|
|
||||||
|
ctx.log?.info(`[${account.accountId}] NDR provider started`);
|
||||||
|
|
||||||
|
// Return cleanup function
|
||||||
|
return {
|
||||||
|
stop: () => {
|
||||||
|
bus.close();
|
||||||
|
activeBuses.delete(account.accountId);
|
||||||
|
ctx.log?.info(`[${account.accountId}] NDR provider stopped`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all active NDR bus handles
|
||||||
|
*/
|
||||||
|
export function getActiveNdrBuses(): Map<string, NdrBusHandle> {
|
||||||
|
return new Map(activeBuses);
|
||||||
|
}
|
||||||
38
extensions/ndr/src/config-schema.ts
Normal file
38
extensions/ndr/src/config-schema.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NDR channel configuration schema
|
||||||
|
*/
|
||||||
|
export const NdrConfigSchema = z.object({
|
||||||
|
/** Private key for signing (hex or nsec format). If not provided, ndr auto-generates one. */
|
||||||
|
privateKey: z.string().optional(),
|
||||||
|
|
||||||
|
/** Owner's pubkey (npub or hex). Only messages from this pubkey are handled as commands. */
|
||||||
|
ownerPubkey: z.string().optional(),
|
||||||
|
|
||||||
|
/** Nostr relays to connect to */
|
||||||
|
relays: z.array(z.string()).optional(),
|
||||||
|
|
||||||
|
/** Whether the channel is enabled */
|
||||||
|
enabled: z.boolean().optional(),
|
||||||
|
|
||||||
|
/** Display name for the account */
|
||||||
|
name: z.string().optional(),
|
||||||
|
|
||||||
|
/** Path to ndr CLI binary (defaults to 'ndr' in PATH) */
|
||||||
|
ndrPath: z.string().optional(),
|
||||||
|
|
||||||
|
/** Custom data directory for ndr */
|
||||||
|
dataDir: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type NdrConfig = z.infer<typeof NdrConfigSchema>;
|
||||||
|
|
||||||
|
/** Default relays if none configured */
|
||||||
|
export const DEFAULT_RELAYS = [
|
||||||
|
"wss://temp.iris.to",
|
||||||
|
"wss://relay.snort.social",
|
||||||
|
"wss://relay.primal.net",
|
||||||
|
"wss://relay.damus.io",
|
||||||
|
"wss://offchain.pub",
|
||||||
|
];
|
||||||
301
extensions/ndr/src/ndr-bus.ts
Normal file
301
extensions/ndr/src/ndr-bus.ts
Normal file
@ -0,0 +1,301 @@
|
|||||||
|
import { spawn, type ChildProcess } from "child_process";
|
||||||
|
|
||||||
|
export interface NdrBusOptions {
|
||||||
|
accountId: string;
|
||||||
|
privateKey: string;
|
||||||
|
relays: string[];
|
||||||
|
ndrPath: string;
|
||||||
|
dataDir: string | null;
|
||||||
|
onMessage: (chatId: string, messageId: string, senderPubkey: string, text: string, reply: (text: string) => Promise<void>) => Promise<void>;
|
||||||
|
onNewSession?: (chatId: string, theirPubkey: string) => Promise<void>;
|
||||||
|
onError?: (error: Error, context: string) => void;
|
||||||
|
onConnect?: () => void;
|
||||||
|
onDisconnect?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NdrBusHandle {
|
||||||
|
sendMessage: (chatId: string, text: string) => Promise<void>;
|
||||||
|
react: (chatId: string, messageId: string, emoji: string) => Promise<void>;
|
||||||
|
createInvite: () => Promise<{ inviteUrl: string; inviteId: string }>;
|
||||||
|
joinInvite: (inviteUrl: string) => Promise<{ chatId: string; theirPubkey: string }>;
|
||||||
|
listChats: () => Promise<Array<{ id: string; their_pubkey: string }>>;
|
||||||
|
close: () => void;
|
||||||
|
isRunning: () => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the NDR bus - manages ndr CLI process for listening and sending
|
||||||
|
*
|
||||||
|
* The `ndr listen` command handles both incoming messages AND invite responses,
|
||||||
|
* so we only need a single listener process.
|
||||||
|
*/
|
||||||
|
export async function startNdrBus(options: NdrBusOptions): Promise<NdrBusHandle> {
|
||||||
|
const {
|
||||||
|
privateKey,
|
||||||
|
relays,
|
||||||
|
ndrPath,
|
||||||
|
dataDir,
|
||||||
|
onMessage,
|
||||||
|
onNewSession,
|
||||||
|
onError,
|
||||||
|
onConnect,
|
||||||
|
onDisconnect,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
let listenProcess: ChildProcess | null = null;
|
||||||
|
let running = false;
|
||||||
|
|
||||||
|
// Build common args
|
||||||
|
const baseArgs: string[] = ["--json"];
|
||||||
|
if (dataDir) {
|
||||||
|
baseArgs.push("--data-dir", dataDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize: login with provided key, or let ndr auto-generate on first use
|
||||||
|
if (privateKey) {
|
||||||
|
await runNdrCommand(ndrPath, [...baseArgs, "login", privateKey]);
|
||||||
|
}
|
||||||
|
// If no privateKey, ndr will auto-generate identity when needed (invite/listen/send)
|
||||||
|
|
||||||
|
// Update relay config if needed (preserve existing config like private_key)
|
||||||
|
if (dataDir && relays.length > 0) {
|
||||||
|
const configPath = `${dataDir}/config.json`;
|
||||||
|
const fs = await import("fs/promises");
|
||||||
|
try {
|
||||||
|
let existingConfig: Record<string, unknown> = {};
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(configPath, "utf-8");
|
||||||
|
existingConfig = JSON.parse(content);
|
||||||
|
} catch {
|
||||||
|
// Config doesn't exist yet, start fresh
|
||||||
|
}
|
||||||
|
// Merge relays into existing config, preserving other fields like private_key
|
||||||
|
const mergedConfig = { ...existingConfig, relays };
|
||||||
|
await fs.writeFile(configPath, JSON.stringify(mergedConfig, null, 2));
|
||||||
|
} catch {
|
||||||
|
// Config dir may not exist yet, ndr will create it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start listening for messages and invite responses (both handled by `ndr listen`)
|
||||||
|
const startListening = () => {
|
||||||
|
listenProcess = spawn(ndrPath, [...baseArgs, "listen"], {
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
|
||||||
|
listenProcess.stdout?.on("data", (data: Buffer) => {
|
||||||
|
const lines = data.toString().split("\n").filter(Boolean);
|
||||||
|
for (const line of lines) {
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(line);
|
||||||
|
|
||||||
|
// Handle incoming messages
|
||||||
|
if (json.event === "message") {
|
||||||
|
const chatId = json.chat_id;
|
||||||
|
const messageId = json.message_id ?? "";
|
||||||
|
const senderPubkey = json.from_pubkey;
|
||||||
|
const content = json.content;
|
||||||
|
|
||||||
|
// Create reply function
|
||||||
|
const reply = async (text: string) => {
|
||||||
|
await runNdrCommand(ndrPath, [...baseArgs, "send", chatId, text]);
|
||||||
|
};
|
||||||
|
|
||||||
|
onMessage(chatId, messageId, senderPubkey, content, reply).catch((err) => {
|
||||||
|
onError?.(err, "message_handler");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle new sessions from invite responses
|
||||||
|
if (json.event === "session_created") {
|
||||||
|
const chatId = json.chat_id;
|
||||||
|
const theirPubkey = json.their_pubkey;
|
||||||
|
|
||||||
|
onNewSession?.(chatId, theirPubkey).catch((err) => {
|
||||||
|
onError?.(err, "new_session_handler");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore non-JSON lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
listenProcess.stderr?.on("data", (data: Buffer) => {
|
||||||
|
const text = data.toString().trim();
|
||||||
|
if (text && !text.includes("Listening")) {
|
||||||
|
onError?.(new Error(text), "listen_stderr");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
listenProcess.on("exit", (code) => {
|
||||||
|
if (running && code !== 0) {
|
||||||
|
onError?.(new Error(`ndr listen exited with code ${code}`), "listen_exit");
|
||||||
|
setTimeout(() => running && startListening(), 5000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
listenProcess.on("error", (err) => {
|
||||||
|
onError?.(err, "listen_spawn");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
running = true;
|
||||||
|
onConnect?.();
|
||||||
|
startListening();
|
||||||
|
|
||||||
|
return {
|
||||||
|
sendMessage: async (chatId: string, text: string) => {
|
||||||
|
const result = await runNdrCommand(ndrPath, [...baseArgs, "send", chatId, text]);
|
||||||
|
if (result.status !== "ok") {
|
||||||
|
throw new Error(result.error || "Failed to send message");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
react: async (chatId: string, messageId: string, emoji: string) => {
|
||||||
|
const result = await runNdrCommand(ndrPath, [...baseArgs, "react", chatId, messageId, emoji]);
|
||||||
|
if (result.status !== "ok") {
|
||||||
|
throw new Error(result.error || "Failed to send reaction");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
createInvite: async () => {
|
||||||
|
const result = await runNdrCommand(ndrPath, [...baseArgs, "invite", "create"]);
|
||||||
|
if (result.status !== "ok") {
|
||||||
|
throw new Error(result.error || "Failed to create invite");
|
||||||
|
}
|
||||||
|
const data = result.data as { url: string; id: string };
|
||||||
|
return { inviteUrl: data.url, inviteId: data.id };
|
||||||
|
},
|
||||||
|
|
||||||
|
joinInvite: async (inviteUrl: string) => {
|
||||||
|
const result = await runNdrCommand(ndrPath, [...baseArgs, "chat", "join", inviteUrl]);
|
||||||
|
if (result.status !== "ok") {
|
||||||
|
throw new Error(result.error || "Failed to join invite");
|
||||||
|
}
|
||||||
|
const data = result.data as { id: string; their_pubkey: string };
|
||||||
|
return { chatId: data.id, theirPubkey: data.their_pubkey };
|
||||||
|
},
|
||||||
|
|
||||||
|
listChats: async () => {
|
||||||
|
const result = await runNdrCommand(ndrPath, [...baseArgs, "chat", "list"]);
|
||||||
|
if (result.status === "ok" && result.data) {
|
||||||
|
const data = result.data as { chats: Array<{ id: string; their_pubkey: string }> };
|
||||||
|
return data.chats || [];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
|
||||||
|
close: () => {
|
||||||
|
running = false;
|
||||||
|
onDisconnect?.();
|
||||||
|
if (listenProcess) {
|
||||||
|
listenProcess.kill();
|
||||||
|
listenProcess = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
isRunning: () => running,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run an ndr CLI command and return parsed JSON output
|
||||||
|
*/
|
||||||
|
async function runNdrCommand(ndrPath: string, args: string[]): Promise<{ status: string; error?: string; data?: unknown }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const proc = spawn(ndrPath, args, {
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
|
||||||
|
proc.stdout?.on("data", (data: Buffer) => {
|
||||||
|
stdout += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.stderr?.on("data", (data: Buffer) => {
|
||||||
|
stderr += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.on("exit", (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(stdout.trim());
|
||||||
|
resolve(json);
|
||||||
|
} catch {
|
||||||
|
resolve({ status: "ok", data: stdout.trim() });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resolve({ status: "error", error: stderr.trim() || `Exit code ${code}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.on("error", (err) => {
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get chat list from ndr
|
||||||
|
*/
|
||||||
|
export async function listChats(ndrPath: string, dataDir: string | null): Promise<Array<{ id: string; their_pubkey: string }>> {
|
||||||
|
const args = ["--json"];
|
||||||
|
if (dataDir) {
|
||||||
|
args.push("--data-dir", dataDir);
|
||||||
|
}
|
||||||
|
args.push("chat", "list");
|
||||||
|
|
||||||
|
const result = await runNdrCommand(ndrPath, args);
|
||||||
|
if (result.status === "ok" && Array.isArray(result.data)) {
|
||||||
|
return result.data as Array<{ id: string; their_pubkey: string }>;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Join a chat via invite URL
|
||||||
|
*/
|
||||||
|
export async function joinChat(
|
||||||
|
ndrPath: string,
|
||||||
|
dataDir: string | null,
|
||||||
|
inviteUrl: string
|
||||||
|
): Promise<{ chatId: string; theirPubkey: string }> {
|
||||||
|
const args = ["--json"];
|
||||||
|
if (dataDir) {
|
||||||
|
args.push("--data-dir", dataDir);
|
||||||
|
}
|
||||||
|
args.push("chat", "join", inviteUrl);
|
||||||
|
|
||||||
|
const result = await runNdrCommand(ndrPath, args);
|
||||||
|
if (result.status !== "ok") {
|
||||||
|
throw new Error(result.error || "Failed to join chat");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = result.data as { id: string; their_pubkey: string };
|
||||||
|
return { chatId: data.id, theirPubkey: data.their_pubkey };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an invite
|
||||||
|
*/
|
||||||
|
export async function createInvite(
|
||||||
|
ndrPath: string,
|
||||||
|
dataDir: string | null
|
||||||
|
): Promise<{ inviteUrl: string; inviteId: string }> {
|
||||||
|
const args = ["--json"];
|
||||||
|
if (dataDir) {
|
||||||
|
args.push("--data-dir", dataDir);
|
||||||
|
}
|
||||||
|
args.push("invite", "create");
|
||||||
|
|
||||||
|
const result = await runNdrCommand(ndrPath, args);
|
||||||
|
if (result.status !== "ok") {
|
||||||
|
throw new Error(result.error || "Failed to create invite");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = result.data as { url: string; id: string };
|
||||||
|
return { inviteUrl: data.url, inviteId: data.id };
|
||||||
|
}
|
||||||
245
extensions/ndr/src/onboarding.ts
Normal file
245
extensions/ndr/src/onboarding.ts
Normal file
@ -0,0 +1,245 @@
|
|||||||
|
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
import { listNdrAccountIds, resolveNdrAccount, resolveDefaultNdrAccountId } from "./types.js";
|
||||||
|
|
||||||
|
const channel = "ndr" as const;
|
||||||
|
|
||||||
|
type ChannelOnboardingAdapter = {
|
||||||
|
channel: typeof channel;
|
||||||
|
getStatus: (ctx: {
|
||||||
|
cfg: ClawdbotConfig;
|
||||||
|
}) => Promise<{
|
||||||
|
channel: typeof channel;
|
||||||
|
configured: boolean;
|
||||||
|
statusLines: string[];
|
||||||
|
selectionHint?: string;
|
||||||
|
quickstartScore?: number;
|
||||||
|
}>;
|
||||||
|
configure: (ctx: {
|
||||||
|
cfg: ClawdbotConfig;
|
||||||
|
prompter: {
|
||||||
|
note: (message: string, title?: string) => Promise<void>;
|
||||||
|
text: (opts: {
|
||||||
|
message: string;
|
||||||
|
placeholder?: string;
|
||||||
|
initialValue?: string;
|
||||||
|
validate?: (value: string | undefined) => string | undefined;
|
||||||
|
}) => Promise<string>;
|
||||||
|
confirm: (opts: { message: string; initialValue?: boolean }) => Promise<boolean>;
|
||||||
|
};
|
||||||
|
}) => Promise<{ cfg: ClawdbotConfig; accountId?: string }>;
|
||||||
|
disable?: (cfg: ClawdbotConfig) => ClawdbotConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse an NDR invite URL to extract the inviter's hex pubkey.
|
||||||
|
* Invite URL format: https://iris.to#{"inviter":"<hex>","ephemeralKey":"...","sharedSecret":"..."}
|
||||||
|
*/
|
||||||
|
function parseInviteUrl(url: string): { inviter: string } | null {
|
||||||
|
try {
|
||||||
|
const trimmed = url.trim();
|
||||||
|
// Handle both full URL and just the fragment
|
||||||
|
let fragment = trimmed;
|
||||||
|
if (trimmed.includes("#")) {
|
||||||
|
fragment = trimmed.split("#")[1] ?? "";
|
||||||
|
}
|
||||||
|
const decoded = decodeURIComponent(fragment);
|
||||||
|
const data = JSON.parse(decoded) as { inviter?: string };
|
||||||
|
if (data.inviter && /^[0-9a-fA-F]{64}$/.test(data.inviter)) {
|
||||||
|
return { inviter: data.inviter };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Invalid URL format
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ndrOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||||
|
channel,
|
||||||
|
|
||||||
|
getStatus: async ({ cfg }) => {
|
||||||
|
const accountIds = listNdrAccountIds(cfg);
|
||||||
|
const hasAccount = accountIds.length > 0;
|
||||||
|
const defaultAccountId = resolveDefaultNdrAccountId(cfg);
|
||||||
|
const account = resolveNdrAccount({ cfg, accountId: defaultAccountId });
|
||||||
|
const enabled = account.enabled;
|
||||||
|
|
||||||
|
return {
|
||||||
|
channel,
|
||||||
|
configured: hasAccount && enabled,
|
||||||
|
statusLines: [
|
||||||
|
`NDR: ${hasAccount ? (enabled ? "configured" : "disabled") : "not configured"}`,
|
||||||
|
],
|
||||||
|
selectionHint: hasAccount
|
||||||
|
? enabled
|
||||||
|
? "configured"
|
||||||
|
: "disabled"
|
||||||
|
: "E2E encrypted · Nostr",
|
||||||
|
quickstartScore: hasAccount && enabled ? 1 : 5,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
configure: async ({ cfg, prompter }) => {
|
||||||
|
await prompter.note(
|
||||||
|
[
|
||||||
|
"NDR (Nostr Double Ratchet) provides forward-secure E2E encrypted messaging.",
|
||||||
|
"",
|
||||||
|
"Install dependencies (requires Rust):",
|
||||||
|
" curl -sSf https://sh.rustup.rs | sh && cargo install ndr hashtree-cli",
|
||||||
|
"",
|
||||||
|
"Control surface: chat.iris.to",
|
||||||
|
].join("\n"),
|
||||||
|
"NDR Setup",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if ndr CLI is available
|
||||||
|
let ndrAvailable = false;
|
||||||
|
try {
|
||||||
|
const { execSync } = await import("child_process");
|
||||||
|
execSync("ndr --version", { stdio: "ignore" });
|
||||||
|
ndrAvailable = true;
|
||||||
|
} catch {
|
||||||
|
// ndr not found
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ndrAvailable) {
|
||||||
|
await prompter.note(
|
||||||
|
[
|
||||||
|
"ndr CLI not found in PATH.",
|
||||||
|
"",
|
||||||
|
"Install: cargo install ndr",
|
||||||
|
"",
|
||||||
|
"You can configure now and install ndr later.",
|
||||||
|
].join("\n"),
|
||||||
|
"Warning",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ask for invite URL from chat.iris.to
|
||||||
|
await prompter.note(
|
||||||
|
[
|
||||||
|
"To connect your bot to your account:",
|
||||||
|
"",
|
||||||
|
"1. Go to chat.iris.to",
|
||||||
|
"2. Click 'New Chat' (+ button)",
|
||||||
|
"3. Click 'Copy your chat link'",
|
||||||
|
"4. Paste the invite URL below",
|
||||||
|
"",
|
||||||
|
"The bot will accept your invite and send a hello message.",
|
||||||
|
].join("\n"),
|
||||||
|
"Chat Invite",
|
||||||
|
);
|
||||||
|
|
||||||
|
const inviteUrl = await prompter.text({
|
||||||
|
message: "Paste your chat invite URL from chat.iris.to",
|
||||||
|
placeholder: "https://chat.iris.to/#...",
|
||||||
|
validate: (value) => {
|
||||||
|
if (!value?.trim()) return "Required";
|
||||||
|
const parsed = parseInviteUrl(value);
|
||||||
|
if (!parsed) return "Invalid invite URL. Should be from chat.iris.to";
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsed = parseInviteUrl(inviteUrl);
|
||||||
|
if (!parsed) {
|
||||||
|
await prompter.note("Failed to parse invite URL.", "Error");
|
||||||
|
return { cfg };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ownerPubkey = parsed.inviter;
|
||||||
|
|
||||||
|
// Try to accept the invite and send hello
|
||||||
|
let chatId: string | null = null;
|
||||||
|
let joinError: string | null = null;
|
||||||
|
let sendError: string | null = null;
|
||||||
|
|
||||||
|
if (ndrAvailable) {
|
||||||
|
const { execSync } = await import("child_process");
|
||||||
|
const os = await import("os");
|
||||||
|
const path = await import("path");
|
||||||
|
|
||||||
|
// Use ~/.clawdbot/ndr-data to match channel plugin's default dataDir
|
||||||
|
const ndrDataDir = path.join(os.homedir(), ".clawdbot", "ndr-data");
|
||||||
|
const ndrCmd = `ndr --data-dir "${ndrDataDir}" --json`;
|
||||||
|
|
||||||
|
// Accept the invite
|
||||||
|
try {
|
||||||
|
const joinOutput = execSync(`${ndrCmd} chat join "${inviteUrl.trim()}"`, {
|
||||||
|
encoding: "utf-8",
|
||||||
|
timeout: 60000,
|
||||||
|
});
|
||||||
|
// Parse JSON output for chat ID
|
||||||
|
// Format: { "status": "ok", "command": "chat.join", "data": { "id": "..." } }
|
||||||
|
try {
|
||||||
|
const joinResult = JSON.parse(joinOutput) as {
|
||||||
|
data?: { id?: string; chat_id?: string };
|
||||||
|
id?: string;
|
||||||
|
chat_id?: string;
|
||||||
|
};
|
||||||
|
chatId = joinResult.data?.id ?? joinResult.data?.chat_id ?? joinResult.id ?? joinResult.chat_id ?? null;
|
||||||
|
} catch {
|
||||||
|
// Fallback: parse from text output
|
||||||
|
const chatMatch = joinOutput.match(/chat[:\s_]+([0-9a-fA-F]{8})/i);
|
||||||
|
if (chatMatch) chatId = chatMatch[1];
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
joinError = err instanceof Error ? err.message : String(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send hello message if we got a chat ID
|
||||||
|
if (chatId) {
|
||||||
|
try {
|
||||||
|
execSync(`${ndrCmd} send "${chatId}" "Hello! I'm your clawdbot agent. 🤖"`, {
|
||||||
|
encoding: "utf-8",
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
sendError = err instanceof Error ? err.message : String(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const next: ClawdbotConfig = {
|
||||||
|
...cfg,
|
||||||
|
channels: {
|
||||||
|
...cfg.channels,
|
||||||
|
ndr: {
|
||||||
|
...cfg.channels?.ndr,
|
||||||
|
enabled: true,
|
||||||
|
ownerPubkey,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const successMsg: string[] = ["NDR channel configured!", "", `Owner: ${ownerPubkey.slice(0, 16)}...`];
|
||||||
|
|
||||||
|
if (chatId) {
|
||||||
|
successMsg.push("", `Chat established: ${chatId}`);
|
||||||
|
if (sendError) {
|
||||||
|
successMsg.push("", `Warning: Failed to send hello message: ${sendError.slice(0, 100)}`);
|
||||||
|
} else {
|
||||||
|
successMsg.push("", "Hello message sent! Check chat.iris.to");
|
||||||
|
}
|
||||||
|
} else if (joinError) {
|
||||||
|
successMsg.push("", `Warning: Failed to join chat: ${joinError.slice(0, 100)}`);
|
||||||
|
successMsg.push("", "Join manually:", ` ndr chat join "${inviteUrl.trim()}"`);
|
||||||
|
} else if (!ndrAvailable) {
|
||||||
|
successMsg.push("", "Install ndr CLI and join manually:", ` ndr chat join "${inviteUrl.trim()}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
successMsg.push("", "Start the gateway: clawdbot gateway run");
|
||||||
|
|
||||||
|
await prompter.note(successMsg.join("\n"), "Setup Complete");
|
||||||
|
|
||||||
|
return { cfg: next };
|
||||||
|
},
|
||||||
|
|
||||||
|
disable: (cfg) => ({
|
||||||
|
...cfg,
|
||||||
|
channels: {
|
||||||
|
...cfg.channels,
|
||||||
|
ndr: { ...cfg.channels?.ndr, enabled: false },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
14
extensions/ndr/src/runtime.ts
Normal file
14
extensions/ndr/src/runtime.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
let runtime: PluginRuntime | null = null;
|
||||||
|
|
||||||
|
export function setNdrRuntime(r: PluginRuntime): void {
|
||||||
|
runtime = r;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNdrRuntime(): PluginRuntime {
|
||||||
|
if (!runtime) {
|
||||||
|
throw new Error("NDR runtime not initialized");
|
||||||
|
}
|
||||||
|
return runtime;
|
||||||
|
}
|
||||||
136
extensions/ndr/src/types.ts
Normal file
136
extensions/ndr/src/types.ts
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||||
|
import { getPublicKey } from "nostr-tools";
|
||||||
|
import { homedir } from "os";
|
||||||
|
import { join } from "path";
|
||||||
|
import { DEFAULT_RELAYS, type NdrConfig } from "./config-schema.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expand ~ to home directory
|
||||||
|
*/
|
||||||
|
function expandTilde(p: string): string {
|
||||||
|
if (p.startsWith("~/")) {
|
||||||
|
return join(homedir(), p.slice(2));
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedNdrAccount {
|
||||||
|
accountId: string;
|
||||||
|
name: string;
|
||||||
|
enabled: boolean;
|
||||||
|
configured: boolean;
|
||||||
|
privateKey: string;
|
||||||
|
publicKey: string | null;
|
||||||
|
ownerPubkey: string | null;
|
||||||
|
relays: string[];
|
||||||
|
ndrPath: string;
|
||||||
|
dataDir: string | null;
|
||||||
|
config: NdrConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all configured NDR account IDs
|
||||||
|
*/
|
||||||
|
export function listNdrAccountIds(cfg: ClawdbotConfig): string[] {
|
||||||
|
const channels = (cfg.channels ?? {}) as Record<string, unknown>;
|
||||||
|
const ndrConfig = channels.ndr;
|
||||||
|
if (!ndrConfig || typeof ndrConfig !== "object") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
// For now, only support single "default" account
|
||||||
|
return ["default"];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the default NDR account ID
|
||||||
|
*/
|
||||||
|
export function resolveDefaultNdrAccountId(cfg: ClawdbotConfig): string | undefined {
|
||||||
|
const ids = listNdrAccountIds(cfg);
|
||||||
|
return ids.length > 0 ? ids[0] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve NDR account configuration
|
||||||
|
*/
|
||||||
|
export function resolveNdrAccount(opts: {
|
||||||
|
cfg: ClawdbotConfig;
|
||||||
|
accountId?: string;
|
||||||
|
}): ResolvedNdrAccount {
|
||||||
|
const { cfg, accountId = "default" } = opts;
|
||||||
|
const channels = (cfg.channels ?? {}) as Record<string, unknown>;
|
||||||
|
const ndrConfig = (channels.ndr ?? {}) as NdrConfig;
|
||||||
|
|
||||||
|
const privateKey = ndrConfig.privateKey ?? "";
|
||||||
|
let publicKey: string | null = null;
|
||||||
|
|
||||||
|
if (privateKey) {
|
||||||
|
try {
|
||||||
|
// Handle hex format
|
||||||
|
const keyBytes = hexToBytes(privateKey);
|
||||||
|
publicKey = getPublicKey(keyBytes);
|
||||||
|
} catch {
|
||||||
|
// Invalid key format
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ndr auto-generates identity if not provided, so we're always "configured"
|
||||||
|
// The actual identity will be created/loaded by ndr on first use
|
||||||
|
const configured = true;
|
||||||
|
const relays = ndrConfig.relays ?? DEFAULT_RELAYS;
|
||||||
|
|
||||||
|
// Normalize owner pubkey to hex if provided
|
||||||
|
let ownerPubkey: string | null = null;
|
||||||
|
if (ndrConfig.ownerPubkey) {
|
||||||
|
ownerPubkey = normalizePubkey(ndrConfig.ownerPubkey);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
accountId,
|
||||||
|
name: ndrConfig.name ?? "NDR",
|
||||||
|
enabled: ndrConfig.enabled !== false,
|
||||||
|
configured,
|
||||||
|
privateKey,
|
||||||
|
publicKey,
|
||||||
|
ownerPubkey,
|
||||||
|
relays,
|
||||||
|
ndrPath: ndrConfig.ndrPath ?? "ndr",
|
||||||
|
// Default to ~/.clawdbot/ndr-data for persistence (container mounts ~/.clawdbot)
|
||||||
|
dataDir: expandTilde(ndrConfig.dataDir ?? "~/.clawdbot/ndr-data"),
|
||||||
|
config: ndrConfig,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert hex string to Uint8Array
|
||||||
|
*/
|
||||||
|
function hexToBytes(hex: string): Uint8Array {
|
||||||
|
const cleaned = hex.replace(/^0x/, "");
|
||||||
|
const bytes = new Uint8Array(cleaned.length / 2);
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
bytes[i] = parseInt(cleaned.substr(i * 2, 2), 16);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a pubkey to hex format
|
||||||
|
*/
|
||||||
|
function normalizePubkey(input: string): string {
|
||||||
|
const trimmed = input.trim();
|
||||||
|
|
||||||
|
// Already hex
|
||||||
|
if (/^[0-9a-fA-F]{64}$/.test(trimmed)) {
|
||||||
|
return trimmed.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// npub format - decode bech32
|
||||||
|
if (trimmed.startsWith("npub1")) {
|
||||||
|
const { nip19 } = require("nostr-tools");
|
||||||
|
const decoded = nip19.decode(trimmed);
|
||||||
|
if (decoded.type === "npub") {
|
||||||
|
return decoded.data as string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Invalid pubkey format: ${input}`);
|
||||||
|
}
|
||||||
@ -248,7 +248,8 @@
|
|||||||
"overrides": {
|
"overrides": {
|
||||||
"@sinclair/typebox": "0.34.47",
|
"@sinclair/typebox": "0.34.47",
|
||||||
"hono": "4.11.4",
|
"hono": "4.11.4",
|
||||||
"tar": "7.5.4"
|
"tar": "7.5.4",
|
||||||
|
"clawdbot": "workspace:moltbot@*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"vitest": {
|
"vitest": {
|
||||||
|
|||||||
117
pnpm-lock.yaml
generated
117
pnpm-lock.yaml
generated
@ -8,6 +8,7 @@ overrides:
|
|||||||
'@sinclair/typebox': 0.34.47
|
'@sinclair/typebox': 0.34.47
|
||||||
hono: 4.11.4
|
hono: 4.11.4
|
||||||
tar: 7.5.4
|
tar: 7.5.4
|
||||||
|
clawdbot: workspace:moltbot@*
|
||||||
|
|
||||||
importers:
|
importers:
|
||||||
|
|
||||||
@ -315,7 +316,7 @@ importers:
|
|||||||
version: 10.5.0
|
version: 10.5.0
|
||||||
devDependencies:
|
devDependencies:
|
||||||
clawdbot:
|
clawdbot:
|
||||||
specifier: workspace:*
|
specifier: workspace:moltbot@*
|
||||||
version: link:../..
|
version: link:../..
|
||||||
|
|
||||||
extensions/imessage: {}
|
extensions/imessage: {}
|
||||||
@ -323,7 +324,7 @@ importers:
|
|||||||
extensions/line:
|
extensions/line:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
clawdbot:
|
clawdbot:
|
||||||
specifier: workspace:*
|
specifier: workspace:moltbot@*
|
||||||
version: link:../..
|
version: link:../..
|
||||||
|
|
||||||
extensions/llm-task: {}
|
extensions/llm-task: {}
|
||||||
@ -349,7 +350,7 @@ importers:
|
|||||||
version: 4.3.6
|
version: 4.3.6
|
||||||
devDependencies:
|
devDependencies:
|
||||||
clawdbot:
|
clawdbot:
|
||||||
specifier: workspace:*
|
specifier: workspace:moltbot@*
|
||||||
version: link:../..
|
version: link:../..
|
||||||
|
|
||||||
extensions/mattermost: {}
|
extensions/mattermost: {}
|
||||||
@ -357,8 +358,8 @@ importers:
|
|||||||
extensions/memory-core:
|
extensions/memory-core:
|
||||||
dependencies:
|
dependencies:
|
||||||
clawdbot:
|
clawdbot:
|
||||||
specifier: '>=2026.1.24-3'
|
specifier: workspace:moltbot@*
|
||||||
version: 2026.1.24-3(@types/express@5.0.6)(audio-decode@2.2.3)(devtools-protocol@0.0.1561482)(typescript@5.9.3)
|
version: link:../..
|
||||||
|
|
||||||
extensions/memory-lancedb:
|
extensions/memory-lancedb:
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -384,7 +385,7 @@ importers:
|
|||||||
specifier: ^1.2.2
|
specifier: ^1.2.2
|
||||||
version: 1.2.2
|
version: 1.2.2
|
||||||
clawdbot:
|
clawdbot:
|
||||||
specifier: workspace:*
|
specifier: workspace:moltbot@*
|
||||||
version: link:../..
|
version: link:../..
|
||||||
express:
|
express:
|
||||||
specifier: ^5.2.1
|
specifier: ^5.2.1
|
||||||
@ -393,12 +394,25 @@ importers:
|
|||||||
specifier: ^4.1.2
|
specifier: ^4.1.2
|
||||||
version: 4.1.2
|
version: 4.1.2
|
||||||
|
|
||||||
|
extensions/ndr:
|
||||||
|
dependencies:
|
||||||
|
nostr-tools:
|
||||||
|
specifier: ^2.20.0
|
||||||
|
version: 2.20.0(typescript@5.9.3)
|
||||||
|
zod:
|
||||||
|
specifier: ^4.3.6
|
||||||
|
version: 4.3.6
|
||||||
|
devDependencies:
|
||||||
|
clawdbot:
|
||||||
|
specifier: workspace:moltbot@*
|
||||||
|
version: link:../..
|
||||||
|
|
||||||
extensions/nextcloud-talk: {}
|
extensions/nextcloud-talk: {}
|
||||||
|
|
||||||
extensions/nostr:
|
extensions/nostr:
|
||||||
dependencies:
|
dependencies:
|
||||||
clawdbot:
|
clawdbot:
|
||||||
specifier: workspace:*
|
specifier: workspace:moltbot@*
|
||||||
version: link:../..
|
version: link:../..
|
||||||
nostr-tools:
|
nostr-tools:
|
||||||
specifier: ^2.20.0
|
specifier: ^2.20.0
|
||||||
@ -440,7 +454,7 @@ importers:
|
|||||||
version: 4.3.6
|
version: 4.3.6
|
||||||
devDependencies:
|
devDependencies:
|
||||||
clawdbot:
|
clawdbot:
|
||||||
specifier: workspace:*
|
specifier: workspace:moltbot@*
|
||||||
version: link:../..
|
version: link:../..
|
||||||
|
|
||||||
extensions/voice-call:
|
extensions/voice-call:
|
||||||
@ -460,7 +474,7 @@ importers:
|
|||||||
extensions/zalo:
|
extensions/zalo:
|
||||||
dependencies:
|
dependencies:
|
||||||
clawdbot:
|
clawdbot:
|
||||||
specifier: workspace:*
|
specifier: workspace:moltbot@*
|
||||||
version: link:../..
|
version: link:../..
|
||||||
undici:
|
undici:
|
||||||
specifier: 7.19.0
|
specifier: 7.19.0
|
||||||
@ -472,7 +486,7 @@ importers:
|
|||||||
specifier: 0.34.47
|
specifier: 0.34.47
|
||||||
version: 0.34.47
|
version: 0.34.47
|
||||||
clawdbot:
|
clawdbot:
|
||||||
specifier: workspace:*
|
specifier: workspace:moltbot@*
|
||||||
version: link:../..
|
version: link:../..
|
||||||
|
|
||||||
ui:
|
ui:
|
||||||
@ -3208,11 +3222,6 @@ packages:
|
|||||||
class-variance-authority@0.7.1:
|
class-variance-authority@0.7.1:
|
||||||
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
|
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
|
||||||
|
|
||||||
clawdbot@2026.1.24-3:
|
|
||||||
resolution: {integrity: sha512-zt9BzhWXduq8ZZR4rfzQDurQWAgmijTTyPZCQGrn5ew6wCEwhxxEr2/NHG7IlCwcfRsKymsY4se9KMhoNz0JtQ==}
|
|
||||||
engines: {node: '>=22.12.0'}
|
|
||||||
hasBin: true
|
|
||||||
|
|
||||||
cli-cursor@5.0.0:
|
cli-cursor@5.0.0:
|
||||||
resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==}
|
resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@ -9092,84 +9101,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
clsx: 2.1.1
|
clsx: 2.1.1
|
||||||
|
|
||||||
clawdbot@2026.1.24-3(@types/express@5.0.6)(audio-decode@2.2.3)(devtools-protocol@0.0.1561482)(typescript@5.9.3):
|
|
||||||
dependencies:
|
|
||||||
'@agentclientprotocol/sdk': 0.13.1(zod@4.3.6)
|
|
||||||
'@aws-sdk/client-bedrock': 3.975.0
|
|
||||||
'@buape/carbon': 0.14.0(hono@4.11.4)
|
|
||||||
'@clack/prompts': 0.11.0
|
|
||||||
'@grammyjs/runner': 2.0.3(grammy@1.39.3)
|
|
||||||
'@grammyjs/transformer-throttler': 1.2.1(grammy@1.39.3)
|
|
||||||
'@homebridge/ciao': 1.3.4
|
|
||||||
'@line/bot-sdk': 10.6.0
|
|
||||||
'@lydell/node-pty': 1.2.0-beta.3
|
|
||||||
'@mariozechner/pi-agent-core': 0.49.3(ws@8.19.0)(zod@4.3.6)
|
|
||||||
'@mariozechner/pi-ai': 0.49.3(ws@8.19.0)(zod@4.3.6)
|
|
||||||
'@mariozechner/pi-coding-agent': 0.49.3(ws@8.19.0)(zod@4.3.6)
|
|
||||||
'@mariozechner/pi-tui': 0.49.3
|
|
||||||
'@mozilla/readability': 0.6.0
|
|
||||||
'@sinclair/typebox': 0.34.47
|
|
||||||
'@slack/bolt': 4.6.0(@types/express@5.0.6)
|
|
||||||
'@slack/web-api': 7.13.0
|
|
||||||
'@whiskeysockets/baileys': 7.0.0-rc.9(audio-decode@2.2.3)(sharp@0.34.5)
|
|
||||||
ajv: 8.17.1
|
|
||||||
body-parser: 2.2.2
|
|
||||||
chalk: 5.6.2
|
|
||||||
chokidar: 5.0.0
|
|
||||||
chromium-bidi: 13.0.1(devtools-protocol@0.0.1561482)
|
|
||||||
cli-highlight: 2.1.11
|
|
||||||
commander: 14.0.2
|
|
||||||
croner: 9.1.0
|
|
||||||
detect-libc: 2.1.2
|
|
||||||
discord-api-types: 0.38.37
|
|
||||||
dotenv: 17.2.3
|
|
||||||
express: 5.2.1
|
|
||||||
file-type: 21.3.0
|
|
||||||
grammy: 1.39.3
|
|
||||||
hono: 4.11.4
|
|
||||||
jiti: 2.6.1
|
|
||||||
json5: 2.2.3
|
|
||||||
jszip: 3.10.1
|
|
||||||
linkedom: 0.18.12
|
|
||||||
long: 5.3.2
|
|
||||||
markdown-it: 14.1.0
|
|
||||||
node-edge-tts: 1.2.9
|
|
||||||
osc-progress: 0.3.0
|
|
||||||
pdfjs-dist: 5.4.530
|
|
||||||
playwright-core: 1.58.0
|
|
||||||
proper-lockfile: 4.1.2
|
|
||||||
qrcode-terminal: 0.12.0
|
|
||||||
sharp: 0.34.5
|
|
||||||
sqlite-vec: 0.1.7-alpha.2
|
|
||||||
tar: 7.5.4
|
|
||||||
tslog: 4.10.2
|
|
||||||
undici: 7.19.0
|
|
||||||
ws: 8.19.0
|
|
||||||
yaml: 2.8.2
|
|
||||||
zod: 4.3.6
|
|
||||||
optionalDependencies:
|
|
||||||
'@napi-rs/canvas': 0.1.88
|
|
||||||
node-llama-cpp: 3.15.0(typescript@5.9.3)
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- '@discordjs/opus'
|
|
||||||
- '@modelcontextprotocol/sdk'
|
|
||||||
- '@types/express'
|
|
||||||
- audio-decode
|
|
||||||
- aws-crt
|
|
||||||
- bufferutil
|
|
||||||
- canvas
|
|
||||||
- debug
|
|
||||||
- devtools-protocol
|
|
||||||
- encoding
|
|
||||||
- ffmpeg-static
|
|
||||||
- jimp
|
|
||||||
- link-preview-js
|
|
||||||
- node-opus
|
|
||||||
- opusscript
|
|
||||||
- supports-color
|
|
||||||
- typescript
|
|
||||||
- utf-8-validate
|
|
||||||
|
|
||||||
cli-cursor@5.0.0:
|
cli-cursor@5.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
restore-cursor: 5.1.0
|
restore-cursor: 5.1.0
|
||||||
|
|||||||
@ -37,6 +37,18 @@ compute_hash() {
|
|||||||
| awk '{print $1}'
|
| awk '{print $1}'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# In Docker builds (vendor/ and apps/ excluded), skip bundling if output exists
|
||||||
|
for path in "${INPUT_PATHS[@]}"; do
|
||||||
|
if [[ ! -e "$path" ]]; then
|
||||||
|
if [[ -f "$OUTPUT_FILE" ]]; then
|
||||||
|
echo "A2UI bundle exists; source files unavailable (docker build?); skipping."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "Error: Required path $path not found and no pre-built bundle exists." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
current_hash="$(compute_hash)"
|
current_hash="$(compute_hash)"
|
||||||
if [[ -f "$HASH_FILE" ]]; then
|
if [[ -f "$HASH_FILE" ]]; then
|
||||||
previous_hash="$(cat "$HASH_FILE")"
|
previous_hash="$(cat "$HASH_FILE")"
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
2567ca5bbc065b922d96717a488d5db3120b5b033c5d0508682d1aa8fbba470a
|
53ab7a34ec406ea6b1a3170a79b3dde01b33a676bb8870215a9f230bf007bf94
|
||||||
|
|||||||
@ -369,6 +369,13 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
ndr: {
|
||||||
|
id: "ndr",
|
||||||
|
capabilities: {
|
||||||
|
chatTypes: ["direct"],
|
||||||
|
},
|
||||||
|
outbound: { textChunkLimit: 4000 },
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildDockFromPlugin(plugin: ChannelPlugin): ChannelDock {
|
function buildDockFromPlugin(plugin: ChannelPlugin): ChannelDock {
|
||||||
|
|||||||
@ -12,6 +12,7 @@ export const CHAT_CHANNEL_ORDER = [
|
|||||||
"slack",
|
"slack",
|
||||||
"signal",
|
"signal",
|
||||||
"imessage",
|
"imessage",
|
||||||
|
"ndr",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type ChatChannelId = (typeof CHAT_CHANNEL_ORDER)[number];
|
export type ChatChannelId = (typeof CHAT_CHANNEL_ORDER)[number];
|
||||||
@ -98,6 +99,16 @@ const CHAT_CHANNEL_META: Record<ChatChannelId, ChannelMeta> = {
|
|||||||
blurb: "this is still a work in progress.",
|
blurb: "this is still a work in progress.",
|
||||||
systemImage: "message.fill",
|
systemImage: "message.fill",
|
||||||
},
|
},
|
||||||
|
ndr: {
|
||||||
|
id: "ndr",
|
||||||
|
label: "NDR",
|
||||||
|
selectionLabel: "NDR (Nostr Double Ratchet)",
|
||||||
|
detailLabel: "NDR",
|
||||||
|
docsPath: "/channels/ndr",
|
||||||
|
docsLabel: "ndr",
|
||||||
|
blurb: "forward-secure E2E encryption via double ratchet over Nostr (chat.iris.to).",
|
||||||
|
selectionExtras: ["https://chat.iris.to"],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CHAT_CHANNEL_ALIASES: Record<string, ChatChannelId> = {
|
export const CHAT_CHANNEL_ALIASES: Record<string, ChatChannelId> = {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user