diff --git a/.gitignore b/.gitignore index 9221777ed..72b9eca47 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,9 @@ pikachat-openclaw/.state/ pikachat-openclaw/node_modules/ pikachat-openclaw/**/node_modules/ pikachat-openclaw/**/package-lock.json +pikachat-claude/node_modules/ +pikachat-claude/package-lock.json +pikachat-claude/dist/ pikachat-openclaw/.env pikachat-openclaw/.env.* @@ -91,4 +94,3 @@ cmd/pika-relay/pika-relay .pika-fixture/ .pikaci/ .pikahut-agent-platform-local/ - diff --git a/docs/claude-channel-plugin-brief.md b/docs/claude-channel-plugin-brief.md new file mode 100644 index 000000000..b52f23084 --- /dev/null +++ b/docs/claude-channel-plugin-brief.md @@ -0,0 +1,193 @@ +--- +summary: Implementation brief for a Claude Code channel plugin backed by pikachat daemon +read_when: + - building or extending the pikachat Claude plugin + - reviewing channel/plugin transport and access design +--- + +# Pikachat Claude Channel Plugin Brief + +## Goal + +Build a Claude Code channel plugin backed by `pikachat daemon`. + +The plugin should expose Pika MLS chats to Claude through the Claude channel contract: + +- inbound chat messages arrive as `notifications/claude/channel` +- Claude replies via ordinary MCP tools +- sender gating prevents prompt injection + +This plugin is a host wrapper around `pikachat daemon`, not a replacement for the daemon protocol. + +## Acceptance + +- DM routing works + - approved 1:1 senders reach Claude as channel events + - Claude can reply into the same DM +- Group routing works + - groups are explicitly enabled + - sender allowlists apply to senders, not rooms + - `requireMention: true` is the default for groups +- Pairing and allowlist exist + - unknown DM senders get a pairing code + - approval adds the sender to the allowlist +- Reply, react, and file send work through MCP tools +- Inbound attachments are surfaced with local paths when available +- Local relay e2e proves: + - remote Pika message + - Claude channel notification + - Claude reply tool call + - remote side receives the reply + +## Constraints + +- Reuse the TypeScript launcher/client patterns from `pikachat-openclaw` +- Avoid direct SQLite reads unless necessary +- Ask before changing the daemon protocol +- Treat `edit_message` as non-MVP unless a native model emerges + +## Existing Reusable Surfaces + +The daemon already exposes the main surfaces needed for an MVP: + +- `send_message` +- `send_media` +- `send_media_batch` +- `react` +- `send_typing` +- `list_groups` +- `list_members` +- `get_messages` +- `message_received` +- `group_joined` +- `group_created` +- `group_updated` + +Relevant references: + +- `crates/pikachat-sidecar/src/protocol.rs` +- `crates/pikachat-sidecar/src/daemon.rs` +- `pikachat-openclaw/openclaw/extensions/pikachat-openclaw/src/sidecar.ts` +- `pikachat-openclaw/openclaw/extensions/pikachat-openclaw/src/daemon-launch.ts` +- `pikachat-openclaw/openclaw/extensions/pikachat-openclaw/src/sidecar-install.ts` + +## Architecture + +- `pikachat daemon` remains the transport/backend child process +- the Claude plugin process is the MCP stdio server +- the plugin: + - launches the daemon + - consumes daemon JSONL events + - applies DM/group access policy + - emits Claude channel notifications + - exposes reply/react/file-send/admin tools + +## Notification Contract + +Each inbound message becomes a Claude channel event with: + +- `content` + - message text + - attachment summary lines with absolute local paths when present +- `meta` + - `chat_id` + - `sender_id` + - `sender_name` + - `message_id` + - `event_id` + - `chat_type` + - `group_name` + - `mentioned` + +The server instructions should tell Claude: + +- inbound messages arrive as `` +- use `reply` with the `chat_id` from the tag +- use `react` with the `event_id` from the tag + +## Access Model + +Store state in `~/.claude/channels/pikachat/access.json`. + +Schema: + +```json +{ + "dmPolicy": "pairing", + "allowFrom": [], + "groups": {}, + "mentionPatterns": [], + "pendingPairings": {} +} +``` + +Rules: + +- DM: + - `pairing`: unknown sender gets a code, message is dropped + - `allowlist`: unknown sender is dropped + - `disabled`: all DM traffic is dropped +- Group: + - group must be explicitly enabled + - per-group `allowFrom` is optional + - `requireMention` defaults to `true` + +## Phases + +### 1. MCP wrapper + +- create plugin directory with `.claude-plugin/plugin.json` and `.mcp.json` +- bundle a stdio MCP server for runtime use +- reuse daemon launch/install/client logic from `pikachat-openclaw` +- align the TypeScript protocol mirror with the current Rust protocol + +### 2. Access model + +- implement `access.json` +- pairing lifecycle +- DM and group gating +- mention detection + +### 3. Parity gaps + +Close host-side gaps first: + +- add TypeScript wrappers for `list_members`, `get_messages`, `send_media_batch`, and `group_updated` +- classify DM vs group via daemon metadata and cached member counts +- avoid SQLite reads + +Escalate before daemon changes for: + +- native `reply_to` +- richer history pagination/search +- explicit historical attachment fetch/download + +### 4. Packaging and tests + +- deterministic Node tests for access, routing, and formatting +- local relay e2e using real `pikachat daemon` +- plugin README with local dev instructions + +## Evaluation Design + +Deterministic tests should cover: + +- pairing code lifecycle +- DM policy decisions +- group allowlist and mention gating +- notification/meta shaping +- attachment text augmentation +- tool-to-daemon command mapping + +Local relay e2e should prove: + +1. a remote Pika user sends a DM through a local relay +2. the Claude plugin emits a channel notification +3. the test calls the plugin reply path +4. the remote user receives the reply + +## Open Questions / Explicit Non-Goals + +- `edit_message` is out of scope for MVP +- reply threading is a parity gap until the daemon exposes reply-tag support +- historical attachment download is a future enhancement diff --git a/pikachat-claude/.claude-plugin/plugin.json b/pikachat-claude/.claude-plugin/plugin.json new file mode 100644 index 000000000..1cc72c3f0 --- /dev/null +++ b/pikachat-claude/.claude-plugin/plugin.json @@ -0,0 +1,6 @@ +{ + "name": "pikachat-claude", + "version": "0.1.0", + "description": "Claude Code channel plugin backed by pikachat daemon", + "mcpServers": "./.mcp.json" +} diff --git a/pikachat-claude/.mcp.json b/pikachat-claude/.mcp.json new file mode 100644 index 000000000..b5337f78b --- /dev/null +++ b/pikachat-claude/.mcp.json @@ -0,0 +1,12 @@ +{ + "mcpServers": { + "pikachat": { + "command": "node", + "args": ["${CLAUDE_PLUGIN_ROOT}/dist/server.js"], + "startupTimeout": 30000, + "env": { + "PIKACHAT_CHANNEL_SOURCE": "pikachat" + } + } + } +} diff --git a/pikachat-claude/README.md b/pikachat-claude/README.md new file mode 100644 index 000000000..08978cc87 --- /dev/null +++ b/pikachat-claude/README.md @@ -0,0 +1,101 @@ +# pikachat-claude + +Claude Code channel plugin backed by `pikachat daemon`. + +## Current scope + +- DM routing with pairing / allowlist +- explicit group enablement with mention gating +- reply / react / file send MCP tools +- inbound attachment surfacing via daemon-provided local paths +- local relay e2e harness + +## Local development + +```sh +cd /Users/futurepaul/dev/sec/other-peoples-code/pika/pikachat-claude +npm install +npm run build +``` + +Then run Claude from the repo root with the plugin directory: + +```sh +cd /Users/futurepaul/dev/sec/other-peoples-code/pika +claude --plugin-dir ./pikachat-claude \ + --dangerously-load-development-channels plugin:pikachat-claude@inline +``` + +Channels require Claude Code `v2.1.80+`. + +Running Claude from inside `pikachat-claude/` is not recommended for local testing because that plugin's `.mcp.json` will also be treated as the project's `.mcp.json`. + +If you prefer a one-shot launch with explicit env overrides, this also works: + +```sh +cd /Users/futurepaul/dev/sec/other-peoples-code/pika +PIKACHAT_RELAYS='["wss://example-relay"]' \ +PIKACHAT_STATE_DIR=~/.local/state/pikachat \ +claude --plugin-dir ./pikachat-claude \ + --dangerously-load-development-channels plugin:pikachat-claude@inline +``` + +The plugin uses the same default relay profile as `pikachat` when `PIKACHAT_RELAYS` is not set. If you are not using a preinstalled `pikachat` binary, the plugin will try to resolve one from GitHub releases using the same logic as `pikachat-openclaw`. + +## Environment + +- `PIKACHAT_RELAYS` + - optional JSON array or comma-separated relay URLs; defaults to the standard pikachat relay profile +- `PIKACHAT_STATE_DIR` + - daemon state dir; set this before first start if you want a dedicated bot identity instead of reusing `~/.local/state/pikachat` +- `PIKACHAT_DAEMON_CMD` +- `PIKACHAT_DAEMON_ARGS` + - JSON array +- `PIKACHAT_DAEMON_VERSION` +- `PIKACHAT_DAEMON_BACKEND` + - `native` or `acp` +- `PIKACHAT_DAEMON_ACP_EXEC` +- `PIKACHAT_DAEMON_ACP_CWD` +- `PIKACHAT_AUTO_ACCEPT_WELCOMES` +- `PIKACHAT_CHANNEL_SOURCE` + +## Testing + +```sh +npm test +npm run test:e2e-local-relay +``` + +The local relay e2e requires working `cargo` and `go` toolchains. + +## Identity / npub + +The daemon creates or loads its identity on startup from `PIKACHAT_STATE_DIR` (or `~/.local/state/pikachat` by default). If the state dir is new, the first daemon start generates a fresh keypair and `npub`. + +To inspect the active identity for a chosen state dir: + +```sh +cargo run -q -p pikachat -- --state-dir /tmp/pikachat-claude-state identity +``` + +If you are using the default state dir, a plain: + +```sh +pikachat identity +``` + +shows the same identity the plugin will use. + +## Startup sanity check + +After launching Claude, verify that the wrapper and daemon are both running: + +```sh +ps -axo pid,ppid,command | rg 'pikachat|dist/server.js' +``` + +You should see: + +- `claude ...` +- `node .../pikachat-claude/dist/server.js` +- `pikachat daemon ...` diff --git a/pikachat-claude/package.json b/pikachat-claude/package.json new file mode 100644 index 000000000..1d035fabd --- /dev/null +++ b/pikachat-claude/package.json @@ -0,0 +1,20 @@ +{ + "name": "pikachat-claude", + "version": "0.1.0", + "private": true, + "description": "Claude Code channel plugin backed by pikachat daemon", + "type": "module", + "scripts": { + "build": "esbuild src/server.ts --bundle --platform=node --format=esm --outfile=dist/server.js --banner:js='#!/usr/bin/env node'", + "typecheck": "tsc --noEmit", + "test": "node --import tsx --test src/**/*.test.ts", + "test:e2e-local-relay": "RUN_PIKACHAT_CLAUDE_E2E=1 node --import tsx --test src/local-relay-e2e.test.ts" + }, + "devDependencies": { + "@types/node": "^22.13.14", + "@modelcontextprotocol/sdk": "^1.17.5", + "esbuild": "^0.25.1", + "tsx": "^4.19.3", + "typescript": "^5.8.2" + } +} diff --git a/pikachat-claude/src/access.test.ts b/pikachat-claude/src/access.test.ts new file mode 100644 index 000000000..2f71664f5 --- /dev/null +++ b/pikachat-claude/src/access.test.ts @@ -0,0 +1,79 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + allowSender, + approvePairing, + defaultAccessState, + denyPairing, + enableGroup, + ensurePendingPairing, + evaluateDmAccess, + evaluateGroupAccess, + pruneExpiredPairings, + removeSender, + setDmPolicy, +} from "./access.js"; + +describe("access state helpers", () => { + it("defaults to pairing policy", () => { + const state = defaultAccessState(); + assert.equal(state.dmPolicy, "pairing"); + assert.deepEqual(state.allowFrom, []); + }); + + it("creates and approves pairings", () => { + const created = ensurePendingPairing(defaultAccessState(), "AABB", "ccdd", 1000); + assert.match(created.pairing.code, /^[0-9a-f]{6}$/); + const approved = approvePairing(created.state, created.pairing.code); + assert.equal(approved.pairing?.senderId, "aabb"); + assert.deepEqual(approved.state.allowFrom, ["aabb"]); + assert.equal(Object.keys(approved.state.pendingPairings).length, 0); + }); + + it("reuses existing live pairings", () => { + const first = ensurePendingPairing(defaultAccessState(), "AABB", "ccdd", 1000); + const second = ensurePendingPairing(first.state, "aabb", "ccdd", 1500); + assert.equal(first.pairing.code, second.pairing.code); + }); + + it("expires stale pairings", () => { + const created = ensurePendingPairing(defaultAccessState(), "AABB", "ccdd", 1000); + const pruned = pruneExpiredPairings(created.state, 1000 + 25 * 60 * 60 * 1000); + assert.equal(Object.keys(pruned.pendingPairings).length, 0); + }); + + it("supports dm allowlist transitions", () => { + const allowed = allowSender(defaultAccessState(), "AABB"); + assert.equal(evaluateDmAccess(allowed, "aabb"), "allowed"); + const removed = removeSender(allowed, "AABB"); + assert.equal(evaluateDmAccess(removed, "aabb"), "pairing"); + assert.equal(evaluateDmAccess(setDmPolicy(removed, "allowlist"), "aabb"), "blocked"); + }); + + it("supports deny pairing", () => { + const created = ensurePendingPairing(defaultAccessState(), "AABB", "ccdd", 1000); + const denied = denyPairing(created.state, created.pairing.code); + assert.equal(denied.pairing?.senderId, "aabb"); + assert.equal(Object.keys(denied.state.pendingPairings).length, 0); + }); + + it("applies per-group allowlist and mention defaults", () => { + const state = enableGroup(defaultAccessState(), "GG", { allowFrom: ["aa"], requireMention: true }); + assert.deepEqual(evaluateGroupAccess(state, "gg", "aa"), { + enabled: true, + requireMention: true, + senderAllowed: true, + }); + assert.deepEqual(evaluateGroupAccess(state, "gg", "bb"), { + enabled: true, + requireMention: true, + senderAllowed: false, + }); + assert.deepEqual(evaluateGroupAccess(state, "missing", "aa"), { + enabled: false, + requireMention: true, + senderAllowed: false, + }); + }); +}); diff --git a/pikachat-claude/src/access.ts b/pikachat-claude/src/access.ts new file mode 100644 index 000000000..ffa00fbc2 --- /dev/null +++ b/pikachat-claude/src/access.ts @@ -0,0 +1,251 @@ +import { randomBytes } from "node:crypto"; +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import path from "node:path"; + +export type DmPolicy = "pairing" | "allowlist" | "disabled"; + +export type GroupAccess = { + requireMention: boolean; + allowFrom: string[]; +}; + +export type PendingPairing = { + code: string; + senderId: string; + chatId: string; + createdAt: number; +}; + +export type AccessState = { + dmPolicy: DmPolicy; + allowFrom: string[]; + groups: Record; + mentionPatterns: string[]; + pendingPairings: Record; +}; + +const PAIRING_TTL_MS = 24 * 60 * 60 * 1000; + +export function defaultAccessState(): AccessState { + return { + dmPolicy: "pairing", + allowFrom: [], + groups: {}, + mentionPatterns: [], + pendingPairings: {}, + }; +} + +function normalizeSenderId(senderId: string): string { + return senderId.trim().toLowerCase(); +} + +function normalizeGroupId(groupId: string): string { + return groupId.trim().toLowerCase(); +} + +export async function loadAccessState(filePath: string): Promise { + try { + const raw = await readFile(filePath, "utf8"); + const parsed = JSON.parse(raw) as Partial; + return { + dmPolicy: + parsed.dmPolicy === "allowlist" || parsed.dmPolicy === "disabled" + ? parsed.dmPolicy + : "pairing", + allowFrom: Array.isArray(parsed.allowFrom) + ? parsed.allowFrom.map((entry) => normalizeSenderId(String(entry))).filter(Boolean) + : [], + groups: Object.fromEntries( + Object.entries(parsed.groups ?? {}).map(([groupId, rawGroup]) => { + const group = rawGroup as Partial | undefined; + return [ + normalizeGroupId(groupId), + { + requireMention: group?.requireMention !== false, + allowFrom: Array.isArray(group?.allowFrom) + ? group!.allowFrom.map((entry) => normalizeSenderId(String(entry))).filter(Boolean) + : [], + }, + ]; + }), + ), + mentionPatterns: Array.isArray(parsed.mentionPatterns) + ? parsed.mentionPatterns.map((entry) => String(entry)).filter(Boolean) + : [], + pendingPairings: Object.fromEntries( + Object.entries(parsed.pendingPairings ?? {}).map(([code, pending]) => { + const entry = pending as Partial | undefined; + return [ + code.trim().toLowerCase(), + { + code: code.trim().toLowerCase(), + senderId: normalizeSenderId(String(entry?.senderId ?? "")), + chatId: normalizeGroupId(String(entry?.chatId ?? "")), + createdAt: Number(entry?.createdAt ?? 0), + }, + ]; + }), + ), + }; + } catch { + return defaultAccessState(); + } +} + +export async function saveAccessState(filePath: string, state: AccessState): Promise { + const directory = path.dirname(filePath); + const tempPath = path.join(directory, `.access.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`); + await mkdir(directory, { recursive: true }); + await writeFile(tempPath, JSON.stringify(state, null, 2) + "\n", "utf8"); + await rename(tempPath, filePath); +} + +export function pruneExpiredPairings(state: AccessState, now: number = Date.now()): AccessState { + const pendingPairings = Object.fromEntries( + Object.entries(state.pendingPairings).filter(([, pending]) => now - pending.createdAt < PAIRING_TTL_MS), + ); + return { ...state, pendingPairings }; +} + +export function evaluateDmAccess(state: AccessState, senderId: string): "allowed" | "pairing" | "blocked" { + const normalized = normalizeSenderId(senderId); + if (state.dmPolicy === "disabled") return "blocked"; + if (state.allowFrom.includes(normalized)) return "allowed"; + return state.dmPolicy === "pairing" ? "pairing" : "blocked"; +} + +export function evaluateGroupAccess( + state: AccessState, + groupId: string, + senderId: string, +): { enabled: boolean; requireMention: boolean; senderAllowed: boolean } { + const normalizedGroupId = normalizeGroupId(groupId); + const normalizedSenderId = normalizeSenderId(senderId); + const group = state.groups[normalizedGroupId]; + if (!group) { + return { enabled: false, requireMention: true, senderAllowed: false }; + } + const senderAllowed = group.allowFrom.length === 0 || group.allowFrom.includes(normalizedSenderId); + return { + enabled: true, + requireMention: group.requireMention !== false, + senderAllowed, + }; +} + +export function ensurePendingPairing( + state: AccessState, + senderId: string, + chatId: string, + now: number = Date.now(), +): { state: AccessState; pairing: PendingPairing } { + const normalizedSenderId = normalizeSenderId(senderId); + const normalizedChatId = normalizeGroupId(chatId); + const existing = Object.values(state.pendingPairings).find( + (pending) => + pending.senderId === normalizedSenderId && + pending.chatId === normalizedChatId && + now - pending.createdAt < PAIRING_TTL_MS, + ); + if (existing) { + return { state, pairing: existing }; + } + const code = randomBytes(3).toString("hex"); + const pairing: PendingPairing = { + code, + senderId: normalizedSenderId, + chatId: normalizedChatId, + createdAt: now, + }; + return { + state: { + ...state, + pendingPairings: { + ...state.pendingPairings, + [code]: pairing, + }, + }, + pairing, + }; +} + +export function approvePairing( + state: AccessState, + code: string, +): { state: AccessState; pairing: PendingPairing | null } { + const normalizedCode = code.trim().toLowerCase(); + const pairing = state.pendingPairings[normalizedCode] ?? null; + if (!pairing) { + return { state, pairing: null }; + } + const pendingPairings = { ...state.pendingPairings }; + delete pendingPairings[normalizedCode]; + return { + state: { + ...state, + allowFrom: Array.from(new Set([...state.allowFrom, pairing.senderId])).sort(), + pendingPairings, + }, + pairing, + }; +} + +export function denyPairing( + state: AccessState, + code: string, +): { state: AccessState; pairing: PendingPairing | null } { + const normalizedCode = code.trim().toLowerCase(); + const pairing = state.pendingPairings[normalizedCode] ?? null; + if (!pairing) { + return { state, pairing: null }; + } + const pendingPairings = { ...state.pendingPairings }; + delete pendingPairings[normalizedCode]; + return { state: { ...state, pendingPairings }, pairing }; +} + +export function setDmPolicy(state: AccessState, dmPolicy: DmPolicy): AccessState { + return { ...state, dmPolicy }; +} + +export function allowSender(state: AccessState, senderId: string): AccessState { + const normalized = normalizeSenderId(senderId); + return { + ...state, + allowFrom: Array.from(new Set([...state.allowFrom, normalized])).sort(), + }; +} + +export function removeSender(state: AccessState, senderId: string): AccessState { + const normalized = normalizeSenderId(senderId); + return { + ...state, + allowFrom: state.allowFrom.filter((entry) => entry !== normalized), + }; +} + +export function enableGroup( + state: AccessState, + groupId: string, + options: Partial = {}, +): AccessState { + const normalizedGroupId = normalizeGroupId(groupId); + return { + ...state, + groups: { + ...state.groups, + [normalizedGroupId]: { + requireMention: options.requireMention !== false, + allowFrom: (options.allowFrom ?? []).map((entry) => normalizeSenderId(entry)).filter(Boolean), + }, + }, + }; +} + +export function disableGroup(state: AccessState, groupId: string): AccessState { + const normalizedGroupId = normalizeGroupId(groupId); + const groups = { ...state.groups }; + delete groups[normalizedGroupId]; + return { ...state, groups }; +} diff --git a/pikachat-claude/src/channel-runtime.test.ts b/pikachat-claude/src/channel-runtime.test.ts new file mode 100644 index 000000000..9d04a6cd0 --- /dev/null +++ b/pikachat-claude/src/channel-runtime.test.ts @@ -0,0 +1,335 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, it } from "node:test"; + +import { createInMemoryChannelForTests } from "./channel-runtime.js"; +import type { PikachatDaemonEventHandler, PikachatDaemonOutMsg } from "./daemon-protocol.js"; + +class FakeDaemon { + handler: PikachatDaemonEventHandler | null = null; + sentMessages: Array<{ chatId: string; content: string }> = []; + sentReactions: Array<{ chatId: string; eventId: string; emoji: string }> = []; + mediaBatches: Array<{ chatId: string; files: string[] }> = []; + ready = { type: "ready", protocol_version: 1, pubkey: "botpub", npub: "npub1bot" } as const; + memberCountByGroup = new Map(); + failSetRelays = false; + shutdownCalls = 0; + + onEvent(handler: PikachatDaemonEventHandler): void { + this.handler = handler; + } + + async waitForReady() { + return this.ready; + } + + async waitForExit() { + return await new Promise(() => {}); + } + + async setRelays() { + if (this.failSetRelays) { + throw new Error("set_relays failed"); + } + } + + async publishKeypackage() {} + + async listGroups() { + return { + groups: [...this.memberCountByGroup.entries()].map(([nostr_group_id, member_count]) => ({ + nostr_group_id, + member_count, + })), + }; + } + + async listMembers(nostrGroupId: string) { + return { member_count: this.memberCountByGroup.get(nostrGroupId) ?? 0 }; + } + + async listPendingWelcomes() { + return { welcomes: [] }; + } + + async acceptWelcome() {} + + async sendMessage(chatId: string, content: string) { + this.sentMessages.push({ chatId, content }); + return { event_id: `event-${this.sentMessages.length}` }; + } + + async sendReaction(chatId: string, eventId: string, emoji: string) { + this.sentReactions.push({ chatId, eventId, emoji }); + return { event_id: "reaction-event" }; + } + + async sendMedia(chatId: string, filePath: string) { + this.mediaBatches.push({ chatId, files: [filePath] }); + return { event_id: `media-${this.mediaBatches.length}` }; + } + + async sendMediaBatch(chatId: string, filePaths: string[]) { + this.mediaBatches.push({ chatId, files: [...filePaths] }); + return { event_id: `media-batch-${this.mediaBatches.length}` }; + } + + async sendTyping() {} + + async getMessages() { + return { messages: [] }; + } + + async shutdown() { + this.shutdownCalls += 1; + } + + pid() { + return 1234; + } + + async emit(event: PikachatDaemonOutMsg): Promise { + if (!this.handler) { + throw new Error("missing event handler"); + } + await this.handler(event); + } +} + +describe("PikachatClaudeChannel", () => { + it("pairs unknown DM senders instead of delivering them", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "pikachat-claude-")); + const daemon = new FakeDaemon(); + daemon.memberCountByGroup.set("dm1", 2); + const notifications: Array<{ content: string; meta: Record }> = []; + const channel = createInMemoryChannelForTests({ + daemon, + onNotification: (notification) => { + notifications.push(notification); + }, + config: { + channelHome: tempDir, + accessFile: path.join(tempDir, "access.json"), + inboxDir: path.join(tempDir, "inbox"), + }, + }); + try { + await channel.start(); + await daemon.emit({ + type: "message_received", + nostr_group_id: "dm1", + from_pubkey: "sender1", + content: "hello", + kind: 9, + created_at: 1, + event_id: "ev1", + message_id: "msg1", + }); + assert.equal(notifications.length, 0); + assert.equal(daemon.sentMessages.length, 1); + assert.match(daemon.sentMessages[0].content, /Pairing code:/); + } finally { + await channel.stop(); + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it("preserves concurrent pending pairings", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "pikachat-claude-")); + const accessFile = path.join(tempDir, "access.json"); + const daemon = new FakeDaemon(); + daemon.memberCountByGroup.set("dm1", 2); + daemon.memberCountByGroup.set("dm2", 2); + const channel = createInMemoryChannelForTests({ + daemon, + config: { + channelHome: tempDir, + accessFile, + inboxDir: path.join(tempDir, "inbox"), + }, + }); + try { + await channel.start(); + await Promise.all([ + daemon.emit({ + type: "message_received", + nostr_group_id: "dm1", + from_pubkey: "sender1", + content: "hello one", + kind: 9, + created_at: 1, + event_id: "ev1", + message_id: "msg1", + }), + daemon.emit({ + type: "message_received", + nostr_group_id: "dm2", + from_pubkey: "sender2", + content: "hello two", + kind: 9, + created_at: 2, + event_id: "ev2", + message_id: "msg2", + }), + ]); + + const persisted = JSON.parse(await readFile(accessFile, "utf8")) as { + pendingPairings: Record; + }; + const senderIds = Object.values(persisted.pendingPairings) + .map((pending) => pending.senderId) + .sort(); + + assert.deepEqual(senderIds, ["sender1", "sender2"]); + assert.equal(daemon.sentMessages.length, 2); + } finally { + await channel.stop(); + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it("delivers allowlisted DMs", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "pikachat-claude-")); + const daemon = new FakeDaemon(); + daemon.memberCountByGroup.set("dm1", 2); + const notifications: Array<{ content: string; meta: Record }> = []; + const channel = createInMemoryChannelForTests({ + daemon, + onNotification: (notification) => { + notifications.push(notification); + }, + config: { + channelHome: tempDir, + accessFile: path.join(tempDir, "access.json"), + inboxDir: path.join(tempDir, "inbox"), + }, + }); + try { + await channel.start(); + await channel.allowSender("sender1"); + await daemon.emit({ + type: "message_received", + nostr_group_id: "dm1", + from_pubkey: "sender1", + content: "hello", + kind: 9, + created_at: 1, + event_id: "ev1", + message_id: "msg1", + media: [{ + filename: "pic.jpg", + mime_type: "image/jpeg", + url: "https://example.com/pic.jpg", + original_hash_hex: "abc123def456", + nonce_hex: "fed654cba321", + scheme_version: "1", + local_path: "/tmp/pic.jpg", + }], + }); + assert.equal(notifications.length, 1); + assert.equal(notifications[0].meta.chat_type, "direct"); + assert.match(notifications[0].content, /\[Attachment: pic\.jpg/); + } finally { + await channel.stop(); + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it("enforces group mention gating", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "pikachat-claude-")); + const daemon = new FakeDaemon(); + daemon.memberCountByGroup.set("group1", 3); + const notifications: Array<{ content: string; meta: Record }> = []; + const channel = createInMemoryChannelForTests({ + daemon, + onNotification: (notification) => { + notifications.push(notification); + }, + config: { + channelHome: tempDir, + accessFile: path.join(tempDir, "access.json"), + inboxDir: path.join(tempDir, "inbox"), + }, + }); + try { + await channel.start(); + await channel.enableGroup("group1", true); + await daemon.emit({ + type: "message_received", + nostr_group_id: "group1", + from_pubkey: "sender1", + content: "plain message", + kind: 9, + created_at: 1, + event_id: "ev1", + message_id: "msg1", + }); + assert.equal(notifications.length, 0); + await daemon.emit({ + type: "message_received", + nostr_group_id: "group1", + from_pubkey: "sender1", + content: "hello npub1bot", + kind: 9, + created_at: 1, + event_id: "ev2", + message_id: "msg2", + }); + assert.equal(notifications.length, 1); + assert.equal(notifications[0].meta.chat_type, "group"); + assert.equal(notifications[0].meta.mentioned, "true"); + } finally { + await channel.stop(); + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it("cleans up the daemon when startup fails", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "pikachat-claude-")); + const daemon = new FakeDaemon(); + daemon.failSetRelays = true; + const channel = createInMemoryChannelForTests({ + daemon, + config: { + channelHome: tempDir, + accessFile: path.join(tempDir, "access.json"), + inboxDir: path.join(tempDir, "inbox"), + }, + }); + try { + await assert.rejects(() => channel.start(), /set_relays failed/); + assert.equal(daemon.shutdownCalls, 1); + } finally { + await channel.stop(); + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it("returns media event ids for outbound attachments", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "pikachat-claude-")); + const inboxDir = path.join(tempDir, "inbox"); + await mkdir(inboxDir, { recursive: true }); + const testFile = path.join(tempDir, "test.txt"); + await writeFile(testFile, "ok"); + + const daemon = new FakeDaemon(); + const channel = createInMemoryChannelForTests({ + daemon, + config: { + channelHome: tempDir, + accessFile: path.join(tempDir, "access.json"), + inboxDir, + }, + }); + try { + await channel.start(); + const result = await channel.reply({ chatId: "dm1", files: [testFile] }); + assert.deepEqual(result.eventIds, ["media-1"]); + } finally { + await channel.stop(); + await rm(tempDir, { recursive: true, force: true }); + } + }); +}); diff --git a/pikachat-claude/src/channel-runtime.ts b/pikachat-claude/src/channel-runtime.ts new file mode 100644 index 000000000..6435a5f6c --- /dev/null +++ b/pikachat-claude/src/channel-runtime.ts @@ -0,0 +1,500 @@ +import { access, mkdir, realpath } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { + allowSender, + approvePairing, + defaultAccessState, + denyPairing, + disableGroup, + enableGroup, + evaluateDmAccess, + evaluateGroupAccess, + ensurePendingPairing, + loadAccessState, + pruneExpiredPairings, + removeSender, + saveAccessState, + setDmPolicy, + type AccessState, + type DmPolicy, +} from "./access.js"; +import type { PikachatClaudeConfig } from "./config.js"; +import { PikachatDaemonClient, type PikachatDaemonLike, type PikachatLogger } from "./daemon-client.js"; +import { buildPikachatDaemonLaunchSpec } from "./daemon-launch.js"; +import type { PikachatDaemonOutMsg } from "./daemon-protocol.js"; +import { augmentMessageText, detectMention, sanitizeMeta } from "./message-format.js"; + +export type ChannelNotification = { + content: string; + meta: Record; +}; + +export type ChannelStatus = { + botPubkey: string | null; + botNpub: string | null; + knownGroups: string[]; +}; + +export type ReplyRequest = { + chatId: string; + text?: string; + replyTo?: string; + files?: string[]; +}; + +export type ReactRequest = { + chatId: string; + eventId: string; + emoji: string; +}; + +export type ChannelRuntimeDeps = { + daemonFactory?: (params: { + cmd: string; + args: string[]; + env?: NodeJS.ProcessEnv; + logger?: PikachatLogger; + }) => PikachatDaemonLike; + now?: () => number; +}; + +type DirectAccessResult = + | { decision: "allowed" | "blocked"; pairingCode?: never } + | { decision: "pairing"; pairingCode: string }; + +export class PikachatClaudeChannel { + #config: PikachatClaudeConfig; + #logger: PikachatLogger | undefined; + #onNotification: ((notification: ChannelNotification) => void | Promise) | undefined; + #deps: Required; + #daemon: PikachatDaemonLike | null = null; + #daemonLaunchAutoAccept = false; + #botPubkey: string | null = null; + #botNpub: string | null = null; + #memberCounts = new Map(); + #accessLock: Promise = Promise.resolve(); + + constructor(params: { + config: PikachatClaudeConfig; + logger?: PikachatLogger; + onNotification?: (notification: ChannelNotification) => void | Promise; + deps?: ChannelRuntimeDeps; + }) { + this.#config = params.config; + this.#logger = params.logger; + this.#onNotification = params.onNotification; + this.#deps = { + daemonFactory: + params.deps?.daemonFactory ?? + ((spawnParams) => new PikachatDaemonClient(spawnParams)), + now: params.deps?.now ?? (() => Date.now()), + }; + } + + status(): ChannelStatus { + return { + botPubkey: this.#botPubkey, + botNpub: this.#botNpub, + knownGroups: [...this.#memberCounts.keys()].sort(), + }; + } + + async start(): Promise { + if (this.#daemon) { + return; + } + if (this.#config.relays.length === 0) { + throw new Error("PIKACHAT_RELAYS is required"); + } + + await mkdir(this.#config.channelHome, { recursive: true }); + await mkdir(this.#config.inboxDir, { recursive: true }); + const initialState = pruneExpiredPairings(await loadAccessState(this.#config.accessFile), this.#deps.now()); + await saveAccessState(this.#config.accessFile, initialState); + + const launch = await buildPikachatDaemonLaunchSpec({ + config: this.#config, + log: this.#logger, + }); + this.#daemonLaunchAutoAccept = launch.autoAcceptWelcomes; + const daemon = this.#deps.daemonFactory({ + cmd: launch.cmd, + args: launch.args, + logger: this.#logger, + }); + daemon.onEvent(async (event) => { + await this.#handleDaemonEvent(event); + }); + this.#daemon = daemon; + + try { + const ready = await daemon.waitForReady(15_000); + this.#botPubkey = ready.pubkey.toLowerCase(); + this.#botNpub = ready.npub; + this.#logger?.info?.( + `[pikachat-claude] daemon ready pubkey=${this.#botPubkey} npub=${this.#botNpub} pid=${daemon.pid() ?? "unknown"}`, + ); + + daemon.waitForExit().then(() => { + if (this.#daemon === daemon) { + this.#daemon = null; + } + }); + + await daemon.setRelays(this.#config.relays); + await daemon.publishKeypackage(this.#config.relays); + await this.#seedKnownGroups(); + } catch (err) { + if (this.#daemon === daemon) { + this.#daemon = null; + } + this.#botPubkey = null; + this.#botNpub = null; + this.#memberCounts.clear(); + try { + await daemon.shutdown(); + } catch (shutdownErr) { + this.#logger?.warn?.( + `[pikachat-claude] failed to stop daemon after startup error: ${shutdownErr instanceof Error ? shutdownErr.message : String(shutdownErr)}`, + ); + } + throw err; + } + } + + async stop(): Promise { + if (!this.#daemon) return; + const daemon = this.#daemon; + this.#daemon = null; + await daemon.shutdown(); + } + + async reply(request: ReplyRequest): Promise<{ eventIds: string[]; notes: string[] }> { + const daemon = this.#requireDaemon(); + const notes: string[] = []; + const eventIds: string[] = []; + const text = request.text?.trim() ?? ""; + const files = await this.#resolveOutboundFiles(request.files ?? []); + + if (!text && files.length === 0) { + throw new Error("reply requires text or files"); + } + + if (request.replyTo?.trim()) { + notes.push("reply_to is not yet wired through the daemon; sent as a normal message"); + } + + if (text) { + const result = await daemon.sendMessage(request.chatId, text); + if (result.event_id) eventIds.push(result.event_id); + } + + if (files.length > 0) { + if (files.length === 1) { + const result = await daemon.sendMedia(request.chatId, files[0]); + if (result.event_id) eventIds.push(result.event_id); + } else { + const result = await daemon.sendMediaBatch(request.chatId, files); + if (result.event_id) eventIds.push(result.event_id); + } + } + + return { eventIds, notes }; + } + + async react(request: ReactRequest): Promise<{ event_id?: string }> { + const daemon = this.#requireDaemon(); + return await daemon.sendReaction(request.chatId, request.eventId, request.emoji); + } + + async accessStatus(): Promise { + return await this.#withAccessLock(async () => await this.#loadAccessState()); + } + + async approvePairing(code: string): Promise<{ senderId: string | null }> { + return await this.#withAccessState(async (state) => { + const approved = approvePairing(state, code); + return { + state: approved.state, + result: { senderId: approved.pairing?.senderId ?? null }, + }; + }); + } + + async denyPairing(code: string): Promise<{ senderId: string | null }> { + return await this.#withAccessState(async (state) => { + const denied = denyPairing(state, code); + return { + state: denied.state, + result: { senderId: denied.pairing?.senderId ?? null }, + }; + }); + } + + async setDmPolicy(dmPolicy: DmPolicy): Promise { + return await this.#withAccessState(async (state) => { + const next = setDmPolicy(state, dmPolicy); + return { state: next, result: next }; + }); + } + + async allowSender(senderId: string): Promise { + return await this.#withAccessState(async (state) => { + const next = allowSender(state, senderId); + return { state: next, result: next }; + }); + } + + async removeSender(senderId: string): Promise { + return await this.#withAccessState(async (state) => { + const next = removeSender(state, senderId); + return { state: next, result: next }; + }); + } + + async enableGroup(groupId: string, requireMention = true, allowFrom: string[] = []): Promise { + return await this.#withAccessState(async (state) => { + const next = enableGroup(state, groupId, { requireMention, allowFrom }); + return { state: next, result: next }; + }); + } + + async disableGroup(groupId: string): Promise { + return await this.#withAccessState(async (state) => { + const next = disableGroup(state, groupId); + return { state: next, result: next }; + }); + } + + async #resolveOutboundFiles(files: string[]): Promise { + if (files.length === 0) { + return []; + } + + const resolvedFiles: string[] = []; + for (const file of files) { + await access(file); + resolvedFiles.push(await realpath(file)); + } + return resolvedFiles; + } + + async #seedKnownGroups(): Promise { + const daemon = this.#requireDaemon(); + try { + const result = (await daemon.listGroups()) as { groups?: Array<{ nostr_group_id?: string; member_count?: number }> }; + for (const group of result.groups ?? []) { + const groupId = String(group.nostr_group_id ?? "").trim().toLowerCase(); + if (!groupId) continue; + this.#memberCounts.set(groupId, Number(group.member_count ?? 0)); + } + } catch (err) { + this.#logger?.warn?.(`[pikachat-claude] failed to seed groups: ${err}`); + } + } + + async #handleDaemonEvent(event: PikachatDaemonOutMsg): Promise { + if (!this.#daemon) return; + switch (event.type) { + case "welcome_received": + if (this.#config.autoAcceptWelcomes && !this.#daemonLaunchAutoAccept) { + try { + await this.#daemon.acceptWelcome(event.wrapper_event_id); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (!message.includes("not_found")) { + throw err; + } + } + } + return; + case "group_joined": + this.#memberCounts.set(event.nostr_group_id.toLowerCase(), event.member_count); + return; + case "group_created": + this.#memberCounts.set(event.nostr_group_id.toLowerCase(), event.member_count); + return; + case "group_updated": + if (typeof event.update.member_count === "number") { + this.#memberCounts.set(event.update.nostr_group_id.toLowerCase(), event.update.member_count); + } + return; + case "message_received": + await this.#handleInboundMessage(event); + return; + default: + return; + } + } + + async #handleInboundMessage(event: Extract): Promise { + if (!this.#botPubkey || !this.#botNpub) return; + const senderId = event.from_pubkey.trim().toLowerCase(); + if (senderId === this.#botPubkey) { + return; + } + + const chatId = event.nostr_group_id.trim().toLowerCase(); + const chatType = await this.#resolveChatType(chatId); + const messageText = augmentMessageText(event.content, event.media ?? []); + + if (chatType === "direct") { + const directAccess = await this.#withAccessState(async (state) => { + const decision = evaluateDmAccess(state, senderId); + if (decision !== "pairing") { + return { state, result: { decision } }; + } + const ensured = ensurePendingPairing(state, senderId, chatId, this.#deps.now()); + return { + state: ensured.state, + result: { decision, pairingCode: ensured.pairing.code }, + }; + }); + if (directAccess.decision === "allowed") { + await this.#emitNotification({ + content: messageText, + meta: sanitizeMeta({ + chat_id: chatId, + sender_id: senderId, + sender_name: senderId, + message_id: event.message_id, + event_id: event.event_id, + chat_type: "direct", + mentioned: "true", + has_attachments: String(Boolean(event.media?.length)), + }), + }); + return; + } + if (directAccess.decision === "pairing" && directAccess.pairingCode) { + await this.#daemon!.sendMessage( + chatId, + `Pairing code: ${directAccess.pairingCode}\nApprove it from Claude with the approve_pairing tool.`, + ); + } + return; + } + + const state = await this.accessStatus(); + const groupDecision = evaluateGroupAccess(state, chatId, senderId); + if (!groupDecision.enabled || !groupDecision.senderAllowed) { + return; + } + const mentioned = detectMention({ + text: messageText, + botPubkey: this.#botPubkey, + botNpub: this.#botNpub, + mentionPatterns: state.mentionPatterns, + }); + if (groupDecision.requireMention && !mentioned) { + return; + } + + await this.#emitNotification({ + content: messageText, + meta: sanitizeMeta({ + chat_id: chatId, + sender_id: senderId, + sender_name: senderId, + message_id: event.message_id, + event_id: event.event_id, + chat_type: "group", + group_name: chatId, + mentioned: String(mentioned), + has_attachments: String(Boolean(event.media?.length)), + }), + }); + } + + async #resolveChatType(chatId: string): Promise<"direct" | "group"> { + const cached = this.#memberCounts.get(chatId); + if (cached !== undefined) { + return cached <= 2 ? "direct" : "group"; + } + try { + const daemon = this.#requireDaemon(); + const members = (await daemon.listMembers(chatId)) as { member_count?: number }; + const memberCount = Number(members.member_count ?? 0); + if (memberCount > 0) { + this.#memberCounts.set(chatId, memberCount); + } + return memberCount <= 2 ? "direct" : "group"; + } catch { + return "group"; + } + } + + async #emitNotification(notification: ChannelNotification): Promise { + if (!this.#onNotification) return; + await this.#onNotification(notification); + } + + async #withAccessState( + fn: (state: AccessState) => Promise<{ state: AccessState; result: T }> | { state: AccessState; result: T }, + ): Promise { + return await this.#withAccessLock(async () => { + const state = await this.#loadAccessState(); + const { state: nextState, result } = await fn(state); + if (nextState !== state) { + await saveAccessState(this.#config.accessFile, nextState); + } + return result; + }); + } + + async #withAccessLock(fn: () => Promise): Promise { + const previous = this.#accessLock; + let release!: () => void; + this.#accessLock = new Promise((resolve) => { + release = resolve; + }); + await previous; + try { + return await fn(); + } finally { + release(); + } + } + + async #loadAccessState(): Promise { + return pruneExpiredPairings(await loadAccessState(this.#config.accessFile), this.#deps.now()); + } + + #requireDaemon(): PikachatDaemonLike { + if (!this.#daemon) { + throw new Error("pikachat daemon is not running"); + } + return this.#daemon; + } +} + +export function createInMemoryChannelForTests(params: { + config?: Partial; + daemon: PikachatDaemonLike; + onNotification?: (notification: ChannelNotification) => void | Promise; + now?: () => number; +}): PikachatClaudeChannel { + const baseDir = path.join(os.tmpdir(), "pikachat-claude-test"); + const config: PikachatClaudeConfig = { + relays: ["ws://127.0.0.1:18080"], + daemonBackend: "native", + daemonCmd: process.execPath, + daemonArgs: ["daemon"], + autoAcceptWelcomes: true, + channelSource: "pikachat", + channelHome: baseDir, + accessFile: path.join(baseDir, "access.json"), + inboxDir: path.join(baseDir, "inbox"), + ...params.config, + }; + return new PikachatClaudeChannel({ + config, + onNotification: params.onNotification, + deps: { + daemonFactory: () => params.daemon, + now: params.now, + }, + }); +} diff --git a/pikachat-claude/src/config.test.ts b/pikachat-claude/src/config.test.ts new file mode 100644 index 000000000..ad43bd521 --- /dev/null +++ b/pikachat-claude/src/config.test.ts @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import os from "node:os"; +import path from "node:path"; +import { describe, it } from "node:test"; + +import { defaultPikachatRelays, resolvePikachatClaudeConfig } from "./config.js"; + +describe("resolvePikachatClaudeConfig", () => { + it("falls back to the default pikachat relay profile", () => { + const config = resolvePikachatClaudeConfig({}); + assert.deepEqual(config.relays, defaultPikachatRelays()); + }); + + it("prefers explicit relay configuration", () => { + const config = resolvePikachatClaudeConfig({ + PIKACHAT_RELAYS: '["wss://example.com","wss://example.org"]', + }); + assert.deepEqual(config.relays, ["wss://example.com", "wss://example.org"]); + }); + + it("resolves channel home and derived paths", () => { + const config = resolvePikachatClaudeConfig({ + PIKACHAT_CLAUDE_HOME: "~/tmp/pikachat-claude-home", + }); + const expectedHome = path.join(os.homedir(), "tmp", "pikachat-claude-home"); + assert.equal(config.channelHome, expectedHome); + assert.equal(config.accessFile, path.join(expectedHome, "access.json")); + assert.equal(config.inboxDir, path.join(expectedHome, "inbox")); + }); + + it("parses autoAcceptWelcomes booleans", () => { + assert.equal(resolvePikachatClaudeConfig({ PIKACHAT_AUTO_ACCEPT_WELCOMES: "true" }).autoAcceptWelcomes, true); + assert.equal(resolvePikachatClaudeConfig({ PIKACHAT_AUTO_ACCEPT_WELCOMES: "false" }).autoAcceptWelcomes, false); + }); + + it("parses daemon args arrays", () => { + const config = resolvePikachatClaudeConfig({ + PIKACHAT_DAEMON_ARGS: '["daemon","--verbose"]', + }); + assert.deepEqual(config.daemonArgs, ["daemon", "--verbose"]); + }); +}); diff --git a/pikachat-claude/src/config.ts b/pikachat-claude/src/config.ts new file mode 100644 index 000000000..87e346f2d --- /dev/null +++ b/pikachat-claude/src/config.ts @@ -0,0 +1,91 @@ +import os from "node:os"; +import path from "node:path"; + +const DEFAULT_MESSAGE_RELAYS = [ + "wss://relay.primal.net", + "wss://nos.lol", + "wss://relay.damus.io", + "wss://us-east.nostr.pikachat.org", + "wss://eu.nostr.pikachat.org", +]; + +export type PikachatClaudeConfig = { + relays: string[]; + stateDir?: string; + daemonCmd?: string; + daemonArgs?: string[]; + daemonVersion?: string; + daemonBackend: "native" | "acp"; + daemonAcpExec?: string; + daemonAcpCwd?: string; + autoAcceptWelcomes: boolean; + channelSource: string; + channelHome: string; + accessFile: string; + inboxDir: string; +}; + +function parseBoolean(value: string | undefined, fallback: boolean): boolean { + if (value === undefined) return fallback; + const normalized = value.trim().toLowerCase(); + if (!normalized) return fallback; + return !["0", "false", "no", "off"].includes(normalized); +} + +function parseStringArray(value: string | undefined): string[] { + if (!value) return []; + const trimmed = value.trim(); + if (!trimmed) return []; + try { + const parsed = JSON.parse(trimmed); + if (Array.isArray(parsed)) { + return parsed.map((entry) => String(entry).trim()).filter(Boolean); + } + } catch { + // fall through + } + return trimmed + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); +} + +function expandTilde(input: string): string { + if (input === "~" || input.startsWith("~/")) { + return path.join(os.homedir(), input.slice(1)); + } + return input; +} + +export function defaultClaudeChannelHome(): string { + return path.join(os.homedir(), ".claude", "channels", "pikachat"); +} + +export function defaultPikachatRelays(): string[] { + return [...DEFAULT_MESSAGE_RELAYS]; +} + +export function resolvePikachatClaudeConfig(env: NodeJS.ProcessEnv = process.env): PikachatClaudeConfig { + const channelHome = path.resolve( + expandTilde(env.PIKACHAT_CLAUDE_HOME?.trim() || defaultClaudeChannelHome()), + ); + const configuredRelays = parseStringArray(env.PIKACHAT_RELAYS); + return { + relays: configuredRelays.length > 0 ? configuredRelays : defaultPikachatRelays(), + stateDir: env.PIKACHAT_STATE_DIR?.trim() || undefined, + daemonCmd: env.PIKACHAT_DAEMON_CMD?.trim() || env.PIKACHAT_SIDECAR_CMD?.trim() || undefined, + daemonArgs: (() => { + const values = parseStringArray(env.PIKACHAT_DAEMON_ARGS?.trim() || env.PIKACHAT_SIDECAR_ARGS?.trim()); + return values.length > 0 ? values : undefined; + })(), + daemonVersion: env.PIKACHAT_DAEMON_VERSION?.trim() || env.PIKACHAT_SIDECAR_VERSION?.trim() || undefined, + daemonBackend: env.PIKACHAT_DAEMON_BACKEND === "acp" ? "acp" : "native", + daemonAcpExec: env.PIKACHAT_DAEMON_ACP_EXEC?.trim() || undefined, + daemonAcpCwd: env.PIKACHAT_DAEMON_ACP_CWD?.trim() || undefined, + autoAcceptWelcomes: parseBoolean(env.PIKACHAT_AUTO_ACCEPT_WELCOMES, true), + channelSource: env.PIKACHAT_CHANNEL_SOURCE?.trim() || "pikachat", + channelHome, + accessFile: path.join(channelHome, "access.json"), + inboxDir: path.join(channelHome, "inbox"), + }; +} diff --git a/pikachat-claude/src/daemon-client.test.ts b/pikachat-claude/src/daemon-client.test.ts new file mode 100644 index 000000000..0b949474c --- /dev/null +++ b/pikachat-claude/src/daemon-client.test.ts @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { SendThrottle } from "./daemon-client.js"; + +describe("SendThrottle", () => { + it("continues running queued sends after a failure", async () => { + const throttle = new SendThrottle(0); + let calls = 0; + + await assert.rejects( + () => + throttle.enqueue(async () => { + calls += 1; + throw new Error("boom"); + }), + /boom/, + ); + + await throttle.enqueue(async () => { + calls += 1; + }); + + assert.equal(calls, 2); + }); +}); diff --git a/pikachat-claude/src/daemon-client.ts b/pikachat-claude/src/daemon-client.ts new file mode 100644 index 000000000..2f9fbc415 --- /dev/null +++ b/pikachat-claude/src/daemon-client.ts @@ -0,0 +1,378 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { once } from "node:events"; +import readline from "node:readline"; +import type { + PikachatDaemonEventHandler, + PikachatDaemonInCmd, + PikachatDaemonOutMsg, +} from "./daemon-protocol.js"; + +export type PikachatLogger = { + debug?: (message: string) => void; + info?: (message: string) => void; + warn?: (message: string) => void; + error?: (message: string) => void; +}; + +export class SendThrottle { + #lastSendAt = 0; + #chain: Promise = Promise.resolve(); + #minIntervalMs: number; + #onError: (err: Error) => void; + + constructor(minIntervalMs = 1000, onError: (err: Error) => void = () => {}) { + this.#minIntervalMs = minIntervalMs; + this.#onError = onError; + } + + enqueue(fn: () => Promise): Promise { + const task = this.#chain.catch(() => undefined).then(async () => { + const now = Date.now(); + const elapsed = now - this.#lastSendAt; + if (elapsed < this.#minIntervalMs) { + await new Promise((resolve) => setTimeout(resolve, this.#minIntervalMs - elapsed)); + } + try { + await fn(); + this.#lastSendAt = Date.now(); + } catch (err) { + this.#onError(err as Error); + throw err; + } + }); + this.#chain = task.catch(() => undefined); + return task; + } +} + +type SendMediaResult = { + event_id?: string; + uploaded_url?: string; + uploaded_urls?: string[]; + original_hash_hex?: string; + original_hashes?: string[]; +}; + +export type DaemonSpawnParams = { + cmd: string; + args: string[]; + env?: NodeJS.ProcessEnv; + logger?: PikachatLogger; +}; + +export interface PikachatDaemonLike { + onEvent(handler: PikachatDaemonEventHandler): void; + waitForReady(timeoutMs?: number): Promise; + waitForExit(): Promise; + setRelays(relays: string[]): Promise; + publishKeypackage(relays: string[]): Promise; + listGroups(): Promise; + listMembers(nostrGroupId: string): Promise; + listPendingWelcomes(): Promise; + acceptWelcome(wrapperEventId: string): Promise; + sendMessage(nostrGroupId: string, content: string): Promise<{ event_id?: string }>; + sendReaction(nostrGroupId: string, eventId: string, emoji: string): Promise<{ event_id?: string }>; + sendMedia( + nostrGroupId: string, + filePath: string, + opts?: { mimeType?: string; filename?: string; caption?: string; blossomServers?: string[] }, + ): Promise; + sendMediaBatch( + nostrGroupId: string, + filePaths: string[], + opts?: { caption?: string; blossomServers?: string[] }, + ): Promise; + sendTyping(nostrGroupId: string): Promise; + getMessages(nostrGroupId: string, limit?: number): Promise; + shutdown(): Promise; + pid(): number | undefined; +} + +export class PikachatDaemonClient implements PikachatDaemonLike { + static readonly DEFAULT_REQUEST_TIMEOUT_MS = 30_000; + #proc: ChildProcessWithoutNullStreams; + #closed = false; + #readySeen = false; + #requestSeq = 0; + #pending = new Map< + string, + { + cmd: string; + resolve: (value: unknown) => void; + reject: (err: Error) => void; + startedAt: number; + timeoutId: NodeJS.Timeout; + } + >(); + #onEvent: PikachatDaemonEventHandler | null = null; + #readyResolve: ((msg: PikachatDaemonOutMsg & { type: "ready" }) => void) | null = null; + #readyReject: ((err: Error) => void) | null = null; + #readyPromise: Promise; + #exitResolve: (() => void) | null = null; + #exitPromise: Promise; + #stderrTail: string[] = []; + #logger: PikachatLogger | undefined; + #sendThrottle: SendThrottle; + #requestTimeoutMs = PikachatDaemonClient.DEFAULT_REQUEST_TIMEOUT_MS; + + constructor(params: DaemonSpawnParams) { + this.#logger = params.logger; + this.#sendThrottle = new SendThrottle(1000, (err) => { + this.#logger?.error?.(`[pikachat-claude] throttled send failed: ${err}`); + }); + this.#proc = spawn(params.cmd, params.args, { + stdio: ["pipe", "pipe", "pipe"], + env: { ...process.env, ...(params.env ?? {}) }, + }); + + const rl = readline.createInterface({ input: this.#proc.stdout }); + rl.on("line", (line: string) => { + void this.#handleLine(line).catch((err) => { + const message = err instanceof Error ? err.message : String(err); + this.#logger?.error?.(`[pikachat-claude] daemon event handling failed: ${message}`); + }); + }); + + this.#proc.stderr.on("data", (buf: Buffer) => { + const lines = String(buf).split(/\r?\n/).map((line) => line.trim()).filter(Boolean); + for (const line of lines) { + this.#stderrTail.push(line); + if (this.#stderrTail.length > 40) { + this.#stderrTail.shift(); + } + if (!this.#readySeen) { + this.#logger?.info?.(`[pikachat] ${line}`); + } else { + this.#logger?.debug?.(`[pikachat] ${line}`); + } + } + }); + + this.#readyPromise = new Promise((resolve, reject) => { + this.#readyResolve = resolve; + this.#readyReject = reject; + }); + this.#exitPromise = new Promise((resolve) => { + this.#exitResolve = resolve; + }); + + this.#proc.on("exit", (code: number | null, signal: NodeJS.Signals | null) => { + this.#closed = true; + if ((code ?? 0) !== 0 && this.#stderrTail.length > 0) { + this.#logger?.error?.( + `[pikachat-claude] daemon exited with recent stderr=${JSON.stringify(this.#stderrTail.slice(-12))}`, + ); + } + const err = new Error(`pikachat daemon exited code=${code ?? "null"} signal=${signal ?? "null"}`); + for (const { reject, timeoutId } of this.#pending.values()) { + clearTimeout(timeoutId); + reject(err); + } + this.#pending.clear(); + this.#readyReject?.(err); + this.#readyResolve = null; + this.#readyReject = null; + this.#exitResolve?.(); + this.#exitResolve = null; + }); + } + + onEvent(handler: PikachatDaemonEventHandler): void { + this.#onEvent = handler; + } + + pid(): number | undefined { + return this.#proc.pid; + } + + waitForExit(): Promise { + if (this.#closed) return Promise.resolve(); + return this.#exitPromise; + } + + async waitForReady(timeoutMs = 10_000): Promise { + if (this.#closed) { + throw new Error("daemon already closed"); + } + const timeoutPromise = new Promise((_resolve, reject) => { + const timer = setTimeout(() => reject(new Error("timeout waiting for daemon ready")), timeoutMs); + (timer as any).unref?.(); + }); + const exitPromise = once(this.#proc, "exit").then(() => { + throw new Error("daemon exited before ready"); + }); + return await Promise.race([this.#readyPromise, timeoutPromise, exitPromise]); + } + + async request(cmd: Omit): Promise { + if (this.#closed) { + throw new Error("daemon is closed"); + } + const requestId = `r${Date.now()}_${++this.#requestSeq}`; + const payload = { ...cmd, request_id: requestId } as PikachatDaemonInCmd; + const line = JSON.stringify(payload); + const startedAt = Date.now(); + const cmdName = String((cmd as { cmd?: string }).cmd ?? "unknown"); + const promise = new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + if (!this.#pending.delete(requestId)) { + return; + } + reject(new Error(`request timeout cmd=${cmdName} request_id=${requestId}`)); + }, this.#requestTimeoutMs); + (timeoutId as any).unref?.(); + this.#pending.set(requestId, { cmd: cmdName, resolve, reject, startedAt, timeoutId }); + }); + this.#proc.stdin.write(`${line}\n`); + return await promise; + } + + async publishKeypackage(relays: string[]): Promise { + await this.request({ cmd: "publish_keypackage", relays } as any); + } + + async setRelays(relays: string[]): Promise { + await this.request({ cmd: "set_relays", relays } as any); + } + + async listPendingWelcomes(): Promise { + return await this.request({ cmd: "list_pending_welcomes" } as const); + } + + async acceptWelcome(wrapperEventId: string): Promise { + await this.request({ cmd: "accept_welcome", wrapper_event_id: wrapperEventId } as any); + } + + async listGroups(): Promise { + return await this.request({ cmd: "list_groups" } as const); + } + + async listMembers(nostrGroupId: string): Promise { + return await this.request({ cmd: "list_members", nostr_group_id: nostrGroupId } as any); + } + + async sendMessage(nostrGroupId: string, content: string): Promise<{ event_id?: string }> { + let result: unknown; + await this.#sendThrottle.enqueue(async () => { + result = await this.request({ cmd: "send_message", nostr_group_id: nostrGroupId, content } as any); + }); + return (result as { event_id?: string }) ?? {}; + } + + async sendReaction(nostrGroupId: string, eventId: string, emoji: string): Promise<{ event_id?: string }> { + let result: unknown; + await this.#sendThrottle.enqueue(async () => { + result = await this.request({ + cmd: "react", + nostr_group_id: nostrGroupId, + event_id: eventId, + emoji, + } as any); + }); + return (result as { event_id?: string }) ?? {}; + } + + async sendMedia( + nostrGroupId: string, + filePath: string, + opts?: { mimeType?: string; filename?: string; caption?: string; blossomServers?: string[] }, + ): Promise { + let result: unknown; + await this.#sendThrottle.enqueue(async () => { + result = await this.request({ + cmd: "send_media", + nostr_group_id: nostrGroupId, + file_path: filePath, + mime_type: opts?.mimeType, + filename: opts?.filename, + caption: opts?.caption, + blossom_servers: opts?.blossomServers, + } as any); + }); + return (result as SendMediaResult) ?? {}; + } + + async sendMediaBatch( + nostrGroupId: string, + filePaths: string[], + opts?: { caption?: string; blossomServers?: string[] }, + ): Promise { + let result: unknown; + await this.#sendThrottle.enqueue(async () => { + result = await this.request({ + cmd: "send_media_batch", + nostr_group_id: nostrGroupId, + file_paths: filePaths, + caption: opts?.caption, + blossom_servers: opts?.blossomServers, + } as any); + }); + return (result as SendMediaResult) ?? {}; + } + + async sendTyping(nostrGroupId: string): Promise { + await this.request({ cmd: "send_typing", nostr_group_id: nostrGroupId } as any); + } + + async getMessages(nostrGroupId: string, limit = 50): Promise { + return await this.request({ cmd: "get_messages", nostr_group_id: nostrGroupId, limit } as any); + } + + async shutdown(): Promise { + if (this.#closed) return; + try { + await this.request({ cmd: "shutdown" } as const); + } catch { + // ignore + } + this.#proc.kill("SIGTERM"); + } + + async #handleLine(line: string): Promise { + const trimmed = line.trim(); + if (!trimmed) return; + let msg: PikachatDaemonOutMsg; + try { + msg = JSON.parse(trimmed) as PikachatDaemonOutMsg; + } catch { + this.#logger?.warn?.(`[pikachat-claude] invalid JSON from daemon: ${trimmed}`); + return; + } + + if (msg.type === "ready") { + this.#readySeen = true; + this.#readyResolve?.(msg); + this.#readyResolve = null; + this.#readyReject = null; + return; + } + + if (msg.type === "ok" || msg.type === "error") { + const requestId = (msg as { request_id?: string | null }).request_id; + if (typeof requestId === "string" && requestId) { + const pending = this.#pending.get(requestId); + if (pending) { + this.#pending.delete(requestId); + clearTimeout(pending.timeoutId); + const elapsedMs = Date.now() - pending.startedAt; + if (msg.type === "ok") { + this.#logger?.debug?.( + `[pikachat-claude] request ok cmd=${pending.cmd} request_id=${requestId} elapsed_ms=${elapsedMs}`, + ); + pending.resolve((msg as { result?: unknown }).result ?? null); + } else { + this.#logger?.warn?.( + `[pikachat-claude] request error cmd=${pending.cmd} request_id=${requestId} elapsed_ms=${elapsedMs} code=${msg.code}`, + ); + pending.reject(new Error(`${msg.code}: ${msg.message}`)); + } + return; + } + } + } + + if (this.#onEvent) { + await this.#onEvent(msg); + } + } +} diff --git a/pikachat-claude/src/daemon-install.ts b/pikachat-claude/src/daemon-install.ts new file mode 100644 index 000000000..3aa65d7e2 --- /dev/null +++ b/pikachat-claude/src/daemon-install.ts @@ -0,0 +1,251 @@ +import { constants } from "node:fs"; +import { access, chmod, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import type { PikachatLogger } from "./daemon-client.js"; + +type GitHubReleaseAsset = { + name: string; + browser_download_url: string; +}; + +type GitHubRelease = { + tag_name: string; + assets: GitHubReleaseAsset[]; +}; + +const DEFAULT_REPO = "sledtools/pika"; +const DEFAULT_BINARY_NAME = "pikachat"; +const VERSION_CHECK_TTL_MS = 24 * 60 * 60 * 1000; + +let pluginVersionCache: string | null = null; + +function parseVer(value: string): number[] { + return value.replace(/^(pikachat-)?v/, "").split(".").map(Number); +} + +function getPackageVersion(): string { + if (pluginVersionCache) return pluginVersionCache; + pluginVersionCache = "0.1.0"; + return pluginVersionCache; +} + +export function isCompatibleVersion(candidate: string, pluginVersion: string): boolean { + const [cMaj = 0, cMin = 0] = parseVer(candidate); + const [pMaj = 0, pMin = 0] = parseVer(pluginVersion); + return cMaj === pMaj && cMin === pMin; +} + +function hasPathSeparator(input: string): boolean { + return input.includes("/") || input.includes("\\"); +} + +async function isExecutableFile(filePath: string): Promise { + try { + await access(filePath, constants.X_OK); + return true; + } catch { + return false; + } +} + +async function resolveFromPath(binary: string): Promise { + const envPath = process.env.PATH ?? ""; + for (const dir of envPath.split(path.delimiter)) { + const trimmed = dir.trim(); + if (!trimmed) continue; + const candidate = path.join(trimmed, binary); + if (await isExecutableFile(candidate)) { + return candidate; + } + } + return null; +} + +async function resolveExistingCommand(cmd: string): Promise { + const trimmed = cmd.trim(); + if (!trimmed) return null; + if (hasPathSeparator(trimmed)) { + const absolute = path.resolve(trimmed); + return (await isExecutableFile(absolute)) ? absolute : null; + } + return await resolveFromPath(trimmed); +} + +function resolvePlatformAsset(): string { + if (process.platform === "linux" && process.arch === "x64") return "pikachat-x86_64-linux"; + if (process.platform === "linux" && process.arch === "arm64") return "pikachat-aarch64-linux"; + if (process.platform === "darwin" && process.arch === "x64") return "pikachat-x86_64-darwin"; + if (process.platform === "darwin" && process.arch === "arm64") return "pikachat-aarch64-darwin"; + throw new Error(`unsupported platform for pikachat auto-install: ${process.platform}/${process.arch}`); +} + +function getCacheDir(): string { + return path.join(os.homedir(), ".claude", "channels", "pikachat", "tools"); +} + +function getBinaryPath(version: string): string { + return path.join(getCacheDir(), version, DEFAULT_BINARY_NAME); +} + +function githubHeaders(): Headers { + const headers = new Headers({ + Accept: "application/vnd.github+json", + "User-Agent": "pikachat-claude", + }); + const token = process.env.GITHUB_TOKEN?.trim(); + if (token) { + headers.set("Authorization", `Bearer ${token}`); + } + return headers; +} + +function releasesListApiUrl(repo: string, page: number): string { + return `https://api.github.com/repos/${repo}/releases?per_page=50&page=${page}`; +} + +function releaseByTagApiUrl(repo: string, version: string): string { + return `https://api.github.com/repos/${repo}/releases/tags/${encodeURIComponent(version)}`; +} + +function normalizeRelease(raw: any): GitHubRelease { + const tagName = typeof raw?.tag_name === "string" ? raw.tag_name : ""; + const assets = Array.isArray(raw?.assets) ? raw.assets : []; + const normalizedAssets: GitHubReleaseAsset[] = assets + .map((entry: any) => ({ + name: typeof entry?.name === "string" ? entry.name : "", + browser_download_url: typeof entry?.browser_download_url === "string" ? entry.browser_download_url : "", + })) + .filter((entry: GitHubReleaseAsset) => entry.name && entry.browser_download_url); + if (!tagName) { + throw new Error("release payload missing tag_name"); + } + return { tag_name: tagName, assets: normalizedAssets }; +} + +async function fetchLatestCompatibleRelease(params: { + repo: string; + assetName: string; + pluginVersion: string; + log?: PikachatLogger; +}): Promise { + const headers = githubHeaders(); + for (let page = 1; page <= 4; page++) { + const response = await fetch(releasesListApiUrl(params.repo, page), { headers }); + if (!response.ok) { + const body = await response.text().catch(() => ""); + throw new Error(`release list lookup failed ${response.status}: ${body.slice(0, 200)}`); + } + const list = (await response.json()) as any[]; + if (!Array.isArray(list) || list.length === 0) break; + for (const raw of list) { + const release = normalizeRelease(raw); + if (!release.assets.some((asset) => asset.name === params.assetName)) continue; + if (isCompatibleVersion(release.tag_name, params.pluginVersion)) { + return release; + } + params.log?.debug?.( + `[pikachat-claude] skipping ${release.tag_name} (incompatible with plugin ${params.pluginVersion})`, + ); + } + } + throw new Error(`no compatible release found for asset ${params.assetName}`); +} + +async function fetchReleaseByTag(params: { repo: string; version: string }): Promise { + const response = await fetch(releaseByTagApiUrl(params.repo, params.version), { headers: githubHeaders() }); + if (!response.ok) { + const body = await response.text().catch(() => ""); + throw new Error(`release lookup failed ${response.status}: ${body.slice(0, 200)}`); + } + return normalizeRelease(await response.json()); +} + +async function resolveVersion(log?: PikachatLogger, pinnedVersion?: string): Promise { + if (pinnedVersion && pinnedVersion !== "latest") { + return pinnedVersion; + } + const cacheDir = getCacheDir(); + const cacheFile = path.join(cacheDir, ".latest-version"); + try { + const raw = JSON.parse(await readFile(cacheFile, "utf8")) as { value?: string; checked_at?: number }; + if ( + typeof raw.value === "string" && + typeof raw.checked_at === "number" && + Date.now() - raw.checked_at < VERSION_CHECK_TTL_MS + ) { + return raw.value; + } + } catch { + // ignore stale cache misses + } + + const release = await fetchLatestCompatibleRelease({ + repo: DEFAULT_REPO, + assetName: resolvePlatformAsset(), + pluginVersion: getPackageVersion(), + log, + }); + await mkdir(cacheDir, { recursive: true }); + await writeFile( + cacheFile, + JSON.stringify({ value: release.tag_name, checked_at: Date.now() }), + "utf8", + ); + return release.tag_name; +} + +async function downloadToFile(url: string, destination: string): Promise { + const response = await fetch(url, { headers: githubHeaders(), redirect: "follow" }); + if (!response.ok || !response.body) { + throw new Error(`download failed ${response.status}: ${url}`); + } + const tmpPath = `${destination}.tmp`; + const buffer = Buffer.from(await response.arrayBuffer()); + await writeFile(tmpPath, buffer); + await rename(tmpPath, destination); +} + +export async function resolvePikachatDaemonCommand(params: { + requestedCmd: string; + pinnedVersion?: string; + log?: PikachatLogger; +}): Promise { + const existing = await resolveExistingCommand(params.requestedCmd); + if (existing) { + return existing; + } + + const requested = params.requestedCmd.trim(); + if (requested !== DEFAULT_BINARY_NAME) { + throw new Error(`daemon command not found: ${requested}`); + } + + const resolvedVersion = await resolveVersion(params.log, params.pinnedVersion); + const binaryPath = getBinaryPath(resolvedVersion); + if (await isExecutableFile(binaryPath)) { + return binaryPath; + } + + await mkdir(path.dirname(binaryPath), { recursive: true }); + const release = await fetchReleaseByTag({ repo: DEFAULT_REPO, version: resolvedVersion }); + const asset = release.assets.find((entry) => entry.name === resolvePlatformAsset()); + if (!asset) { + throw new Error(`release ${release.tag_name} missing asset ${resolvePlatformAsset()}`); + } + + await downloadToFile(asset.browser_download_url, binaryPath); + await chmod(binaryPath, 0o755); + + try { + const fileStat = await stat(binaryPath); + if (!fileStat.isFile()) { + throw new Error(`downloaded path is not a file: ${binaryPath}`); + } + } catch (err) { + await rm(binaryPath, { force: true }); + throw err; + } + return binaryPath; +} diff --git a/pikachat-claude/src/daemon-launch.ts b/pikachat-claude/src/daemon-launch.ts new file mode 100644 index 000000000..aba958923 --- /dev/null +++ b/pikachat-claude/src/daemon-launch.ts @@ -0,0 +1,78 @@ +import os from "node:os"; +import path from "node:path"; + +import type { PikachatClaudeConfig } from "./config.js"; +import { resolvePikachatDaemonCommand } from "./daemon-install.js"; +import type { PikachatLogger } from "./daemon-client.js"; + +export type PikachatDaemonLaunchSpec = { + cmd: string; + args: string[]; + stateDir: string; + backend: "native" | "acp"; + autoAcceptWelcomes: boolean; +}; + +function expandTilde(input: string): string { + if (input === "~" || input.startsWith("~/")) { + return path.join(os.homedir(), input.slice(1)); + } + return input; +} + +function defaultStateDir(): string { + return path.join(os.homedir(), ".local", "state", "pikachat"); +} + +function defaultAcpCwd(stateDir: string, configured?: string): string { + if (configured && configured.trim()) { + return path.resolve(expandTilde(configured.trim())); + } + return path.join(stateDir, "acp"); +} + +function hasDaemonFlag(args: string[], flag: string): boolean { + return args.includes(flag); +} + +export async function buildPikachatDaemonLaunchSpec(params: { + config: PikachatClaudeConfig; + env?: NodeJS.ProcessEnv; + log?: PikachatLogger; +}): Promise { + const stateDir = path.resolve(expandTilde(params.config.stateDir?.trim() || defaultStateDir())); + const relays = params.config.relays.map((entry) => entry.trim()).filter(Boolean); + const cmd = await resolvePikachatDaemonCommand({ + requestedCmd: params.config.daemonCmd?.trim() || "pikachat", + pinnedVersion: params.config.daemonVersion, + log: params.log, + }); + + const relayArgs = (relays.length > 0 ? relays : ["ws://127.0.0.1:18080"]).flatMap((relay) => [ + "--relay", + relay, + ]); + let args = params.config.daemonArgs ?? ["daemon", ...relayArgs, "--state-dir", stateDir]; + const looksLikeDaemonCommand = args[0] === "daemon"; + + if (looksLikeDaemonCommand && params.config.autoAcceptWelcomes && !hasDaemonFlag(args, "--auto-accept-welcomes")) { + args = [...args, "--auto-accept-welcomes"]; + } + + if (looksLikeDaemonCommand && params.config.daemonBackend === "acp") { + if (!hasDaemonFlag(args, "--acp-exec")) { + args = [...args, "--acp-exec", params.config.daemonAcpExec?.trim() || "npx -y pi-acp"]; + } + if (!hasDaemonFlag(args, "--acp-cwd")) { + args = [...args, "--acp-cwd", defaultAcpCwd(stateDir, params.config.daemonAcpCwd)]; + } + } + + return { + cmd, + args, + stateDir, + backend: params.config.daemonBackend, + autoAcceptWelcomes: hasDaemonFlag(args, "--auto-accept-welcomes"), + }; +} diff --git a/pikachat-claude/src/daemon-protocol.ts b/pikachat-claude/src/daemon-protocol.ts new file mode 100644 index 000000000..a69e8b149 --- /dev/null +++ b/pikachat-claude/src/daemon-protocol.ts @@ -0,0 +1,178 @@ +/** + * TypeScript mirror of the native pikachat daemon JSONL protocol. + * + * Keep this aligned with crates/pikachat-sidecar/src/protocol.rs. + */ + +export type PikachatDaemonOutMsg = + | { type: "ready"; protocol_version: number; pubkey: string; npub: string } + | { type: "ok"; request_id?: string | null; result?: unknown } + | { type: "error"; request_id?: string | null; code: string; message: string } + | { type: "keypackage_published"; event_id: string } + | { + type: "welcome_received"; + wrapper_event_id: string; + welcome_event_id: string; + from_pubkey: string; + nostr_group_id: string; + group_name: string; + } + | { type: "group_joined"; nostr_group_id: string; mls_group_id: string; member_count: number } + | { + type: "message_received"; + nostr_group_id: string; + from_pubkey: string; + content: string; + kind: number; + created_at: number; + event_id: string; + message_id: string; + media?: Array<{ + url: string; + mime_type: string; + filename: string; + original_hash_hex: string; + nonce_hex: string; + scheme_version: string; + width?: number | null; + height?: number | null; + local_path?: string | null; + }>; + } + | { + type: "call_invite_received"; + call_id: string; + from_pubkey: string; + nostr_group_id: string; + } + | { + type: "call_session_started"; + call_id: string; + nostr_group_id: string; + from_pubkey: string; + } + | { type: "call_session_ended"; call_id: string; reason: string } + | { + type: "call_debug"; + call_id: string; + tx_frames: number; + rx_frames: number; + rx_dropped: number; + } + | { + type: "call_audio_chunk"; + call_id: string; + audio_path: string; + sample_rate: number; + channels: number; + } + | { + type: "call_data"; + call_id: string; + payload_hex: string; + track_name: string; + } + | { + type: "group_created"; + nostr_group_id: string; + mls_group_id: string; + peer_pubkey: string; + member_count: number; + } + | { + type: "group_updated"; + update: { + kind: "created" | "members_added" | "members_removed" | "profile_updated" | "left"; + nostr_group_id: string; + member_count?: number | null; + members?: Array<{ pubkey: string; is_admin: boolean }>; + profile?: { + nostr_group_id: string; + owner_pubkey: string; + name: string; + about: string; + picture_url?: string | null; + } | null; + }; + }; + +export type PikachatDaemonInCmd = + | { cmd: "publish_keypackage"; request_id: string; relays: string[] } + | { cmd: "set_relays"; request_id: string; relays: string[] } + | { cmd: "list_pending_welcomes"; request_id: string } + | { cmd: "accept_welcome"; request_id: string; wrapper_event_id: string } + | { cmd: "list_groups"; request_id: string } + | { cmd: "add_members"; request_id: string; nostr_group_id: string; peer_pubkeys: string[] } + | { cmd: "list_members"; request_id: string; nostr_group_id: string } + | { cmd: "remove_members"; request_id: string; nostr_group_id: string; peer_pubkeys: string[] } + | { cmd: "leave_group"; request_id: string; nostr_group_id: string } + | { cmd: "get_group_profile"; request_id: string; nostr_group_id: string } + | { + cmd: "update_group_profile"; + request_id: string; + nostr_group_id: string; + name: string; + about: string; + } + | { + cmd: "upload_group_profile_image"; + request_id: string; + nostr_group_id: string; + image_base64: string; + mime_type: string; + } + | { cmd: "hypernote_catalog"; request_id: string } + | { cmd: "send_message"; request_id: string; nostr_group_id: string; content: string } + | { + cmd: "send_hypernote"; + request_id: string; + nostr_group_id: string; + content: string; + title?: string; + state?: string; + } + | { cmd: "react"; request_id: string; nostr_group_id: string; event_id: string; emoji: string } + | { + cmd: "submit_hypernote_action"; + request_id: string; + nostr_group_id: string; + event_id: string; + action: string; + form?: Record; + } + | { + cmd: "send_media"; + request_id: string; + nostr_group_id: string; + file_path: string; + mime_type?: string; + filename?: string; + caption?: string; + blossom_servers?: string[]; + } + | { + cmd: "send_media_batch"; + request_id: string; + nostr_group_id: string; + file_paths: string[]; + caption?: string; + blossom_servers?: string[]; + } + | { cmd: "send_typing"; request_id: string; nostr_group_id: string } + | { cmd: "accept_call"; request_id: string; call_id: string } + | { cmd: "reject_call"; request_id: string; call_id: string; reason?: string } + | { cmd: "end_call"; request_id: string; call_id: string; reason?: string } + | { cmd: "send_audio_response"; request_id: string; call_id: string; tts_text: string } + | { + cmd: "send_audio_file"; + request_id: string; + call_id: string; + audio_path: string; + sample_rate: number; + channels?: number; + } + | { cmd: "init_group"; request_id: string; peer_pubkey: string; group_name?: string } + | { cmd: "get_messages"; request_id: string; nostr_group_id: string; limit?: number } + | { cmd: "shutdown"; request_id: string }; + +export type PikachatDaemonEventHandler = (msg: PikachatDaemonOutMsg) => void | Promise; diff --git a/pikachat-claude/src/local-relay-e2e.test.ts b/pikachat-claude/src/local-relay-e2e.test.ts new file mode 100644 index 000000000..47ddbe3f3 --- /dev/null +++ b/pikachat-claude/src/local-relay-e2e.test.ts @@ -0,0 +1,282 @@ +import assert from "node:assert/strict"; +import { spawn, type ChildProcess } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { test } from "node:test"; + +import { PikachatClaudeChannel, type ChannelNotification } from "./channel-runtime.js"; +import type { PikachatClaudeConfig } from "./config.js"; + +const ROOT = path.resolve(import.meta.dirname, "..", ".."); + +async function waitFor(fn: () => Promise, timeoutMs: number, label: string): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const value = await fn(); + if (value !== null) return value; + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error(`timeout waiting for ${label}`); +} + +async function waitForHealth(port: number): Promise { + await waitFor(async () => { + try { + const response = await fetch(`http://127.0.0.1:${port}/health`); + return response.ok ? true : null; + } catch { + return null; + } + }, 15_000, "relay health"); +} + +async function runCommandJson( + cmd: string, + args: string[], + options: { cwd?: string; env?: NodeJS.ProcessEnv } = {}, +): Promise { + const result = await runCommand(cmd, args, options); + const trimmed = result.stdout.trim(); + return trimmed ? JSON.parse(trimmed) : null; +} + +async function runCommand( + cmd: string, + args: string[], + options: { cwd?: string; env?: NodeJS.ProcessEnv } = {}, +): Promise<{ stdout: string; stderr: string }> { + return await new Promise((resolve, reject) => { + const child = spawn(cmd, args, { + cwd: options.cwd ?? ROOT, + env: { ...process.env, ...(options.env ?? {}) }, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (buf: Buffer) => { + stdout += String(buf); + }); + child.stderr.on("data", (buf: Buffer) => { + stderr += String(buf); + }); + child.on("error", reject); + child.on("exit", (code) => { + if (code === 0) { + resolve({ stdout, stderr }); + } else { + reject(new Error(`${cmd} ${args.join(" ")} failed code=${code}\nstdout:\n${stdout}\nstderr:\n${stderr}`)); + } + }); + }); +} + +async function startRelay(tempDir: string): Promise<{ child: ChildProcess; relayUrl: string }> { + const relayBin = path.join(tempDir, "pika-relay"); + await runCommand("go", ["build", "-o", relayBin, "."], { + cwd: path.join(ROOT, "cmd", "pika-relay"), + }); + return await new Promise((resolve, reject) => { + const child = spawn(relayBin, [], { + cwd: ROOT, + env: { + ...process.env, + PORT: "0", + DATA_DIR: path.join(tempDir, "relay-data"), + MEDIA_DIR: path.join(tempDir, "relay-media"), + }, + stdio: ["ignore", "ignore", "pipe"], + }); + let stderr = ""; + child.stderr.on("data", (buf: Buffer) => { + stderr += String(buf); + const match = stderr.match(/PIKA_RELAY_PORT=(\d+)/); + if (match) { + resolve({ child, relayUrl: `ws://127.0.0.1:${match[1]}` }); + } + }); + child.on("error", reject); + child.on("exit", (code) => { + reject(new Error(`relay exited early code=${code}\nstderr:\n${stderr}`)); + }); + }); +} + +async function stopChild(child: ChildProcess): Promise { + if (child.exitCode !== null) return; + await new Promise((resolve) => { + child.once("exit", () => resolve()); + child.kill("SIGTERM"); + }); +} + +test( + "local relay e2e: inbound message produces notification and reply reaches remote sender", + { skip: !process.env.RUN_PIKACHAT_CLAUDE_E2E, timeout: 120_000 }, + async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "pikachat-claude-e2e-")); + let relayChild: ChildProcess | null = null; + let runtime: PikachatClaudeChannel | null = null; + + try { + const relay = await startRelay(tempDir); + relayChild = relay.child; + const relayPort = Number(new URL(relay.relayUrl).port); + await waitForHealth(relayPort); + + const senderStateDir = path.join(tempDir, "sender"); + const botStateDir = path.join(tempDir, "bot-state"); + const channelHome = path.join(tempDir, "channel-home"); + + const notifications: ChannelNotification[] = []; + let resolveNotification: ((value: ChannelNotification) => void) | null = null; + const firstNotification = new Promise((resolve) => { + resolveNotification = resolve; + }); + + const config: PikachatClaudeConfig = { + relays: [relay.relayUrl], + stateDir: botStateDir, + daemonCmd: "cargo", + daemonArgs: [ + "run", + "-q", + "-p", + "pikachat", + "--", + "--state-dir", + botStateDir, + "--relay", + relay.relayUrl, + "daemon", + "--auto-accept-welcomes", + ], + daemonBackend: "native", + autoAcceptWelcomes: true, + channelSource: "pikachat", + channelHome, + accessFile: path.join(channelHome, "access.json"), + inboxDir: path.join(channelHome, "inbox"), + }; + + runtime = new PikachatClaudeChannel({ + config, + logger: { + debug: () => {}, + info: () => {}, + warn: (message) => process.stderr.write(`[e2e warn] ${message}\n`), + error: (message) => process.stderr.write(`[e2e err] ${message}\n`), + }, + onNotification: async (notification) => { + notifications.push(notification); + resolveNotification?.(notification); + resolveNotification = null; + }, + }); + await runtime.start(); + const status = runtime.status(); + assert.ok(status.botPubkey, "runtime should expose bot pubkey"); + + await runCommand("cargo", ["run", "-q", "-p", "pikachat", "--", "--state-dir", senderStateDir, "init"], { + cwd: ROOT, + }); + const identity = await runCommandJson( + "cargo", + ["run", "-q", "-p", "pikachat", "--", "--state-dir", senderStateDir, "identity"], + { cwd: ROOT }, + ); + await runCommand("cargo", ["run", "-q", "-p", "pikachat", "--", "--state-dir", senderStateDir, "--relay", relay.relayUrl, "publish-kp"], { + cwd: ROOT, + }); + + await runtime.allowSender(String(identity.pubkey)); + + await runCommand( + "cargo", + [ + "run", + "-q", + "-p", + "pikachat", + "--", + "--state-dir", + senderStateDir, + "--relay", + relay.relayUrl, + "send", + "--to", + String(status.botPubkey), + "--content", + "hello from claude e2e", + ], + { cwd: ROOT }, + ); + + const inbound = await firstNotification; + assert.equal(inbound.meta.chat_type, "direct"); + assert.equal(inbound.meta.sender_id, String(identity.pubkey).toLowerCase()); + + const replyText = "E2E_OK_claude_channel"; + await runtime.reply({ chatId: inbound.meta.chat_id, text: replyText }); + + await runCommand( + "cargo", + [ + "run", + "-q", + "-p", + "pikachat", + "--", + "--state-dir", + senderStateDir, + "--relay", + relay.relayUrl, + "listen", + "--timeout", + "8", + "--lookback", + "120", + ], + { cwd: ROOT }, + ); + + const messages = await waitFor(async () => { + const result = await runCommandJson( + "cargo", + [ + "run", + "-q", + "-p", + "pikachat", + "--", + "--state-dir", + senderStateDir, + "messages", + "--group", + inbound.meta.chat_id, + "--limit", + "10", + ], + { cwd: ROOT }, + ); + return Array.isArray(result?.messages) && + result.messages.some((message: { content?: string }) => String(message.content ?? "").includes(replyText)) + ? result + : null; + }, 30_000, "remote reply ingestion"); + + assert.ok( + messages.messages.some((message: { content?: string }) => String(message.content ?? "").includes(replyText)), + ); + assert.equal(notifications.length >= 1, true); + } finally { + if (runtime) { + await runtime.stop().catch(() => {}); + } + if (relayChild) { + await stopChild(relayChild).catch(() => {}); + } + await rm(tempDir, { recursive: true, force: true }); + } + }, +); diff --git a/pikachat-claude/src/message-format.test.ts b/pikachat-claude/src/message-format.test.ts new file mode 100644 index 000000000..30c8b91f4 --- /dev/null +++ b/pikachat-claude/src/message-format.test.ts @@ -0,0 +1,63 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { augmentMessageText, detectMention, sanitizeMeta } from "./message-format.js"; + +describe("message formatting helpers", () => { + it("appends attachment lines", () => { + const result = augmentMessageText("check this out", [ + { + filename: "photo.jpg", + mime_type: "image/jpeg", + width: 1920, + height: 1080, + local_path: "/tmp/photo.jpg", + }, + ]); + assert.equal( + result, + "check this out\n[Attachment: photo.jpg — image/jpeg (1920x1080) file:///tmp/photo.jpg]", + ); + }); + + it("sanitizes invalid meta keys", () => { + assert.deepEqual( + sanitizeMeta({ + chat_id: "abc", + "bad-key": "nope", + empty: " ", + }), + { chat_id: "abc" }, + ); + }); + + it("detects mentions via npub, pubkey, and regex patterns", () => { + assert.equal( + detectMention({ + text: "hey nostr:npub1test", + botPubkey: "abcdef", + botNpub: "npub1test", + mentionPatterns: [], + }), + true, + ); + assert.equal( + detectMention({ + text: "hello assistant", + botPubkey: "abcdef", + botNpub: "npub1test", + mentionPatterns: ["\\bassistant\\b"], + }), + true, + ); + assert.equal( + detectMention({ + text: "plain message", + botPubkey: "abcdef", + botNpub: "npub1test", + mentionPatterns: [], + }), + false, + ); + }); +}); diff --git a/pikachat-claude/src/message-format.ts b/pikachat-claude/src/message-format.ts new file mode 100644 index 000000000..d287854cc --- /dev/null +++ b/pikachat-claude/src/message-format.ts @@ -0,0 +1,63 @@ +type MediaLike = { + filename: string; + mime_type: string; + width?: number | null; + height?: number | null; + local_path?: string | null; + url?: string; +}; + +const MAX_MENTION_PATTERN_LENGTH = 100; + +export function augmentMessageText(content: string, media: MediaLike[] = []): string { + if (!media.length) return content; + const mediaLines = media.map((item) => { + const dims = item.width && item.height ? ` (${item.width}x${item.height})` : ""; + const localFile = item.local_path ? ` file://${item.local_path}` : ""; + const url = !item.local_path && item.url ? ` ${item.url}` : ""; + return `[Attachment: ${item.filename} — ${item.mime_type}${dims}${localFile}${url}]`; + }); + return content ? `${content}\n${mediaLines.join("\n")}` : mediaLines.join("\n"); +} + +export function sanitizeMeta(input: Record): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(input)) { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue; + const normalized = String(value ?? "").trim(); + if (!normalized) continue; + out[key] = normalized; + } + return out; +} + +export function detectMention(params: { + text: string; + botPubkey: string; + botNpub: string; + mentionPatterns: string[]; +}): boolean { + const text = params.text.toLowerCase(); + const pubkey = params.botPubkey.toLowerCase(); + const npub = params.botNpub.toLowerCase(); + + if (npub && (text.includes(`nostr:${npub}`) || text.includes(npub))) { + return true; + } + if (pubkey && (text.includes(`@${pubkey}`) || text.includes(pubkey))) { + return true; + } + for (const pattern of params.mentionPatterns) { + try { + if (pattern.length > MAX_MENTION_PATTERN_LENGTH) { + continue; + } + if (new RegExp(pattern, "i").test(params.text)) { + return true; + } + } catch { + // ignore invalid regex entries + } + } + return false; +} diff --git a/pikachat-claude/src/server.ts b/pikachat-claude/src/server.ts new file mode 100644 index 000000000..870285334 --- /dev/null +++ b/pikachat-claude/src/server.ts @@ -0,0 +1,276 @@ +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; + +import { resolvePikachatClaudeConfig } from "./config.js"; +import { PikachatClaudeChannel } from "./channel-runtime.js"; + +const config = resolvePikachatClaudeConfig(process.env); + +function log(message: string): void { + process.stderr.write(`${message}\n`); +} + +const mcp = new Server( + { name: config.channelSource, version: "0.1.0" }, + { + capabilities: { + experimental: { "claude/channel": {} }, + tools: {}, + }, + instructions: + `Messages arrive as .... ` + + `Reply with the reply tool, passing the chat_id from the tag. ` + + `React with the react tool, passing chat_id and event_id from the tag. ` + + `Unknown direct-message senders may require the approve_pairing tool before their messages will be delivered.`, + }, +); + +const runtime = new PikachatClaudeChannel({ + config, + logger: { + debug: (message) => log(message), + info: (message) => log(message), + warn: (message) => log(message), + error: (message) => log(message), + }, + onNotification: async ({ content, meta }) => { + try { + await mcp.notification({ + method: "notifications/claude/channel", + params: { + content, + meta, + }, + }); + } catch (err) { + log( + `[pikachat-claude] failed to forward notification chat_id=${meta.chat_id ?? "unknown"}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + }, +}); + +function textResult(text: string) { + return { content: [{ type: "text", text }] }; +} + +function requireNonEmptyString(args: Record, key: string): string { + const value = args[key]; + if (typeof value !== "string" || value.trim() === "") { + throw new Error(`missing or invalid '${key}'`); + } + return value.trim(); +} + +mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: "reply", + description: "Send a text reply and optional files back through pikachat.", + inputSchema: { + type: "object", + properties: { + chat_id: { type: "string", description: "Target chat/group ID from the channel tag" }, + text: { type: "string", description: "Reply text" }, + reply_to: { type: "string", description: "Optional inbound event/message ID to reply to" }, + files: { + type: "array", + items: { type: "string" }, + description: "Absolute file paths to send as attachments", + }, + }, + required: ["chat_id"], + }, + }, + { + name: "react", + description: "React to a message by event ID.", + inputSchema: { + type: "object", + properties: { + chat_id: { type: "string" }, + event_id: { type: "string" }, + emoji: { type: "string" }, + }, + required: ["chat_id", "event_id", "emoji"], + }, + }, + { + name: "access_status", + description: "Show current DM policy, sender allowlist, groups, and pending pairings.", + inputSchema: { type: "object", properties: {} }, + }, + { + name: "approve_pairing", + description: "Approve a pending DM pairing code and add the sender to the allowlist.", + inputSchema: { + type: "object", + properties: { + code: { type: "string" }, + }, + required: ["code"], + }, + }, + { + name: "deny_pairing", + description: "Deny a pending DM pairing code.", + inputSchema: { + type: "object", + properties: { + code: { type: "string" }, + }, + required: ["code"], + }, + }, + { + name: "set_dm_policy", + description: "Set DM policy to pairing, allowlist, or disabled.", + inputSchema: { + type: "object", + properties: { + policy: { type: "string", enum: ["pairing", "allowlist", "disabled"] }, + }, + required: ["policy"], + }, + }, + { + name: "allow_sender", + description: "Add a sender pubkey to the DM allowlist.", + inputSchema: { + type: "object", + properties: { + sender_id: { type: "string" }, + }, + required: ["sender_id"], + }, + }, + { + name: "remove_sender", + description: "Remove a sender pubkey from the DM allowlist.", + inputSchema: { + type: "object", + properties: { + sender_id: { type: "string" }, + }, + required: ["sender_id"], + }, + }, + { + name: "enable_group", + description: "Enable a group and optionally require mentions and restrict allowed senders.", + inputSchema: { + type: "object", + properties: { + group_id: { type: "string" }, + require_mention: { type: "boolean" }, + allow_from: { + type: "array", + items: { type: "string" }, + }, + }, + required: ["group_id"], + }, + }, + { + name: "disable_group", + description: "Disable a group from delivering inbound messages.", + inputSchema: { + type: "object", + properties: { + group_id: { type: "string" }, + }, + required: ["group_id"], + }, + }, + ], +})); + +mcp.setRequestHandler(CallToolRequestSchema, async (request) => { + const args = (request.params.arguments ?? {}) as Record; + switch (request.params.name) { + case "reply": { + const result = await runtime.reply({ + chatId: requireNonEmptyString(args, "chat_id"), + text: typeof args.text === "string" ? args.text : undefined, + replyTo: typeof args.reply_to === "string" ? args.reply_to : undefined, + files: Array.isArray(args.files) ? args.files.map((entry) => String(entry)) : undefined, + }); + const notes = result.notes.length > 0 ? ` notes=${JSON.stringify(result.notes)}` : ""; + return textResult(`sent${notes}`); + } + case "react": { + await runtime.react({ + chatId: requireNonEmptyString(args, "chat_id"), + eventId: requireNonEmptyString(args, "event_id"), + emoji: requireNonEmptyString(args, "emoji"), + }); + return textResult("reaction sent"); + } + case "access_status": { + return textResult(JSON.stringify(await runtime.accessStatus(), null, 2)); + } + case "approve_pairing": { + const result = await runtime.approvePairing(requireNonEmptyString(args, "code")); + return textResult(result.senderId ? `approved ${result.senderId}` : "pairing code not found"); + } + case "deny_pairing": { + const result = await runtime.denyPairing(requireNonEmptyString(args, "code")); + return textResult(result.senderId ? `denied ${result.senderId}` : "pairing code not found"); + } + case "set_dm_policy": { + const policy = String(args.policy ?? ""); + if (policy !== "pairing" && policy !== "allowlist" && policy !== "disabled") { + throw new Error(`invalid policy: ${policy}`); + } + return textResult(JSON.stringify(await runtime.setDmPolicy(policy), null, 2)); + } + case "allow_sender": { + return textResult(JSON.stringify(await runtime.allowSender(requireNonEmptyString(args, "sender_id")), null, 2)); + } + case "remove_sender": { + return textResult( + JSON.stringify(await runtime.removeSender(requireNonEmptyString(args, "sender_id")), null, 2), + ); + } + case "enable_group": { + return textResult( + JSON.stringify( + await runtime.enableGroup( + requireNonEmptyString(args, "group_id"), + args.require_mention !== false, + Array.isArray(args.allow_from) ? args.allow_from.map((entry) => String(entry)) : [], + ), + null, + 2, + ), + ); + } + case "disable_group": { + return textResult(JSON.stringify(await runtime.disableGroup(requireNonEmptyString(args, "group_id")), null, 2)); + } + default: + throw new Error(`unknown tool: ${request.params.name}`); + } +}); + +async function main(): Promise { + await runtime.start(); + await mcp.connect(new StdioServerTransport()); +} + +void main().catch(async (err) => { + log(`[pikachat-claude] fatal: ${err instanceof Error ? err.stack ?? err.message : String(err)}`); + try { + await runtime.stop(); + } catch (stopErr) { + log(`[pikachat-claude] stop failed: ${stopErr instanceof Error ? stopErr.message : String(stopErr)}`); + } + process.exit(1); +}); + +for (const signal of ["SIGINT", "SIGTERM"] as const) { + process.on(signal, () => { + void runtime.stop().finally(() => process.exit(0)); + }); +} diff --git a/pikachat-claude/tsconfig.json b/pikachat-claude/tsconfig.json new file mode 100644 index 000000000..98d64b6d1 --- /dev/null +++ b/pikachat-claude/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts"] +}