@soyeht/soyeht 0.2.7 → 0.2.9

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.
@@ -5,7 +5,7 @@
5
5
  ],
6
6
  "name": "Soyeht",
7
7
  "description": "Channel plugin for the Soyeht Flutter mobile app",
8
- "version": "0.2.7",
8
+ "version": "0.2.9",
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.2.7",
3
+ "version": "0.2.9",
4
4
  "description": "OpenClaw channel plugin for the Soyeht Flutter mobile app",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/config.ts CHANGED
@@ -71,14 +71,32 @@ function readAccountsSection(
71
71
  return section?.["accounts"] as Record<string, unknown> | undefined;
72
72
  }
73
73
 
74
+ /**
75
+ * Read flat config from `channels.soyeht.*` (all keys except "accounts").
76
+ * Used as defaults when no explicit account entry exists.
77
+ */
78
+ function readFlatConfig(cfg: OpenClawConfig): Record<string, unknown> {
79
+ const section = readConfigSection(cfg);
80
+ if (!section) return {};
81
+ const flat: Record<string, unknown> = {};
82
+ for (const [key, value] of Object.entries(section)) {
83
+ if (key !== "accounts") {
84
+ flat[key] = value;
85
+ }
86
+ }
87
+ return flat;
88
+ }
89
+
74
90
  function readAccountConfig(
75
91
  cfg: OpenClawConfig,
76
92
  accountId: string,
77
93
  ): Record<string, unknown> {
94
+ const flat = readFlatConfig(cfg);
78
95
  const accounts = readAccountsSection(cfg);
79
- if (!accounts) return {};
80
- const entry = accounts[accountId];
81
- return entry && typeof entry === "object" ? (entry as Record<string, unknown>) : {};
96
+ const entry = accounts?.[accountId];
97
+ const accountRaw = entry && typeof entry === "object" ? (entry as Record<string, unknown>) : {};
98
+ // Merge: flat config as defaults, account-specific overrides
99
+ return { ...flat, ...accountRaw };
82
100
  }
83
101
 
84
102
  // ---------------------------------------------------------------------------
