@soyeht/soyeht 0.1.1
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/README.md +73 -0
- package/docs/PROTOCOL.md +388 -0
- package/openclaw.plugin.json +14 -0
- package/package.json +49 -0
- package/src/channel.ts +157 -0
- package/src/config.ts +203 -0
- package/src/crypto.ts +227 -0
- package/src/envelope-v2.ts +201 -0
- package/src/http.ts +175 -0
- package/src/identity.ts +157 -0
- package/src/index.ts +120 -0
- package/src/media.ts +100 -0
- package/src/openclaw-plugin-sdk.d.ts +209 -0
- package/src/outbound.ts +198 -0
- package/src/pairing.ts +324 -0
- package/src/ratchet.ts +262 -0
- package/src/rpc.ts +503 -0
- package/src/security.ts +158 -0
- package/src/service.ts +177 -0
- package/src/types.ts +213 -0
- package/src/version.ts +1 -0
- package/src/x3dh.ts +105 -0
package/README.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Soyeht OpenClaw Plugin
|
|
2
|
+
|
|
3
|
+
Channel plugin for connecting the Soyeht Flutter mobile app to an OpenClaw gateway.
|
|
4
|
+
|
|
5
|
+
## Install in OpenClaw
|
|
6
|
+
|
|
7
|
+
After this package is published to npm, install it on the OpenClaw host with:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
openclaw plugins install @soyeht/soyeht@0.1.1 --pin
|
|
11
|
+
openclaw plugins enable soyeht
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Then verify:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
openclaw plugins list
|
|
18
|
+
openclaw plugins info soyeht
|
|
19
|
+
openclaw plugins doctor
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Minimal configuration
|
|
23
|
+
|
|
24
|
+
Add a Soyeht account under `channels.soyeht.accounts.default`:
|
|
25
|
+
|
|
26
|
+
```yaml
|
|
27
|
+
channels:
|
|
28
|
+
soyeht:
|
|
29
|
+
accounts:
|
|
30
|
+
default:
|
|
31
|
+
enabled: true
|
|
32
|
+
backendBaseUrl: https://your-backend.example
|
|
33
|
+
pluginAuthToken: your-plugin-auth-token
|
|
34
|
+
security:
|
|
35
|
+
enabled: true
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Local validation
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
npm ci
|
|
42
|
+
npm run validate
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Publish to npm
|
|
46
|
+
|
|
47
|
+
1. Make sure the package name and scope are available on npm.
|
|
48
|
+
2. Log in:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
npm login
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
3. Bump the version:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
npm version patch
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
This also syncs `openclaw.plugin.json`.
|
|
61
|
+
|
|
62
|
+
4. Publish publicly:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
npm publish
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The package is configured with `publishConfig.access=public`, so the first publish of the scoped package does not need an extra `--access public`.
|
|
69
|
+
|
|
70
|
+
## Protocol docs
|
|
71
|
+
|
|
72
|
+
- [Protocol](docs/PROTOCOL.md)
|
|
73
|
+
- [Flutter agent prompt](docs/FLUTTER_AGENT_PROMPT.md)
|
package/docs/PROTOCOL.md
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
# Soyeht Plugin Security Protocol
|
|
2
|
+
|
|
3
|
+
This document describes the pairing and secure-session contract currently implemented by the plugin.
|
|
4
|
+
|
|
5
|
+
## Scope
|
|
6
|
+
|
|
7
|
+
The security flow has 3 phases:
|
|
8
|
+
|
|
9
|
+
1. QR pairing bootstrap
|
|
10
|
+
2. Two-step authenticated X3DH handshake
|
|
11
|
+
3. Envelope V2 messaging with symmetric and DH ratchet state
|
|
12
|
+
|
|
13
|
+
The canonical implementation lives in:
|
|
14
|
+
|
|
15
|
+
- `src/pairing.ts`
|
|
16
|
+
- `src/rpc.ts`
|
|
17
|
+
- `src/x3dh.ts`
|
|
18
|
+
- `src/envelope-v2.ts`
|
|
19
|
+
- `src/ratchet.ts`
|
|
20
|
+
|
|
21
|
+
## Encoding Rules
|
|
22
|
+
|
|
23
|
+
- All binary fields are base64url without padding.
|
|
24
|
+
- All timestamps are Unix epoch milliseconds.
|
|
25
|
+
- `accountId` should be normalized to lowercase and trimmed.
|
|
26
|
+
- Nonces must be base64url and decode to 16-64 bytes.
|
|
27
|
+
|
|
28
|
+
## Long-Term Keys
|
|
29
|
+
|
|
30
|
+
Plugin:
|
|
31
|
+
|
|
32
|
+
- Ed25519 identity key pair
|
|
33
|
+
- X25519 static DH key pair
|
|
34
|
+
|
|
35
|
+
App:
|
|
36
|
+
|
|
37
|
+
- Ed25519 identity key pair
|
|
38
|
+
- X25519 static DH key pair
|
|
39
|
+
|
|
40
|
+
The app should generate and persist its long-term keys locally. Do not send or embed app private keys in QR codes.
|
|
41
|
+
|
|
42
|
+
## Fingerprint
|
|
43
|
+
|
|
44
|
+
Fingerprint algorithm:
|
|
45
|
+
|
|
46
|
+
- `SHA-256(signPubRaw || dhPubRaw)`
|
|
47
|
+
- truncate to the first 16 bytes
|
|
48
|
+
- hex encode lowercase
|
|
49
|
+
|
|
50
|
+
The plugin implementation is in `src/crypto.ts`.
|
|
51
|
+
|
|
52
|
+
## RPC Methods
|
|
53
|
+
|
|
54
|
+
### `soyeht.security.pairing.start`
|
|
55
|
+
|
|
56
|
+
Purpose:
|
|
57
|
+
|
|
58
|
+
- Called by the host/plugin side to create a short-lived pairing payload for QR rendering.
|
|
59
|
+
|
|
60
|
+
Request params:
|
|
61
|
+
|
|
62
|
+
```json
|
|
63
|
+
{
|
|
64
|
+
"accountId": "default",
|
|
65
|
+
"allowOverwrite": false,
|
|
66
|
+
"ttlMs": 90000
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Success payload:
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
{
|
|
74
|
+
"qrPayload": {
|
|
75
|
+
"version": 1,
|
|
76
|
+
"type": "soyeht_pairing_qr",
|
|
77
|
+
"accountId": "default",
|
|
78
|
+
"pairingToken": "<base64url-32-bytes>",
|
|
79
|
+
"expiresAt": 1730000000000,
|
|
80
|
+
"allowOverwrite": false,
|
|
81
|
+
"pluginIdentityKey": "<base64url-32-bytes>",
|
|
82
|
+
"pluginDhKey": "<base64url-32-bytes>",
|
|
83
|
+
"fingerprint": "<32-char-hex>",
|
|
84
|
+
"signature": "<base64url-ed25519-signature>"
|
|
85
|
+
},
|
|
86
|
+
"qrText": "{...same JSON stringified...}",
|
|
87
|
+
"expiresAt": 1730000000000
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
QR signature transcript:
|
|
92
|
+
|
|
93
|
+
```text
|
|
94
|
+
pairing_qr_v1|{accountId}|{pairingToken}|{expiresAt}|{allowOverwrite?1:0}|{pluginIdentityKey}|{pluginDhKey}|{fingerprint}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
The signature is Ed25519 with the plugin identity private key.
|
|
98
|
+
|
|
99
|
+
### `soyeht.security.pair`
|
|
100
|
+
|
|
101
|
+
Purpose:
|
|
102
|
+
|
|
103
|
+
- Called by the app after scanning the QR to register its public keys and consume the one-time `pairingToken`.
|
|
104
|
+
|
|
105
|
+
Request params:
|
|
106
|
+
|
|
107
|
+
```json
|
|
108
|
+
{
|
|
109
|
+
"accountId": "default",
|
|
110
|
+
"pairingToken": "<from QR>",
|
|
111
|
+
"appIdentityKey": "<base64url-ed25519-public-key>",
|
|
112
|
+
"appDhKey": "<base64url-x25519-public-key>",
|
|
113
|
+
"appSignature": "<base64url-ed25519-signature>"
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
App pairing proof transcript:
|
|
118
|
+
|
|
119
|
+
```text
|
|
120
|
+
pairing_proof_v1|{accountId}|{pairingToken}|{expiresAt}|{appIdentityKey}|{appDhKey}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
The app signs this transcript with its Ed25519 identity private key.
|
|
124
|
+
|
|
125
|
+
Success payload:
|
|
126
|
+
|
|
127
|
+
```json
|
|
128
|
+
{
|
|
129
|
+
"accountId": "default",
|
|
130
|
+
"handshakeRequired": true,
|
|
131
|
+
"pluginIdentityKey": "<base64url-32-bytes>",
|
|
132
|
+
"pluginDhKey": "<base64url-32-bytes>",
|
|
133
|
+
"fingerprint": "<32-char-hex>"
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Important behavior:
|
|
138
|
+
|
|
139
|
+
- `pairingToken` is single-use.
|
|
140
|
+
- Expired tokens are rejected.
|
|
141
|
+
- If `allowOverwrite=true`, existing peer and session state for that account may be replaced.
|
|
142
|
+
- On peer replacement, old session state is cleared.
|
|
143
|
+
|
|
144
|
+
### `soyeht.security.handshake.init`
|
|
145
|
+
|
|
146
|
+
Purpose:
|
|
147
|
+
|
|
148
|
+
- Step 1 of the secure-session bootstrap.
|
|
149
|
+
|
|
150
|
+
Request params:
|
|
151
|
+
|
|
152
|
+
```json
|
|
153
|
+
{
|
|
154
|
+
"accountId": "default",
|
|
155
|
+
"appEphemeralKey": "<base64url-x25519-public-key>",
|
|
156
|
+
"nonce": "<base64url-random-16-to-64-bytes>",
|
|
157
|
+
"timestamp": 1730000000000
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Behavior:
|
|
162
|
+
|
|
163
|
+
- Validates peer exists
|
|
164
|
+
- Validates nonce/timestamp
|
|
165
|
+
- Rejects nonce reuse
|
|
166
|
+
- Generates plugin ephemeral X25519 key
|
|
167
|
+
- Builds the full handshake transcript
|
|
168
|
+
- Signs it with the plugin Ed25519 identity key
|
|
169
|
+
- Stores a pending handshake challenge in memory
|
|
170
|
+
|
|
171
|
+
Success payload:
|
|
172
|
+
|
|
173
|
+
```json
|
|
174
|
+
{
|
|
175
|
+
"version": 2,
|
|
176
|
+
"phase": "init",
|
|
177
|
+
"complete": false,
|
|
178
|
+
"pluginEphemeralKey": "<base64url-x25519-public-key>",
|
|
179
|
+
"nonce": "<same nonce>",
|
|
180
|
+
"timestamp": 1730000000000,
|
|
181
|
+
"serverTimestamp": 1730000000100,
|
|
182
|
+
"challengeExpiresAt": 1730000030100,
|
|
183
|
+
"expiresAt": 1730086400100,
|
|
184
|
+
"pluginSignature": "<base64url-ed25519-signature>"
|
|
185
|
+
}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Full handshake transcript:
|
|
189
|
+
|
|
190
|
+
```text
|
|
191
|
+
x3dh_v2|{accountId}|{appEphemeralKey}|{pluginEphemeralKey}|{nonce}|{timestamp}|{expiresAt}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### `soyeht.security.handshake.finish`
|
|
195
|
+
|
|
196
|
+
Purpose:
|
|
197
|
+
|
|
198
|
+
- Step 2 of the secure-session bootstrap.
|
|
199
|
+
|
|
200
|
+
Request params:
|
|
201
|
+
|
|
202
|
+
```json
|
|
203
|
+
{
|
|
204
|
+
"version": 2,
|
|
205
|
+
"accountId": "default",
|
|
206
|
+
"nonce": "<same nonce from init>",
|
|
207
|
+
"appSignature": "<base64url-ed25519-signature>"
|
|
208
|
+
}
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
The app signs the full handshake transcript returned implicitly by `handshake.init`:
|
|
212
|
+
|
|
213
|
+
```text
|
|
214
|
+
x3dh_v2|{accountId}|{appEphemeralKey}|{pluginEphemeralKey}|{nonce}|{timestamp}|{expiresAt}
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Success payload:
|
|
218
|
+
|
|
219
|
+
```json
|
|
220
|
+
{
|
|
221
|
+
"version": 2,
|
|
222
|
+
"phase": "finish",
|
|
223
|
+
"complete": true,
|
|
224
|
+
"expiresAt": 1730086400100
|
|
225
|
+
}
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Important behavior:
|
|
229
|
+
|
|
230
|
+
- The plugin verifies `appSignature` against the app identity key registered during pairing.
|
|
231
|
+
- The pending handshake must still be unexpired.
|
|
232
|
+
- On success, the plugin creates and stores a ratchet session for that account.
|
|
233
|
+
|
|
234
|
+
### Legacy alias
|
|
235
|
+
|
|
236
|
+
`soyeht.security.handshake` is currently registered as a compatibility alias for `soyeht.security.handshake.init`.
|
|
237
|
+
|
|
238
|
+
New clients should use:
|
|
239
|
+
|
|
240
|
+
- `soyeht.security.handshake.init`
|
|
241
|
+
- `soyeht.security.handshake.finish`
|
|
242
|
+
|
|
243
|
+
## App-Side Verification Requirements
|
|
244
|
+
|
|
245
|
+
After scanning the QR:
|
|
246
|
+
|
|
247
|
+
1. Parse `qrPayload`
|
|
248
|
+
2. Check `type == "soyeht_pairing_qr"`
|
|
249
|
+
3. Check `expiresAt > now`
|
|
250
|
+
4. Verify the QR signature using `pluginIdentityKey`
|
|
251
|
+
5. Recompute fingerprint from `pluginIdentityKey` + `pluginDhKey` and compare with `fingerprint`
|
|
252
|
+
|
|
253
|
+
After `handshake.init`:
|
|
254
|
+
|
|
255
|
+
1. Build the full handshake transcript exactly as above
|
|
256
|
+
2. Verify `pluginSignature` using the plugin Ed25519 identity public key from the paired plugin
|
|
257
|
+
3. Only then sign the transcript with the app Ed25519 identity private key
|
|
258
|
+
4. Send `handshake.finish`
|
|
259
|
+
|
|
260
|
+
## X3DH Derivation
|
|
261
|
+
|
|
262
|
+
Plugin/responder side implementation:
|
|
263
|
+
|
|
264
|
+
- `DH1 = X25519(pluginStaticDhPriv, appEphemeralPub)`
|
|
265
|
+
- `DH2 = X25519(pluginEphemeralPriv, appStaticDhPub)`
|
|
266
|
+
- `DH3 = X25519(pluginEphemeralPriv, appEphemeralPub)`
|
|
267
|
+
- `ikm = DH1 || DH2 || DH3`
|
|
268
|
+
- `root = HKDF-SHA256(ikm, nonceBytes, "soyeht-v2-root", 32)`
|
|
269
|
+
- `pluginSend = HKDF-SHA256(root, empty, "soyeht-v2-chain-send", 32)`
|
|
270
|
+
- `pluginRecv = HKDF-SHA256(root, empty, "soyeht-v2-chain-recv", 32)`
|
|
271
|
+
|
|
272
|
+
App/initiator side must derive the same root but swap directional chains:
|
|
273
|
+
|
|
274
|
+
- `DH1 = X25519(appEphemeralPriv, pluginStaticDhPub)`
|
|
275
|
+
- `DH2 = X25519(appStaticDhPriv, pluginEphemeralPub)`
|
|
276
|
+
- `DH3 = X25519(appEphemeralPriv, pluginEphemeralPub)`
|
|
277
|
+
- `ikm = DH1 || DH2 || DH3`
|
|
278
|
+
- `root = HKDF-SHA256(ikm, nonceBytes, "soyeht-v2-root", 32)`
|
|
279
|
+
- `appSend = HKDF-SHA256(root, empty, "soyeht-v2-chain-recv", 32)`
|
|
280
|
+
- `appRecv = HKDF-SHA256(root, empty, "soyeht-v2-chain-send", 32)`
|
|
281
|
+
|
|
282
|
+
Reference:
|
|
283
|
+
|
|
284
|
+
- `src/x3dh.ts`
|
|
285
|
+
- `test/protocol-v2-integration.test.ts`
|
|
286
|
+
|
|
287
|
+
## Envelope V2
|
|
288
|
+
|
|
289
|
+
Envelope shape:
|
|
290
|
+
|
|
291
|
+
```json
|
|
292
|
+
{
|
|
293
|
+
"v": 2,
|
|
294
|
+
"accountId": "default",
|
|
295
|
+
"direction": "app_to_plugin",
|
|
296
|
+
"counter": 0,
|
|
297
|
+
"timestamp": 1730000000000,
|
|
298
|
+
"dhRatchetKey": "<optional base64url x25519 public key>",
|
|
299
|
+
"ciphertext": "<base64url>",
|
|
300
|
+
"iv": "<base64url 12 bytes>",
|
|
301
|
+
"tag": "<base64url 16 bytes>"
|
|
302
|
+
}
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
AAD string:
|
|
306
|
+
|
|
307
|
+
```text
|
|
308
|
+
{v}|{accountId}|{direction}|{counter}|{timestamp}
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
Symmetric ratchet step:
|
|
312
|
+
|
|
313
|
+
- `messageKey = HKDF-SHA256(chainKey, empty, "soyeht-v2-msg-key", 32)`
|
|
314
|
+
- `nextChainKey = HKDF-SHA256(chainKey, empty, "soyeht-v2-chain-advance", 32)`
|
|
315
|
+
|
|
316
|
+
Encryption:
|
|
317
|
+
|
|
318
|
+
- AES-256-GCM
|
|
319
|
+
- key = `messageKey`
|
|
320
|
+
- iv = random 12 bytes
|
|
321
|
+
- aad = UTF-8 bytes of the AAD string above
|
|
322
|
+
|
|
323
|
+
Direction rules:
|
|
324
|
+
|
|
325
|
+
- App -> plugin envelopes must use `direction = "app_to_plugin"`
|
|
326
|
+
- Plugin -> app envelopes use `direction = "plugin_to_app"`
|
|
327
|
+
|
|
328
|
+
## App State To Persist
|
|
329
|
+
|
|
330
|
+
Persist on the app side:
|
|
331
|
+
|
|
332
|
+
- app Ed25519 identity key pair
|
|
333
|
+
- app X25519 static DH key pair
|
|
334
|
+
- paired plugin public keys
|
|
335
|
+
- fingerprint
|
|
336
|
+
- current ratchet session state
|
|
337
|
+
- root key
|
|
338
|
+
- send chain key + counter
|
|
339
|
+
- recv chain key + counter
|
|
340
|
+
- current app ephemeral DH key
|
|
341
|
+
- peer last ephemeral DH key
|
|
342
|
+
- createdAt / expiresAt
|
|
343
|
+
|
|
344
|
+
Do not persist used pairing tokens.
|
|
345
|
+
|
|
346
|
+
## Common Error Codes
|
|
347
|
+
|
|
348
|
+
Pairing:
|
|
349
|
+
|
|
350
|
+
- `PAIRING_REQUIRED`
|
|
351
|
+
- `PAIRING_EXPIRED`
|
|
352
|
+
- `PAIRING_ACCOUNT_MISMATCH`
|
|
353
|
+
- `PEER_ALREADY_PAIRED`
|
|
354
|
+
- `INVALID_SIGNATURE`
|
|
355
|
+
- `INVALID_KEY`
|
|
356
|
+
|
|
357
|
+
Handshake:
|
|
358
|
+
|
|
359
|
+
- `PEER_NOT_FOUND`
|
|
360
|
+
- `INVALID_NONCE`
|
|
361
|
+
- `NONCE_REUSED`
|
|
362
|
+
- `TIMESTAMP_OUT_OF_RANGE`
|
|
363
|
+
- `HANDSHAKE_NOT_FOUND`
|
|
364
|
+
- `HANDSHAKE_EXPIRED`
|
|
365
|
+
- `INVALID_SIGNATURE`
|
|
366
|
+
|
|
367
|
+
Webhook/envelope:
|
|
368
|
+
|
|
369
|
+
- `session_required`
|
|
370
|
+
- `session_expired`
|
|
371
|
+
- `account_mismatch`
|
|
372
|
+
- `counter_mismatch`
|
|
373
|
+
- `direction_mismatch`
|
|
374
|
+
- `version_mismatch`
|
|
375
|
+
|
|
376
|
+
## Recommended Client Flow
|
|
377
|
+
|
|
378
|
+
1. Plugin/host calls `soyeht.security.pairing.start`
|
|
379
|
+
2. Plugin/host renders `qrText` as QR
|
|
380
|
+
3. App scans QR and verifies it
|
|
381
|
+
4. App generates long-term keys if needed
|
|
382
|
+
5. App calls `soyeht.security.pair`
|
|
383
|
+
6. App generates a fresh ephemeral X25519 key + nonce
|
|
384
|
+
7. App calls `soyeht.security.handshake.init`
|
|
385
|
+
8. App verifies `pluginSignature`
|
|
386
|
+
9. App signs the same transcript and calls `soyeht.security.handshake.finish`
|
|
387
|
+
10. App derives the same X3DH session locally and starts Envelope V2 messaging
|
|
388
|
+
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "soyeht",
|
|
3
|
+
"channels": [
|
|
4
|
+
"soyeht"
|
|
5
|
+
],
|
|
6
|
+
"name": "Soyeht",
|
|
7
|
+
"description": "Channel plugin for the Soyeht Flutter mobile app",
|
|
8
|
+
"version": "0.1.1",
|
|
9
|
+
"configSchema": {
|
|
10
|
+
"type": "object",
|
|
11
|
+
"additionalProperties": false,
|
|
12
|
+
"properties": {}
|
|
13
|
+
}
|
|
14
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@soyeht/soyeht",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "OpenClaw channel plugin for the Soyeht Flutter mobile app",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"src/",
|
|
9
|
+
"docs/PROTOCOL.md",
|
|
10
|
+
"openclaw.plugin.json",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"publishConfig": {
|
|
14
|
+
"access": "public"
|
|
15
|
+
},
|
|
16
|
+
"openclaw": {
|
|
17
|
+
"extensions": [
|
|
18
|
+
"./src/index.ts"
|
|
19
|
+
],
|
|
20
|
+
"channel": {
|
|
21
|
+
"id": "soyeht",
|
|
22
|
+
"label": "Soyeht",
|
|
23
|
+
"selectionLabel": "Soyeht (Flutter app)",
|
|
24
|
+
"blurb": "Connects a Soyeht Flutter app to OpenClaw with QR-based secure pairing."
|
|
25
|
+
},
|
|
26
|
+
"install": {
|
|
27
|
+
"npmSpec": "@soyeht/soyeht",
|
|
28
|
+
"defaultChoice": "npm"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"check:package": "node scripts/check-package-metadata.mjs",
|
|
33
|
+
"validate": "npm run check:package && npm run typecheck && npm test",
|
|
34
|
+
"pack:check": "npm pack --dry-run",
|
|
35
|
+
"typecheck": "tsc --noEmit",
|
|
36
|
+
"test": "vitest run",
|
|
37
|
+
"test:watch": "vitest",
|
|
38
|
+
"prepublishOnly": "npm run validate && npm run pack:check",
|
|
39
|
+
"version": "node scripts/sync-plugin-manifest-version.mjs"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@sinclair/typebox": "^0.34.0"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@types/node": "^25.3.5",
|
|
46
|
+
"typescript": "^5.7.0",
|
|
47
|
+
"vitest": "^3.0.0"
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/channel.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { ChannelPlugin } from "openclaw/plugin-sdk";
|
|
3
|
+
import type { ResolvedSoyehtAccount } from "./config.js";
|
|
4
|
+
import {
|
|
5
|
+
resolveSoyehtAccount,
|
|
6
|
+
listSoyehtAccountIds,
|
|
7
|
+
} from "./config.js";
|
|
8
|
+
import { SoyehtChannelConfigSchema } from "./types.js";
|
|
9
|
+
import {
|
|
10
|
+
buildOutboundEnvelope,
|
|
11
|
+
postToBackend,
|
|
12
|
+
} from "./outbound.js";
|
|
13
|
+
import type { SecurityV2Deps } from "./service.js";
|
|
14
|
+
|
|
15
|
+
export function createSoyehtChannel(v2deps?: SecurityV2Deps): ChannelPlugin<ResolvedSoyehtAccount> {
|
|
16
|
+
return {
|
|
17
|
+
id: "soyeht",
|
|
18
|
+
|
|
19
|
+
meta: {
|
|
20
|
+
id: "soyeht",
|
|
21
|
+
label: "Soyeht",
|
|
22
|
+
selectionLabel: "Soyeht (Mobile App)",
|
|
23
|
+
docsPath: "/channels/soyeht",
|
|
24
|
+
blurb:
|
|
25
|
+
"Connect the Soyeht Flutter mobile app to exchange text, audio, and file messages.",
|
|
26
|
+
systemImage: "iphone",
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
capabilities: {
|
|
30
|
+
chatTypes: ["direct"],
|
|
31
|
+
media: true,
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
reload: {
|
|
35
|
+
configPrefixes: ["channels.soyeht"],
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
configSchema: {
|
|
39
|
+
schema: SoyehtChannelConfigSchema,
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
config: {
|
|
43
|
+
listAccountIds: (cfg) => listSoyehtAccountIds(cfg),
|
|
44
|
+
resolveAccount: (cfg, accountId) => resolveSoyehtAccount(cfg, accountId),
|
|
45
|
+
defaultAccountId: () => "default",
|
|
46
|
+
isEnabled: (account) => account.enabled,
|
|
47
|
+
isConfigured: (account) => Boolean(account.backendBaseUrl && account.pluginAuthToken),
|
|
48
|
+
describeAccount: (account) => ({
|
|
49
|
+
accountId: account.accountId,
|
|
50
|
+
enabled: account.enabled,
|
|
51
|
+
configured: Boolean(account.backendBaseUrl && account.pluginAuthToken),
|
|
52
|
+
backendConfigured: Boolean(account.backendBaseUrl),
|
|
53
|
+
securityEnabled: account.security.enabled,
|
|
54
|
+
allowProactive: account.allowProactive,
|
|
55
|
+
}),
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
outbound: {
|
|
59
|
+
deliveryMode: "direct",
|
|
60
|
+
|
|
61
|
+
async sendText(ctx) {
|
|
62
|
+
const account = resolveSoyehtAccount(ctx.cfg, ctx.accountId);
|
|
63
|
+
if (!account.backendBaseUrl) {
|
|
64
|
+
return {
|
|
65
|
+
channel: "soyeht",
|
|
66
|
+
messageId: randomUUID(),
|
|
67
|
+
meta: { error: true, reason: "no_backend_url" },
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const ratchetSession = v2deps?.sessions.get(account.accountId);
|
|
72
|
+
|
|
73
|
+
if (account.security.enabled && !ratchetSession) {
|
|
74
|
+
return {
|
|
75
|
+
channel: "soyeht",
|
|
76
|
+
messageId: randomUUID(),
|
|
77
|
+
meta: { error: true, reason: "session_required" },
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (ratchetSession && ratchetSession.expiresAt < Date.now()) {
|
|
82
|
+
return {
|
|
83
|
+
channel: "soyeht",
|
|
84
|
+
messageId: randomUUID(),
|
|
85
|
+
meta: { error: true, reason: "session_expired" },
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const envelope = buildOutboundEnvelope(account.accountId, ctx.to, {
|
|
90
|
+
contentType: "text",
|
|
91
|
+
text: ctx.text,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
return postToBackend(account.backendBaseUrl, account.pluginAuthToken, envelope, {
|
|
95
|
+
ratchetSession,
|
|
96
|
+
dhRatchetCfg: {
|
|
97
|
+
intervalMessages: account.security.dhRatchetIntervalMessages,
|
|
98
|
+
intervalMs: account.security.dhRatchetIntervalMs,
|
|
99
|
+
},
|
|
100
|
+
onSessionUpdated: (updated) => {
|
|
101
|
+
v2deps?.sessions.set(account.accountId, updated);
|
|
102
|
+
},
|
|
103
|
+
securityEnabled: account.security.enabled,
|
|
104
|
+
});
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
async sendMedia(ctx) {
|
|
108
|
+
const account = resolveSoyehtAccount(ctx.cfg, ctx.accountId);
|
|
109
|
+
if (!account.backendBaseUrl || !ctx.mediaUrl) {
|
|
110
|
+
return {
|
|
111
|
+
channel: "soyeht",
|
|
112
|
+
messageId: randomUUID(),
|
|
113
|
+
meta: { error: true, reason: !ctx.mediaUrl ? "no_media_url" : "no_backend_url" },
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const ratchetSession = v2deps?.sessions.get(account.accountId);
|
|
118
|
+
|
|
119
|
+
if (account.security.enabled && !ratchetSession) {
|
|
120
|
+
return {
|
|
121
|
+
channel: "soyeht",
|
|
122
|
+
messageId: randomUUID(),
|
|
123
|
+
meta: { error: true, reason: "session_required" },
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (ratchetSession && ratchetSession.expiresAt < Date.now()) {
|
|
128
|
+
return {
|
|
129
|
+
channel: "soyeht",
|
|
130
|
+
messageId: randomUUID(),
|
|
131
|
+
meta: { error: true, reason: "session_expired" },
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const envelope = buildOutboundEnvelope(account.accountId, ctx.to, {
|
|
136
|
+
contentType: "audio",
|
|
137
|
+
renderStyle: "voice_note",
|
|
138
|
+
mimeType: "audio/wav",
|
|
139
|
+
filename: "voice.wav",
|
|
140
|
+
url: ctx.mediaUrl,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
return postToBackend(account.backendBaseUrl, account.pluginAuthToken, envelope, {
|
|
144
|
+
ratchetSession,
|
|
145
|
+
dhRatchetCfg: {
|
|
146
|
+
intervalMessages: account.security.dhRatchetIntervalMessages,
|
|
147
|
+
intervalMs: account.security.dhRatchetIntervalMs,
|
|
148
|
+
},
|
|
149
|
+
onSessionUpdated: (updated) => {
|
|
150
|
+
v2deps?.sessions.set(account.accountId, updated);
|
|
151
|
+
},
|
|
152
|
+
securityEnabled: account.security.enabled,
|
|
153
|
+
});
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|