@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.
- package/clawdbot.plugin.json +19 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +195 -0
- package/dist/pairing.d.ts +13 -0
- package/dist/pairing.js +25 -0
- package/dist/push.d.ts +6 -0
- package/dist/push.js +16 -0
- package/dist/relay-registration.d.ts +12 -0
- package/dist/relay-registration.js +69 -0
- package/dist/tunnel.d.ts +30 -0
- package/dist/tunnel.js +151 -0
- package/package.json +43 -0
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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 {};
|
package/dist/pairing.js
ADDED
|
@@ -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
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
|
+
}
|
package/dist/tunnel.d.ts
ADDED
|
@@ -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
|
+
}
|