@@ -199,8 +217,16 @@ export function resolveSoyehtAccount(
199
217
 
200
218
  export function listSoyehtAccountIds(cfg: OpenClawConfig): string[] {
201
219
  const accounts = readAccountsSection(cfg);
202
- return listConfiguredAccountIds({
220
+ const ids = listConfiguredAccountIds({
203
221
  accounts: accounts as Record<string, unknown> | undefined,
204
222
  normalizeAccountId,
205
223
  });
224
+ // If no explicit accounts but flat config exists, treat as "default" account
225
+ if (ids.length === 0) {
226
+ const flat = readFlatConfig(cfg);
227
+ if (Object.keys(flat).length > 0) {
228
+ return [DEFAULT_ACCOUNT_ID];
229
+ }
230
+ }
231
+ return ids;
206
232
  }
package/src/http.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
- import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
+ import type { OpenClawPluginApi, PluginRuntimeChannel } from "openclaw/plugin-sdk";
3
3
  import { normalizeAccountId, resolveSoyehtAccount } from "./config.js";
4
- import { decryptEnvelopeV2, validateEnvelopeV2, type EnvelopeV2 } from "./envelope-v2.js";
4
+ import { decryptEnvelopeV2, encryptEnvelopeV2, validateEnvelopeV2, type EnvelopeV2 } from "./envelope-v2.js";
5
+ import { buildOutboundEnvelope } from "./outbound.js";
5
6
  import { cloneRatchetSession, zeroBuffer, type RatchetState } from "./ratchet.js";
6
7
  import type { SecurityV2Deps } from "./service.js";
7
8
  import { PLUGIN_VERSION } from "./version.js";
@@ -125,9 +126,23 @@ export function processInboundEnvelope(
125
126
  // GET /soyeht/health
126
127
  // ---------------------------------------------------------------------------
127
128
 
128
- export function healthHandler(_api: OpenClawPluginApi) {
129
+ export function healthHandler(_api: OpenClawPluginApi, v2deps?: SecurityV2Deps) {
129
130
  return async (_req: IncomingMessage, res: ServerResponse) => {
130
- sendJson(res, 200, { ok: true, plugin: "soyeht", version: PLUGIN_VERSION });
131
+ const ready = v2deps?.ready ?? false;
132
+ const identityLoaded = Boolean(v2deps?.identity);
133
+ const activeSessions = v2deps?.sessions.size ?? 0;
134
+ const pairedPeers = v2deps?.peers.size ?? 0;
135
+ const queueStats = v2deps?.outboundQueue.stats() ?? { accounts: 0, totalMessages: 0 };
136
+
137
+ sendJson(res, ready ? 200 : 503, {
138
+ ok: ready,
139
+ plugin: "soyeht",
140
+ version: PLUGIN_VERSION,
141
+ identity: identityLoaded,
142
+ peers: pairedPeers,
143
+ sessions: activeSessions,
144
+ queue: queueStats,
145
+ });
131
146
  };
132
147
  }
133
148
 
@@ -236,15 +251,38 @@ export function inboundHandler(
236
251
  return;
237
252
  }
238
253
 
239
- // TODO: Push decrypted message to OpenClaw agent pipeline.
240
- // The exact API depends on the OpenClaw runtime (e.g. api.channel.pushInbound()).
241
- // For now the message is decrypted and the session is updated;
242
- // wiring to the agent conversation will be done when the runtime API is known.
243
- api.logger.info("[soyeht] Inbound message received (direct mode)", {
244
- accountId: result.accountId,
245
- });
254
+ const { plaintext, accountId } = result;
246
255
 
247
- sendJson(res, 200, { ok: true, received: true, accountId: result.accountId });
256
+ // Parse the decrypted message (EnvelopeV2 plaintext is JSON with contentType/text)
257
+ let messageText: string;
258
+ try {
259
+ const parsed = JSON.parse(plaintext);
260
+ messageText = parsed.text ?? parsed.body ?? plaintext;
261
+ } catch {
262
+ messageText = plaintext;
263
+ }
264
+
265
+ api.logger.info("[soyeht] Inbound message received (direct mode)", { accountId });
266
+
267
+ // Respond 200 immediately — agent dispatch happens in background
268
+ sendJson(res, 200, { ok: true, received: true, accountId });
269
+
270
+ // Fire-and-forget: dispatch to agent pipeline
271
+ const channelRuntime = (api.runtime as Record<string, unknown>).channel as
272
+ | PluginRuntimeChannel
273
+ | undefined;
274
+ if (channelRuntime?.routing && channelRuntime?.reply) {
275
+ dispatchToAgent(api, v2deps, channelRuntime, accountId, messageText).catch((err) => {
276
+ api.logger.error("[soyeht] Agent dispatch failed", {
277
+ accountId,
278
+ error: err instanceof Error ? err.message : String(err),
279
+ });
280
+ });
281
+ } else {
282
+ api.logger.warn(
283
+ "[soyeht] channelRuntime not available — message decrypted but not delivered to agent",
284
+ );
285
+ }
248
286
  } catch (err) {
249
287
  const message = err instanceof Error ? err.message : "unknown_error";
250
288
  sendJson(res, 400, { ok: false, error: message });
@@ -252,6 +290,119 @@ export function inboundHandler(
252
290
  };
253
291
  }
254
292
 
293
+ // ---------------------------------------------------------------------------
294
+ // Dispatch inbound message to OpenClaw agent pipeline (fire-and-forget)
295
+ // ---------------------------------------------------------------------------
296
+
297
+ async function dispatchToAgent(
298
+ api: OpenClawPluginApi,
299
+ v2deps: SecurityV2Deps,
300
+ channelRuntime: PluginRuntimeChannel,
301
+ accountId: string,
302
+ messageText: string,
303
+ ): Promise<void> {
304
+ const cfg = await api.runtime.config.loadConfig();
305
+ const account = resolveSoyehtAccount(cfg, accountId);
306
+
307
+ const route = channelRuntime.routing.resolveAgentRoute({
308
+ cfg,
309
+ channel: "soyeht",
310
+ accountId: account.accountId,
311
+ peer: { kind: "direct", id: accountId },
312
+ });
313
+
314
+ const sessionCfg = (cfg as Record<string, unknown>).session as
315
+ | Record<string, unknown>
316
+ | undefined;
317
+ const storePath = channelRuntime.session.resolveStorePath(
318
+ sessionCfg?.store as string | undefined,
319
+ { agentId: route.agentId },
320
+ );
321
+
322
+ const envelopeOptions = channelRuntime.reply.resolveEnvelopeFormatOptions(cfg);
323
+ const previousTimestamp = channelRuntime.session.readSessionUpdatedAt({
324
+ storePath,
325
+ sessionKey: route.sessionKey,
326
+ });
327
+
328
+ const body = channelRuntime.reply.formatAgentEnvelope({
329
+ channel: "Soyeht",
330
+ from: `soyeht:${accountId}`,
331
+ timestamp: Date.now(),
332
+ previousTimestamp,
333
+ envelope: envelopeOptions,
334
+ body: messageText,
335
+ });
336
+
337
+ const ctxPayload = channelRuntime.reply.finalizeInboundContext({
338
+ Body: body,
339
+ BodyForAgent: messageText,
340
+ RawBody: messageText,
341
+ From: `soyeht:${accountId}`,
342
+ To: `soyeht:${accountId}`,
343
+ SessionKey: route.sessionKey,
344
+ AccountId: route.accountId,
345
+ ChatType: "direct",
346
+ ConversationLabel: `soyeht:${accountId}`,
347
+ SenderName: accountId,
348
+ SenderId: accountId,
349
+ CommandAuthorized: true,
350
+ Provider: "soyeht",
351
+ Surface: "soyeht",
352
+ OriginatingChannel: "soyeht",
353
+ OriginatingTo: `soyeht:${accountId}`,
354
+ });
355
+
356
+ await channelRuntime.session.recordInboundSession({
357
+ storePath,
358
+ sessionKey: (ctxPayload as Record<string, unknown>).SessionKey as string ?? route.sessionKey,
359
+ ctx: ctxPayload,
360
+ onRecordError: (err: unknown) => {
361
+ api.logger.error("[soyeht] Failed updating session meta", { error: String(err) });
362
+ },
363
+ });
364
+
365
+ await channelRuntime.reply.dispatchReplyWithBufferedBlockDispatcher({
366
+ ctx: ctxPayload,
367
+ cfg,
368
+ dispatcherOptions: {
369
+ deliver: async (payload: { text?: string; mediaUrl?: string }) => {
370
+ const text = payload.text;
371
+ if (!text) return;
372
+
373
+ const ratchetSession = v2deps.sessions.get(accountId);
374
+ if (!ratchetSession) {
375
+ api.logger.warn("[soyeht] No ratchet session for reply delivery", { accountId });
376
+ return;
377
+ }
378
+
379
+ const envelope = buildOutboundEnvelope(accountId, accountId, {
380
+ contentType: "text",
381
+ text,
382
+ });
383
+
384
+ const { envelope: v2env, updatedSession } = encryptEnvelopeV2({
385
+ session: ratchetSession,
386
+ accountId,
387
+ plaintext: JSON.stringify(envelope),
388
+ dhRatchetCfg: {
389
+ intervalMessages: account.security.dhRatchetIntervalMessages,
390
+ intervalMs: account.security.dhRatchetIntervalMs,
391
+ },
392
+ });
393
+ v2deps.sessions.set(accountId, updatedSession);
394
+ v2deps.outboundQueue.enqueue(accountId, v2env);
395
+ },
396
+ onError: (err: unknown, info: { kind: string }) => {
397
+ api.logger.error(`[soyeht] ${info.kind} reply failed`, {
398
+ accountId,
399
+ error: err instanceof Error ? err.message : String(err),
400
+ });
401
+ },
402
+ },
403
+ });
404
+ }
405
+
255
406
  // ---------------------------------------------------------------------------
256
407
  // GET /soyeht/events/:accountId (SSE — plugin → app outbound stream)
257
408
  // ---------------------------------------------------------------------------
package/src/index.ts CHANGED
@@ -104,7 +104,7 @@ const soyehtPlugin: OpenClawPluginDefinition = {
104
104
  api.registerHttpRoute({
105
105
  path: "/soyeht/health",
106
106
  auth: "plugin",
107
- handler: healthHandler(api),
107
+ handler: healthHandler(api, v2deps),
108
108
  });
109
109
  api.registerHttpRoute({
110
110
  path: "/soyeht/webhook/deliver",
@@ -140,6 +140,105 @@ declare module "openclaw/plugin-sdk" {
140
140
  sampleRate?: number;
141
141
  };
142
142
 
143
+ // ---- Channel runtime types (subset used by soyeht inbound dispatch) ----
144
+
145
+ export type ResolvedAgentRoute = {
146
+ agentId: string;
147
+ channel: string;
148
+ accountId: string;
149
+ sessionKey: string;
150
+ mainSessionKey: string;
151
+ lastRoutePolicy: "main" | "session";
152
+ matchedBy: string;
153
+ };
154
+
155
+ export type EnvelopeFormatOptions = {
156
+ timezone?: string;
157
+ includeTimestamp?: boolean;
158
+ includeElapsed?: boolean;
159
+ userTimezone?: string;
160
+ };
161
+
162
+ export type ReplyPayload = {
163
+ text?: string;
164
+ mediaUrl?: string;
165
+ mediaUrls?: string[];
166
+ isError?: boolean;
167
+ isReasoning?: boolean;
168
+ [key: string]: unknown;
169
+ };
170
+
171
+ export type ReplyDispatchKind = "tool" | "block" | "final";
172
+
173
+ export type PluginRuntimeChannel = {
174
+ routing: {
175
+ resolveAgentRoute: (params: {
176
+ cfg: OpenClawConfig;
177
+ channel: string;
178
+ accountId?: string | null;
179
+ peer?: { kind: ChatType; id: string } | null;
180
+ parentPeer?: { kind: ChatType; id: string } | null;
181
+ guildId?: string | null;
182
+ teamId?: string | null;
183
+ memberRoleIds?: string[];
184
+ }) => ResolvedAgentRoute;
185
+ buildAgentSessionKey: (...args: unknown[]) => string;
186
+ };
187
+ reply: {
188
+ finalizeInboundContext: <T extends Record<string, unknown>>(
189
+ ctx: T,
190
+ opts?: Record<string, unknown>,
191
+ ) => T;
192
+ formatAgentEnvelope: (params: {
193
+ channel: string;
194
+ from?: string;
195
+ timestamp?: number | Date;
196
+ previousTimestamp?: number | Date;
197
+ envelope?: EnvelopeFormatOptions;
198
+ body: string;
199
+ host?: string;
200
+ ip?: string;
201
+ }) => string;
202
+ resolveEnvelopeFormatOptions: (cfg?: OpenClawConfig) => EnvelopeFormatOptions;
203
+ dispatchReplyWithBufferedBlockDispatcher: (params: {
204
+ ctx: Record<string, unknown>;
205
+ cfg: OpenClawConfig;
206
+ dispatcherOptions: {
207
+ deliver: (
208
+ payload: ReplyPayload,
209
+ info?: { kind: ReplyDispatchKind },
210
+ ) => Promise<void>;
211
+ onError?: (err: unknown, info: { kind: string }) => void;
212
+ [key: string]: unknown;
213
+ };
214
+ replyOptions?: Record<string, unknown>;
215
+ }) => Promise<unknown>;
216
+ dispatchReplyFromConfig: (...args: unknown[]) => Promise<unknown>;
217
+ [key: string]: unknown;
218
+ };
219
+ session: {
220
+ resolveStorePath: (store?: string, opts?: { agentId?: string }) => string;
221
+ readSessionUpdatedAt: (params: {
222
+ storePath: string;
223
+ sessionKey: string;
224
+ }) => number | undefined;
225
+ recordInboundSession: (params: {
226
+ storePath: string;
227
+ sessionKey: string;
228
+ ctx: Record<string, unknown>;
229
+ onRecordError: (err: unknown) => void;
230
+ [key: string]: unknown;
231
+ }) => Promise<void>;
232
+ [key: string]: unknown;
233
+ };
234
+ activity: {
235
+ record: (...args: unknown[]) => void;
236
+ [key: string]: unknown;
237
+ };
238
+ text: { [key: string]: unknown };
239
+ [key: string]: unknown;
240
+ };
241
+
143
242
  export type PluginRuntime = {
144
243
  config: {
145
244
  loadConfig: () => Promise<OpenClawConfig>;
@@ -160,6 +259,7 @@ declare module "openclaw/plugin-sdk" {
160
259
  prefsPath?: string;
161
260
  }) => Promise<TtsTelephonyResult>;
162
261
  };
262
+ channel?: PluginRuntimeChannel;
163
263
  [key: string]: unknown;
164
264
  };
165
265
 
@@ -41,6 +41,8 @@ export type OutboundQueue = {
41
41
  prune(): number;
42
42
  clear(): void;
43
43
 
44
+ stats(): { accounts: number; totalMessages: number };
45
+
44
46
  // Stream token management (for SSE auth)
45
47
  createStreamToken(accountId: string, expiresAt: number): string;
46
48
  validateStreamToken(token: string): StreamTokenInfo | null;
@@ -217,12 +219,21 @@ export function createOutboundQueue(opts: OutboundQueueOptions = {}): OutboundQu
217
219
  }
218
220
  }
219
221
 
222
+ function stats(): { accounts: number; totalMessages: number } {
223
+ let totalMessages = 0;
224
+ for (const q of queues.values()) {
225
+ totalMessages += q.length;
226
+ }
227
+ return { accounts: queues.size, totalMessages };
228
+ }
229
+
220
230
  return {
221
231
  enqueue,
222
232
  subscribe,
223
233
  hasSubscribers,
224
234
  prune,
225
235
  clear,
236
+ stats,
226
237
  createStreamToken,
227
238
  validateStreamToken,
228
239
  revokeStreamTokensForAccount,
package/src/types.ts CHANGED
@@ -31,54 +31,57 @@ export type SoyehtAccountConfig = {
31
31
  };
32
32
  };
33
33
 
34
- export const SoyehtAccountConfigSchema: JsonSchema = {
35
- type: "object",
36
- additionalProperties: false,
37
- properties: {
38
- enabled: { type: "boolean" },
39
- backendBaseUrl: { type: "string" },
40
- pluginAuthToken: { type: "string" },
41
- gatewayUrl: { type: "string" },
42
- allowProactive: { type: "boolean" },
43
- audio: {
44
- type: "object",
45
- additionalProperties: false,
46
- properties: {
47
- transcribeInbound: { type: "boolean" },
48
- ttsOutbound: { type: "boolean" },
49
- },
34
+ // Shared properties object reused at account level and channel top level
35
+ const accountConfigProperties: Record<string, unknown> = {
36
+ enabled: { type: "boolean" },
37
+ backendBaseUrl: { type: "string" },
38
+ pluginAuthToken: { type: "string" },
39
+ gatewayUrl: { type: "string" },
40
+ allowProactive: { type: "boolean" },
41
+ audio: {
42
+ type: "object",
43
+ additionalProperties: false,
44
+ properties: {
45
+ transcribeInbound: { type: "boolean" },
46
+ ttsOutbound: { type: "boolean" },
50
47
  },
51
- files: {
52
- type: "object",
53
- additionalProperties: false,
54
- properties: {
55
- acceptInbound: { type: "boolean" },
56
- maxBytes: { type: "number" },
57
- },
48
+ },
49
+ files: {
50
+ type: "object",
51
+ additionalProperties: false,
52
+ properties: {
53
+ acceptInbound: { type: "boolean" },
54
+ maxBytes: { type: "number" },
58
55
  },
59
- security: {
60
- type: "object",
61
- additionalProperties: false,
62
- properties: {
63
- enabled: { type: "boolean" },
64
- timestampToleranceMs: { type: "number" },
65
- dhRatchetIntervalMessages: { type: "number" },
66
- dhRatchetIntervalMs: { type: "number" },
67
- sessionMaxAgeMs: { type: "number" },
68
- rateLimit: {
69
- type: "object",
70
- additionalProperties: false,
71
- properties: {
72
- maxRequests: { type: "number" },
73
- windowMs: { type: "number" },
74
- },
56
+ },
57
+ security: {
58
+ type: "object",
59
+ additionalProperties: false,
60
+ properties: {
61
+ enabled: { type: "boolean" },
62
+ timestampToleranceMs: { type: "number" },
63
+ dhRatchetIntervalMessages: { type: "number" },
64
+ dhRatchetIntervalMs: { type: "number" },
65
+ sessionMaxAgeMs: { type: "number" },
66
+ rateLimit: {
67
+ type: "object",
68
+ additionalProperties: false,
69
+ properties: {
70
+ maxRequests: { type: "number" },
71
+ windowMs: { type: "number" },
75
72
  },
76
73
  },
77
74
  },
78
75
  },
79
76
  };
80
77
 
81
- export type SoyehtChannelConfig = {
78
+ export const SoyehtAccountConfigSchema: JsonSchema = {
79
+ type: "object",
80
+ additionalProperties: false,
81
+ properties: accountConfigProperties,
82
+ };
83
+
84
+ export type SoyehtChannelConfig = SoyehtAccountConfig & {
82
85
  accounts?: Record<string, SoyehtAccountConfig>;
83
86
  };
84
87
 
@@ -86,6 +89,9 @@ export const SoyehtChannelConfigSchema: JsonSchema = {
86
89
  type: "object",
87
90
  additionalProperties: false,
88
91
  properties: {
92
+ // Top-level account fields (flat config shorthand for single-account setups)
93
+ ...accountConfigProperties,
94
+ // Named accounts (multi-account support)
89
95
  accounts: {
90
96
  type: "object",
91
97
  additionalProperties: SoyehtAccountConfigSchema,
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const PLUGIN_VERSION = "0.2.7";
1
+ export const PLUGIN_VERSION = "0.2.9";