@sesamespace/sesame 0.2.2 → 0.2.3
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 +203 -8
- package/openclaw.plugin.json +1 -1
- package/package.json +2 -1
package/dist/index.js
CHANGED
|
@@ -19,6 +19,18 @@ const connections = new Map();
|
|
|
19
19
|
function getLogger() {
|
|
20
20
|
return (pluginRuntime?.logging?.getChildLogger?.({ plugin: "sesame" }) ?? console);
|
|
21
21
|
}
|
|
22
|
+
function guessContentType(fileName) {
|
|
23
|
+
const ext = fileName.split(".").pop()?.toLowerCase() ?? "";
|
|
24
|
+
const map = {
|
|
25
|
+
jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif",
|
|
26
|
+
webp: "image/webp", svg: "image/svg+xml", pdf: "application/pdf",
|
|
27
|
+
mp3: "audio/mpeg", mp4: "video/mp4", webm: "video/webm",
|
|
28
|
+
json: "application/json", txt: "text/plain", md: "text/markdown",
|
|
29
|
+
csv: "text/csv", html: "text/html", xml: "application/xml",
|
|
30
|
+
zip: "application/zip", tar: "application/x-tar", gz: "application/gzip",
|
|
31
|
+
};
|
|
32
|
+
return map[ext] ?? "application/octet-stream";
|
|
33
|
+
}
|
|
22
34
|
const sesameChannelPlugin = {
|
|
23
35
|
id: "sesame",
|
|
24
36
|
meta: {
|
|
@@ -31,7 +43,7 @@ const sesameChannelPlugin = {
|
|
|
31
43
|
},
|
|
32
44
|
capabilities: {
|
|
33
45
|
chatTypes: ["direct", "group"],
|
|
34
|
-
media:
|
|
46
|
+
media: true,
|
|
35
47
|
reactions: true,
|
|
36
48
|
threads: true,
|
|
37
49
|
editing: true,
|
|
@@ -154,9 +166,108 @@ const sesameChannelPlugin = {
|
|
|
154
166
|
};
|
|
155
167
|
},
|
|
156
168
|
sendMedia: async (ctx) => {
|
|
157
|
-
|
|
158
|
-
const
|
|
159
|
-
|
|
169
|
+
const { text, mediaUrl, cfg, accountId } = ctx;
|
|
170
|
+
const account = sesameChannelPlugin.config.resolveAccount(cfg, accountId);
|
|
171
|
+
if (!account?.apiKey)
|
|
172
|
+
throw new Error("Sesame not configured");
|
|
173
|
+
const channelId = ctx.to?.startsWith("sesame:") ? ctx.to.slice(7) : ctx.to;
|
|
174
|
+
const caption = ctx.caption ?? text ?? "";
|
|
175
|
+
const log = getLogger();
|
|
176
|
+
try {
|
|
177
|
+
// Resolve media source: local file path or URL
|
|
178
|
+
const fs = await import("fs");
|
|
179
|
+
const path = await import("path");
|
|
180
|
+
let fileBuffer;
|
|
181
|
+
let fileName;
|
|
182
|
+
let contentType;
|
|
183
|
+
if (mediaUrl && (mediaUrl.startsWith("/") || mediaUrl.startsWith("~"))) {
|
|
184
|
+
// Local file path
|
|
185
|
+
const resolvedPath = mediaUrl.startsWith("~")
|
|
186
|
+
? mediaUrl.replace("~", process.env.HOME ?? "")
|
|
187
|
+
: mediaUrl;
|
|
188
|
+
fileBuffer = fs.readFileSync(resolvedPath);
|
|
189
|
+
fileName = path.basename(resolvedPath);
|
|
190
|
+
contentType = guessContentType(fileName);
|
|
191
|
+
}
|
|
192
|
+
else if (mediaUrl) {
|
|
193
|
+
// Remote URL — download it
|
|
194
|
+
const response = await fetch(mediaUrl);
|
|
195
|
+
if (!response.ok)
|
|
196
|
+
throw new Error(`Failed to download media: ${response.status}`);
|
|
197
|
+
fileBuffer = Buffer.from(await response.arrayBuffer());
|
|
198
|
+
// Extract filename from URL or Content-Disposition
|
|
199
|
+
const urlPath = new URL(mediaUrl).pathname;
|
|
200
|
+
fileName = path.basename(urlPath) || "attachment";
|
|
201
|
+
contentType = response.headers.get("content-type") ?? guessContentType(fileName);
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
// No media URL — fall back to text
|
|
205
|
+
return sesameChannelPlugin.outbound.sendText({ ...ctx, text: caption || "[media attachment]" });
|
|
206
|
+
}
|
|
207
|
+
// 1. Get presigned upload URL from Sesame Drive
|
|
208
|
+
const uploadRes = await fetch(`${account.apiUrl}/api/v1/drive/files/upload-url`, {
|
|
209
|
+
method: "POST",
|
|
210
|
+
headers: {
|
|
211
|
+
"Content-Type": "application/json",
|
|
212
|
+
Authorization: `Bearer ${account.apiKey}`,
|
|
213
|
+
},
|
|
214
|
+
body: JSON.stringify({ fileName, contentType, size: fileBuffer.length }),
|
|
215
|
+
});
|
|
216
|
+
if (!uploadRes.ok)
|
|
217
|
+
throw new Error(`Drive upload-url failed: ${uploadRes.status}`);
|
|
218
|
+
const uploadData = (await uploadRes.json());
|
|
219
|
+
const { uploadUrl, fileId, s3Key } = uploadData.data;
|
|
220
|
+
// 2. Upload to S3
|
|
221
|
+
const s3Res = await fetch(uploadUrl, {
|
|
222
|
+
method: "PUT",
|
|
223
|
+
headers: { "Content-Type": contentType },
|
|
224
|
+
body: new Uint8Array(fileBuffer),
|
|
225
|
+
});
|
|
226
|
+
if (!s3Res.ok)
|
|
227
|
+
throw new Error(`S3 upload failed: ${s3Res.status}`);
|
|
228
|
+
// 3. Register file in Drive
|
|
229
|
+
const regRes = await fetch(`${account.apiUrl}/api/v1/drive/files`, {
|
|
230
|
+
method: "POST",
|
|
231
|
+
headers: {
|
|
232
|
+
"Content-Type": "application/json",
|
|
233
|
+
Authorization: `Bearer ${account.apiKey}`,
|
|
234
|
+
},
|
|
235
|
+
body: JSON.stringify({ fileId, s3Key, fileName, contentType, size: fileBuffer.length }),
|
|
236
|
+
});
|
|
237
|
+
if (!regRes.ok)
|
|
238
|
+
throw new Error(`Drive register failed: ${regRes.status}`);
|
|
239
|
+
// 4. Send message with attachment
|
|
240
|
+
const msgRes = await fetch(`${account.apiUrl}/api/v1/channels/${channelId}/messages`, {
|
|
241
|
+
method: "POST",
|
|
242
|
+
headers: {
|
|
243
|
+
"Content-Type": "application/json",
|
|
244
|
+
Authorization: `Bearer ${account.apiKey}`,
|
|
245
|
+
},
|
|
246
|
+
body: JSON.stringify({
|
|
247
|
+
content: caption || fileName,
|
|
248
|
+
kind: "text",
|
|
249
|
+
intent: "chat",
|
|
250
|
+
attachmentIds: [fileId],
|
|
251
|
+
}),
|
|
252
|
+
});
|
|
253
|
+
if (!msgRes.ok)
|
|
254
|
+
throw new Error(`Send message failed: ${msgRes.status}`);
|
|
255
|
+
const msgData = (await msgRes.json());
|
|
256
|
+
log.info?.(`[sesame] Sent file "${fileName}" (${fileBuffer.length} bytes) to ${channelId}`);
|
|
257
|
+
return {
|
|
258
|
+
channel: "sesame",
|
|
259
|
+
messageId: msgData.data?.id ?? msgData.id ?? "unknown",
|
|
260
|
+
chatId: channelId,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
catch (err) {
|
|
264
|
+
log.error?.(`[sesame] sendMedia failed: ${err}`);
|
|
265
|
+
// Fall back to text with caption
|
|
266
|
+
return sesameChannelPlugin.outbound.sendText({
|
|
267
|
+
...ctx,
|
|
268
|
+
text: caption || "[media attachment — upload failed]",
|
|
269
|
+
});
|
|
270
|
+
}
|
|
160
271
|
},
|
|
161
272
|
},
|
|
162
273
|
messaging: {
|
|
@@ -207,6 +318,9 @@ async function connect(account, ctx) {
|
|
|
207
318
|
stopping: false,
|
|
208
319
|
ctx,
|
|
209
320
|
channelMap: new Map(),
|
|
321
|
+
sentMessageIds: new Set(),
|
|
322
|
+
recentOutboundCount: 0,
|
|
323
|
+
outboundWindowStart: Date.now(),
|
|
210
324
|
};
|
|
211
325
|
connections.set(account.accountId, state);
|
|
212
326
|
try {
|
|
@@ -286,6 +400,10 @@ function handleEvent(event, account, state, ctx) {
|
|
|
286
400
|
switch (event.type) {
|
|
287
401
|
case "authenticated":
|
|
288
402
|
state.authenticated = true;
|
|
403
|
+
if (event.principalId && !state.agentId) {
|
|
404
|
+
state.agentId = event.principalId;
|
|
405
|
+
log.info?.(`[sesame] Agent ID from auth: ${state.agentId}`);
|
|
406
|
+
}
|
|
289
407
|
state.heartbeatTimer = setInterval(() => {
|
|
290
408
|
if (state.ws?.readyState === 1) {
|
|
291
409
|
state.ws.send(JSON.stringify({ type: "ping" }));
|
|
@@ -294,9 +412,45 @@ function handleEvent(event, account, state, ctx) {
|
|
|
294
412
|
log.info?.("[sesame] Authenticated successfully");
|
|
295
413
|
state.ws?.send(JSON.stringify({ type: "replay", cursors: {} }));
|
|
296
414
|
break;
|
|
297
|
-
case "message":
|
|
298
|
-
|
|
415
|
+
case "message": {
|
|
416
|
+
const msg = event.message ?? event.data;
|
|
417
|
+
// Skip voice messages without transcription — wait for voice.transcribed event
|
|
418
|
+
if (msg?.kind === "voice") {
|
|
419
|
+
const meta = msg.metadata ?? {};
|
|
420
|
+
const transcript = meta.transcript;
|
|
421
|
+
if (!transcript) {
|
|
422
|
+
log.info?.(`[sesame] Voice message ${msg.id} — waiting for transcription`);
|
|
423
|
+
break;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
handleMessage(msg, account, state, ctx);
|
|
427
|
+
break;
|
|
428
|
+
}
|
|
429
|
+
case "voice.transcribed": {
|
|
430
|
+
// Transcription completed — dispatch as a regular message with transcript text
|
|
431
|
+
const vtData = event;
|
|
432
|
+
const transcript = vtData.transcript;
|
|
433
|
+
const messageId = vtData.messageId;
|
|
434
|
+
const channelId = vtData.channelId;
|
|
435
|
+
const senderId = vtData.senderId;
|
|
436
|
+
if (!transcript || !messageId || !channelId) {
|
|
437
|
+
log.warn?.("[sesame] voice.transcribed event missing fields");
|
|
438
|
+
break;
|
|
439
|
+
}
|
|
440
|
+
log.info?.(`[sesame] Voice transcribed for ${messageId}: "${transcript.slice(0, 80)}"`);
|
|
441
|
+
// Build a synthetic message object so handleMessage can process it
|
|
442
|
+
handleMessage({
|
|
443
|
+
id: messageId,
|
|
444
|
+
channelId,
|
|
445
|
+
senderId,
|
|
446
|
+
kind: "voice",
|
|
447
|
+
content: transcript,
|
|
448
|
+
plaintext: transcript,
|
|
449
|
+
metadata: { transcript },
|
|
450
|
+
createdAt: new Date().toISOString(),
|
|
451
|
+
}, account, state, ctx);
|
|
299
452
|
break;
|
|
453
|
+
}
|
|
300
454
|
case "membership": {
|
|
301
455
|
// Track new channel joins so ChatType resolves correctly
|
|
302
456
|
const mData = event.data ?? event.membership ?? event;
|
|
@@ -327,13 +481,27 @@ async function handleMessage(message, account, state, ctx) {
|
|
|
327
481
|
const core = pluginRuntime;
|
|
328
482
|
if (!core)
|
|
329
483
|
return;
|
|
330
|
-
|
|
484
|
+
// For voice messages, prefer transcript from metadata over content
|
|
485
|
+
const msgMeta = message.metadata ?? {};
|
|
486
|
+
const voiceTranscript = message.kind === "voice" ? msgMeta.transcript : undefined;
|
|
487
|
+
const rawText = voiceTranscript ?? message.content ?? message.plaintext ?? message.body ?? message.text ?? "";
|
|
488
|
+
const bodyText = message.kind === "voice" && rawText ? `(voice note)\n${rawText}` : rawText;
|
|
331
489
|
const channelId = message.channelId;
|
|
332
490
|
const messageId = message.id;
|
|
333
491
|
log.info?.(`[sesame] Message from ${message.senderId} in ${channelId}: "${bodyText.slice(0, 100)}"`);
|
|
334
|
-
// Skip own messages
|
|
492
|
+
// Skip own messages (by sender ID)
|
|
335
493
|
if (message.senderId === state.agentId)
|
|
336
494
|
return;
|
|
495
|
+
// Skip messages we sent (by message ID tracking)
|
|
496
|
+
if (state.sentMessageIds.has(messageId)) {
|
|
497
|
+
state.sentMessageIds.delete(messageId);
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
// Don't process messages until we know our own agent ID
|
|
501
|
+
if (!state.agentId) {
|
|
502
|
+
log.warn?.(`[sesame] Skipping message — agentId not resolved yet`);
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
337
505
|
// Channel filter (empty = all channels)
|
|
338
506
|
if (account.channels.length > 0 && !account.channels.includes(channelId))
|
|
339
507
|
return;
|
|
@@ -448,6 +616,16 @@ async function handleMessage(message, account, state, ctx) {
|
|
|
448
616
|
const fullReply = replyBuffer.join("\n\n").trim();
|
|
449
617
|
if (!fullReply)
|
|
450
618
|
return;
|
|
619
|
+
// Outbound circuit breaker: max 5 messages per 10s window
|
|
620
|
+
const cbNow = Date.now();
|
|
621
|
+
if (cbNow - state.outboundWindowStart > 10000) {
|
|
622
|
+
state.recentOutboundCount = 0;
|
|
623
|
+
state.outboundWindowStart = cbNow;
|
|
624
|
+
}
|
|
625
|
+
if (state.recentOutboundCount >= 5) {
|
|
626
|
+
log.error?.(`[sesame] Circuit breaker: suppressing outbound message (${state.recentOutboundCount} msgs in ${Math.round((cbNow - state.outboundWindowStart) / 1000)}s)`);
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
451
629
|
log.info?.(`[sesame] Flushing buffered reply (${fullReply.length} chars): "${fullReply.slice(0, 100)}"`);
|
|
452
630
|
const res = await fetch(`${account.apiUrl}/api/v1/channels/${channelId}/messages`, {
|
|
453
631
|
method: "POST",
|
|
@@ -467,6 +645,23 @@ async function handleMessage(message, account, state, ctx) {
|
|
|
467
645
|
}
|
|
468
646
|
else {
|
|
469
647
|
log.info?.(`[sesame] Sent buffered reply to ${channelId} (${res.status})`);
|
|
648
|
+
// Track sent message ID to detect self-echoes
|
|
649
|
+
try {
|
|
650
|
+
const resData = (await res.clone().json().catch(() => ({})));
|
|
651
|
+
const sentId = resData.data?.id ?? resData.id;
|
|
652
|
+
if (sentId) {
|
|
653
|
+
state.sentMessageIds.add(sentId);
|
|
654
|
+
// Cap set size at 1000
|
|
655
|
+
if (state.sentMessageIds.size > 1000) {
|
|
656
|
+
const first = state.sentMessageIds.values().next().value;
|
|
657
|
+
if (first)
|
|
658
|
+
state.sentMessageIds.delete(first);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
catch { }
|
|
663
|
+
// Track outbound count for circuit breaker
|
|
664
|
+
state.recentOutboundCount++;
|
|
470
665
|
}
|
|
471
666
|
};
|
|
472
667
|
const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({
|
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.3",
|
|
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.3.3",
|
|
59
60
|
"typescript": "^5.9.3"
|
|
60
61
|
}
|
|
61
62
|
}
|