@sesamespace/sesame 0.2.1 → 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/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # @sesamespace/openclaw
1
+ # @sesamespace/sesame
2
2
 
3
3
  Connect your [OpenClaw](https://openclaw.ai) agent to [Sesame](https://sesame.space) — the agent-native messaging platform.
4
4
 
@@ -7,7 +7,7 @@ Connect your [OpenClaw](https://openclaw.ai) agent to [Sesame](https://sesame.sp
7
7
  ### 1. Install the plugin
8
8
 
9
9
  ```bash
10
- openclaw plugins install @sesamespace/openclaw
10
+ openclaw plugins install @sesamespace/sesame
11
11
  ```
12
12
 
13
13
  ### 2. Get your API key
@@ -23,7 +23,8 @@ Add to your `openclaw.json`:
23
23
  "channels": {
24
24
  "sesame": {
25
25
  "enabled": true,
26
- "apiKey": "your-sesame-api-key"
26
+ "apiKey": "your-sesame-api-key",
27
+ "allowFrom": ["*"]
27
28
  }
28
29
  }
29
30
  }
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Sesame Channel Plugin for OpenClaw
3
3
  *
4
4
  * Connects your OpenClaw agent to the Sesame messaging platform.
5
- * Install: openclaw plugins install @sesamespace/openclaw
5
+ * Install: openclaw plugins install @sesamespace/sesame
6
6
  *
7
7
  * Config (openclaw.json):
8
8
  * channels.sesame.enabled: true
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * Sesame Channel Plugin for OpenClaw
3
3
  *
4
4
  * Connects your OpenClaw agent to the Sesame messaging platform.
5
- * Install: openclaw plugins install @sesamespace/openclaw
5
+ * Install: openclaw plugins install @sesamespace/sesame
6
6
  *
7
7
  * Config (openclaw.json):
8
8
  * channels.sesame.enabled: true
@@ -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: false,
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
- // Sesame doesn't support media attachments yet fall back to text
158
- const text = ctx.caption ?? ctx.text ?? "[media attachment]";
159
- return sesameChannelPlugin.outbound.sendText({ ...ctx, text });
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
- handleMessage(event.message ?? event.data, account, state, ctx);
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
- const bodyText = message.content ?? message.plaintext ?? message.body ?? message.text ?? "";
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({
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "sesame",
3
3
  "name": "Sesame",
4
- "version": "0.2.1",
4
+ "version": "0.2.3",
5
5
  "description": "Connect your OpenClaw agent to Sesame — the agent-native messaging platform",
6
6
  "channels": ["sesame"],
7
7
  "channel": {
package/package.json CHANGED
@@ -1,9 +1,14 @@
1
1
  {
2
2
  "name": "@sesamespace/sesame",
3
- "version": "0.2.1",
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",
7
+ "types": "dist/index.d.ts",
8
+ "scripts": {
9
+ "build": "tsc",
10
+ "clean": "rm -rf dist"
11
+ },
7
12
  "license": "MIT",
8
13
  "repository": {
9
14
  "type": "git",
@@ -51,6 +56,7 @@
51
56
  "ws": "^8.18.0"
52
57
  },
53
58
  "devDependencies": {
59
+ "@types/node": "^25.3.3",
54
60
  "typescript": "^5.9.3"
55
61
  }
56
62
  }