@sesamespace/sesame 0.2.2 → 0.2.4
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/dist/index.js +445 -23
- package/openclaw.plugin.json +1 -1
- package/package.json +2 -1
package/dist/index.js
CHANGED
|
@@ -16,9 +16,24 @@
|
|
|
16
16
|
let pluginRuntime = null;
|
|
17
17
|
// Connection state per account
|
|
18
18
|
const connections = new Map();
|
|
19
|
+
// Streaming state: tracks channels where streaming already delivered the message
|
|
20
|
+
// Key: channelId, Value: { messageId, text } — outbound.sendText should edit instead of send
|
|
21
|
+
const streamDelivered = new Map();
|
|
19
22
|
function getLogger() {
|
|
20
23
|
return (pluginRuntime?.logging?.getChildLogger?.({ plugin: "sesame" }) ?? console);
|
|
21
24
|
}
|
|
25
|
+
function guessContentType(fileName) {
|
|
26
|
+
const ext = fileName.split(".").pop()?.toLowerCase() ?? "";
|
|
27
|
+
const map = {
|
|
28
|
+
jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif",
|
|
29
|
+
webp: "image/webp", svg: "image/svg+xml", pdf: "application/pdf",
|
|
30
|
+
mp3: "audio/mpeg", mp4: "video/mp4", webm: "video/webm",
|
|
31
|
+
json: "application/json", txt: "text/plain", md: "text/markdown",
|
|
32
|
+
csv: "text/csv", html: "text/html", xml: "application/xml",
|
|
33
|
+
zip: "application/zip", tar: "application/x-tar", gz: "application/gzip",
|
|
34
|
+
};
|
|
35
|
+
return map[ext] ?? "application/octet-stream";
|
|
36
|
+
}
|
|
22
37
|
const sesameChannelPlugin = {
|
|
23
38
|
id: "sesame",
|
|
24
39
|
meta: {
|
|
@@ -31,7 +46,7 @@ const sesameChannelPlugin = {
|
|
|
31
46
|
},
|
|
32
47
|
capabilities: {
|
|
33
48
|
chatTypes: ["direct", "group"],
|
|
34
|
-
media:
|
|
49
|
+
media: true,
|
|
35
50
|
reactions: true,
|
|
36
51
|
threads: true,
|
|
37
52
|
editing: true,
|
|
@@ -134,6 +149,30 @@ const sesameChannelPlugin = {
|
|
|
134
149
|
if (!account?.apiKey)
|
|
135
150
|
throw new Error("Sesame not configured");
|
|
136
151
|
const channelId = to?.startsWith("sesame:") ? to.slice(7) : to;
|
|
152
|
+
const log = getLogger();
|
|
153
|
+
// Check if streaming already delivered this message
|
|
154
|
+
const delivered = streamDelivered.get(channelId);
|
|
155
|
+
if (delivered) {
|
|
156
|
+
streamDelivered.delete(channelId);
|
|
157
|
+
// Do a final edit to ensure the complete text is correct, then return
|
|
158
|
+
log.info?.(`[sesame] outbound.sendText: streaming already sent to ${channelId}, doing final edit`);
|
|
159
|
+
const editRes = await fetch(`${account.apiUrl}/api/v1/channels/${channelId}/messages/${delivered.messageId}`, {
|
|
160
|
+
method: "PATCH",
|
|
161
|
+
headers: {
|
|
162
|
+
"Content-Type": "application/json",
|
|
163
|
+
Authorization: `Bearer ${account.apiKey}`,
|
|
164
|
+
},
|
|
165
|
+
body: JSON.stringify({ content: text }),
|
|
166
|
+
});
|
|
167
|
+
if (!editRes.ok) {
|
|
168
|
+
log.warn?.(`[sesame] Final stream edit in sendText failed (${editRes.status})`);
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
channel: "sesame",
|
|
172
|
+
messageId: delivered.messageId,
|
|
173
|
+
chatId: channelId,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
137
176
|
const response = await fetch(`${account.apiUrl}/api/v1/channels/${channelId}/messages`, {
|
|
138
177
|
method: "POST",
|
|
139
178
|
headers: {
|
|
@@ -154,9 +193,108 @@ const sesameChannelPlugin = {
|
|
|
154
193
|
};
|
|
155
194
|
},
|
|
156
195
|
sendMedia: async (ctx) => {
|
|
157
|
-
|
|
158
|
-
const
|
|
159
|
-
|
|
196
|
+
const { text, mediaUrl, cfg, accountId } = ctx;
|
|
197
|
+
const account = sesameChannelPlugin.config.resolveAccount(cfg, accountId);
|
|
198
|
+
if (!account?.apiKey)
|
|
199
|
+
throw new Error("Sesame not configured");
|
|
200
|
+
const channelId = ctx.to?.startsWith("sesame:") ? ctx.to.slice(7) : ctx.to;
|
|
201
|
+
const caption = ctx.caption ?? text ?? "";
|
|
202
|
+
const log = getLogger();
|
|
203
|
+
try {
|
|
204
|
+
// Resolve media source: local file path or URL
|
|
205
|
+
const fs = await import("fs");
|
|
206
|
+
const path = await import("path");
|
|
207
|
+
let fileBuffer;
|
|
208
|
+
let fileName;
|
|
209
|
+
let contentType;
|
|
210
|
+
if (mediaUrl && (mediaUrl.startsWith("/") || mediaUrl.startsWith("~"))) {
|
|
211
|
+
// Local file path
|
|
212
|
+
const resolvedPath = mediaUrl.startsWith("~")
|
|
213
|
+
? mediaUrl.replace("~", process.env.HOME ?? "")
|
|
214
|
+
: mediaUrl;
|
|
215
|
+
fileBuffer = fs.readFileSync(resolvedPath);
|
|
216
|
+
fileName = path.basename(resolvedPath);
|
|
217
|
+
contentType = guessContentType(fileName);
|
|
218
|
+
}
|
|
219
|
+
else if (mediaUrl) {
|
|
220
|
+
// Remote URL — download it
|
|
221
|
+
const response = await fetch(mediaUrl);
|
|
222
|
+
if (!response.ok)
|
|
223
|
+
throw new Error(`Failed to download media: ${response.status}`);
|
|
224
|
+
fileBuffer = Buffer.from(await response.arrayBuffer());
|
|
225
|
+
// Extract filename from URL or Content-Disposition
|
|
226
|
+
const urlPath = new URL(mediaUrl).pathname;
|
|
227
|
+
fileName = path.basename(urlPath) || "attachment";
|
|
228
|
+
contentType = response.headers.get("content-type") ?? guessContentType(fileName);
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
// No media URL — fall back to text
|
|
232
|
+
return sesameChannelPlugin.outbound.sendText({ ...ctx, text: caption || "[media attachment]" });
|
|
233
|
+
}
|
|
234
|
+
// 1. Get presigned upload URL from Sesame Drive
|
|
235
|
+
const uploadRes = await fetch(`${account.apiUrl}/api/v1/drive/files/upload-url`, {
|
|
236
|
+
method: "POST",
|
|
237
|
+
headers: {
|
|
238
|
+
"Content-Type": "application/json",
|
|
239
|
+
Authorization: `Bearer ${account.apiKey}`,
|
|
240
|
+
},
|
|
241
|
+
body: JSON.stringify({ fileName, contentType, size: fileBuffer.length }),
|
|
242
|
+
});
|
|
243
|
+
if (!uploadRes.ok)
|
|
244
|
+
throw new Error(`Drive upload-url failed: ${uploadRes.status}`);
|
|
245
|
+
const uploadData = (await uploadRes.json());
|
|
246
|
+
const { uploadUrl, fileId, s3Key } = uploadData.data;
|
|
247
|
+
// 2. Upload to S3
|
|
248
|
+
const s3Res = await fetch(uploadUrl, {
|
|
249
|
+
method: "PUT",
|
|
250
|
+
headers: { "Content-Type": contentType },
|
|
251
|
+
body: new Uint8Array(fileBuffer),
|
|
252
|
+
});
|
|
253
|
+
if (!s3Res.ok)
|
|
254
|
+
throw new Error(`S3 upload failed: ${s3Res.status}`);
|
|
255
|
+
// 3. Register file in Drive
|
|
256
|
+
const regRes = await fetch(`${account.apiUrl}/api/v1/drive/files`, {
|
|
257
|
+
method: "POST",
|
|
258
|
+
headers: {
|
|
259
|
+
"Content-Type": "application/json",
|
|
260
|
+
Authorization: `Bearer ${account.apiKey}`,
|
|
261
|
+
},
|
|
262
|
+
body: JSON.stringify({ fileId, s3Key, fileName, contentType, size: fileBuffer.length }),
|
|
263
|
+
});
|
|
264
|
+
if (!regRes.ok)
|
|
265
|
+
throw new Error(`Drive register failed: ${regRes.status}`);
|
|
266
|
+
// 4. Send message with attachment
|
|
267
|
+
const msgRes = await fetch(`${account.apiUrl}/api/v1/channels/${channelId}/messages`, {
|
|
268
|
+
method: "POST",
|
|
269
|
+
headers: {
|
|
270
|
+
"Content-Type": "application/json",
|
|
271
|
+
Authorization: `Bearer ${account.apiKey}`,
|
|
272
|
+
},
|
|
273
|
+
body: JSON.stringify({
|
|
274
|
+
content: caption || fileName,
|
|
275
|
+
kind: "text",
|
|
276
|
+
intent: "chat",
|
|
277
|
+
attachmentIds: [fileId],
|
|
278
|
+
}),
|
|
279
|
+
});
|
|
280
|
+
if (!msgRes.ok)
|
|
281
|
+
throw new Error(`Send message failed: ${msgRes.status}`);
|
|
282
|
+
const msgData = (await msgRes.json());
|
|
283
|
+
log.info?.(`[sesame] Sent file "${fileName}" (${fileBuffer.length} bytes) to ${channelId}`);
|
|
284
|
+
return {
|
|
285
|
+
channel: "sesame",
|
|
286
|
+
messageId: msgData.data?.id ?? msgData.id ?? "unknown",
|
|
287
|
+
chatId: channelId,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
catch (err) {
|
|
291
|
+
log.error?.(`[sesame] sendMedia failed: ${err}`);
|
|
292
|
+
// Fall back to text with caption
|
|
293
|
+
return sesameChannelPlugin.outbound.sendText({
|
|
294
|
+
...ctx,
|
|
295
|
+
text: caption || "[media attachment — upload failed]",
|
|
296
|
+
});
|
|
297
|
+
}
|
|
160
298
|
},
|
|
161
299
|
},
|
|
162
300
|
messaging: {
|
|
@@ -193,6 +331,90 @@ const sesameChannelPlugin = {
|
|
|
193
331
|
},
|
|
194
332
|
},
|
|
195
333
|
};
|
|
334
|
+
// ── Wake Context (cold start optimization) ──
|
|
335
|
+
async function fetchWakeContext(account, agentId, ctx) {
|
|
336
|
+
const log = getLogger();
|
|
337
|
+
try {
|
|
338
|
+
const response = await fetch(`${account.apiUrl}/api/v1/agents/${agentId}/wake`, { headers: { Authorization: `Bearer ${account.apiKey}` } });
|
|
339
|
+
if (!response.ok) {
|
|
340
|
+
log.warn?.(`[sesame] Wake endpoint returned ${response.status}`);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
const result = await response.json();
|
|
344
|
+
if (!result.ok)
|
|
345
|
+
return;
|
|
346
|
+
const data = result.data;
|
|
347
|
+
const lines = ["[Sesame Platform Context]"];
|
|
348
|
+
// Focused task
|
|
349
|
+
if (data.focusedTask) {
|
|
350
|
+
const t = data.focusedTask;
|
|
351
|
+
lines.push(`\nFocused Task: T-${t.taskNumber} — ${t.title} (${t.status})`);
|
|
352
|
+
if (t.context?.notes)
|
|
353
|
+
lines.push(`Notes: ${t.context.notes}`);
|
|
354
|
+
if (t.context?.background)
|
|
355
|
+
lines.push(`Background: ${t.context.background}`);
|
|
356
|
+
}
|
|
357
|
+
// Active tasks summary
|
|
358
|
+
const active = data.tasks?.active ?? [];
|
|
359
|
+
const blocked = data.tasks?.blocked ?? [];
|
|
360
|
+
const todo = data.tasks?.todo ?? [];
|
|
361
|
+
if (active.length + blocked.length + todo.length > 0) {
|
|
362
|
+
lines.push(`\nTasks: ${active.length} active, ${blocked.length} blocked, ${todo.length} todo`);
|
|
363
|
+
for (const t of active.slice(0, 5)) {
|
|
364
|
+
lines.push(` [active] T-${t.taskNumber}: ${t.title}`);
|
|
365
|
+
}
|
|
366
|
+
for (const t of blocked.slice(0, 3)) {
|
|
367
|
+
lines.push(` [blocked] T-${t.taskNumber}: ${t.title}`);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
// Unread channels
|
|
371
|
+
const unread = data.channels?.unread ?? [];
|
|
372
|
+
if (unread.length > 0) {
|
|
373
|
+
lines.push(`\nUnread: ${unread.length} channel${unread.length > 1 ? "s" : ""} with unread messages`);
|
|
374
|
+
}
|
|
375
|
+
// Upcoming schedule
|
|
376
|
+
const upcoming = data.schedule?.upcoming ?? [];
|
|
377
|
+
if (upcoming.length > 0) {
|
|
378
|
+
lines.push(`\nSchedule: ${upcoming.length} upcoming event${upcoming.length > 1 ? "s" : ""}`);
|
|
379
|
+
for (const e of upcoming.slice(0, 3)) {
|
|
380
|
+
lines.push(` ${e.title} — ${e.nextOccurrenceAt ?? "soon"}`);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
// Pinned memories
|
|
384
|
+
const pinned = data.memory?.pinned ?? [];
|
|
385
|
+
if (pinned.length > 0) {
|
|
386
|
+
lines.push(`\nPinned Memories:`);
|
|
387
|
+
for (const m of pinned.slice(0, 10)) {
|
|
388
|
+
lines.push(` [${m.category}/${m.key}] ${m.content.slice(0, 200)}`);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
// Session state
|
|
392
|
+
if (data.state?.state) {
|
|
393
|
+
lines.push(`\nSession State: ${JSON.stringify(data.state.state).slice(0, 500)}`);
|
|
394
|
+
}
|
|
395
|
+
// Inject as system context via wake event
|
|
396
|
+
if (lines.length > 1) {
|
|
397
|
+
const contextText = lines.join("\n");
|
|
398
|
+
log.info?.(`[sesame] Injecting wake context (${contextText.length} chars)`);
|
|
399
|
+
// Use the OpenClaw plugin API to inject a wake event
|
|
400
|
+
try {
|
|
401
|
+
const runtime = pluginRuntime;
|
|
402
|
+
if (runtime?.events?.emit) {
|
|
403
|
+
runtime.events.emit("wake", {
|
|
404
|
+
text: contextText,
|
|
405
|
+
source: "sesame-wake",
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
catch (e) {
|
|
410
|
+
log.warn?.(`[sesame] Could not inject wake event: ${e}`);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
catch (err) {
|
|
415
|
+
log.warn?.(`[sesame] Wake context error: ${err}`);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
196
418
|
// ── WebSocket connection ──
|
|
197
419
|
async function connect(account, ctx) {
|
|
198
420
|
const log = ctx.log ?? getLogger();
|
|
@@ -207,6 +429,9 @@ async function connect(account, ctx) {
|
|
|
207
429
|
stopping: false,
|
|
208
430
|
ctx,
|
|
209
431
|
channelMap: new Map(),
|
|
432
|
+
sentMessageIds: new Set(),
|
|
433
|
+
recentOutboundCount: 0,
|
|
434
|
+
outboundWindowStart: Date.now(),
|
|
210
435
|
};
|
|
211
436
|
connections.set(account.accountId, state);
|
|
212
437
|
try {
|
|
@@ -286,6 +511,10 @@ function handleEvent(event, account, state, ctx) {
|
|
|
286
511
|
switch (event.type) {
|
|
287
512
|
case "authenticated":
|
|
288
513
|
state.authenticated = true;
|
|
514
|
+
if (event.principalId && !state.agentId) {
|
|
515
|
+
state.agentId = event.principalId;
|
|
516
|
+
log.info?.(`[sesame] Agent ID from auth: ${state.agentId}`);
|
|
517
|
+
}
|
|
289
518
|
state.heartbeatTimer = setInterval(() => {
|
|
290
519
|
if (state.ws?.readyState === 1) {
|
|
291
520
|
state.ws.send(JSON.stringify({ type: "ping" }));
|
|
@@ -293,10 +522,67 @@ function handleEvent(event, account, state, ctx) {
|
|
|
293
522
|
}, event.heartbeatIntervalMs ?? 30000);
|
|
294
523
|
log.info?.("[sesame] Authenticated successfully");
|
|
295
524
|
state.ws?.send(JSON.stringify({ type: "replay", cursors: {} }));
|
|
525
|
+
// Call wake endpoint for cold start context (fire and forget)
|
|
526
|
+
if (state.agentId) {
|
|
527
|
+
fetchWakeContext(account, state.agentId, ctx).catch((err) => log.warn?.(`[sesame] Wake context fetch failed: ${err}`));
|
|
528
|
+
}
|
|
296
529
|
break;
|
|
297
|
-
case "message":
|
|
298
|
-
|
|
530
|
+
case "message": {
|
|
531
|
+
const msg = event.message ?? event.data;
|
|
532
|
+
// For voice messages: check if transcript is available.
|
|
533
|
+
// If not, check plaintext (API may have set it during sync transcription).
|
|
534
|
+
// Only skip if neither is available — wait for voice.transcribed event.
|
|
535
|
+
if (msg?.kind === "voice") {
|
|
536
|
+
const meta = msg.metadata ?? {};
|
|
537
|
+
const transcript = meta.transcript;
|
|
538
|
+
const plaintext = msg.plaintext;
|
|
539
|
+
const hasTranscript = transcript || (plaintext && plaintext !== "(voice note)");
|
|
540
|
+
if (!hasTranscript) {
|
|
541
|
+
log.info?.(`[sesame] Voice message ${msg.id} — no transcript yet, waiting for voice.transcribed (30s fallback)`);
|
|
542
|
+
// Track pending voice messages for fallback delivery
|
|
543
|
+
const pendingKey = `voice:${msg.id}`;
|
|
544
|
+
state.sentMessageIds.add(pendingKey); // reuse set to track pending
|
|
545
|
+
setTimeout(() => {
|
|
546
|
+
if (state.sentMessageIds.has(pendingKey)) {
|
|
547
|
+
state.sentMessageIds.delete(pendingKey);
|
|
548
|
+
log.warn?.(`[sesame] Voice message ${msg.id} — transcription timeout, delivering without transcript`);
|
|
549
|
+
handleMessage(msg, account, state, ctx);
|
|
550
|
+
}
|
|
551
|
+
}, 30000);
|
|
552
|
+
break;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
handleMessage(msg, account, state, ctx);
|
|
556
|
+
break;
|
|
557
|
+
}
|
|
558
|
+
case "voice.transcribed": {
|
|
559
|
+
// Transcription completed — dispatch as a regular message with transcript text
|
|
560
|
+
const vtData = event;
|
|
561
|
+
const transcript = vtData.transcript;
|
|
562
|
+
const messageId = vtData.messageId;
|
|
563
|
+
const channelId = vtData.channelId;
|
|
564
|
+
const senderId = vtData.senderId;
|
|
565
|
+
if (!transcript || !messageId || !channelId) {
|
|
566
|
+
log.warn?.("[sesame] voice.transcribed event missing fields");
|
|
567
|
+
break;
|
|
568
|
+
}
|
|
569
|
+
// Clear pending fallback so we don't double-deliver
|
|
570
|
+
const pendingKey = `voice:${messageId}`;
|
|
571
|
+
state.sentMessageIds.delete(pendingKey);
|
|
572
|
+
log.info?.(`[sesame] Voice transcribed for ${messageId}: "${transcript.slice(0, 80)}"`);
|
|
573
|
+
// Build a synthetic message object so handleMessage can process it
|
|
574
|
+
handleMessage({
|
|
575
|
+
id: messageId,
|
|
576
|
+
channelId,
|
|
577
|
+
senderId,
|
|
578
|
+
kind: "voice",
|
|
579
|
+
content: transcript,
|
|
580
|
+
plaintext: transcript,
|
|
581
|
+
metadata: { transcript },
|
|
582
|
+
createdAt: new Date().toISOString(),
|
|
583
|
+
}, account, state, ctx);
|
|
299
584
|
break;
|
|
585
|
+
}
|
|
300
586
|
case "membership": {
|
|
301
587
|
// Track new channel joins so ChatType resolves correctly
|
|
302
588
|
const mData = event.data ?? event.membership ?? event;
|
|
@@ -327,13 +613,40 @@ async function handleMessage(message, account, state, ctx) {
|
|
|
327
613
|
const core = pluginRuntime;
|
|
328
614
|
if (!core)
|
|
329
615
|
return;
|
|
330
|
-
|
|
616
|
+
// For voice messages, prefer transcript from metadata over content
|
|
617
|
+
const msgMeta = message.metadata ?? {};
|
|
618
|
+
const voiceTranscript = message.kind === "voice" ? msgMeta.transcript : undefined;
|
|
619
|
+
// Prefer plaintext over content — plaintext includes attachment info block
|
|
620
|
+
// appended by the API (download URLs, file names, sizes)
|
|
621
|
+
const rawText = voiceTranscript ?? message.plaintext ?? message.content ?? message.body ?? message.text ?? "";
|
|
622
|
+
// If plaintext didn't include attachments but metadata does, build the block ourselves
|
|
623
|
+
let bodyText = message.kind === "voice" && rawText ? `(voice note)\n${rawText}` : rawText;
|
|
624
|
+
const attachments = msgMeta.attachments;
|
|
625
|
+
if (attachments?.length && !bodyText.includes("[Attachments]")) {
|
|
626
|
+
const lines = attachments.map((a) => {
|
|
627
|
+
const sizeKB = Math.round((a.size ?? 0) / 1024);
|
|
628
|
+
const sizeStr = sizeKB >= 1024 ? `${(sizeKB / 1024).toFixed(1)} MB` : `${sizeKB} KB`;
|
|
629
|
+
const dl = a.downloadUrl ? `\n Download: ${a.downloadUrl}` : "";
|
|
630
|
+
return `- ${a.fileName ?? "file"} (${a.contentType ?? "unknown"}, ${sizeStr})${dl}`;
|
|
631
|
+
});
|
|
632
|
+
bodyText = bodyText + `\n\n[Attachments]\n${lines.join("\n")}`;
|
|
633
|
+
}
|
|
331
634
|
const channelId = message.channelId;
|
|
332
635
|
const messageId = message.id;
|
|
333
636
|
log.info?.(`[sesame] Message from ${message.senderId} in ${channelId}: "${bodyText.slice(0, 100)}"`);
|
|
334
|
-
// Skip own messages
|
|
637
|
+
// Skip own messages (by sender ID)
|
|
335
638
|
if (message.senderId === state.agentId)
|
|
336
639
|
return;
|
|
640
|
+
// Skip messages we sent (by message ID tracking)
|
|
641
|
+
if (state.sentMessageIds.has(messageId)) {
|
|
642
|
+
state.sentMessageIds.delete(messageId);
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
// Don't process messages until we know our own agent ID
|
|
646
|
+
if (!state.agentId) {
|
|
647
|
+
log.warn?.(`[sesame] Skipping message — agentId not resolved yet`);
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
337
650
|
// Channel filter (empty = all channels)
|
|
338
651
|
if (account.channels.length > 0 && !account.channels.includes(channelId))
|
|
339
652
|
return;
|
|
@@ -442,31 +755,102 @@ async function handleMessage(message, account, state, ctx) {
|
|
|
442
755
|
sessionKey: inboundCtx.SessionKey ?? sesameSessionKey,
|
|
443
756
|
ctx: inboundCtx,
|
|
444
757
|
});
|
|
445
|
-
//
|
|
758
|
+
// ── Streaming reply: send initial message, then edit in place ──
|
|
759
|
+
const sesameStreamMode = cfg.channels?.sesame?.streamMode ?? "buffer";
|
|
446
760
|
const replyBuffer = [];
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
761
|
+
let streamMessageId = null;
|
|
762
|
+
let streamSending = false; // lock to prevent concurrent first-message sends
|
|
763
|
+
let streamEditTimer = null;
|
|
764
|
+
let streamPendingText = "";
|
|
765
|
+
const STREAM_EDIT_DEBOUNCE_MS = 300; // don't edit more than ~3x/sec
|
|
766
|
+
const trackSentMessage = (sentId) => {
|
|
767
|
+
state.sentMessageIds.add(sentId);
|
|
768
|
+
if (state.sentMessageIds.size > 1000) {
|
|
769
|
+
const first = state.sentMessageIds.values().next().value;
|
|
770
|
+
if (first)
|
|
771
|
+
state.sentMessageIds.delete(first);
|
|
772
|
+
}
|
|
773
|
+
};
|
|
774
|
+
const sendNewMessage = async (text) => {
|
|
775
|
+
// Outbound circuit breaker: max 5 messages per 10s window
|
|
776
|
+
const cbNow = Date.now();
|
|
777
|
+
if (cbNow - state.outboundWindowStart > 10000) {
|
|
778
|
+
state.recentOutboundCount = 0;
|
|
779
|
+
state.outboundWindowStart = cbNow;
|
|
780
|
+
}
|
|
781
|
+
if (state.recentOutboundCount >= 5) {
|
|
782
|
+
log.error?.(`[sesame] Circuit breaker: suppressing outbound message`);
|
|
783
|
+
return null;
|
|
784
|
+
}
|
|
452
785
|
const res = await fetch(`${account.apiUrl}/api/v1/channels/${channelId}/messages`, {
|
|
453
786
|
method: "POST",
|
|
454
787
|
headers: {
|
|
455
788
|
"Content-Type": "application/json",
|
|
456
789
|
Authorization: `Bearer ${account.apiKey}`,
|
|
457
790
|
},
|
|
458
|
-
body: JSON.stringify({
|
|
459
|
-
content: fullReply,
|
|
460
|
-
kind: "text",
|
|
461
|
-
intent: "chat",
|
|
462
|
-
}),
|
|
791
|
+
body: JSON.stringify({ content: text, kind: "text", intent: "chat" }),
|
|
463
792
|
});
|
|
464
793
|
if (!res.ok) {
|
|
465
794
|
const err = await res.text().catch(() => "");
|
|
466
795
|
log.error?.(`[sesame] Send failed (${res.status}): ${err.slice(0, 200)}`);
|
|
796
|
+
return null;
|
|
797
|
+
}
|
|
798
|
+
state.recentOutboundCount++;
|
|
799
|
+
const resData = (await res.json().catch(() => ({})));
|
|
800
|
+
const sentId = resData.data?.id ?? resData.id ?? null;
|
|
801
|
+
if (sentId)
|
|
802
|
+
trackSentMessage(sentId);
|
|
803
|
+
return sentId;
|
|
804
|
+
};
|
|
805
|
+
const editMessage = async (msgId, text) => {
|
|
806
|
+
const res = await fetch(`${account.apiUrl}/api/v1/channels/${channelId}/messages/${msgId}`, {
|
|
807
|
+
method: "PATCH",
|
|
808
|
+
headers: {
|
|
809
|
+
"Content-Type": "application/json",
|
|
810
|
+
Authorization: `Bearer ${account.apiKey}`,
|
|
811
|
+
},
|
|
812
|
+
body: JSON.stringify({ content: text }),
|
|
813
|
+
});
|
|
814
|
+
if (!res.ok) {
|
|
815
|
+
log.warn?.(`[sesame] Edit failed (${res.status})`);
|
|
816
|
+
}
|
|
817
|
+
};
|
|
818
|
+
const scheduleStreamEdit = () => {
|
|
819
|
+
if (streamEditTimer)
|
|
820
|
+
return; // already scheduled
|
|
821
|
+
streamEditTimer = setTimeout(async () => {
|
|
822
|
+
streamEditTimer = null;
|
|
823
|
+
if (streamMessageId && streamPendingText) {
|
|
824
|
+
await editMessage(streamMessageId, streamPendingText);
|
|
825
|
+
}
|
|
826
|
+
}, STREAM_EDIT_DEBOUNCE_MS);
|
|
827
|
+
};
|
|
828
|
+
const flushBuffer = async () => {
|
|
829
|
+
const fullReply = replyBuffer.join("\n\n").trim();
|
|
830
|
+
// If nothing in buffer but we streamed via onPartialReply, do final edit with last known text
|
|
831
|
+
if (!fullReply && streamMessageId && streamPendingText) {
|
|
832
|
+
clearTimeout(streamEditTimer);
|
|
833
|
+
streamEditTimer = null;
|
|
834
|
+
await editMessage(streamMessageId, streamPendingText);
|
|
835
|
+
streamDelivered.set(channelId, { messageId: streamMessageId, text: streamPendingText });
|
|
836
|
+
log.info?.(`[sesame] Final stream edit from partial (${streamPendingText.length} chars), marked as delivered`);
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
if (!fullReply)
|
|
840
|
+
return;
|
|
841
|
+
if (streamMessageId) {
|
|
842
|
+
// Final edit with complete text
|
|
843
|
+
clearTimeout(streamEditTimer);
|
|
844
|
+
streamEditTimer = null;
|
|
845
|
+
await editMessage(streamMessageId, fullReply);
|
|
846
|
+
// Signal to outbound.sendText that this channel was already handled
|
|
847
|
+
streamDelivered.set(channelId, { messageId: streamMessageId, text: fullReply });
|
|
848
|
+
log.info?.(`[sesame] Final stream edit (${fullReply.length} chars), marked as delivered`);
|
|
467
849
|
}
|
|
468
850
|
else {
|
|
469
|
-
|
|
851
|
+
// No streaming happened — don't send here, let outbound.sendText handle it
|
|
852
|
+
// (avoids double-send when OpenClaw calls both deliver + outbound.sendText)
|
|
853
|
+
log.info?.(`[sesame] Buffer mode: deferring to outbound.sendText (${fullReply.length} chars)`);
|
|
470
854
|
}
|
|
471
855
|
};
|
|
472
856
|
const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({
|
|
@@ -475,18 +859,56 @@ async function handleMessage(message, account, state, ctx) {
|
|
|
475
859
|
onIdle: () => clearInterval(typingInterval),
|
|
476
860
|
deliver: async (payload) => {
|
|
477
861
|
const replyText = payload.text ?? payload.body ?? payload.content ?? "";
|
|
478
|
-
if (replyText)
|
|
479
|
-
|
|
862
|
+
if (!replyText)
|
|
863
|
+
return;
|
|
864
|
+
replyBuffer.push(replyText);
|
|
865
|
+
const fullSoFar = replyBuffer.join("\n\n").trim();
|
|
866
|
+
if (sesameStreamMode === "partial" && fullSoFar.length > 0) {
|
|
867
|
+
streamPendingText = fullSoFar;
|
|
868
|
+
if (!streamMessageId && !streamSending) {
|
|
869
|
+
// First chunk — send initial message (with lock to prevent race)
|
|
870
|
+
streamSending = true;
|
|
871
|
+
streamMessageId = await sendNewMessage(fullSoFar);
|
|
872
|
+
streamSending = false;
|
|
873
|
+
log.info?.(`[sesame] Stream started, msgId=${streamMessageId}`);
|
|
874
|
+
}
|
|
875
|
+
else if (streamMessageId) {
|
|
876
|
+
// Subsequent chunks — debounced edit
|
|
877
|
+
scheduleStreamEdit();
|
|
878
|
+
}
|
|
879
|
+
// If streamSending is true, another deliver call is already creating the message — skip
|
|
880
|
+
}
|
|
480
881
|
},
|
|
481
882
|
onError: (err) => {
|
|
482
883
|
log.error?.(`[sesame] Reply failed: ${String(err)}`);
|
|
483
884
|
},
|
|
484
885
|
});
|
|
886
|
+
// Add onPartialReply for real-time streaming (token-by-token updates)
|
|
887
|
+
const streamingReplyOptions = { ...replyOptions };
|
|
888
|
+
if (sesameStreamMode === "partial") {
|
|
889
|
+
streamingReplyOptions.onPartialReply = async (payload) => {
|
|
890
|
+
const text = payload.text;
|
|
891
|
+
if (!text)
|
|
892
|
+
return;
|
|
893
|
+
streamPendingText = text;
|
|
894
|
+
if (!streamMessageId && !streamSending) {
|
|
895
|
+
streamSending = true;
|
|
896
|
+
streamMessageId = await sendNewMessage(text);
|
|
897
|
+
streamSending = false;
|
|
898
|
+
if (streamMessageId) {
|
|
899
|
+
log.info?.(`[sesame] Stream started, msgId=${streamMessageId}`);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
else if (streamMessageId) {
|
|
903
|
+
scheduleStreamEdit();
|
|
904
|
+
}
|
|
905
|
+
};
|
|
906
|
+
}
|
|
485
907
|
await core.channel.reply.dispatchReplyFromConfig({
|
|
486
908
|
ctx: inboundCtx,
|
|
487
909
|
cfg,
|
|
488
910
|
dispatcher,
|
|
489
|
-
replyOptions,
|
|
911
|
+
replyOptions: streamingReplyOptions,
|
|
490
912
|
});
|
|
491
913
|
typingStopped = true;
|
|
492
914
|
clearInterval(typingInterval);
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sesamespace/sesame",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"description": "Sesame channel plugin for OpenClaw — connect your AI agent to the Sesame messaging platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -56,6 +56,7 @@
|
|
|
56
56
|
"ws": "^8.18.0"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
|
+
"@types/node": "^25.5.0",
|
|
59
60
|
"typescript": "^5.9.3"
|
|
60
61
|
}
|
|
61
62
|
}
|