@soyeht/soyeht 0.1.1 → 0.2.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/README.md +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -4
- package/src/channel.ts +121 -19
- package/src/config.ts +3 -0
- package/src/http.ts +223 -41
- package/src/index.ts +16 -1
- package/src/outbound-queue.ts +230 -0
- package/src/pairing.ts +86 -10
- package/src/rpc.ts +54 -44
- package/src/service.ts +5 -0
- package/src/types.ts +414 -162
- package/src/version.ts +1 -1
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ Channel plugin for connecting the Soyeht Flutter mobile app to an OpenClaw gatew
|
|
|
7
7
|
After this package is published to npm, install it on the OpenClaw host with:
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
openclaw plugins install @soyeht/soyeht@0.1.
|
|
10
|
+
openclaw plugins install @soyeht/soyeht@0.1.2 --pin
|
|
11
11
|
openclaw plugins enable soyeht
|
|
12
12
|
```
|
|
13
13
|
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@soyeht/soyeht",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "OpenClaw channel plugin for the Soyeht Flutter mobile app",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -38,9 +38,6 @@
|
|
|
38
38
|
"prepublishOnly": "npm run validate && npm run pack:check",
|
|
39
39
|
"version": "node scripts/sync-plugin-manifest-version.mjs"
|
|
40
40
|
},
|
|
41
|
-
"dependencies": {
|
|
42
|
-
"@sinclair/typebox": "^0.34.0"
|
|
43
|
-
},
|
|
44
41
|
"devDependencies": {
|
|
45
42
|
"@types/node": "^25.3.5",
|
|
46
43
|
"typescript": "^5.7.0",
|
package/src/channel.ts
CHANGED
|
@@ -10,8 +10,38 @@ import {
|
|
|
10
10
|
buildOutboundEnvelope,
|
|
11
11
|
postToBackend,
|
|
12
12
|
} from "./outbound.js";
|
|
13
|
+
import { encryptEnvelopeV2 } from "./envelope-v2.js";
|
|
13
14
|
import type { SecurityV2Deps } from "./service.js";
|
|
14
15
|
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Transport mode detection
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
type TransportMode = "direct" | "backend";
|
|
21
|
+
|
|
22
|
+
function resolveTransportMode(
|
|
23
|
+
account: ResolvedSoyehtAccount,
|
|
24
|
+
v2deps?: SecurityV2Deps,
|
|
25
|
+
): TransportMode {
|
|
26
|
+
const hasSession = v2deps?.sessions.has(account.accountId) ?? false;
|
|
27
|
+
const hasBackend = Boolean(account.backendBaseUrl && account.pluginAuthToken);
|
|
28
|
+
const hasSseSubscribers = v2deps?.outboundQueue.hasSubscribers(account.accountId) ?? false;
|
|
29
|
+
|
|
30
|
+
// Direct mode: active session + SSE client connected (or queue available)
|
|
31
|
+
if (hasSession && (hasSseSubscribers || !hasBackend)) {
|
|
32
|
+
return "direct";
|
|
33
|
+
}
|
|
34
|
+
// Backend mode: backend configured
|
|
35
|
+
if (hasBackend) {
|
|
36
|
+
return "backend";
|
|
37
|
+
}
|
|
38
|
+
// Default: direct (even without SSE subscriber — messages queue for later pickup)
|
|
39
|
+
if (hasSession) {
|
|
40
|
+
return "direct";
|
|
41
|
+
}
|
|
42
|
+
return "backend";
|
|
43
|
+
}
|
|
44
|
+
|
|
15
45
|
export function createSoyehtChannel(v2deps?: SecurityV2Deps): ChannelPlugin<ResolvedSoyehtAccount> {
|
|
16
46
|
return {
|
|
17
47
|
id: "soyeht",
|
|
@@ -44,15 +74,26 @@ export function createSoyehtChannel(v2deps?: SecurityV2Deps): ChannelPlugin<Reso
|
|
|
44
74
|
resolveAccount: (cfg, accountId) => resolveSoyehtAccount(cfg, accountId),
|
|
45
75
|
defaultAccountId: () => "default",
|
|
46
76
|
isEnabled: (account) => account.enabled,
|
|
47
|
-
isConfigured: (account) =>
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
77
|
+
isConfigured: (account) => {
|
|
78
|
+
// Direct mode: active V2 session is sufficient
|
|
79
|
+
const hasSession = v2deps?.sessions.has(account.accountId) ?? false;
|
|
80
|
+
if (hasSession) return true;
|
|
81
|
+
// Backend mode: backendBaseUrl + pluginAuthToken
|
|
82
|
+
return Boolean(account.backendBaseUrl && account.pluginAuthToken);
|
|
83
|
+
},
|
|
84
|
+
describeAccount: (account) => {
|
|
85
|
+
const mode = resolveTransportMode(account, v2deps);
|
|
86
|
+
return {
|
|
87
|
+
accountId: account.accountId,
|
|
88
|
+
enabled: account.enabled,
|
|
89
|
+
configured: Boolean(account.backendBaseUrl && account.pluginAuthToken) ||
|
|
90
|
+
(v2deps?.sessions.has(account.accountId) ?? false),
|
|
91
|
+
backendConfigured: Boolean(account.backendBaseUrl),
|
|
92
|
+
securityEnabled: account.security.enabled,
|
|
93
|
+
allowProactive: account.allowProactive,
|
|
94
|
+
transportMode: mode,
|
|
95
|
+
};
|
|
96
|
+
},
|
|
56
97
|
},
|
|
57
98
|
|
|
58
99
|
outbound: {
|
|
@@ -60,14 +101,6 @@ export function createSoyehtChannel(v2deps?: SecurityV2Deps): ChannelPlugin<Reso
|
|
|
60
101
|
|
|
61
102
|
async sendText(ctx) {
|
|
62
103
|
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
104
|
const ratchetSession = v2deps?.sessions.get(account.accountId);
|
|
72
105
|
|
|
73
106
|
if (account.security.enabled && !ratchetSession) {
|
|
@@ -86,11 +119,25 @@ export function createSoyehtChannel(v2deps?: SecurityV2Deps): ChannelPlugin<Reso
|
|
|
86
119
|
};
|
|
87
120
|
}
|
|
88
121
|
|
|
122
|
+
const mode = resolveTransportMode(account, v2deps);
|
|
89
123
|
const envelope = buildOutboundEnvelope(account.accountId, ctx.to, {
|
|
90
124
|
contentType: "text",
|
|
91
125
|
text: ctx.text,
|
|
92
126
|
});
|
|
93
127
|
|
|
128
|
+
if (mode === "direct" && ratchetSession && v2deps) {
|
|
129
|
+
return enqueueOutbound(account, envelope, ratchetSession, v2deps);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Backend mode (or no session for direct)
|
|
133
|
+
if (!account.backendBaseUrl) {
|
|
134
|
+
return {
|
|
135
|
+
channel: "soyeht",
|
|
136
|
+
messageId: randomUUID(),
|
|
137
|
+
meta: { error: true, reason: "no_backend_url" },
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
94
141
|
return postToBackend(account.backendBaseUrl, account.pluginAuthToken, envelope, {
|
|
95
142
|
ratchetSession,
|
|
96
143
|
dhRatchetCfg: {
|
|
@@ -106,11 +153,11 @@ export function createSoyehtChannel(v2deps?: SecurityV2Deps): ChannelPlugin<Reso
|
|
|
106
153
|
|
|
107
154
|
async sendMedia(ctx) {
|
|
108
155
|
const account = resolveSoyehtAccount(ctx.cfg, ctx.accountId);
|
|
109
|
-
if (!
|
|
156
|
+
if (!ctx.mediaUrl) {
|
|
110
157
|
return {
|
|
111
158
|
channel: "soyeht",
|
|
112
159
|
messageId: randomUUID(),
|
|
113
|
-
meta: { error: true, reason:
|
|
160
|
+
meta: { error: true, reason: "no_media_url" },
|
|
114
161
|
};
|
|
115
162
|
}
|
|
116
163
|
|
|
@@ -132,6 +179,7 @@ export function createSoyehtChannel(v2deps?: SecurityV2Deps): ChannelPlugin<Reso
|
|
|
132
179
|
};
|
|
133
180
|
}
|
|
134
181
|
|
|
182
|
+
const mode = resolveTransportMode(account, v2deps);
|
|
135
183
|
const envelope = buildOutboundEnvelope(account.accountId, ctx.to, {
|
|
136
184
|
contentType: "audio",
|
|
137
185
|
renderStyle: "voice_note",
|
|
@@ -140,6 +188,19 @@ export function createSoyehtChannel(v2deps?: SecurityV2Deps): ChannelPlugin<Reso
|
|
|
140
188
|
url: ctx.mediaUrl,
|
|
141
189
|
});
|
|
142
190
|
|
|
191
|
+
if (mode === "direct" && ratchetSession && v2deps) {
|
|
192
|
+
return enqueueOutbound(account, envelope, ratchetSession, v2deps);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Backend mode
|
|
196
|
+
if (!account.backendBaseUrl) {
|
|
197
|
+
return {
|
|
198
|
+
channel: "soyeht",
|
|
199
|
+
messageId: randomUUID(),
|
|
200
|
+
meta: { error: true, reason: "no_backend_url" },
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
143
204
|
return postToBackend(account.backendBaseUrl, account.pluginAuthToken, envelope, {
|
|
144
205
|
ratchetSession,
|
|
145
206
|
dhRatchetCfg: {
|
|
@@ -155,3 +216,44 @@ export function createSoyehtChannel(v2deps?: SecurityV2Deps): ChannelPlugin<Reso
|
|
|
155
216
|
},
|
|
156
217
|
};
|
|
157
218
|
}
|
|
219
|
+
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
// Encrypt + enqueue for direct mode (outbound via SSE)
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
import type { OutboundEnvelope } from "./types.js";
|
|
225
|
+
import type { RatchetState } from "./ratchet.js";
|
|
226
|
+
|
|
227
|
+
function enqueueOutbound(
|
|
228
|
+
account: ResolvedSoyehtAccount,
|
|
229
|
+
envelope: OutboundEnvelope,
|
|
230
|
+
ratchetSession: RatchetState,
|
|
231
|
+
v2deps: SecurityV2Deps,
|
|
232
|
+
): { channel: string; messageId: string; meta?: Record<string, unknown> } {
|
|
233
|
+
if (ratchetSession.expiresAt < Date.now()) {
|
|
234
|
+
return {
|
|
235
|
+
channel: "soyeht",
|
|
236
|
+
messageId: envelope.deliveryId,
|
|
237
|
+
meta: { error: true, reason: "session_expired" },
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const { envelope: v2env, updatedSession } = encryptEnvelopeV2({
|
|
242
|
+
session: ratchetSession,
|
|
243
|
+
accountId: envelope.accountId,
|
|
244
|
+
plaintext: JSON.stringify(envelope),
|
|
245
|
+
dhRatchetCfg: {
|
|
246
|
+
intervalMessages: account.security.dhRatchetIntervalMessages,
|
|
247
|
+
intervalMs: account.security.dhRatchetIntervalMs,
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
v2deps.sessions.set(account.accountId, updatedSession);
|
|
251
|
+
|
|
252
|
+
const entry = v2deps.outboundQueue.enqueue(account.accountId, v2env);
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
channel: "soyeht",
|
|
256
|
+
messageId: envelope.deliveryId,
|
|
257
|
+
meta: { transportMode: "direct", queueEntryId: entry.id },
|
|
258
|
+
};
|
|
259
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -23,6 +23,7 @@ export type ResolvedSoyehtAccount = {
|
|
|
23
23
|
enabled: boolean;
|
|
24
24
|
backendBaseUrl: string;
|
|
25
25
|
pluginAuthToken: string;
|
|
26
|
+
gatewayUrl: string;
|
|
26
27
|
allowProactive: boolean;
|
|
27
28
|
audio: {
|
|
28
29
|
transcribeInbound: boolean;
|
|
@@ -133,6 +134,8 @@ export function resolveSoyehtAccount(
|
|
|
133
134
|
: "",
|
|
134
135
|
pluginAuthToken:
|
|
135
136
|
typeof raw["pluginAuthToken"] === "string" ? raw["pluginAuthToken"] : "",
|
|
137
|
+
gatewayUrl:
|
|
138
|
+
typeof raw["gatewayUrl"] === "string" ? raw["gatewayUrl"] : "",
|
|
136
139
|
allowProactive:
|
|
137
140
|
typeof raw["allowProactive"] === "boolean" ? raw["allowProactive"] : false,
|
|
138
141
|
audio: {
|
package/src/http.ts
CHANGED
|
@@ -43,6 +43,67 @@ async function readRawBodyBuffer(req: IncomingMessage): Promise<Buffer> {
|
|
|
43
43
|
|
|
44
44
|
export { readRawBodyBuffer };
|
|
45
45
|
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Shared: process an inbound EnvelopeV2 (validate + decrypt + update session)
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
export type ProcessInboundResult =
|
|
51
|
+
| { ok: true; plaintext: string; accountId: string; envelope: EnvelopeV2 }
|
|
52
|
+
| { ok: false; status: number; error: string };
|
|
53
|
+
|
|
54
|
+
export function processInboundEnvelope(
|
|
55
|
+
api: OpenClawPluginApi,
|
|
56
|
+
v2deps: SecurityV2Deps,
|
|
57
|
+
envelope: EnvelopeV2,
|
|
58
|
+
hintedAccountId?: string,
|
|
59
|
+
): ProcessInboundResult {
|
|
60
|
+
const envelopeAccountId = normalizeAccountId(envelope.accountId);
|
|
61
|
+
if (hintedAccountId && hintedAccountId !== envelopeAccountId) {
|
|
62
|
+
return { ok: false, status: 401, error: "account_mismatch" };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const accountId = hintedAccountId ?? envelopeAccountId;
|
|
66
|
+
const session = v2deps.sessions.get(accountId);
|
|
67
|
+
if (!session) {
|
|
68
|
+
return { ok: false, status: 401, error: "session_required" };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (session.expiresAt < Date.now()) {
|
|
72
|
+
return { ok: false, status: 401, error: "session_expired" };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (session.accountId !== envelopeAccountId) {
|
|
76
|
+
return { ok: false, status: 401, error: "account_mismatch" };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const validation = validateEnvelopeV2(envelope, session);
|
|
80
|
+
if (!validation.valid) {
|
|
81
|
+
api.logger.warn("[soyeht] Envelope validation failed", { error: validation.error, accountId });
|
|
82
|
+
return { ok: false, status: 401, error: validation.error };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Deep-clone session before decryption so in-place Buffer mutations
|
|
86
|
+
// cannot corrupt the stored session on failure.
|
|
87
|
+
const sessionClone = cloneRatchetSession(session);
|
|
88
|
+
|
|
89
|
+
let plaintext: string;
|
|
90
|
+
let updatedSession;
|
|
91
|
+
try {
|
|
92
|
+
const result = decryptEnvelopeV2({ session: sessionClone, envelope });
|
|
93
|
+
plaintext = result.plaintext;
|
|
94
|
+
updatedSession = result.updatedSession;
|
|
95
|
+
} catch (err) {
|
|
96
|
+
const msg = err instanceof Error ? err.message : "decryption_failed";
|
|
97
|
+
api.logger.warn("[soyeht] Envelope decryption failed", { error: msg, accountId });
|
|
98
|
+
return { ok: false, status: 401, error: msg };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Update session
|
|
102
|
+
v2deps.sessions.set(accountId, updatedSession);
|
|
103
|
+
|
|
104
|
+
return { ok: true, plaintext, accountId, envelope };
|
|
105
|
+
}
|
|
106
|
+
|
|
46
107
|
// ---------------------------------------------------------------------------
|
|
47
108
|
// GET /soyeht/health
|
|
48
109
|
// ---------------------------------------------------------------------------
|
|
@@ -54,7 +115,7 @@ export function healthHandler(_api: OpenClawPluginApi) {
|
|
|
54
115
|
}
|
|
55
116
|
|
|
56
117
|
// ---------------------------------------------------------------------------
|
|
57
|
-
// POST /soyeht/webhook/deliver
|
|
118
|
+
// POST /soyeht/webhook/deliver (legacy backend mode)
|
|
58
119
|
// ---------------------------------------------------------------------------
|
|
59
120
|
|
|
60
121
|
export function webhookHandler(
|
|
@@ -89,7 +150,6 @@ export function webhookHandler(
|
|
|
89
150
|
try {
|
|
90
151
|
const rawBody = await readRawBodyBuffer(req);
|
|
91
152
|
|
|
92
|
-
// Parse the envelope
|
|
93
153
|
let envelope: EnvelopeV2;
|
|
94
154
|
try {
|
|
95
155
|
envelope = JSON.parse(rawBody.toString("utf8")) as EnvelopeV2;
|
|
@@ -98,60 +158,76 @@ export function webhookHandler(
|
|
|
98
158
|
return;
|
|
99
159
|
}
|
|
100
160
|
|
|
101
|
-
const
|
|
102
|
-
if (
|
|
103
|
-
sendJson(res,
|
|
161
|
+
const result = processInboundEnvelope(api, v2deps, envelope, hintedAccountId);
|
|
162
|
+
if (!result.ok) {
|
|
163
|
+
sendJson(res, result.status, { ok: false, error: result.error });
|
|
104
164
|
return;
|
|
105
165
|
}
|
|
106
166
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
167
|
+
api.logger.info("[soyeht] Webhook delivery received", { accountId: result.accountId });
|
|
168
|
+
sendJson(res, 200, { ok: true, received: true });
|
|
169
|
+
} catch (err) {
|
|
170
|
+
const message = err instanceof Error ? err.message : "unknown_error";
|
|
171
|
+
sendJson(res, 400, { ok: false, error: message });
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
}
|
|
113
175
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
// POST /soyeht/messages/inbound (direct mode — app → plugin → agent)
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
export function inboundHandler(
|
|
181
|
+
api: OpenClawPluginApi,
|
|
182
|
+
v2deps: SecurityV2Deps,
|
|
183
|
+
) {
|
|
184
|
+
return async (req: IncomingMessage, res: ServerResponse) => {
|
|
185
|
+
if (req.method !== "POST") {
|
|
186
|
+
sendJson(res, 405, { ok: false, error: "method_not_allowed" });
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (!v2deps.ready) {
|
|
191
|
+
sendJson(res, 503, { ok: false, error: "service_unavailable" });
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
118
194
|
|
|
119
|
-
|
|
120
|
-
|
|
195
|
+
try {
|
|
196
|
+
const rawBody = await readRawBodyBuffer(req);
|
|
197
|
+
|
|
198
|
+
let envelope: EnvelopeV2;
|
|
199
|
+
try {
|
|
200
|
+
envelope = JSON.parse(rawBody.toString("utf8")) as EnvelopeV2;
|
|
201
|
+
} catch {
|
|
202
|
+
sendJson(res, 400, { ok: false, error: "invalid_json" });
|
|
121
203
|
return;
|
|
122
204
|
}
|
|
123
205
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
206
|
+
const envelopeAccountId = normalizeAccountId(envelope.accountId);
|
|
207
|
+
|
|
208
|
+
// Rate limit per-account
|
|
209
|
+
const { allowed, retryAfterMs } = v2deps.rateLimiter.check(`inbound:${envelopeAccountId}`);
|
|
210
|
+
if (!allowed) {
|
|
211
|
+
res.setHeader("Retry-After", String(Math.ceil((retryAfterMs ?? 60_000) / 1000)));
|
|
212
|
+
sendJson(res, 429, { ok: false, error: "rate_limited" });
|
|
129
213
|
return;
|
|
130
214
|
}
|
|
131
215
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
// Decrypt
|
|
137
|
-
let plaintext: string;
|
|
138
|
-
let updatedSession;
|
|
139
|
-
try {
|
|
140
|
-
const result = decryptEnvelopeV2({ session: sessionClone, envelope });
|
|
141
|
-
plaintext = result.plaintext;
|
|
142
|
-
updatedSession = result.updatedSession;
|
|
143
|
-
} catch (err) {
|
|
144
|
-
const msg = err instanceof Error ? err.message : "decryption_failed";
|
|
145
|
-
api.logger.warn("[soyeht] Webhook decryption failed", { error: msg, accountId });
|
|
146
|
-
sendJson(res, 401, { ok: false, error: msg });
|
|
216
|
+
const result = processInboundEnvelope(api, v2deps, envelope);
|
|
217
|
+
if (!result.ok) {
|
|
218
|
+
sendJson(res, result.status, { ok: false, error: result.error });
|
|
147
219
|
return;
|
|
148
220
|
}
|
|
149
221
|
|
|
150
|
-
//
|
|
151
|
-
|
|
222
|
+
// TODO: Push decrypted message to OpenClaw agent pipeline.
|
|
223
|
+
// The exact API depends on the OpenClaw runtime (e.g. api.channel.pushInbound()).
|
|
224
|
+
// For now the message is decrypted and the session is updated;
|
|
225
|
+
// wiring to the agent conversation will be done when the runtime API is known.
|
|
226
|
+
api.logger.info("[soyeht] Inbound message received (direct mode)", {
|
|
227
|
+
accountId: result.accountId,
|
|
228
|
+
});
|
|
152
229
|
|
|
153
|
-
|
|
154
|
-
sendJson(res, 200, { ok: true, received: true });
|
|
230
|
+
sendJson(res, 200, { ok: true, received: true, accountId: result.accountId });
|
|
155
231
|
} catch (err) {
|
|
156
232
|
const message = err instanceof Error ? err.message : "unknown_error";
|
|
157
233
|
sendJson(res, 400, { ok: false, error: message });
|
|
@@ -159,6 +235,112 @@ export function webhookHandler(
|
|
|
159
235
|
};
|
|
160
236
|
}
|
|
161
237
|
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
// GET /soyeht/events/:accountId (SSE — plugin → app outbound stream)
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
const SSE_KEEPALIVE_MS = 30_000;
|
|
243
|
+
|
|
244
|
+
export function sseHandler(
|
|
245
|
+
api: OpenClawPluginApi,
|
|
246
|
+
v2deps: SecurityV2Deps,
|
|
247
|
+
) {
|
|
248
|
+
return async (req: IncomingMessage, res: ServerResponse) => {
|
|
249
|
+
if (req.method !== "GET") {
|
|
250
|
+
sendJson(res, 405, { ok: false, error: "method_not_allowed" });
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (!v2deps.ready) {
|
|
255
|
+
sendJson(res, 503, { ok: false, error: "service_unavailable" });
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Extract accountId from URL: /soyeht/events/<accountId>
|
|
260
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
261
|
+
const pathParts = url.pathname.split("/").filter(Boolean);
|
|
262
|
+
// Expected: ["soyeht", "events", "<accountId>"]
|
|
263
|
+
const accountId = pathParts.length >= 3 ? normalizeAccountId(pathParts[2]) : undefined;
|
|
264
|
+
if (!accountId) {
|
|
265
|
+
sendJson(res, 400, { ok: false, error: "missing_account_id" });
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Auth: validate stream token from Authorization header or query param
|
|
270
|
+
const authHeader = req.headers.authorization ?? "";
|
|
271
|
+
const bearerMatch = authHeader.match(/^Bearer\s+(.+)$/i);
|
|
272
|
+
const token = bearerMatch?.[1] ?? url.searchParams.get("token") ?? "";
|
|
273
|
+
|
|
274
|
+
if (!token) {
|
|
275
|
+
sendJson(res, 401, { ok: false, error: "stream_token_required" });
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const tokenInfo = v2deps.outboundQueue.validateStreamToken(token);
|
|
280
|
+
if (!tokenInfo) {
|
|
281
|
+
sendJson(res, 401, { ok: false, error: "invalid_stream_token" });
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (tokenInfo.accountId !== accountId) {
|
|
286
|
+
sendJson(res, 403, { ok: false, error: "token_account_mismatch" });
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Verify active session exists
|
|
291
|
+
const session = v2deps.sessions.get(accountId);
|
|
292
|
+
if (!session || session.expiresAt < Date.now()) {
|
|
293
|
+
sendJson(res, 401, { ok: false, error: "session_required" });
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// SSE headers
|
|
298
|
+
res.writeHead(200, {
|
|
299
|
+
"Content-Type": "text/event-stream",
|
|
300
|
+
"Cache-Control": "no-cache",
|
|
301
|
+
Connection: "keep-alive",
|
|
302
|
+
"X-Accel-Buffering": "no",
|
|
303
|
+
});
|
|
304
|
+
res.write(`retry: 3000\n\n`);
|
|
305
|
+
|
|
306
|
+
api.logger.info("[soyeht] SSE client connected", { accountId });
|
|
307
|
+
|
|
308
|
+
// Subscribe to outbound queue
|
|
309
|
+
const subscription = v2deps.outboundQueue.subscribe(accountId);
|
|
310
|
+
|
|
311
|
+
// Keepalive ping
|
|
312
|
+
const keepalive = setInterval(() => {
|
|
313
|
+
if (!res.destroyed) {
|
|
314
|
+
res.write(`: ping\n\n`);
|
|
315
|
+
}
|
|
316
|
+
}, SSE_KEEPALIVE_MS);
|
|
317
|
+
if (typeof keepalive === "object" && "unref" in keepalive) {
|
|
318
|
+
keepalive.unref();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Client disconnect
|
|
322
|
+
const onClose = () => {
|
|
323
|
+
clearInterval(keepalive);
|
|
324
|
+
subscription.unsubscribe();
|
|
325
|
+
api.logger.info("[soyeht] SSE client disconnected", { accountId });
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
req.on("close", onClose);
|
|
329
|
+
res.on("close", onClose);
|
|
330
|
+
|
|
331
|
+
// Stream messages
|
|
332
|
+
try {
|
|
333
|
+
for await (const entry of subscription) {
|
|
334
|
+
if (res.destroyed) break;
|
|
335
|
+
const data = JSON.stringify(entry.data);
|
|
336
|
+
res.write(`id: ${entry.id}\ndata: ${data}\n\n`);
|
|
337
|
+
}
|
|
338
|
+
} catch {
|
|
339
|
+
// subscription closed or error — clean up handled by onClose
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
162
344
|
// ---------------------------------------------------------------------------
|
|
163
345
|
// POST /soyeht/livekit/token — stub
|
|
164
346
|
// ---------------------------------------------------------------------------
|
package/src/index.ts
CHANGED
|
@@ -13,6 +13,8 @@ import {
|
|
|
13
13
|
healthHandler,
|
|
14
14
|
webhookHandler,
|
|
15
15
|
livekitTokenHandler,
|
|
16
|
+
inboundHandler,
|
|
17
|
+
sseHandler,
|
|
16
18
|
} from "./http.js";
|
|
17
19
|
import {
|
|
18
20
|
createSoyehtService,
|
|
@@ -71,7 +73,7 @@ const soyehtPlugin: OpenClawPluginDefinition = {
|
|
|
71
73
|
|
|
72
74
|
configSchema: emptyPluginConfigSchema,
|
|
73
75
|
|
|
74
|
-
|
|
76
|
+
register(api) {
|
|
75
77
|
// V2 deps — identity/sessions loaded in service.start()
|
|
76
78
|
const v2deps = createSecurityV2Deps();
|
|
77
79
|
|
|
@@ -110,6 +112,19 @@ const soyehtPlugin: OpenClawPluginDefinition = {
|
|
|
110
112
|
handler: livekitTokenHandler(api),
|
|
111
113
|
});
|
|
112
114
|
|
|
115
|
+
// Direct mode routes (app ↔ plugin)
|
|
116
|
+
api.registerHttpRoute({
|
|
117
|
+
path: "/soyeht/messages/inbound",
|
|
118
|
+
auth: "plugin",
|
|
119
|
+
handler: inboundHandler(api, v2deps),
|
|
120
|
+
});
|
|
121
|
+
api.registerHttpRoute({
|
|
122
|
+
path: "/soyeht/events",
|
|
123
|
+
auth: "plugin",
|
|
124
|
+
match: "prefix",
|
|
125
|
+
handler: sseHandler(api, v2deps),
|
|
126
|
+
});
|
|
127
|
+
|
|
113
128
|
// Background service (manages state lifecycle)
|
|
114
129
|
api.registerService(createSoyehtService(api, v2deps));
|
|
115
130
|
|