port42-openclaw 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/.github/workflows/publish.yml +25 -0
- package/LICENSE +21 -0
- package/README.md +74 -0
- package/dist/connection.d.ts +33 -0
- package/dist/connection.js +159 -0
- package/dist/crypto.d.ts +8 -0
- package/dist/crypto.js +44 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.js +120 -0
- package/dist/invite.d.ts +13 -0
- package/dist/invite.js +23 -0
- package/dist/protocol.d.ts +30 -0
- package/dist/protocol.js +66 -0
- package/openclaw.plugin.json +44 -0
- package/package.json +37 -0
- package/src/connection.ts +201 -0
- package/src/crypto.ts +49 -0
- package/src/index.ts +165 -0
- package/src/invite.ts +31 -0
- package/src/protocol.ts +106 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
name: Publish Package
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- 'v*'
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
id-token: write
|
|
10
|
+
contents: read
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
publish:
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
|
|
18
|
+
- uses: actions/setup-node@v4
|
|
19
|
+
with:
|
|
20
|
+
node-version: '22'
|
|
21
|
+
registry-url: 'https://registry.npmjs.org'
|
|
22
|
+
|
|
23
|
+
- run: npm ci
|
|
24
|
+
- run: npm run build
|
|
25
|
+
- run: npm publish
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Gordon J. Mattey
|
|
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,74 @@
|
|
|
1
|
+
# port42-openclaw
|
|
2
|
+
|
|
3
|
+
Port42 channel adapter for OpenClaw. Bring your OpenClaw agents into [Port42](https://port42.ai) companion computing channels.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
openclaw plugins install port42-openclaw
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
Someone shares a Port42 channel invite link with you. Add it to OpenClaw:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
openclaw channel add port42 \
|
|
17
|
+
--invite "https://your-host.ngrok-free.dev/invite?id=CHANNEL-UUID&name=my-channel&key=BASE64KEY" \
|
|
18
|
+
--agent my-researcher \
|
|
19
|
+
--name "Researcher"
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Your agent appears in the Port42 channel. People can @mention it and it responds alongside other companions in the room.
|
|
23
|
+
|
|
24
|
+
### Manual config
|
|
25
|
+
|
|
26
|
+
Or edit `openclaw.json` directly:
|
|
27
|
+
|
|
28
|
+
```json
|
|
29
|
+
{
|
|
30
|
+
"channels": {
|
|
31
|
+
"port42-project": {
|
|
32
|
+
"type": "port42",
|
|
33
|
+
"invite": "https://your-host.ngrok-free.dev/invite?id=CHANNEL-UUID&name=my-channel&key=BASE64KEY",
|
|
34
|
+
"displayName": "Researcher",
|
|
35
|
+
"trigger": "mention"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Config options
|
|
42
|
+
|
|
43
|
+
| Option | Required | Default | Description |
|
|
44
|
+
|--------|----------|---------|-------------|
|
|
45
|
+
| `invite` | yes* | — | Port42 HTTPS invite link |
|
|
46
|
+
| `gateway` | yes* | — | WebSocket URL (derived from invite if provided) |
|
|
47
|
+
| `channelId` | yes* | — | Channel UUID (parsed from invite if provided) |
|
|
48
|
+
| `encryptionKey` | no | — | AES-256 key (parsed from invite if provided) |
|
|
49
|
+
| `displayName` | yes | — | How the agent appears in Port42 |
|
|
50
|
+
| `trigger` | no | `mention` | `mention` (respond to @name) or `all` (respond to everything) |
|
|
51
|
+
|
|
52
|
+
*Provide either `invite` or both `gateway` + `channelId`.
|
|
53
|
+
|
|
54
|
+
## How it works
|
|
55
|
+
|
|
56
|
+
The adapter connects to a Port42 gateway as a regular peer over WebSocket. From Port42's perspective, your OpenClaw agent is just another companion in the channel.
|
|
57
|
+
|
|
58
|
+
- Messages are end-to-end encrypted (AES-256-GCM) using the channel key from the invite link
|
|
59
|
+
- The agent shows up in the presence list when connected
|
|
60
|
+
- Typing indicators show when the agent is generating a response
|
|
61
|
+
- Auto-reconnects if the connection drops
|
|
62
|
+
|
|
63
|
+
## Building from source
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
git clone https://github.com/gordonmattey/port42-openclaw.git
|
|
67
|
+
cd port42-openclaw
|
|
68
|
+
npm install
|
|
69
|
+
npm run build
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## License
|
|
73
|
+
|
|
74
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket connection to a Port42 gateway with reconnection.
|
|
3
|
+
*/
|
|
4
|
+
export interface ConnectionConfig {
|
|
5
|
+
gateway: string;
|
|
6
|
+
channelId: string;
|
|
7
|
+
senderId: string;
|
|
8
|
+
displayName: string;
|
|
9
|
+
encryptionKey: string | null;
|
|
10
|
+
trigger: 'mention' | 'all';
|
|
11
|
+
onMessage: (senderName: string, content: string, messageId: string) => void;
|
|
12
|
+
onPresence?: (onlineIds: string[]) => void;
|
|
13
|
+
onConnected?: () => void;
|
|
14
|
+
onDisconnected?: () => void;
|
|
15
|
+
}
|
|
16
|
+
export declare class Port42Connection {
|
|
17
|
+
private ws;
|
|
18
|
+
private config;
|
|
19
|
+
private reconnectDelay;
|
|
20
|
+
private maxReconnectDelay;
|
|
21
|
+
private shouldReconnect;
|
|
22
|
+
private identified;
|
|
23
|
+
constructor(config: ConnectionConfig);
|
|
24
|
+
connect(): void;
|
|
25
|
+
disconnect(): void;
|
|
26
|
+
sendResponse(content: string): void;
|
|
27
|
+
sendTyping(isTyping: boolean): void;
|
|
28
|
+
private openSocket;
|
|
29
|
+
private handleEnvelope;
|
|
30
|
+
private handleIncomingMessage;
|
|
31
|
+
private send;
|
|
32
|
+
private scheduleReconnect;
|
|
33
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* WebSocket connection to a Port42 gateway with reconnection.
|
|
4
|
+
*/
|
|
5
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
6
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
7
|
+
};
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.Port42Connection = void 0;
|
|
10
|
+
const ws_1 = __importDefault(require("ws"));
|
|
11
|
+
const protocol_1 = require("./protocol");
|
|
12
|
+
const crypto_1 = require("./crypto");
|
|
13
|
+
class Port42Connection {
|
|
14
|
+
ws = null;
|
|
15
|
+
config;
|
|
16
|
+
reconnectDelay = 3000;
|
|
17
|
+
maxReconnectDelay = 30000;
|
|
18
|
+
shouldReconnect = true;
|
|
19
|
+
identified = false;
|
|
20
|
+
constructor(config) {
|
|
21
|
+
this.config = config;
|
|
22
|
+
}
|
|
23
|
+
connect() {
|
|
24
|
+
this.shouldReconnect = true;
|
|
25
|
+
this.openSocket();
|
|
26
|
+
}
|
|
27
|
+
disconnect() {
|
|
28
|
+
this.shouldReconnect = false;
|
|
29
|
+
if (this.ws && this.ws.readyState === ws_1.default.OPEN) {
|
|
30
|
+
this.send((0, protocol_1.createLeave)(this.config.channelId));
|
|
31
|
+
this.ws.close();
|
|
32
|
+
}
|
|
33
|
+
this.ws = null;
|
|
34
|
+
this.config.onDisconnected?.();
|
|
35
|
+
}
|
|
36
|
+
sendResponse(content) {
|
|
37
|
+
if (!this.ws || this.ws.readyState !== ws_1.default.OPEN)
|
|
38
|
+
return;
|
|
39
|
+
if (this.config.encryptionKey) {
|
|
40
|
+
const payload = {
|
|
41
|
+
content,
|
|
42
|
+
senderName: this.config.displayName,
|
|
43
|
+
senderOwner: null,
|
|
44
|
+
replyToId: null,
|
|
45
|
+
};
|
|
46
|
+
const blob = (0, crypto_1.encrypt)(payload, this.config.encryptionKey);
|
|
47
|
+
this.send((0, protocol_1.createMessage)(this.config.channelId, this.config.senderId, this.config.displayName, blob, true));
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
this.send((0, protocol_1.createMessage)(this.config.channelId, this.config.senderId, this.config.displayName, content, false));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
sendTyping(isTyping) {
|
|
54
|
+
if (!this.ws || this.ws.readyState !== ws_1.default.OPEN)
|
|
55
|
+
return;
|
|
56
|
+
this.send((0, protocol_1.createTyping)(this.config.channelId, this.config.senderId, isTyping));
|
|
57
|
+
}
|
|
58
|
+
openSocket() {
|
|
59
|
+
try {
|
|
60
|
+
this.ws = new ws_1.default(this.config.gateway);
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
console.error('[port42] Failed to create WebSocket:', err);
|
|
64
|
+
this.scheduleReconnect();
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
this.ws.on('open', () => {
|
|
68
|
+
this.reconnectDelay = 3000;
|
|
69
|
+
this.identified = false;
|
|
70
|
+
this.send((0, protocol_1.createIdentify)(this.config.senderId, this.config.displayName));
|
|
71
|
+
});
|
|
72
|
+
this.ws.on('message', (data) => {
|
|
73
|
+
try {
|
|
74
|
+
const envelope = JSON.parse(data.toString());
|
|
75
|
+
this.handleEnvelope(envelope);
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
console.error('[port42] Failed to parse message:', err);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
this.ws.on('close', () => {
|
|
82
|
+
this.identified = false;
|
|
83
|
+
this.config.onDisconnected?.();
|
|
84
|
+
this.scheduleReconnect();
|
|
85
|
+
});
|
|
86
|
+
this.ws.on('error', (err) => {
|
|
87
|
+
console.error('[port42] WebSocket error:', err.message);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
handleEnvelope(envelope) {
|
|
91
|
+
switch (envelope.type) {
|
|
92
|
+
case 'welcome':
|
|
93
|
+
this.identified = true;
|
|
94
|
+
this.send((0, protocol_1.createJoin)(this.config.channelId));
|
|
95
|
+
this.config.onConnected?.();
|
|
96
|
+
break;
|
|
97
|
+
case 'message':
|
|
98
|
+
this.handleIncomingMessage(envelope);
|
|
99
|
+
break;
|
|
100
|
+
case 'presence':
|
|
101
|
+
if (envelope.online_ids) {
|
|
102
|
+
this.config.onPresence?.(envelope.online_ids);
|
|
103
|
+
}
|
|
104
|
+
break;
|
|
105
|
+
case 'error':
|
|
106
|
+
console.error('[port42] Gateway error:', envelope.error);
|
|
107
|
+
break;
|
|
108
|
+
case 'ack':
|
|
109
|
+
// Message delivered
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
handleIncomingMessage(envelope) {
|
|
114
|
+
if (!envelope.payload || !envelope.message_id)
|
|
115
|
+
return;
|
|
116
|
+
// Ignore own messages
|
|
117
|
+
if (envelope.sender_id === this.config.senderId)
|
|
118
|
+
return;
|
|
119
|
+
// ACK receipt
|
|
120
|
+
this.send((0, protocol_1.createAck)(envelope.message_id, this.config.channelId));
|
|
121
|
+
// Decrypt if needed
|
|
122
|
+
let content;
|
|
123
|
+
let senderName;
|
|
124
|
+
if (envelope.payload.encrypted && this.config.encryptionKey) {
|
|
125
|
+
const decrypted = (0, crypto_1.decrypt)(envelope.payload.content, this.config.encryptionKey);
|
|
126
|
+
if (!decrypted) {
|
|
127
|
+
console.error('[port42] Decryption failed for message:', envelope.message_id);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
content = decrypted.content;
|
|
131
|
+
senderName = decrypted.senderName || envelope.sender_name || 'Unknown';
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
content = envelope.payload.content;
|
|
135
|
+
senderName = envelope.payload.senderName || envelope.sender_name || 'Unknown';
|
|
136
|
+
}
|
|
137
|
+
// Check trigger rules
|
|
138
|
+
if (this.config.trigger === 'mention') {
|
|
139
|
+
const mentionPattern = new RegExp(`@${this.config.displayName}\\b`, 'i');
|
|
140
|
+
if (!mentionPattern.test(content))
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
this.config.onMessage(senderName, content, envelope.message_id);
|
|
144
|
+
}
|
|
145
|
+
send(envelope) {
|
|
146
|
+
if (this.ws && this.ws.readyState === ws_1.default.OPEN) {
|
|
147
|
+
this.ws.send(JSON.stringify(envelope));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
scheduleReconnect() {
|
|
151
|
+
if (!this.shouldReconnect)
|
|
152
|
+
return;
|
|
153
|
+
console.log(`[port42] Reconnecting in ${this.reconnectDelay / 1000}s...`);
|
|
154
|
+
setTimeout(() => this.openSocket(), this.reconnectDelay);
|
|
155
|
+
this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
exports.Port42Connection = Port42Connection;
|
|
159
|
+
//# sourceMappingURL=connection.js.map
|
package/dist/crypto.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AES-256-GCM encryption/decryption matching Port42's ChannelCrypto.
|
|
3
|
+
*
|
|
4
|
+
* Wire format: base64(nonce[12] + ciphertext + tag[16])
|
|
5
|
+
*/
|
|
6
|
+
import type { Payload } from './protocol';
|
|
7
|
+
export declare function encrypt(payload: Payload, keyBase64: string): string;
|
|
8
|
+
export declare function decrypt(blob: string, keyBase64: string): Payload | null;
|
package/dist/crypto.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* AES-256-GCM encryption/decryption matching Port42's ChannelCrypto.
|
|
4
|
+
*
|
|
5
|
+
* Wire format: base64(nonce[12] + ciphertext + tag[16])
|
|
6
|
+
*/
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
exports.encrypt = encrypt;
|
|
9
|
+
exports.decrypt = decrypt;
|
|
10
|
+
const node_crypto_1 = require("node:crypto");
|
|
11
|
+
const ALGORITHM = 'aes-256-gcm';
|
|
12
|
+
const NONCE_LENGTH = 12;
|
|
13
|
+
const TAG_LENGTH = 16;
|
|
14
|
+
function encrypt(payload, keyBase64) {
|
|
15
|
+
const key = Buffer.from(keyBase64, 'base64');
|
|
16
|
+
const nonce = (0, node_crypto_1.randomBytes)(NONCE_LENGTH);
|
|
17
|
+
const cleartext = JSON.stringify(payload);
|
|
18
|
+
const cipher = (0, node_crypto_1.createCipheriv)(ALGORITHM, key, nonce);
|
|
19
|
+
const encrypted = Buffer.concat([cipher.update(cleartext, 'utf8'), cipher.final()]);
|
|
20
|
+
const tag = cipher.getAuthTag();
|
|
21
|
+
// nonce + ciphertext + tag
|
|
22
|
+
const blob = Buffer.concat([nonce, encrypted, tag]);
|
|
23
|
+
return blob.toString('base64');
|
|
24
|
+
}
|
|
25
|
+
function decrypt(blob, keyBase64) {
|
|
26
|
+
try {
|
|
27
|
+
const key = Buffer.from(keyBase64, 'base64');
|
|
28
|
+
const data = Buffer.from(blob, 'base64');
|
|
29
|
+
if (data.length < NONCE_LENGTH + TAG_LENGTH) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
const nonce = data.subarray(0, NONCE_LENGTH);
|
|
33
|
+
const tag = data.subarray(data.length - TAG_LENGTH);
|
|
34
|
+
const ciphertext = data.subarray(NONCE_LENGTH, data.length - TAG_LENGTH);
|
|
35
|
+
const decipher = (0, node_crypto_1.createDecipheriv)(ALGORITHM, key, nonce);
|
|
36
|
+
decipher.setAuthTag(tag);
|
|
37
|
+
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
38
|
+
return JSON.parse(decrypted.toString('utf8'));
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
//# sourceMappingURL=crypto.js.map
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Port42 channel adapter for OpenClaw.
|
|
3
|
+
*
|
|
4
|
+
* Bridges OpenClaw agents into Port42 companion computing channels.
|
|
5
|
+
* Users install with: openclaw plugins install port42-openclaw
|
|
6
|
+
* Then add a channel with a Port42 invite link.
|
|
7
|
+
*/
|
|
8
|
+
export interface Port42ChannelConfig {
|
|
9
|
+
invite?: string;
|
|
10
|
+
gateway?: string;
|
|
11
|
+
channelId?: string;
|
|
12
|
+
encryptionKey?: string;
|
|
13
|
+
displayName: string;
|
|
14
|
+
trigger?: 'mention' | 'all';
|
|
15
|
+
}
|
|
16
|
+
interface PluginAPI {
|
|
17
|
+
registerChannel(name: string, handler: ChannelHandler): void;
|
|
18
|
+
log: {
|
|
19
|
+
info(msg: string): void;
|
|
20
|
+
error(msg: string): void;
|
|
21
|
+
debug(msg: string): void;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
interface ChannelHandler {
|
|
25
|
+
connect(config: Port42ChannelConfig): Promise<ChannelInstance>;
|
|
26
|
+
}
|
|
27
|
+
interface ChannelInstance {
|
|
28
|
+
send(content: string): Promise<void>;
|
|
29
|
+
disconnect(): Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* OpenClaw plugin entry point.
|
|
33
|
+
*/
|
|
34
|
+
export declare function register(api: PluginAPI): void;
|
|
35
|
+
export { parseInviteLink } from './invite';
|
|
36
|
+
export { Port42Connection } from './connection';
|
|
37
|
+
export type { ConnectionConfig } from './connection';
|
|
38
|
+
export type { InviteConfig } from './invite';
|
|
39
|
+
export { encrypt, decrypt } from './crypto';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Port42 channel adapter for OpenClaw.
|
|
4
|
+
*
|
|
5
|
+
* Bridges OpenClaw agents into Port42 companion computing channels.
|
|
6
|
+
* Users install with: openclaw plugins install port42-openclaw
|
|
7
|
+
* Then add a channel with a Port42 invite link.
|
|
8
|
+
*/
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.decrypt = exports.encrypt = exports.Port42Connection = exports.parseInviteLink = void 0;
|
|
11
|
+
exports.register = register;
|
|
12
|
+
const invite_1 = require("./invite");
|
|
13
|
+
const connection_1 = require("./connection");
|
|
14
|
+
const node_crypto_1 = require("node:crypto");
|
|
15
|
+
/**
|
|
16
|
+
* Generate a stable sender ID from the display name.
|
|
17
|
+
* This ensures the same agent gets the same ID across restarts
|
|
18
|
+
* so the gateway recognizes it as the same peer.
|
|
19
|
+
*/
|
|
20
|
+
function stableSenderId(displayName) {
|
|
21
|
+
const hash = (0, node_crypto_1.createHash)('sha256').update(`port42-openclaw:${displayName}`).digest('hex');
|
|
22
|
+
// Format as UUID-like string for compatibility
|
|
23
|
+
return [
|
|
24
|
+
hash.slice(0, 8),
|
|
25
|
+
hash.slice(8, 12),
|
|
26
|
+
hash.slice(12, 16),
|
|
27
|
+
hash.slice(16, 20),
|
|
28
|
+
hash.slice(20, 32),
|
|
29
|
+
].join('-');
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Resolve config from either an invite link or explicit fields.
|
|
33
|
+
*/
|
|
34
|
+
function resolveConfig(config) {
|
|
35
|
+
if (config.invite) {
|
|
36
|
+
const parsed = (0, invite_1.parseInviteLink)(config.invite);
|
|
37
|
+
return {
|
|
38
|
+
gateway: config.gateway || parsed.gateway,
|
|
39
|
+
channelId: config.channelId || parsed.channelId,
|
|
40
|
+
encryptionKey: config.encryptionKey || parsed.encryptionKey,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
if (!config.gateway || !config.channelId) {
|
|
44
|
+
throw new Error('Port42 channel requires either an invite link or explicit gateway + channelId');
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
gateway: config.gateway,
|
|
48
|
+
channelId: config.channelId,
|
|
49
|
+
encryptionKey: config.encryptionKey || null,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* OpenClaw plugin entry point.
|
|
54
|
+
*/
|
|
55
|
+
function register(api) {
|
|
56
|
+
api.registerChannel('port42', {
|
|
57
|
+
async connect(config) {
|
|
58
|
+
const resolved = resolveConfig(config);
|
|
59
|
+
const senderId = stableSenderId(config.displayName);
|
|
60
|
+
const trigger = config.trigger || 'mention';
|
|
61
|
+
api.log.info(`Connecting to Port42 channel ${resolved.channelId} as "${config.displayName}"`);
|
|
62
|
+
return new Promise((resolve, reject) => {
|
|
63
|
+
let messageHandler = null;
|
|
64
|
+
const connection = new connection_1.Port42Connection({
|
|
65
|
+
gateway: resolved.gateway,
|
|
66
|
+
channelId: resolved.channelId,
|
|
67
|
+
senderId,
|
|
68
|
+
displayName: config.displayName,
|
|
69
|
+
encryptionKey: resolved.encryptionKey,
|
|
70
|
+
trigger,
|
|
71
|
+
onMessage(senderName, content, messageId) {
|
|
72
|
+
if (messageHandler) {
|
|
73
|
+
messageHandler(senderName, content, messageId);
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
onConnected() {
|
|
77
|
+
api.log.info(`Connected to Port42 as "${config.displayName}"`);
|
|
78
|
+
resolve({
|
|
79
|
+
async send(content) {
|
|
80
|
+
connection.sendTyping(true);
|
|
81
|
+
// Small delay so typing indicator shows before response
|
|
82
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
83
|
+
connection.sendResponse(content);
|
|
84
|
+
connection.sendTyping(false);
|
|
85
|
+
},
|
|
86
|
+
async disconnect() {
|
|
87
|
+
connection.disconnect();
|
|
88
|
+
api.log.info(`Disconnected from Port42`);
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
},
|
|
92
|
+
onDisconnected() {
|
|
93
|
+
api.log.debug('Port42 connection lost');
|
|
94
|
+
},
|
|
95
|
+
onPresence(onlineIds) {
|
|
96
|
+
api.log.debug(`Port42 presence: ${onlineIds.length} online`);
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
// Wire up the message handler for OpenClaw to receive inbound messages
|
|
100
|
+
messageHandler = (senderName, content, _messageId) => {
|
|
101
|
+
api.log.debug(`[${senderName}]: ${content.slice(0, 100)}`);
|
|
102
|
+
};
|
|
103
|
+
connection.connect();
|
|
104
|
+
// Timeout if we can't connect within 15 seconds
|
|
105
|
+
setTimeout(() => {
|
|
106
|
+
reject(new Error('Port42 connection timeout (15s)'));
|
|
107
|
+
}, 15000);
|
|
108
|
+
});
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
// Re-export modules for standalone use
|
|
113
|
+
var invite_2 = require("./invite");
|
|
114
|
+
Object.defineProperty(exports, "parseInviteLink", { enumerable: true, get: function () { return invite_2.parseInviteLink; } });
|
|
115
|
+
var connection_2 = require("./connection");
|
|
116
|
+
Object.defineProperty(exports, "Port42Connection", { enumerable: true, get: function () { return connection_2.Port42Connection; } });
|
|
117
|
+
var crypto_1 = require("./crypto");
|
|
118
|
+
Object.defineProperty(exports, "encrypt", { enumerable: true, get: function () { return crypto_1.encrypt; } });
|
|
119
|
+
Object.defineProperty(exports, "decrypt", { enumerable: true, get: function () { return crypto_1.decrypt; } });
|
|
120
|
+
//# sourceMappingURL=index.js.map
|
package/dist/invite.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse Port42 HTTPS invite links into connection config.
|
|
3
|
+
*
|
|
4
|
+
* Invite format:
|
|
5
|
+
* https://<host>/invite?id=<channel-uuid>&name=<channel-name>&key=<url-encoded-base64-aes-key>
|
|
6
|
+
*/
|
|
7
|
+
export interface InviteConfig {
|
|
8
|
+
gateway: string;
|
|
9
|
+
channelId: string;
|
|
10
|
+
channelName: string;
|
|
11
|
+
encryptionKey: string | null;
|
|
12
|
+
}
|
|
13
|
+
export declare function parseInviteLink(invite: string): InviteConfig;
|
package/dist/invite.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Parse Port42 HTTPS invite links into connection config.
|
|
4
|
+
*
|
|
5
|
+
* Invite format:
|
|
6
|
+
* https://<host>/invite?id=<channel-uuid>&name=<channel-name>&key=<url-encoded-base64-aes-key>
|
|
7
|
+
*/
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.parseInviteLink = parseInviteLink;
|
|
10
|
+
function parseInviteLink(invite) {
|
|
11
|
+
const url = new URL(invite);
|
|
12
|
+
const channelId = url.searchParams.get('id');
|
|
13
|
+
if (!channelId) {
|
|
14
|
+
throw new Error('Invite link missing channel id (id= parameter)');
|
|
15
|
+
}
|
|
16
|
+
const channelName = url.searchParams.get('name') || 'unknown';
|
|
17
|
+
const encryptionKey = url.searchParams.get('key') || null;
|
|
18
|
+
// Derive WebSocket URL from the HTTPS host
|
|
19
|
+
const protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
20
|
+
const gateway = `${protocol}//${url.host}/ws`;
|
|
21
|
+
return { gateway, channelId, channelName, encryptionKey };
|
|
22
|
+
}
|
|
23
|
+
//# sourceMappingURL=invite.js.map
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Port42 gateway WebSocket protocol types.
|
|
3
|
+
*/
|
|
4
|
+
export type EnvelopeType = 'identify' | 'welcome' | 'join' | 'leave' | 'message' | 'ack' | 'presence' | 'typing' | 'error';
|
|
5
|
+
export interface Envelope {
|
|
6
|
+
type: EnvelopeType;
|
|
7
|
+
channel_id?: string;
|
|
8
|
+
sender_id?: string;
|
|
9
|
+
sender_name?: string;
|
|
10
|
+
message_id?: string;
|
|
11
|
+
payload?: Payload;
|
|
12
|
+
timestamp?: number;
|
|
13
|
+
error?: string;
|
|
14
|
+
online_ids?: string[];
|
|
15
|
+
status?: 'online' | 'offline';
|
|
16
|
+
companion_ids?: string[];
|
|
17
|
+
}
|
|
18
|
+
export interface Payload {
|
|
19
|
+
content: string;
|
|
20
|
+
senderName?: string;
|
|
21
|
+
senderOwner?: string | null;
|
|
22
|
+
replyToId?: string | null;
|
|
23
|
+
encrypted?: boolean;
|
|
24
|
+
}
|
|
25
|
+
export declare function createIdentify(senderId: string, senderName: string): Envelope;
|
|
26
|
+
export declare function createJoin(channelId: string, companionIds?: string[]): Envelope;
|
|
27
|
+
export declare function createLeave(channelId: string): Envelope;
|
|
28
|
+
export declare function createMessage(channelId: string, senderId: string, senderName: string, content: string, encrypted?: boolean): Envelope;
|
|
29
|
+
export declare function createAck(messageId: string, channelId: string): Envelope;
|
|
30
|
+
export declare function createTyping(channelId: string, senderId: string, isTyping: boolean): Envelope;
|
package/dist/protocol.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Port42 gateway WebSocket protocol types.
|
|
4
|
+
*/
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.createIdentify = createIdentify;
|
|
7
|
+
exports.createJoin = createJoin;
|
|
8
|
+
exports.createLeave = createLeave;
|
|
9
|
+
exports.createMessage = createMessage;
|
|
10
|
+
exports.createAck = createAck;
|
|
11
|
+
exports.createTyping = createTyping;
|
|
12
|
+
function createIdentify(senderId, senderName) {
|
|
13
|
+
return {
|
|
14
|
+
type: 'identify',
|
|
15
|
+
sender_id: senderId,
|
|
16
|
+
sender_name: senderName,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
function createJoin(channelId, companionIds = []) {
|
|
20
|
+
return {
|
|
21
|
+
type: 'join',
|
|
22
|
+
channel_id: channelId,
|
|
23
|
+
companion_ids: companionIds,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
function createLeave(channelId) {
|
|
27
|
+
return {
|
|
28
|
+
type: 'leave',
|
|
29
|
+
channel_id: channelId,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function createMessage(channelId, senderId, senderName, content, encrypted = false) {
|
|
33
|
+
return {
|
|
34
|
+
type: 'message',
|
|
35
|
+
channel_id: channelId,
|
|
36
|
+
sender_id: senderId,
|
|
37
|
+
sender_name: senderName,
|
|
38
|
+
message_id: crypto.randomUUID(),
|
|
39
|
+
payload: {
|
|
40
|
+
content,
|
|
41
|
+
senderName: encrypted ? '' : senderName,
|
|
42
|
+
senderOwner: null,
|
|
43
|
+
replyToId: null,
|
|
44
|
+
encrypted,
|
|
45
|
+
},
|
|
46
|
+
timestamp: Date.now(),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function createAck(messageId, channelId) {
|
|
50
|
+
return {
|
|
51
|
+
type: 'ack',
|
|
52
|
+
message_id: messageId,
|
|
53
|
+
channel_id: channelId,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
function createTyping(channelId, senderId, isTyping) {
|
|
57
|
+
return {
|
|
58
|
+
type: 'typing',
|
|
59
|
+
channel_id: channelId,
|
|
60
|
+
sender_id: senderId,
|
|
61
|
+
payload: {
|
|
62
|
+
content: isTyping ? 'typing' : 'stopped',
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
//# sourceMappingURL=protocol.js.map
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "port42-openclaw",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Port42 channel adapter — bring your OpenClaw agents into Port42 companion computing channels",
|
|
5
|
+
"type": "channel",
|
|
6
|
+
"entry": "dist/index.js",
|
|
7
|
+
"author": "Port42",
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"homepage": "https://port42.ai",
|
|
10
|
+
"repository": "https://github.com/gordonmattey/port42-openclaw",
|
|
11
|
+
"config": {
|
|
12
|
+
"invite": {
|
|
13
|
+
"type": "string",
|
|
14
|
+
"description": "Port42 HTTPS invite link",
|
|
15
|
+
"required": false
|
|
16
|
+
},
|
|
17
|
+
"gateway": {
|
|
18
|
+
"type": "string",
|
|
19
|
+
"description": "WebSocket URL of Port42 gateway",
|
|
20
|
+
"required": false
|
|
21
|
+
},
|
|
22
|
+
"channelId": {
|
|
23
|
+
"type": "string",
|
|
24
|
+
"description": "Port42 channel UUID",
|
|
25
|
+
"required": false
|
|
26
|
+
},
|
|
27
|
+
"encryptionKey": {
|
|
28
|
+
"type": "string",
|
|
29
|
+
"description": "Base64 AES-256 encryption key",
|
|
30
|
+
"required": false
|
|
31
|
+
},
|
|
32
|
+
"displayName": {
|
|
33
|
+
"type": "string",
|
|
34
|
+
"description": "Agent display name in Port42",
|
|
35
|
+
"required": true
|
|
36
|
+
},
|
|
37
|
+
"trigger": {
|
|
38
|
+
"type": "string",
|
|
39
|
+
"enum": ["mention", "all"],
|
|
40
|
+
"default": "mention",
|
|
41
|
+
"description": "Respond to @mentions only or all messages"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "port42-openclaw",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Port42 channel adapter for OpenClaw — bring your agents into Port42 companion computing channels",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc",
|
|
9
|
+
"dev": "tsc --watch",
|
|
10
|
+
"test": "node --test dist/**/*.test.js"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"openclaw",
|
|
14
|
+
"port42",
|
|
15
|
+
"channel",
|
|
16
|
+
"plugin",
|
|
17
|
+
"ai",
|
|
18
|
+
"companion-computing"
|
|
19
|
+
],
|
|
20
|
+
"author": "Port42",
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "https://github.com/gordonmattey/port42-openclaw"
|
|
25
|
+
},
|
|
26
|
+
"homepage": "https://port42.ai",
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=18"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"ws": "^8.16.0"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/ws": "^8.5.10",
|
|
35
|
+
"typescript": "^5.4.0"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket connection to a Port42 gateway with reconnection.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import WebSocket from 'ws';
|
|
6
|
+
import {
|
|
7
|
+
type Envelope,
|
|
8
|
+
createIdentify,
|
|
9
|
+
createJoin,
|
|
10
|
+
createLeave,
|
|
11
|
+
createAck,
|
|
12
|
+
createTyping,
|
|
13
|
+
createMessage,
|
|
14
|
+
} from './protocol';
|
|
15
|
+
import { encrypt, decrypt } from './crypto';
|
|
16
|
+
|
|
17
|
+
export interface ConnectionConfig {
|
|
18
|
+
gateway: string;
|
|
19
|
+
channelId: string;
|
|
20
|
+
senderId: string;
|
|
21
|
+
displayName: string;
|
|
22
|
+
encryptionKey: string | null;
|
|
23
|
+
trigger: 'mention' | 'all';
|
|
24
|
+
onMessage: (senderName: string, content: string, messageId: string) => void;
|
|
25
|
+
onPresence?: (onlineIds: string[]) => void;
|
|
26
|
+
onConnected?: () => void;
|
|
27
|
+
onDisconnected?: () => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class Port42Connection {
|
|
31
|
+
private ws: WebSocket | null = null;
|
|
32
|
+
private config: ConnectionConfig;
|
|
33
|
+
private reconnectDelay = 3000;
|
|
34
|
+
private maxReconnectDelay = 30000;
|
|
35
|
+
private shouldReconnect = true;
|
|
36
|
+
private identified = false;
|
|
37
|
+
|
|
38
|
+
constructor(config: ConnectionConfig) {
|
|
39
|
+
this.config = config;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
connect(): void {
|
|
43
|
+
this.shouldReconnect = true;
|
|
44
|
+
this.openSocket();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
disconnect(): void {
|
|
48
|
+
this.shouldReconnect = false;
|
|
49
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
50
|
+
this.send(createLeave(this.config.channelId));
|
|
51
|
+
this.ws.close();
|
|
52
|
+
}
|
|
53
|
+
this.ws = null;
|
|
54
|
+
this.config.onDisconnected?.();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
sendResponse(content: string): void {
|
|
58
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
|
59
|
+
|
|
60
|
+
if (this.config.encryptionKey) {
|
|
61
|
+
const payload = {
|
|
62
|
+
content,
|
|
63
|
+
senderName: this.config.displayName,
|
|
64
|
+
senderOwner: null,
|
|
65
|
+
replyToId: null,
|
|
66
|
+
};
|
|
67
|
+
const blob = encrypt(payload, this.config.encryptionKey);
|
|
68
|
+
this.send(createMessage(
|
|
69
|
+
this.config.channelId,
|
|
70
|
+
this.config.senderId,
|
|
71
|
+
this.config.displayName,
|
|
72
|
+
blob,
|
|
73
|
+
true,
|
|
74
|
+
));
|
|
75
|
+
} else {
|
|
76
|
+
this.send(createMessage(
|
|
77
|
+
this.config.channelId,
|
|
78
|
+
this.config.senderId,
|
|
79
|
+
this.config.displayName,
|
|
80
|
+
content,
|
|
81
|
+
false,
|
|
82
|
+
));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
sendTyping(isTyping: boolean): void {
|
|
87
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
|
88
|
+
this.send(createTyping(this.config.channelId, this.config.senderId, isTyping));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private openSocket(): void {
|
|
92
|
+
try {
|
|
93
|
+
this.ws = new WebSocket(this.config.gateway);
|
|
94
|
+
} catch (err) {
|
|
95
|
+
console.error('[port42] Failed to create WebSocket:', err);
|
|
96
|
+
this.scheduleReconnect();
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
this.ws.on('open', () => {
|
|
101
|
+
this.reconnectDelay = 3000;
|
|
102
|
+
this.identified = false;
|
|
103
|
+
this.send(createIdentify(this.config.senderId, this.config.displayName));
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
this.ws.on('message', (data) => {
|
|
107
|
+
try {
|
|
108
|
+
const envelope: Envelope = JSON.parse(data.toString());
|
|
109
|
+
this.handleEnvelope(envelope);
|
|
110
|
+
} catch (err) {
|
|
111
|
+
console.error('[port42] Failed to parse message:', err);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
this.ws.on('close', () => {
|
|
116
|
+
this.identified = false;
|
|
117
|
+
this.config.onDisconnected?.();
|
|
118
|
+
this.scheduleReconnect();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
this.ws.on('error', (err) => {
|
|
122
|
+
console.error('[port42] WebSocket error:', err.message);
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private handleEnvelope(envelope: Envelope): void {
|
|
127
|
+
switch (envelope.type) {
|
|
128
|
+
case 'welcome':
|
|
129
|
+
this.identified = true;
|
|
130
|
+
this.send(createJoin(this.config.channelId));
|
|
131
|
+
this.config.onConnected?.();
|
|
132
|
+
break;
|
|
133
|
+
|
|
134
|
+
case 'message':
|
|
135
|
+
this.handleIncomingMessage(envelope);
|
|
136
|
+
break;
|
|
137
|
+
|
|
138
|
+
case 'presence':
|
|
139
|
+
if (envelope.online_ids) {
|
|
140
|
+
this.config.onPresence?.(envelope.online_ids);
|
|
141
|
+
}
|
|
142
|
+
break;
|
|
143
|
+
|
|
144
|
+
case 'error':
|
|
145
|
+
console.error('[port42] Gateway error:', envelope.error);
|
|
146
|
+
break;
|
|
147
|
+
|
|
148
|
+
case 'ack':
|
|
149
|
+
// Message delivered
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private handleIncomingMessage(envelope: Envelope): void {
|
|
155
|
+
if (!envelope.payload || !envelope.message_id) return;
|
|
156
|
+
|
|
157
|
+
// Ignore own messages
|
|
158
|
+
if (envelope.sender_id === this.config.senderId) return;
|
|
159
|
+
|
|
160
|
+
// ACK receipt
|
|
161
|
+
this.send(createAck(envelope.message_id, this.config.channelId!));
|
|
162
|
+
|
|
163
|
+
// Decrypt if needed
|
|
164
|
+
let content: string;
|
|
165
|
+
let senderName: string;
|
|
166
|
+
|
|
167
|
+
if (envelope.payload.encrypted && this.config.encryptionKey) {
|
|
168
|
+
const decrypted = decrypt(envelope.payload.content, this.config.encryptionKey);
|
|
169
|
+
if (!decrypted) {
|
|
170
|
+
console.error('[port42] Decryption failed for message:', envelope.message_id);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
content = decrypted.content;
|
|
174
|
+
senderName = decrypted.senderName || envelope.sender_name || 'Unknown';
|
|
175
|
+
} else {
|
|
176
|
+
content = envelope.payload.content;
|
|
177
|
+
senderName = envelope.payload.senderName || envelope.sender_name || 'Unknown';
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Check trigger rules
|
|
181
|
+
if (this.config.trigger === 'mention') {
|
|
182
|
+
const mentionPattern = new RegExp(`@${this.config.displayName}\\b`, 'i');
|
|
183
|
+
if (!mentionPattern.test(content)) return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
this.config.onMessage(senderName, content, envelope.message_id);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private send(envelope: Envelope): void {
|
|
190
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
191
|
+
this.ws.send(JSON.stringify(envelope));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private scheduleReconnect(): void {
|
|
196
|
+
if (!this.shouldReconnect) return;
|
|
197
|
+
console.log(`[port42] Reconnecting in ${this.reconnectDelay / 1000}s...`);
|
|
198
|
+
setTimeout(() => this.openSocket(), this.reconnectDelay);
|
|
199
|
+
this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay);
|
|
200
|
+
}
|
|
201
|
+
}
|
package/src/crypto.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AES-256-GCM encryption/decryption matching Port42's ChannelCrypto.
|
|
3
|
+
*
|
|
4
|
+
* Wire format: base64(nonce[12] + ciphertext + tag[16])
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
|
8
|
+
import type { Payload } from './protocol';
|
|
9
|
+
|
|
10
|
+
const ALGORITHM = 'aes-256-gcm';
|
|
11
|
+
const NONCE_LENGTH = 12;
|
|
12
|
+
const TAG_LENGTH = 16;
|
|
13
|
+
|
|
14
|
+
export function encrypt(payload: Payload, keyBase64: string): string {
|
|
15
|
+
const key = Buffer.from(keyBase64, 'base64');
|
|
16
|
+
const nonce = randomBytes(NONCE_LENGTH);
|
|
17
|
+
const cleartext = JSON.stringify(payload);
|
|
18
|
+
|
|
19
|
+
const cipher = createCipheriv(ALGORITHM, key, nonce);
|
|
20
|
+
const encrypted = Buffer.concat([cipher.update(cleartext, 'utf8'), cipher.final()]);
|
|
21
|
+
const tag = cipher.getAuthTag();
|
|
22
|
+
|
|
23
|
+
// nonce + ciphertext + tag
|
|
24
|
+
const blob = Buffer.concat([nonce, encrypted, tag]);
|
|
25
|
+
return blob.toString('base64');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function decrypt(blob: string, keyBase64: string): Payload | null {
|
|
29
|
+
try {
|
|
30
|
+
const key = Buffer.from(keyBase64, 'base64');
|
|
31
|
+
const data = Buffer.from(blob, 'base64');
|
|
32
|
+
|
|
33
|
+
if (data.length < NONCE_LENGTH + TAG_LENGTH) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const nonce = data.subarray(0, NONCE_LENGTH);
|
|
38
|
+
const tag = data.subarray(data.length - TAG_LENGTH);
|
|
39
|
+
const ciphertext = data.subarray(NONCE_LENGTH, data.length - TAG_LENGTH);
|
|
40
|
+
|
|
41
|
+
const decipher = createDecipheriv(ALGORITHM, key, nonce);
|
|
42
|
+
decipher.setAuthTag(tag);
|
|
43
|
+
|
|
44
|
+
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
45
|
+
return JSON.parse(decrypted.toString('utf8'));
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Port42 channel adapter for OpenClaw.
|
|
3
|
+
*
|
|
4
|
+
* Bridges OpenClaw agents into Port42 companion computing channels.
|
|
5
|
+
* Users install with: openclaw plugins install port42-openclaw
|
|
6
|
+
* Then add a channel with a Port42 invite link.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { parseInviteLink, type InviteConfig } from './invite';
|
|
10
|
+
import { Port42Connection, type ConnectionConfig } from './connection';
|
|
11
|
+
import { createHash } from 'node:crypto';
|
|
12
|
+
|
|
13
|
+
export interface Port42ChannelConfig {
|
|
14
|
+
invite?: string;
|
|
15
|
+
gateway?: string;
|
|
16
|
+
channelId?: string;
|
|
17
|
+
encryptionKey?: string;
|
|
18
|
+
displayName: string;
|
|
19
|
+
trigger?: 'mention' | 'all';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface PluginAPI {
|
|
23
|
+
registerChannel(
|
|
24
|
+
name: string,
|
|
25
|
+
handler: ChannelHandler,
|
|
26
|
+
): void;
|
|
27
|
+
log: {
|
|
28
|
+
info(msg: string): void;
|
|
29
|
+
error(msg: string): void;
|
|
30
|
+
debug(msg: string): void;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface ChannelHandler {
|
|
35
|
+
connect(config: Port42ChannelConfig): Promise<ChannelInstance>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface ChannelInstance {
|
|
39
|
+
send(content: string): Promise<void>;
|
|
40
|
+
disconnect(): Promise<void>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Generate a stable sender ID from the display name.
|
|
45
|
+
* This ensures the same agent gets the same ID across restarts
|
|
46
|
+
* so the gateway recognizes it as the same peer.
|
|
47
|
+
*/
|
|
48
|
+
function stableSenderId(displayName: string): string {
|
|
49
|
+
const hash = createHash('sha256').update(`port42-openclaw:${displayName}`).digest('hex');
|
|
50
|
+
// Format as UUID-like string for compatibility
|
|
51
|
+
return [
|
|
52
|
+
hash.slice(0, 8),
|
|
53
|
+
hash.slice(8, 12),
|
|
54
|
+
hash.slice(12, 16),
|
|
55
|
+
hash.slice(16, 20),
|
|
56
|
+
hash.slice(20, 32),
|
|
57
|
+
].join('-');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Resolve config from either an invite link or explicit fields.
|
|
62
|
+
*/
|
|
63
|
+
function resolveConfig(config: Port42ChannelConfig): {
|
|
64
|
+
gateway: string;
|
|
65
|
+
channelId: string;
|
|
66
|
+
encryptionKey: string | null;
|
|
67
|
+
} {
|
|
68
|
+
if (config.invite) {
|
|
69
|
+
const parsed = parseInviteLink(config.invite);
|
|
70
|
+
return {
|
|
71
|
+
gateway: config.gateway || parsed.gateway,
|
|
72
|
+
channelId: config.channelId || parsed.channelId,
|
|
73
|
+
encryptionKey: config.encryptionKey || parsed.encryptionKey,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!config.gateway || !config.channelId) {
|
|
78
|
+
throw new Error('Port42 channel requires either an invite link or explicit gateway + channelId');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
gateway: config.gateway,
|
|
83
|
+
channelId: config.channelId,
|
|
84
|
+
encryptionKey: config.encryptionKey || null,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* OpenClaw plugin entry point.
|
|
90
|
+
*/
|
|
91
|
+
export function register(api: PluginAPI): void {
|
|
92
|
+
api.registerChannel('port42', {
|
|
93
|
+
async connect(config: Port42ChannelConfig): Promise<ChannelInstance> {
|
|
94
|
+
const resolved = resolveConfig(config);
|
|
95
|
+
const senderId = stableSenderId(config.displayName);
|
|
96
|
+
const trigger = config.trigger || 'mention';
|
|
97
|
+
|
|
98
|
+
api.log.info(`Connecting to Port42 channel ${resolved.channelId} as "${config.displayName}"`);
|
|
99
|
+
|
|
100
|
+
return new Promise<ChannelInstance>((resolve, reject) => {
|
|
101
|
+
let messageHandler: ((senderName: string, content: string, messageId: string) => void) | null = null;
|
|
102
|
+
|
|
103
|
+
const connection = new Port42Connection({
|
|
104
|
+
gateway: resolved.gateway,
|
|
105
|
+
channelId: resolved.channelId,
|
|
106
|
+
senderId,
|
|
107
|
+
displayName: config.displayName,
|
|
108
|
+
encryptionKey: resolved.encryptionKey,
|
|
109
|
+
trigger,
|
|
110
|
+
|
|
111
|
+
onMessage(senderName, content, messageId) {
|
|
112
|
+
if (messageHandler) {
|
|
113
|
+
messageHandler(senderName, content, messageId);
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
onConnected() {
|
|
118
|
+
api.log.info(`Connected to Port42 as "${config.displayName}"`);
|
|
119
|
+
resolve({
|
|
120
|
+
async send(content: string) {
|
|
121
|
+
connection.sendTyping(true);
|
|
122
|
+
// Small delay so typing indicator shows before response
|
|
123
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
124
|
+
connection.sendResponse(content);
|
|
125
|
+
connection.sendTyping(false);
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
async disconnect() {
|
|
129
|
+
connection.disconnect();
|
|
130
|
+
api.log.info(`Disconnected from Port42`);
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
onDisconnected() {
|
|
136
|
+
api.log.debug('Port42 connection lost');
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
onPresence(onlineIds) {
|
|
140
|
+
api.log.debug(`Port42 presence: ${onlineIds.length} online`);
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// Wire up the message handler for OpenClaw to receive inbound messages
|
|
145
|
+
messageHandler = (senderName, content, _messageId) => {
|
|
146
|
+
api.log.debug(`[${senderName}]: ${content.slice(0, 100)}`);
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
connection.connect();
|
|
150
|
+
|
|
151
|
+
// Timeout if we can't connect within 15 seconds
|
|
152
|
+
setTimeout(() => {
|
|
153
|
+
reject(new Error('Port42 connection timeout (15s)'));
|
|
154
|
+
}, 15000);
|
|
155
|
+
});
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Re-export modules for standalone use
|
|
161
|
+
export { parseInviteLink } from './invite';
|
|
162
|
+
export { Port42Connection } from './connection';
|
|
163
|
+
export type { ConnectionConfig } from './connection';
|
|
164
|
+
export type { InviteConfig } from './invite';
|
|
165
|
+
export { encrypt, decrypt } from './crypto';
|
package/src/invite.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse Port42 HTTPS invite links into connection config.
|
|
3
|
+
*
|
|
4
|
+
* Invite format:
|
|
5
|
+
* https://<host>/invite?id=<channel-uuid>&name=<channel-name>&key=<url-encoded-base64-aes-key>
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface InviteConfig {
|
|
9
|
+
gateway: string;
|
|
10
|
+
channelId: string;
|
|
11
|
+
channelName: string;
|
|
12
|
+
encryptionKey: string | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function parseInviteLink(invite: string): InviteConfig {
|
|
16
|
+
const url = new URL(invite);
|
|
17
|
+
|
|
18
|
+
const channelId = url.searchParams.get('id');
|
|
19
|
+
if (!channelId) {
|
|
20
|
+
throw new Error('Invite link missing channel id (id= parameter)');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const channelName = url.searchParams.get('name') || 'unknown';
|
|
24
|
+
const encryptionKey = url.searchParams.get('key') || null;
|
|
25
|
+
|
|
26
|
+
// Derive WebSocket URL from the HTTPS host
|
|
27
|
+
const protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
28
|
+
const gateway = `${protocol}//${url.host}/ws`;
|
|
29
|
+
|
|
30
|
+
return { gateway, channelId, channelName, encryptionKey };
|
|
31
|
+
}
|
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Port42 gateway WebSocket protocol types.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export type EnvelopeType =
|
|
6
|
+
| 'identify'
|
|
7
|
+
| 'welcome'
|
|
8
|
+
| 'join'
|
|
9
|
+
| 'leave'
|
|
10
|
+
| 'message'
|
|
11
|
+
| 'ack'
|
|
12
|
+
| 'presence'
|
|
13
|
+
| 'typing'
|
|
14
|
+
| 'error';
|
|
15
|
+
|
|
16
|
+
export interface Envelope {
|
|
17
|
+
type: EnvelopeType;
|
|
18
|
+
channel_id?: string;
|
|
19
|
+
sender_id?: string;
|
|
20
|
+
sender_name?: string;
|
|
21
|
+
message_id?: string;
|
|
22
|
+
payload?: Payload;
|
|
23
|
+
timestamp?: number;
|
|
24
|
+
error?: string;
|
|
25
|
+
online_ids?: string[];
|
|
26
|
+
status?: 'online' | 'offline';
|
|
27
|
+
companion_ids?: string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface Payload {
|
|
31
|
+
content: string;
|
|
32
|
+
senderName?: string;
|
|
33
|
+
senderOwner?: string | null;
|
|
34
|
+
replyToId?: string | null;
|
|
35
|
+
encrypted?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function createIdentify(senderId: string, senderName: string): Envelope {
|
|
39
|
+
return {
|
|
40
|
+
type: 'identify',
|
|
41
|
+
sender_id: senderId,
|
|
42
|
+
sender_name: senderName,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function createJoin(channelId: string, companionIds: string[] = []): Envelope {
|
|
47
|
+
return {
|
|
48
|
+
type: 'join',
|
|
49
|
+
channel_id: channelId,
|
|
50
|
+
companion_ids: companionIds,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function createLeave(channelId: string): Envelope {
|
|
55
|
+
return {
|
|
56
|
+
type: 'leave',
|
|
57
|
+
channel_id: channelId,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function createMessage(
|
|
62
|
+
channelId: string,
|
|
63
|
+
senderId: string,
|
|
64
|
+
senderName: string,
|
|
65
|
+
content: string,
|
|
66
|
+
encrypted: boolean = false,
|
|
67
|
+
): Envelope {
|
|
68
|
+
return {
|
|
69
|
+
type: 'message',
|
|
70
|
+
channel_id: channelId,
|
|
71
|
+
sender_id: senderId,
|
|
72
|
+
sender_name: senderName,
|
|
73
|
+
message_id: crypto.randomUUID(),
|
|
74
|
+
payload: {
|
|
75
|
+
content,
|
|
76
|
+
senderName: encrypted ? '' : senderName,
|
|
77
|
+
senderOwner: null,
|
|
78
|
+
replyToId: null,
|
|
79
|
+
encrypted,
|
|
80
|
+
},
|
|
81
|
+
timestamp: Date.now(),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function createAck(messageId: string, channelId: string): Envelope {
|
|
86
|
+
return {
|
|
87
|
+
type: 'ack',
|
|
88
|
+
message_id: messageId,
|
|
89
|
+
channel_id: channelId,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function createTyping(
|
|
94
|
+
channelId: string,
|
|
95
|
+
senderId: string,
|
|
96
|
+
isTyping: boolean,
|
|
97
|
+
): Envelope {
|
|
98
|
+
return {
|
|
99
|
+
type: 'typing',
|
|
100
|
+
channel_id: channelId,
|
|
101
|
+
sender_id: senderId,
|
|
102
|
+
payload: {
|
|
103
|
+
content: isTyping ? 'typing' : 'stopped',
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "commonjs",
|
|
5
|
+
"lib": ["ES2022"],
|
|
6
|
+
"outDir": "dist",
|
|
7
|
+
"rootDir": "src",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"declaration": true,
|
|
12
|
+
"sourceMap": true
|
|
13
|
+
},
|
|
14
|
+
"include": ["src/**/*"]
|
|
15
|
+
}
|