@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 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.1 --pin
10
+ openclaw plugins install @soyeht/soyeht@0.1.2 --pin
11
11
  openclaw plugins enable soyeht
12
12
  ```
13
13
 
@@ -5,7 +5,7 @@
5
5
  ],
6
6
  "name": "Soyeht",
7
7
  "description": "Channel plugin for the Soyeht Flutter mobile app",
8
- "version": "0.1.1",
8
+ "version": "0.2.0",
9
9
  "configSchema": {
10
10
  "type": "object",
11
11
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soyeht/soyeht",
3
- "version": "0.1.1",
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) => 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
- }),
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 (!account.backendBaseUrl || !ctx.mediaUrl) {
156
+ if (!ctx.mediaUrl) {
110
157
  return {
111
158
  channel: "soyeht",
112
159
  messageId: randomUUID(),
113
- meta: { error: true, reason: !ctx.mediaUrl ? "no_media_url" : "no_backend_url" },
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 envelopeAccountId = normalizeAccountId(envelope.accountId);
102
- if (hintedAccountId && hintedAccountId !== envelopeAccountId) {
103
- sendJson(res, 401, { ok: false, error: "account_mismatch" });
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
- const accountId = hintedAccountId ?? envelopeAccountId;
108
- const session = v2deps.sessions.get(accountId);
109
- if (!session) {
110
- sendJson(res, 401, { ok: false, error: "session_required" });
111
- return;
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
- if (session.expiresAt < Date.now()) {
115
- sendJson(res, 401, { ok: false, error: "session_expired" });
116
- return;
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
- if (session.accountId !== envelopeAccountId) {
120
- sendJson(res, 401, { ok: false, error: "account_mismatch" });
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
- // Validate envelope (trust only the parsed body — no header overrides)
125
- const validation = validateEnvelopeV2(envelope, session);
126
- if (!validation.valid) {
127
- api.logger.warn("[soyeht] Webhook validation failed", { error: validation.error, accountId });
128
- sendJson(res, 401, { ok: false, error: validation.error });
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
- // Deep-clone session before decryption so in-place Buffer mutations
133
- // (rootKey.fill(0), chainKey.fill(0)) cannot corrupt the stored session on failure.
134
- const sessionClone = cloneRatchetSession(session);
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
- // Update session
151
- v2deps.sessions.set(accountId, updatedSession);
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
- api.logger.info("[soyeht] Webhook delivery received", { accountId });
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
- async register(api) {
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