expo-openclaw-chat 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Bruno Barbieri
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,153 @@
1
+ # 🦞 expo-openclaw-chat 🦞
2
+
3
+ Minimal chat SDK for Expo apps to connect to OpenClaw gateway.
4
+
5
+ <p align="center">
6
+ <img src="screenshot.png" width="300" alt="Demo Chat Screenshot" />
7
+ <br/>
8
+ <em>A picture is worth a thousand words.</em>
9
+ </p>
10
+
11
+ ## Installation
12
+
13
+ ```bash
14
+ npm install expo-openclaw-chat
15
+ ```
16
+
17
+ ## Quick Start
18
+
19
+ ```tsx
20
+ import { createChat } from "expo-openclaw-chat";
21
+
22
+ // Create chat instance
23
+ const chat = createChat({
24
+ gatewayUrl: "wss://your-gateway.example.com",
25
+ token: "your-auth-token", // or use password/deviceToken
26
+ });
27
+
28
+ // Wrap your app with ChatProvider
29
+ function App() {
30
+ return (
31
+ <chat.ChatProvider>
32
+ <YourApp />
33
+ </chat.ChatProvider>
34
+ );
35
+ }
36
+
37
+ // Open chat modal from anywhere
38
+ chat.open();
39
+
40
+ // Close chat modal
41
+ chat.close();
42
+ ```
43
+
44
+ ## Configuration Options
45
+
46
+ ```tsx
47
+ createChat({
48
+ // Required
49
+ gatewayUrl: string; // WebSocket URL (wss:// or ws://)
50
+
51
+ // Authentication (pick one)
52
+ token?: string; // Simple auth token
53
+ password?: string; // Password auth
54
+ deviceToken?: string; // Device token from pairing
55
+
56
+ // Optional
57
+ sessionKey?: string; // Chat session key (auto-generated if not provided)
58
+ title?: string; // Modal title (default: "Chat")
59
+ placeholder?: string; // Input placeholder text
60
+ showImagePicker?: boolean; // Show image picker button (requires expo-image-picker)
61
+ clientId?: string; // Client ID for gateway registration
62
+
63
+ // Callbacks
64
+ onOpen?: () => void; // Called when modal opens
65
+ onClose?: () => void; // Called when modal closes
66
+
67
+ // Storage
68
+ storage?: Storage; // Custom storage for device identity
69
+ });
70
+ ```
71
+
72
+ ## Optional Dependencies
73
+
74
+ Install these for additional features:
75
+
76
+ ```bash
77
+ # Image attachments
78
+ npm install expo-image-picker
79
+
80
+ # Markdown rendering
81
+ npm install react-native-marked
82
+
83
+ # Persistent device identity (recommended)
84
+ npm install react-native-mmkv
85
+ ```
86
+
87
+ ## Custom Storage
88
+
89
+ By default, device identity is stored in memory (regenerates on app restart). For persistent identity:
90
+
91
+ ```tsx
92
+ import { setStorage } from "expo-openclaw-chat/lib";
93
+ import { MMKV } from "react-native-mmkv";
94
+
95
+ const storage = new MMKV({ id: "my-app" });
96
+ setStorage(storage);
97
+ ```
98
+
99
+ ## Advanced Usage
100
+
101
+ ### Using Individual Components
102
+
103
+ ```tsx
104
+ import {
105
+ GatewayClient,
106
+ ChatEngine,
107
+ ChatModal,
108
+ ChatList,
109
+ ChatBubble,
110
+ ChatInput
111
+ } from "expo-openclaw-chat";
112
+
113
+ // Create client manually
114
+ const client = new GatewayClient("wss://gateway.example.com", {
115
+ token: "your-token",
116
+ });
117
+
118
+ // Connect
119
+ await client.connect();
120
+
121
+ // Create chat engine
122
+ const engine = new ChatEngine(client, "session-key");
123
+
124
+ // Listen for updates
125
+ engine.on("update", () => {
126
+ console.log("Messages:", engine.messages);
127
+ });
128
+
129
+ // Send a message
130
+ await engine.send("Hello!");
131
+ ```
132
+
133
+ ### Direct Core Access
134
+
135
+ ```tsx
136
+ import {
137
+ GatewayClient,
138
+ generateIdempotencyKey,
139
+ loadOrCreateIdentity,
140
+ } from "expo-openclaw-chat/lib";
141
+ ```
142
+
143
+ ## Demo App
144
+
145
+ See the [demo](./demo) folder for a complete example.
146
+
147
+
148
+ https://github.com/user-attachments/assets/69316dca-5bbf-42b7-93b9-fa9bc3bbc5f5
149
+
150
+
151
+ ## License
152
+
153
+ MIT
package/lib/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ // Subpath export for Metro compatibility
2
+ export * from "../src/core";
package/package.json ADDED
@@ -0,0 +1,87 @@
1
+ {
2
+ "name": "expo-openclaw-chat",
3
+ "version": "0.1.0",
4
+ "description": "Minimal chat SDK for Expo apps to connect to OpenClaw gateway",
5
+ "main": "src/index.ts",
6
+ "types": "src/index.ts",
7
+ "exports": {
8
+ ".": "./src/index.ts",
9
+ "./lib": "./lib/index.ts"
10
+ },
11
+ "files": [
12
+ "src",
13
+ "lib"
14
+ ],
15
+ "scripts": {
16
+ "typecheck": "tsc --noEmit",
17
+ "test": "jest"
18
+ },
19
+ "dependencies": {
20
+ "@noble/ed25519": "^3.0.0",
21
+ "@noble/hashes": "^2.0.0"
22
+ },
23
+ "peerDependencies": {
24
+ "expo": ">=50.0.0",
25
+ "react": ">=18.0.0",
26
+ "react-native": ">=0.72.0",
27
+ "react-native-keyboard-controller": ">=1.0.0",
28
+ "react-native-reanimated": ">=3.0.0",
29
+ "react-native-safe-area-context": ">=4.0.0"
30
+ },
31
+ "peerDependenciesMeta": {
32
+ "expo-image-picker": {
33
+ "optional": true
34
+ },
35
+ "react-native-marked": {
36
+ "optional": true
37
+ },
38
+ "react-native-mmkv": {
39
+ "optional": true
40
+ }
41
+ },
42
+ "devDependencies": {
43
+ "@babel/core": "^7.24.0",
44
+ "@babel/preset-env": "^7.24.0",
45
+ "@types/jest": "^29.5.0",
46
+ "@types/react": "^19.2.10",
47
+ "@types/react-native": "^0.72.8",
48
+ "babel-jest": "^29.7.0",
49
+ "jest": "^29.7.0",
50
+ "react-native-keyboard-controller": "^1.20.7",
51
+ "react-native-reanimated": "^4.2.1",
52
+ "react-native-safe-area-context": "^5.6.2",
53
+ "ts-jest": "^29.1.0",
54
+ "typescript": "^5.3.0"
55
+ },
56
+ "jest": {
57
+ "preset": "ts-jest",
58
+ "testEnvironment": "node",
59
+ "testMatch": [
60
+ "**/__tests__/**/*.test.ts"
61
+ ],
62
+ "moduleFileExtensions": [
63
+ "ts",
64
+ "js"
65
+ ],
66
+ "transformIgnorePatterns": [
67
+ "node_modules/(?!(@noble)/)"
68
+ ],
69
+ "transform": {
70
+ "^.+\\.ts$": "ts-jest",
71
+ "^.+\\.js$": "babel-jest"
72
+ }
73
+ },
74
+ "keywords": [
75
+ "expo",
76
+ "react-native",
77
+ "chat",
78
+ "openclaw",
79
+ "ai"
80
+ ],
81
+ "author": "Bruno Barbieri",
82
+ "license": "MIT",
83
+ "repository": {
84
+ "type": "git",
85
+ "url": "https://github.com/anthropics/expo-openclaw-chat"
86
+ }
87
+ }
@@ -0,0 +1,289 @@
1
+ /**
2
+ * Tests for ChatEngine
3
+ */
4
+
5
+ import { ChatEngine, type PendingAttachment } from "../engine";
6
+ import type { GatewayClient } from "../../core/client";
7
+ import type { ChatEventPayload } from "../../core/protocol";
8
+
9
+ // Mock client type with test helpers
10
+ type MockGatewayClient = GatewayClient & {
11
+ _emitConnectionState: (state: string) => void;
12
+ _emitChatEvent: (payload: ChatEventPayload) => void;
13
+ };
14
+
15
+ // Mock GatewayClient
16
+ function createMockClient(overrides: Partial<GatewayClient> = {}): MockGatewayClient {
17
+ const connectionStateListeners = new Set<(state: string) => void>();
18
+ const chatEventListeners = new Set<(payload: ChatEventPayload) => void>();
19
+
20
+ return {
21
+ isConnected: true,
22
+ connectionState: "connected",
23
+ serverInfo: null,
24
+
25
+ onConnectionStateChange: jest.fn((cb) => {
26
+ connectionStateListeners.add(cb);
27
+ return () => connectionStateListeners.delete(cb);
28
+ }),
29
+
30
+ onChatEvent: jest.fn((cb) => {
31
+ chatEventListeners.add(cb);
32
+ return () => chatEventListeners.delete(cb);
33
+ }),
34
+
35
+ chatSubscribe: jest.fn(),
36
+ chatSend: jest.fn().mockResolvedValue({ runId: "test-run-id" }),
37
+ chatAbort: jest.fn().mockResolvedValue(undefined),
38
+
39
+ // Test helpers
40
+ _emitConnectionState: (state: string) => {
41
+ connectionStateListeners.forEach((cb) => cb(state));
42
+ },
43
+ _emitChatEvent: (payload: ChatEventPayload) => {
44
+ chatEventListeners.forEach((cb) => cb(payload));
45
+ },
46
+
47
+ ...overrides,
48
+ } as unknown as MockGatewayClient;
49
+ }
50
+
51
+ describe("ChatEngine", () => {
52
+ let mockClient: MockGatewayClient;
53
+ let engine: ChatEngine;
54
+
55
+ beforeEach(() => {
56
+ mockClient = createMockClient();
57
+ engine = new ChatEngine(mockClient as unknown as GatewayClient, "test-session");
58
+ });
59
+
60
+ afterEach(() => {
61
+ engine.destroy();
62
+ });
63
+
64
+ describe("constructor", () => {
65
+ it("initializes with empty messages", () => {
66
+ expect(engine.messages).toEqual([]);
67
+ expect(engine.isStreaming).toBe(false);
68
+ expect(engine.error).toBeNull();
69
+ });
70
+
71
+ it("subscribes to chat events for the session", () => {
72
+ expect(mockClient.chatSubscribe).toHaveBeenCalledWith("test-session");
73
+ });
74
+ });
75
+
76
+ describe("send", () => {
77
+ it("adds a user message to the list", async () => {
78
+ await engine.send("Hello, world!");
79
+
80
+ expect(engine.messages.length).toBe(2); // user + assistant placeholder
81
+ const userMessage = engine.messages[0];
82
+ expect(userMessage).toBeDefined();
83
+ expect(userMessage!.role).toBe("user");
84
+ expect(userMessage!.content).toEqual([{ type: "text", text: "Hello, world!" }]);
85
+ });
86
+
87
+ it("calls chatSend on the client", async () => {
88
+ await engine.send("Test message");
89
+
90
+ expect(mockClient.chatSend).toHaveBeenCalledWith(
91
+ "test-session",
92
+ "Test message",
93
+ expect.objectContaining({
94
+ idempotencyKey: expect.any(String),
95
+ }),
96
+ );
97
+ });
98
+
99
+ it("does not send empty messages", async () => {
100
+ await engine.send("");
101
+ await engine.send(" ");
102
+
103
+ expect(mockClient.chatSend).not.toHaveBeenCalled();
104
+ expect(engine.messages).toEqual([]);
105
+ });
106
+
107
+ it("handles attachments", async () => {
108
+ const attachments: PendingAttachment[] = [
109
+ {
110
+ id: "img-1",
111
+ fileName: "test.jpg",
112
+ mimeType: "image/jpeg",
113
+ content: "base64data",
114
+ type: "image",
115
+ },
116
+ ];
117
+
118
+ await engine.send("With image", attachments);
119
+
120
+ const message = engine.messages[0];
121
+ expect(message).toBeDefined();
122
+ expect(message!.content.length).toBe(2); // image + text
123
+ expect(message!.content[0]!.type).toBe("image");
124
+ });
125
+
126
+ it("sets error when not connected", async () => {
127
+ const disconnectedClient = createMockClient({ isConnected: false });
128
+ const disconnectedEngine = new ChatEngine(
129
+ disconnectedClient as unknown as GatewayClient,
130
+ "test-session",
131
+ );
132
+
133
+ await disconnectedEngine.send("Test");
134
+
135
+ expect(disconnectedEngine.error).not.toBeNull();
136
+ expect(disconnectedEngine.error?.message).toBe("Not connected");
137
+
138
+ disconnectedEngine.destroy();
139
+ });
140
+ });
141
+
142
+ describe("abort", () => {
143
+ it("calls chatAbort on the client", async () => {
144
+ await engine.send("Test");
145
+ await engine.abort();
146
+
147
+ expect(mockClient.chatAbort).toHaveBeenCalled();
148
+ });
149
+
150
+ it("does nothing when not streaming", async () => {
151
+ await engine.abort();
152
+
153
+ expect(mockClient.chatAbort).not.toHaveBeenCalled();
154
+ });
155
+ });
156
+
157
+ describe("clear", () => {
158
+ it("removes all messages", async () => {
159
+ await engine.send("Message 1");
160
+ engine.clear();
161
+
162
+ expect(engine.messages).toEqual([]);
163
+ expect(engine.isStreaming).toBe(false);
164
+ });
165
+ });
166
+
167
+ describe("event handling", () => {
168
+ it("handles delta events", async () => {
169
+ await engine.send("Test");
170
+
171
+ // Simulate streaming delta
172
+ mockClient._emitChatEvent({
173
+ runId: "test-run-id",
174
+ sessionKey: "test-session",
175
+ state: "delta",
176
+ message: {
177
+ role: "assistant",
178
+ content: [{ type: "text", text: "Partial response" }],
179
+ },
180
+ } as ChatEventPayload);
181
+
182
+ // Wait for flush timer
183
+ await new Promise((resolve) => setTimeout(resolve, 300));
184
+
185
+ const assistantMsg = engine.messages.find((m) => m.role === "assistant");
186
+ expect(assistantMsg).toBeDefined();
187
+ expect(assistantMsg?.isStreaming).toBe(true);
188
+ });
189
+
190
+ it("handles complete events", async () => {
191
+ await engine.send("Test");
192
+
193
+ // Simulate complete
194
+ mockClient._emitChatEvent({
195
+ runId: "test-run-id",
196
+ sessionKey: "test-session",
197
+ state: "complete",
198
+ message: {
199
+ role: "assistant",
200
+ content: [{ type: "text", text: "Final response" }],
201
+ },
202
+ } as ChatEventPayload);
203
+
204
+ const assistantMsg = engine.messages.find((m) => m.role === "assistant");
205
+ expect(assistantMsg?.isStreaming).toBe(false);
206
+ expect(engine.isStreaming).toBe(false);
207
+ });
208
+
209
+ it("handles error events", async () => {
210
+ await engine.send("Test");
211
+
212
+ mockClient._emitChatEvent({
213
+ runId: "test-run-id",
214
+ sessionKey: "test-session",
215
+ state: "error",
216
+ errorMessage: "Something went wrong",
217
+ } as ChatEventPayload);
218
+
219
+ expect(engine.error?.message).toBe("Something went wrong");
220
+ expect(engine.isStreaming).toBe(false);
221
+ });
222
+
223
+ it("ignores events for other sessions", async () => {
224
+ await engine.send("Test");
225
+
226
+ mockClient._emitChatEvent({
227
+ runId: "other-run-id",
228
+ sessionKey: "other-session",
229
+ state: "delta",
230
+ message: {
231
+ role: "assistant",
232
+ content: [{ type: "text", text: "Wrong session" }],
233
+ },
234
+ } as ChatEventPayload);
235
+
236
+ // Should only have user message + placeholder
237
+ expect(engine.messages.length).toBe(2);
238
+ });
239
+ });
240
+
241
+ describe("event subscriptions", () => {
242
+ it("notifies update listeners", async () => {
243
+ const updates: number[] = [];
244
+ engine.on("update", () => updates.push(engine.messages.length));
245
+
246
+ await engine.send("Test");
247
+
248
+ expect(updates.length).toBeGreaterThan(0);
249
+ });
250
+
251
+ it("notifies error listeners", async () => {
252
+ const errors: Error[] = [];
253
+ engine.on("error", (err) => errors.push(err));
254
+
255
+ // Trigger an error
256
+ mockClient._emitChatEvent({
257
+ runId: "test-run-id",
258
+ sessionKey: "test-session",
259
+ state: "error",
260
+ errorMessage: "Test error",
261
+ } as ChatEventPayload);
262
+
263
+ expect(errors.length).toBe(1);
264
+ expect(errors[0]!.message).toBe("Test error");
265
+ });
266
+
267
+ it("allows unsubscribing", () => {
268
+ const updates: number[] = [];
269
+ const unsub = engine.on("update", () => updates.push(1));
270
+
271
+ unsub();
272
+ engine.clear();
273
+
274
+ expect(updates).toEqual([]);
275
+ });
276
+ });
277
+
278
+ describe("destroy", () => {
279
+ it("cleans up subscriptions", () => {
280
+ const updates: number[] = [];
281
+ engine.on("update", () => updates.push(1));
282
+
283
+ engine.destroy();
284
+ engine.clear(); // This would normally trigger update
285
+
286
+ expect(updates).toEqual([]);
287
+ });
288
+ });
289
+ });