@ambient-os/openclaw-channel 0.0.2

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.
@@ -0,0 +1,19 @@
1
+ {
2
+ "id": "openclaw-channel",
3
+ "name": "Ambient",
4
+ "version": "1.0.0",
5
+ "configSchema": {
6
+ "type": "object",
7
+ "properties": {
8
+ "pushEndpoint": { "type": "string" },
9
+ "pushApiKey": { "type": "string" },
10
+ "relayUrl": { "type": "string" },
11
+ "gatewayId": { "type": "string" },
12
+ "tunnelSecret": { "type": "string" }
13
+ }
14
+ },
15
+ "uiHints": {
16
+ "pushApiKey": { "label": "Push API Key", "sensitive": true },
17
+ "tunnelSecret": { "label": "Tunnel Secret", "sensitive": true }
18
+ }
19
+ }
@@ -0,0 +1 @@
1
+ export default function register(api: any): void;
package/dist/index.js ADDED
@@ -0,0 +1,195 @@
1
+ import { generatePairingToken, validatePairingToken } from "./pairing";
2
+ import { sendPushNotification } from "./push";
3
+ import { ensureRelayRegistration } from "./relay-registration";
4
+ import { TunnelClient } from "./tunnel";
5
+ const plugin = {
6
+ id: "ambient",
7
+ meta: {
8
+ id: "ambient",
9
+ label: "Ambient",
10
+ selectionLabel: "Ambient iOS App",
11
+ docsPath: "/channels/ambient",
12
+ blurb: "Voice, display, and messaging via Ambient.",
13
+ aliases: ["amb"],
14
+ },
15
+ capabilities: {
16
+ chatTypes: ["direct"],
17
+ media: {
18
+ send: { image: true, audio: true, video: true },
19
+ receive: { image: true, audio: true, video: true },
20
+ },
21
+ },
22
+ config: {
23
+ listAccountIds: (cfg) => {
24
+ const ambient = cfg.channels?.ambient;
25
+ return Object.keys(ambient?.accounts ?? {});
26
+ },
27
+ resolveAccount: (cfg, accountId) => {
28
+ const ambient = cfg.channels?.ambient;
29
+ const accounts = ambient?.accounts ?? {};
30
+ return accounts[accountId ?? "default"];
31
+ },
32
+ },
33
+ outbound: {
34
+ deliveryMode: "direct",
35
+ sendText: async (ctx) => {
36
+ const { text, account, config } = ctx;
37
+ const ambient = config.channels?.ambient;
38
+ if (!account.pushToken) {
39
+ return { ok: false, error: "No push token for this device" };
40
+ }
41
+ try {
42
+ await sendPushNotification({
43
+ endpoint: ambient?.pushEndpoint ?? "https://api.ambient.app/push",
44
+ apiKey: ambient?.pushApiKey ?? "",
45
+ deviceToken: account.pushToken,
46
+ payload: {
47
+ type: "message",
48
+ text,
49
+ timestamp: Date.now(),
50
+ },
51
+ });
52
+ return { ok: true };
53
+ }
54
+ catch (err) {
55
+ return { ok: false, error: String(err) };
56
+ }
57
+ },
58
+ sendMedia: async (ctx) => {
59
+ const { media, caption, account, config } = ctx;
60
+ const ambient = config.channels?.ambient;
61
+ if (!account.pushToken) {
62
+ return { ok: false, error: "No push token for this device" };
63
+ }
64
+ try {
65
+ await sendPushNotification({
66
+ endpoint: ambient?.pushEndpoint ?? "https://api.ambient.app/push",
67
+ apiKey: ambient?.pushApiKey ?? "",
68
+ deviceToken: account.pushToken,
69
+ payload: {
70
+ type: "media",
71
+ url: media.url,
72
+ mimeType: media.mimeType,
73
+ caption,
74
+ timestamp: Date.now(),
75
+ },
76
+ });
77
+ return { ok: true };
78
+ }
79
+ catch (err) {
80
+ return { ok: false, error: String(err) };
81
+ }
82
+ },
83
+ },
84
+ };
85
+ export default function register(api) {
86
+ api.registerChannel({ plugin });
87
+ // Set up relay tunnel if configured
88
+ let tunnelClient = null;
89
+ let relayNodeUrl = null;
90
+ (async () => {
91
+ try {
92
+ const relay = await ensureRelayRegistration(api);
93
+ if (relay) {
94
+ relayNodeUrl = relay.nodeUrl;
95
+ const localGatewayUrl = `ws://localhost:${api.getConfig?.()?.gateway?.port ?? 18789}`;
96
+ tunnelClient = new TunnelClient({
97
+ tunnelUrl: relay.tunnelUrl,
98
+ localGatewayUrl,
99
+ });
100
+ tunnelClient.connect();
101
+ console.log(`[Ambient] Relay tunnel active, node URL: ${relay.nodeUrl}`);
102
+ }
103
+ }
104
+ catch (err) {
105
+ console.error("[Ambient] Failed to set up relay tunnel:", err);
106
+ }
107
+ })();
108
+ // Register the /ambient pairing command
109
+ api.registerCommand({
110
+ name: "ambient",
111
+ description: "Connect your Ambient iOS app",
112
+ acceptsArgs: true,
113
+ requireAuth: true,
114
+ handler: async (ctx) => {
115
+ const pairingToken = await generatePairingToken({
116
+ senderId: ctx.senderId,
117
+ channel: ctx.channel,
118
+ expiresIn: 300_000, // 5 minutes
119
+ });
120
+ // Use relay URL if available, otherwise fall back to direct connection
121
+ let gatewayUrl;
122
+ if (relayNodeUrl) {
123
+ gatewayUrl = relayNodeUrl;
124
+ }
125
+ else {
126
+ const gatewayHost = ctx.config.gateway?.publicHost ?? "localhost";
127
+ const gatewayPort = ctx.config.gateway?.port ?? 18789;
128
+ const useTls = ctx.config.gateway?.tls?.enabled ?? false;
129
+ const protocol = useTls ? "wss" : "ws";
130
+ gatewayUrl = `${protocol}://${gatewayHost}:${gatewayPort}`;
131
+ }
132
+ const params = new URLSearchParams({
133
+ token: pairingToken,
134
+ gateway: gatewayUrl,
135
+ });
136
+ const deepLink = `ambient://pair?${params.toString()}`;
137
+ return {
138
+ text: `**Connect Ambient**\n\nTap the link below on your iPhone to pair your Ambient app:\n\n${deepLink}\n\n_Link expires in 5 minutes._`,
139
+ };
140
+ },
141
+ });
142
+ // Register gateway RPC method for completing pairing
143
+ api.registerGatewayMethod("ambient.pair.complete", async (ctx) => {
144
+ const { params, respond } = ctx;
145
+ try {
146
+ const pairingData = await validatePairingToken(params.pairingToken);
147
+ if (!pairingData) {
148
+ respond(false, { error: "Invalid or expired pairing token" });
149
+ return;
150
+ }
151
+ const deviceToken = crypto.randomUUID();
152
+ const account = {
153
+ deviceId: params.deviceId,
154
+ deviceToken,
155
+ pushToken: params.pushToken,
156
+ displayName: params.displayName,
157
+ enabled: true,
158
+ pairedAt: Date.now(),
159
+ };
160
+ // Store the account in gateway config
161
+ // api.patchConfig({ channels: { ambient: { accounts: { [params.deviceId]: account }}}});
162
+ respond(true, {
163
+ deviceToken,
164
+ accountId: params.deviceId,
165
+ message: "Paired successfully",
166
+ });
167
+ }
168
+ catch (err) {
169
+ respond(false, { error: String(err) });
170
+ }
171
+ });
172
+ // Register push token update handler
173
+ api.registerGatewayMethod("ambient.pushToken.update", async (ctx) => {
174
+ const { params, respond } = ctx;
175
+ // Update the push token for this device in config
176
+ // api.patchConfig({ channels: { ambient: { accounts: { [params.deviceId]: { pushToken: params.pushToken } }}}});
177
+ respond(true, { ok: true });
178
+ });
179
+ // Register inbound webhook (for future HTTP-based inbound if needed)
180
+ api.registerHttpHandler({
181
+ method: "POST",
182
+ path: "/webhooks/ambient/inbound",
183
+ handler: async (req, res) => {
184
+ const { deviceId, message } = req.body;
185
+ const authHeader = req.headers.authorization;
186
+ if (!authHeader?.startsWith("Bearer ")) {
187
+ res.status(401).json({ error: "Unauthorized" });
188
+ return;
189
+ }
190
+ // Route to the agent session
191
+ // api.routeInbound({ channel: "ambient", accountId: deviceId, text: message });
192
+ res.json({ ok: true });
193
+ },
194
+ });
195
+ }
@@ -0,0 +1,13 @@
1
+ interface PairingData {
2
+ senderId: string;
3
+ channel: string;
4
+ createdAt: number;
5
+ expiresAt: number;
6
+ }
7
+ export declare function generatePairingToken(opts: {
8
+ senderId: string;
9
+ channel: string;
10
+ expiresIn: number;
11
+ }): Promise<string>;
12
+ export declare function validatePairingToken(token: string): Promise<PairingData | null>;
13
+ export {};
@@ -0,0 +1,25 @@
1
+ import * as crypto from "crypto";
2
+ // In production, use Redis or a proper store
3
+ const pairingTokens = new Map();
4
+ export async function generatePairingToken(opts) {
5
+ const token = crypto.randomBytes(32).toString("base64url");
6
+ pairingTokens.set(token, {
7
+ senderId: opts.senderId,
8
+ channel: opts.channel,
9
+ createdAt: Date.now(),
10
+ expiresAt: Date.now() + opts.expiresIn,
11
+ });
12
+ return token;
13
+ }
14
+ export async function validatePairingToken(token) {
15
+ const data = pairingTokens.get(token);
16
+ if (!data)
17
+ return null;
18
+ if (Date.now() > data.expiresAt) {
19
+ pairingTokens.delete(token);
20
+ return null;
21
+ }
22
+ // Consume the token (one-time use)
23
+ pairingTokens.delete(token);
24
+ return data;
25
+ }
package/dist/push.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ export declare function sendPushNotification(opts: {
2
+ endpoint: string;
3
+ apiKey: string;
4
+ deviceToken: string;
5
+ payload: any;
6
+ }): Promise<void>;
package/dist/push.js ADDED
@@ -0,0 +1,16 @@
1
+ export async function sendPushNotification(opts) {
2
+ const response = await fetch(opts.endpoint, {
3
+ method: "POST",
4
+ headers: {
5
+ "Content-Type": "application/json",
6
+ Authorization: `Bearer ${opts.apiKey}`,
7
+ },
8
+ body: JSON.stringify({
9
+ deviceToken: opts.deviceToken,
10
+ payload: opts.payload,
11
+ }),
12
+ });
13
+ if (!response.ok) {
14
+ throw new Error(`Push failed: ${response.status} ${response.statusText}`);
15
+ }
16
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Ensures this gateway is registered with the relay service.
3
+ * If already registered (gatewayId + tunnelSecret in config), returns existing values.
4
+ * Otherwise, generates a gatewayId, calls POST /v1/register, and stores the result.
5
+ */
6
+ export declare function ensureRelayRegistration(api: any): Promise<{
7
+ gatewayId: string;
8
+ tunnelSecret: string;
9
+ tunnelUrl: string;
10
+ nodeUrl: string;
11
+ relayUrl: string;
12
+ } | null>;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Ensures this gateway is registered with the relay service.
3
+ * If already registered (gatewayId + tunnelSecret in config), returns existing values.
4
+ * Otherwise, generates a gatewayId, calls POST /v1/register, and stores the result.
5
+ */
6
+ export async function ensureRelayRegistration(api) {
7
+ const config = api.getConfig?.() ?? {};
8
+ const ambient = config.channels?.ambient;
9
+ const relayUrl = ambient?.relayUrl ?? "https://ambient-relay.sunnya97.workers.dev";
10
+ // Already registered?
11
+ if (ambient?.gatewayId && ambient?.tunnelSecret) {
12
+ const baseWss = relayUrl
13
+ .replace("https://", "wss://")
14
+ .replace("http://", "ws://");
15
+ return {
16
+ gatewayId: ambient.gatewayId,
17
+ tunnelSecret: ambient.tunnelSecret,
18
+ tunnelUrl: `${baseWss}/gateway/${ambient.gatewayId}/tunnel?secret=${ambient.tunnelSecret}`,
19
+ nodeUrl: `${baseWss}/gateway/${ambient.gatewayId}/node`,
20
+ relayUrl,
21
+ };
22
+ }
23
+ // Generate a new gatewayId
24
+ const gatewayId = "gw_" + randomHex(16);
25
+ try {
26
+ const response = await fetch(`${relayUrl}/v1/register`, {
27
+ method: "POST",
28
+ headers: {
29
+ "Content-Type": "application/json",
30
+ },
31
+ body: JSON.stringify({ gatewayId }),
32
+ });
33
+ if (!response.ok) {
34
+ console.error(`[Relay] Registration failed: ${response.status} ${response.statusText}`);
35
+ return null;
36
+ }
37
+ const result = (await response.json());
38
+ // Store credentials in config
39
+ if (api.patchConfig) {
40
+ api.patchConfig({
41
+ channels: {
42
+ ambient: {
43
+ gatewayId: result.gatewayId,
44
+ tunnelSecret: result.tunnelSecret,
45
+ },
46
+ },
47
+ });
48
+ }
49
+ console.log(`[Relay] Registered gateway: ${result.gatewayId}`);
50
+ return {
51
+ gatewayId: result.gatewayId,
52
+ tunnelSecret: result.tunnelSecret,
53
+ tunnelUrl: result.tunnelUrl,
54
+ nodeUrl: result.nodeUrl,
55
+ relayUrl,
56
+ };
57
+ }
58
+ catch (err) {
59
+ console.error("[Relay] Registration error:", err);
60
+ return null;
61
+ }
62
+ }
63
+ function randomHex(bytes) {
64
+ const buf = new Uint8Array(bytes);
65
+ crypto.getRandomValues(buf);
66
+ return Array.from(buf)
67
+ .map((b) => b.toString(16).padStart(2, "0"))
68
+ .join("");
69
+ }
@@ -0,0 +1,30 @@
1
+ interface TunnelClientOptions {
2
+ tunnelUrl: string;
3
+ localGatewayUrl: string;
4
+ onStatusChange?: (connected: boolean) => void;
5
+ }
6
+ /**
7
+ * TunnelClient maintains a persistent WebSocket to the relay service
8
+ * and bridges incoming node connections to the local gateway.
9
+ */
10
+ export declare class TunnelClient {
11
+ private tunnelUrl;
12
+ private localGatewayUrl;
13
+ private onStatusChange?;
14
+ private tunnelWs;
15
+ private localConnections;
16
+ private reconnectDelay;
17
+ private maxReconnectDelay;
18
+ private reconnectTimer;
19
+ private closed;
20
+ constructor(options: TunnelClientOptions);
21
+ connect(): void;
22
+ disconnect(): void;
23
+ private scheduleReconnect;
24
+ private handleTunnelMessage;
25
+ private handleNodeConnect;
26
+ private handleNodeFrame;
27
+ private handleNodeDisconnect;
28
+ private sendToRelay;
29
+ }
30
+ export {};
package/dist/tunnel.js ADDED
@@ -0,0 +1,151 @@
1
+ import WebSocket from "ws";
2
+ /**
3
+ * TunnelClient maintains a persistent WebSocket to the relay service
4
+ * and bridges incoming node connections to the local gateway.
5
+ */
6
+ export class TunnelClient {
7
+ tunnelUrl;
8
+ localGatewayUrl;
9
+ onStatusChange;
10
+ tunnelWs = null;
11
+ localConnections = new Map();
12
+ reconnectDelay = 1000;
13
+ maxReconnectDelay = 30000;
14
+ reconnectTimer = null;
15
+ closed = false;
16
+ constructor(options) {
17
+ this.tunnelUrl = options.tunnelUrl;
18
+ this.localGatewayUrl = options.localGatewayUrl;
19
+ this.onStatusChange = options.onStatusChange;
20
+ }
21
+ connect() {
22
+ if (this.closed)
23
+ return;
24
+ this.tunnelWs = new WebSocket(this.tunnelUrl);
25
+ this.tunnelWs.on("open", () => {
26
+ console.log("[Tunnel] Connected to relay");
27
+ this.reconnectDelay = 1000;
28
+ this.onStatusChange?.(true);
29
+ });
30
+ this.tunnelWs.on("message", (data) => {
31
+ const raw = typeof data === "string" ? data : data.toString();
32
+ this.handleTunnelMessage(raw);
33
+ });
34
+ this.tunnelWs.on("close", () => {
35
+ console.log("[Tunnel] Disconnected from relay");
36
+ this.onStatusChange?.(false);
37
+ this.scheduleReconnect();
38
+ });
39
+ this.tunnelWs.on("error", (err) => {
40
+ console.error("[Tunnel] Error:", err.message);
41
+ // 'close' event will fire after error, triggering reconnect
42
+ });
43
+ }
44
+ disconnect() {
45
+ this.closed = true;
46
+ if (this.reconnectTimer) {
47
+ clearTimeout(this.reconnectTimer);
48
+ this.reconnectTimer = null;
49
+ }
50
+ this.tunnelWs?.close();
51
+ this.tunnelWs = null;
52
+ // Close all local connections
53
+ for (const [nodeId, ws] of this.localConnections) {
54
+ ws.close();
55
+ }
56
+ this.localConnections.clear();
57
+ }
58
+ scheduleReconnect() {
59
+ if (this.closed)
60
+ return;
61
+ console.log(`[Tunnel] Reconnecting in ${this.reconnectDelay}ms`);
62
+ this.reconnectTimer = setTimeout(() => {
63
+ this.reconnectTimer = null;
64
+ this.connect();
65
+ }, this.reconnectDelay);
66
+ this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay);
67
+ }
68
+ handleTunnelMessage(raw) {
69
+ let msg;
70
+ try {
71
+ msg = JSON.parse(raw);
72
+ }
73
+ catch {
74
+ console.error("[Tunnel] Invalid message:", raw);
75
+ return;
76
+ }
77
+ switch (msg.type) {
78
+ case "tunnel.nodeConnect":
79
+ if (msg.nodeId)
80
+ this.handleNodeConnect(msg.nodeId);
81
+ break;
82
+ case "tunnel.frame":
83
+ if (msg.nodeId && msg.frame)
84
+ this.handleNodeFrame(msg.nodeId, msg.frame);
85
+ break;
86
+ case "tunnel.nodeDisconnect":
87
+ if (msg.nodeId)
88
+ this.handleNodeDisconnect(msg.nodeId);
89
+ break;
90
+ case "tunnel.ping":
91
+ this.sendToRelay({
92
+ type: "tunnel.pong",
93
+ ts: msg.ts ?? Date.now(),
94
+ });
95
+ break;
96
+ }
97
+ }
98
+ handleNodeConnect(nodeId) {
99
+ // Close existing connection for this nodeId if any
100
+ const existing = this.localConnections.get(nodeId);
101
+ if (existing) {
102
+ existing.close();
103
+ this.localConnections.delete(nodeId);
104
+ }
105
+ console.log(`[Tunnel] Node ${nodeId} connected, opening local WS`);
106
+ const localWs = new WebSocket(this.localGatewayUrl);
107
+ localWs.on("open", () => {
108
+ console.log(`[Tunnel] Local WS for node ${nodeId} connected`);
109
+ });
110
+ localWs.on("message", (data) => {
111
+ // Forward local gateway response back through the tunnel
112
+ const frame = typeof data === "string" ? data : data.toString();
113
+ this.sendToRelay({
114
+ type: "tunnel.frame",
115
+ nodeId,
116
+ frame,
117
+ });
118
+ });
119
+ localWs.on("close", () => {
120
+ console.log(`[Tunnel] Local WS for node ${nodeId} closed`);
121
+ this.localConnections.delete(nodeId);
122
+ // Notify relay that this node's local connection is gone
123
+ this.sendToRelay({
124
+ type: "tunnel.nodeDisconnect",
125
+ nodeId,
126
+ });
127
+ });
128
+ localWs.on("error", (err) => {
129
+ console.error(`[Tunnel] Local WS error for node ${nodeId}:`, err.message);
130
+ });
131
+ this.localConnections.set(nodeId, localWs);
132
+ }
133
+ handleNodeFrame(nodeId, frame) {
134
+ const localWs = this.localConnections.get(nodeId);
135
+ if (localWs && localWs.readyState === WebSocket.OPEN) {
136
+ localWs.send(frame);
137
+ }
138
+ }
139
+ handleNodeDisconnect(nodeId) {
140
+ const localWs = this.localConnections.get(nodeId);
141
+ if (localWs) {
142
+ localWs.close();
143
+ this.localConnections.delete(nodeId);
144
+ }
145
+ }
146
+ sendToRelay(msg) {
147
+ if (this.tunnelWs && this.tunnelWs.readyState === WebSocket.OPEN) {
148
+ this.tunnelWs.send(JSON.stringify(msg));
149
+ }
150
+ }
151
+ }
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@ambient-os/openclaw-channel",
3
+ "version": "0.0.2",
4
+ "description": "OpenClaw channel plugin for Ambient iOS app — messaging, pairing, and push notifications",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "files": [
8
+ "dist",
9
+ "clawdbot.plugin.json"
10
+ ],
11
+ "clawdbot": {
12
+ "extensions": [
13
+ "./dist/index.js"
14
+ ],
15
+ "channel": {
16
+ "id": "ambient",
17
+ "label": "Ambient",
18
+ "selectionLabel": "Ambient iOS App",
19
+ "docsPath": "/channels/ambient",
20
+ "blurb": "Connect your Ambient iOS app for voice, display, and messaging.",
21
+ "order": 50,
22
+ "aliases": [
23
+ "amb"
24
+ ]
25
+ },
26
+ "install": {
27
+ "npmSpec": "@ambient-os/openclaw-channel"
28
+ }
29
+ },
30
+ "scripts": {
31
+ "build": "tsc",
32
+ "typecheck": "tsc --noEmit",
33
+ "prepublishOnly": "npm run build"
34
+ },
35
+ "dependencies": {
36
+ "ws": "^8.18.0"
37
+ },
38
+ "devDependencies": {
39
+ "@types/node": "^25.2.3",
40
+ "@types/ws": "^8.5.13",
41
+ "typescript": "^5.3.0"
42
+ }
43
+ }