openclaw-quiubo 2.6.27 → 2.6.29

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
@@ -145,28 +145,25 @@ On first gateway startup, if the bot has no groups, the plugin automatically cre
145
145
 
146
146
  Run multiple AI agents on a single OpenClaw gateway — each with their own Quiubo chat, workspace, model, and cron jobs.
147
147
 
148
- ### Step 1: Add the agent
148
+ Each agent needs two things: a **bot identity** in Quiubo (so it has its own chat presence) and an **agent entry** in OpenClaw (so it has its own workspace and model). They can share the same SDK API key.
149
149
 
150
- In `~/.openclaw/openclaw.json`:
150
+ ### Step 1: Create a bot identity in Quiubo
151
151
 
152
- ```json
153
- {
154
- "agents": {
155
- "list": [
156
- { "id": "main" },
157
- {
158
- "id": "project-manager",
159
- "model": "anthropic/claude-sonnet-4-5",
160
- "workspace": "~/.openclaw/workspace-project-manager"
161
- }
162
- ]
163
- }
164
- }
152
+ Each agent needs its own bot identity. You create one by running the setup wizard with a new account ID:
153
+
154
+ ```bash
155
+ openclaw channels add --channel quiubo
165
156
  ```
166
157
 
167
- ### Step 2: Add a Quiubo account for the agent
158
+ The wizard will:
159
+
160
+ 1. Ask for an **Account ID** — enter a short name for this agent (e.g. `pm`, `support`, `research`). Your first/default agent uses `default`.
161
+ 2. Ask for your **SDK API Key** — you can reuse the same `qub_...` key across all agents.
162
+ 3. **Create or select a bot identity** — choose "Create new bot identity" to give this agent its own username and display name in Quiubo.
168
163
 
169
- Each account needs its own bot identity (can share the same API key):
164
+ > **Where to get an SDK API Key:** In the Quiubo app, go to **Settings > Developer > API Keys**. One key works for all agents under the same app.
165
+
166
+ After the wizard completes, the new account is saved to `~/.openclaw/openclaw.json` automatically:
170
167
 
171
168
  ```json
172
169
  {
@@ -174,11 +171,11 @@ Each account needs its own bot identity (can share the same API key):
174
171
  "quiubo": {
175
172
  "accounts": {
176
173
  "default": {
177
- "sdkApiKey": "qub_...",
174
+ "apiKey": "qub_...",
178
175
  "botIdentityId": "<main-bot-uuid>"
179
176
  },
180
177
  "pm": {
181
- "sdkApiKey": "qub_...",
178
+ "apiKey": "qub_...",
182
179
  "botIdentityId": "<pm-bot-uuid>"
183
180
  }
184
181
  }
@@ -187,7 +184,30 @@ Each account needs its own bot identity (can share the same API key):
187
184
  }
188
185
  ```
189
186
 
190
- ### Step 3: Bind accounts to agents
187
+ ### Step 2: Add the agent to OpenClaw
188
+
189
+ In `~/.openclaw/openclaw.json`, add an entry to the agents list:
190
+
191
+ ```json
192
+ {
193
+ "agents": {
194
+ "list": [
195
+ { "id": "main" },
196
+ {
197
+ "id": "project-manager",
198
+ "model": "anthropic/claude-sonnet-4-5",
199
+ "workspace": "~/.openclaw/workspace-project-manager"
200
+ }
201
+ ]
202
+ }
203
+ }
204
+ ```
205
+
206
+ Each agent can have its own model, workspace, and system prompt.
207
+
208
+ ### Step 3: Bind the Quiubo account to the agent
209
+
210
+ Tell OpenClaw which Quiubo account maps to which agent:
191
211
 
192
212
  ```json
193
213
  {
@@ -233,6 +253,16 @@ Quiubo Chat (You + PM Bot) → account: pm → binding → agent: projec
233
253
  - **Workspaces**: Fully isolated — agents can't see each other's files
234
254
  - **Cursors**: Each account has its own cursor file — no clobbering
235
255
 
256
+ ### Adding more agents
257
+
258
+ Repeat steps 1–5 for each new agent. The key command is:
259
+
260
+ ```bash
261
+ openclaw channels add --channel quiubo
262
+ ```
263
+
264
+ Enter a unique Account ID each time (e.g. `support`, `research`). The wizard handles identity creation and config — then add the agent, binding, and workspace as shown above.
265
+
236
266
  ## Markdown Attachments
237
267
 
238
268
  Agents can send `.md` file attachments alongside messages. Attachments appear as tappable cards in the Quiubo app with a full markdown viewer.
package/dist/index.js CHANGED
@@ -9233,6 +9233,31 @@ var QuiuboApiClient = class {
9233
9233
  identity_id: identityId
9234
9234
  });
9235
9235
  }
9236
+ // ========================================================================
9237
+ // Attachment Upload
9238
+ // ========================================================================
9239
+ /**
9240
+ * Get a presigned S3 URL for uploading an image attachment
9241
+ */
9242
+ async getAttachmentPresignUrl(groupId, opts) {
9243
+ return this.request("POST", `/groups/${groupId}/attachments/presign`, opts);
9244
+ }
9245
+ /**
9246
+ * Upload a file buffer to a presigned S3 URL
9247
+ */
9248
+ async uploadToPresignedUrl(uploadUrl, buffer, contentType) {
9249
+ const response = await fetch(uploadUrl, {
9250
+ method: "PUT",
9251
+ headers: {
9252
+ "Content-Type": contentType,
9253
+ "Content-Length": String(buffer.length)
9254
+ },
9255
+ body: buffer
9256
+ });
9257
+ if (!response.ok) {
9258
+ throw new Error(`S3 upload failed: ${response.status} ${response.statusText}`);
9259
+ }
9260
+ }
9236
9261
  };
9237
9262
 
9238
9263
  // src/realtime-gateway.ts
@@ -13300,23 +13325,57 @@ async function persistSeed(seedFile, seedB64) {
13300
13325
  } catch {
13301
13326
  }
13302
13327
  }
13303
- async function readMdAttachments(mediaUrls, source, log, accountId) {
13328
+ var IMAGE_MIME_TYPES = {
13329
+ ".jpg": "image/jpeg",
13330
+ ".jpeg": "image/jpeg",
13331
+ ".png": "image/png",
13332
+ ".webp": "image/webp"
13333
+ };
13334
+ var MAX_IMAGE_BYTES = 5 * 1024 * 1024;
13335
+ async function readOutboundAttachments(mediaUrls, source, client, groupId, log, accountId) {
13304
13336
  const attachments = [];
13305
13337
  const seen = /* @__PURE__ */ new Set();
13306
13338
  for (const url of mediaUrls) {
13307
13339
  if (seen.has(url)) continue;
13308
13340
  seen.add(url);
13309
13341
  const filename = basename(url);
13310
- if (!filename.endsWith(".md")) continue;
13311
- try {
13312
- const content = await readFile2(url, "utf-8");
13313
- if (content.length > 1024 * 1024) {
13314
- log?.warn?.(`[${accountId}] Quiubo: skipping ${filename} \u2014 exceeds 1MB`);
13315
- continue;
13342
+ const ext = filename.substring(filename.lastIndexOf(".")).toLowerCase();
13343
+ if (ext === ".md") {
13344
+ try {
13345
+ const content = await readFile2(url, "utf-8");
13346
+ if (content.length > 1024 * 1024) {
13347
+ log?.warn?.(`[${accountId}] Quiubo: skipping ${filename} \u2014 exceeds 1MB`);
13348
+ continue;
13349
+ }
13350
+ attachments.push({ filename, mimeType: "text/markdown", content, source });
13351
+ } catch (err) {
13352
+ log?.warn?.(`[${accountId}] Quiubo: failed to read ${url}: ${err}`);
13353
+ }
13354
+ } else if (ext in IMAGE_MIME_TYPES) {
13355
+ const mimeType = IMAGE_MIME_TYPES[ext];
13356
+ try {
13357
+ const buffer = await readFile2(url);
13358
+ if (buffer.length > MAX_IMAGE_BYTES) {
13359
+ log?.warn?.(`[${accountId}] Quiubo: skipping ${filename} \u2014 exceeds 5MB (${buffer.length} bytes)`);
13360
+ continue;
13361
+ }
13362
+ const presign = await client.getAttachmentPresignUrl(groupId, {
13363
+ contentType: mimeType,
13364
+ contentLength: buffer.length,
13365
+ filename
13366
+ });
13367
+ await client.uploadToPresignedUrl(presign.uploadUrl, buffer, mimeType);
13368
+ attachments.push({
13369
+ filename,
13370
+ mimeType,
13371
+ storageUrl: presign.storageUrl,
13372
+ sizeBytes: buffer.length,
13373
+ source
13374
+ });
13375
+ log?.info?.(`[${accountId}] Quiubo: uploaded image ${filename} (${buffer.length} bytes) \u2192 ${presign.storageUrl}`);
13376
+ } catch (err) {
13377
+ log?.warn?.(`[${accountId}] Quiubo: failed to upload image ${url}: ${err}`);
13316
13378
  }
13317
- attachments.push({ filename, mimeType: "text/markdown", content, source });
13318
- } catch (err) {
13319
- log?.warn?.(`[${accountId}] Quiubo: failed to read ${url}: ${err}`);
13320
13379
  }
13321
13380
  }
13322
13381
  return attachments;
@@ -13675,8 +13734,6 @@ var quiuboPlugin = {
13675
13734
  if (Array.isArray(ctx.mediaUrls)) urls.push(...ctx.mediaUrls);
13676
13735
  const accountId = ctx.accountId ?? DEFAULT_ACCOUNT_ID;
13677
13736
  const log = loggers.get(accountId);
13678
- const mdAttachments = await readMdAttachments(urls, "agent", log, accountId);
13679
- const plaintext = text || (mdAttachments.length === 0 ? "[Media attachment]" : "");
13680
13737
  let client = clients.get(accountId);
13681
13738
  let account = accounts.get(accountId);
13682
13739
  if (!client || !account) {
@@ -13695,23 +13752,37 @@ var quiuboPlugin = {
13695
13752
  if (!groupId) {
13696
13753
  groupId = await resolveAnnounceGroupId(accountId, log);
13697
13754
  }
13698
- log?.info?.(`[${accountId}] [outbound:sendMedia] groupId=${groupId}, urls=${urls.length}, attachments=${mdAttachments.length}`);
13699
13755
  if (!groupId) {
13700
13756
  log?.error?.(`[${accountId}] [outbound:sendMedia] no groupId \u2014 ctx keys=${Object.keys(ctx).join(",")}`);
13701
13757
  return { ok: false, error: "No groupId in outbound context" };
13702
13758
  }
13759
+ const outboundAttachments = await readOutboundAttachments(urls, "agent", client, groupId, log, accountId);
13760
+ const imageAttachments = outboundAttachments.filter((a) => a.mimeType.startsWith("image/"));
13761
+ const nonImageAttachments = outboundAttachments.filter((a) => !a.mimeType.startsWith("image/"));
13762
+ const plaintext = text || (outboundAttachments.length === 0 ? "[Media attachment]" : "");
13763
+ log?.info?.(`[${accountId}] [outbound:sendMedia] groupId=${groupId}, urls=${urls.length}, attachments=${outboundAttachments.length} (${imageAttachments.length} images)`);
13703
13764
  const senderId = account.botIdentityId;
13704
13765
  if (!senderId) {
13705
13766
  return { ok: false, error: "No botIdentityId configured" };
13706
13767
  }
13707
13768
  try {
13708
- const apiResp = await client.sendMessage(groupId, {
13709
- senderIdentityId: senderId,
13710
- plaintext,
13711
- metadata: { format: "markdown" },
13712
- ...mdAttachments.length > 0 ? { attachments: mdAttachments } : {}
13713
- });
13714
- log?.info?.(`[${accountId}] [outbound:sendMedia] sent to group ${groupId} (realtime=${apiResp?.realtimeDelivered})`);
13769
+ if (plaintext || nonImageAttachments.length > 0) {
13770
+ const apiResp = await client.sendMessage(groupId, {
13771
+ senderIdentityId: senderId,
13772
+ plaintext: plaintext || " ",
13773
+ metadata: { format: "markdown" },
13774
+ ...nonImageAttachments.length > 0 ? { attachments: nonImageAttachments } : {}
13775
+ });
13776
+ log?.info?.(`[${accountId}] [outbound:sendMedia] sent to group ${groupId} (realtime=${apiResp?.realtimeDelivered})`);
13777
+ }
13778
+ for (const img of imageAttachments) {
13779
+ const imgResp = await client.sendMessage(groupId, {
13780
+ senderIdentityId: senderId,
13781
+ plaintext: " ",
13782
+ attachments: [img]
13783
+ });
13784
+ log?.info?.(`[${accountId}] [outbound:sendMedia] image ${img.filename} sent to group ${groupId} (realtime=${imgResp?.realtimeDelivered})`);
13785
+ }
13715
13786
  return { ok: true };
13716
13787
  } catch (error) {
13717
13788
  if (error instanceof QuiuboApiError) {
@@ -14232,8 +14303,29 @@ async function routeInboundMessage(opts) {
14232
14303
  const imageAttachments = (msg.attachments ?? []).filter(
14233
14304
  (att) => att.storageUrl && att.mimeType?.startsWith("image/")
14234
14305
  );
14235
- const mediaUrls = imageAttachments.map((att) => att.storageUrl);
14236
- const mediaTypes = imageAttachments.map((att) => att.mimeType);
14306
+ const mediaPaths = [];
14307
+ const mediaTypes = [];
14308
+ if (imageAttachments.length > 0) {
14309
+ const mediaDir = join2(process.env.HOME ?? process.env.USERPROFILE ?? "", ".openclaw", "media", "inbound");
14310
+ await mkdir2(mediaDir, { recursive: true });
14311
+ for (const att of imageAttachments) {
14312
+ try {
14313
+ const resp = await fetch(att.storageUrl);
14314
+ if (!resp.ok) {
14315
+ log?.warn?.(`[${accountId}] Failed to download attachment ${att.filename}: ${resp.status}`);
14316
+ continue;
14317
+ }
14318
+ const buffer = Buffer.from(await resp.arrayBuffer());
14319
+ const ext = att.filename?.split(".").pop() || "png";
14320
+ const localPath = join2(mediaDir, `${msg.messageId}-${mediaPaths.length}.${ext}`);
14321
+ await writeFile2(localPath, buffer);
14322
+ mediaPaths.push(localPath);
14323
+ mediaTypes.push(att.mimeType);
14324
+ } catch (err) {
14325
+ log?.warn?.(`[${accountId}] Failed to download attachment ${att.filename}: ${err}`);
14326
+ }
14327
+ }
14328
+ }
14237
14329
  if (msg.attachments?.length) {
14238
14330
  for (const att of msg.attachments) {
14239
14331
  if (att.storageUrl && att.mimeType?.startsWith("image/")) continue;
@@ -14243,7 +14335,7 @@ async function routeInboundMessage(opts) {
14243
14335
  [Attached: ${att.filename}${sizeKb ? `, ${sizeKb}` : ""}]`;
14244
14336
  }
14245
14337
  }
14246
- log?.info?.(`[${accountId}] Quiubo: inbound from ${senderId} in group ${groupId}: ${text?.slice(0, 100)}`);
14338
+ log?.info?.(`[${accountId}] Quiubo: inbound from ${senderId} in group ${groupId}: ${text?.slice(0, 100)}${mediaPaths.length > 0 ? ` [${mediaPaths.length} image(s)]` : ""}`);
14247
14339
  const sendTyping = async () => {
14248
14340
  try {
14249
14341
  await client.sendTypingIndicator(groupId, botIdentityId);
@@ -14274,8 +14366,11 @@ async function routeInboundMessage(opts) {
14274
14366
  Surface: CHANNEL_ID,
14275
14367
  MessageSid: `quiubo-${msg.messageId}`,
14276
14368
  Timestamp: msg.createdAt ? new Date(msg.createdAt).getTime() : Date.now(),
14277
- ...mediaUrls.length > 0 && {
14278
- MediaUrls: mediaUrls,
14369
+ ...mediaPaths.length > 0 && {
14370
+ MediaPath: mediaPaths[0],
14371
+ MediaUrl: mediaPaths[0],
14372
+ MediaPaths: mediaPaths,
14373
+ MediaUrls: mediaPaths,
14279
14374
  MediaTypes: mediaTypes
14280
14375
  }
14281
14376
  });
@@ -14297,13 +14392,13 @@ async function routeInboundMessage(opts) {
14297
14392
  const fileUrls = [];
14298
14393
  if (payload.mediaUrl) fileUrls.push(payload.mediaUrl);
14299
14394
  if (Array.isArray(payload.mediaUrls)) fileUrls.push(...payload.mediaUrls);
14300
- const mdAttachments = await readMdAttachments(fileUrls, agentSource, log, accountId);
14395
+ const outboundAttachments = await readOutboundAttachments(fileUrls, agentSource, client, groupId, log, accountId);
14301
14396
  if (payload.media?.length) {
14302
- const existingFilenames = new Set(mdAttachments.map((a) => a.filename));
14397
+ const existingFilenames = new Set(outboundAttachments.map((a) => a.filename));
14303
14398
  for (const m of payload.media) {
14304
14399
  const filename = m.filename || m.name || "";
14305
14400
  if (filename.endsWith(".md") && typeof m.content === "string" && !existingFilenames.has(filename)) {
14306
- mdAttachments.push({
14401
+ outboundAttachments.push({
14307
14402
  filename,
14308
14403
  mimeType: "text/markdown",
14309
14404
  content: m.content,
@@ -14312,8 +14407,8 @@ async function routeInboundMessage(opts) {
14312
14407
  }
14313
14408
  }
14314
14409
  }
14315
- log?.info?.(`[${accountId}] Quiubo: after dedup: ${mdAttachments.length} attachments [${mdAttachments.map((a) => a.filename).join(", ")}]`);
14316
- if (!payload.text && mdAttachments.length === 0) {
14410
+ log?.info?.(`[${accountId}] Quiubo: after dedup: ${outboundAttachments.length} attachments [${outboundAttachments.map((a) => a.filename).join(", ")}]`);
14411
+ if (!payload.text && outboundAttachments.length === 0) {
14317
14412
  log?.debug?.(`[${accountId}] Quiubo: skipping empty ${info.kind} reply`);
14318
14413
  return;
14319
14414
  }
@@ -14326,11 +14421,13 @@ async function routeInboundMessage(opts) {
14326
14421
  if (payload.text) {
14327
14422
  payload.text = payload.text.replace(/\n*Reasoning:\n_[^_]*_\n*/g, "").trim();
14328
14423
  }
14329
- if (!payload.text && mdAttachments.length === 0) {
14424
+ if (!payload.text && outboundAttachments.length === 0) {
14330
14425
  log?.debug?.(`[${accountId}] Quiubo: skipping empty reply after reasoning strip`);
14331
14426
  return;
14332
14427
  }
14333
- log?.info?.(`[${accountId}] Quiubo: delivering ${info.kind} reply [${payload.text?.length ?? 0} chars, ${mdAttachments.length} attachments]`);
14428
+ const imageAttachments2 = outboundAttachments.filter((a) => a.mimeType.startsWith("image/"));
14429
+ const nonImageAttachments = outboundAttachments.filter((a) => !a.mimeType.startsWith("image/"));
14430
+ log?.info?.(`[${accountId}] Quiubo: delivering ${info.kind} reply [${payload.text?.length ?? 0} chars, ${nonImageAttachments.length} non-image attachments, ${imageAttachments2.length} images]`);
14334
14431
  try {
14335
14432
  let ciphertextOut;
14336
14433
  if (e2eeGrantedGroups?.has(groupId) && keyManager && payload.text) {
@@ -14347,16 +14444,26 @@ async function routeInboundMessage(opts) {
14347
14444
  log?.warn?.(`[${accountId}] Quiubo: no epoch key for E2EE group ${groupId}, sending plaintext`);
14348
14445
  }
14349
14446
  }
14350
- const apiResp = await client.sendMessage(groupId, {
14351
- senderIdentityId: botIdentityId,
14352
- ...ciphertextOut ? { ciphertext: ciphertextOut } : { plaintext: payload.text || "" },
14353
- metadata: { format: "markdown" },
14354
- ...mdAttachments.length > 0 ? { attachments: mdAttachments } : {}
14355
- });
14356
- if (apiResp?.realtimeDelivered === false) {
14357
- log?.warn?.(`[${accountId}] Quiubo: reply persisted but Pusher delivery failed for group ${groupId} \u2014 clients will see on next poll`);
14358
- } else {
14359
- log?.info?.(`[${accountId}] Quiubo: reply sent to group ${groupId} (realtime=${apiResp?.realtimeDelivered})`);
14447
+ if (payload.text || nonImageAttachments.length > 0) {
14448
+ const apiResp = await client.sendMessage(groupId, {
14449
+ senderIdentityId: botIdentityId,
14450
+ ...ciphertextOut ? { ciphertext: ciphertextOut } : { plaintext: payload.text || " " },
14451
+ metadata: { format: "markdown" },
14452
+ ...nonImageAttachments.length > 0 ? { attachments: nonImageAttachments } : {}
14453
+ });
14454
+ if (apiResp?.realtimeDelivered === false) {
14455
+ log?.warn?.(`[${accountId}] Quiubo: reply persisted but Pusher delivery failed for group ${groupId} \u2014 clients will see on next poll`);
14456
+ } else {
14457
+ log?.info?.(`[${accountId}] Quiubo: reply sent to group ${groupId} (realtime=${apiResp?.realtimeDelivered})`);
14458
+ }
14459
+ }
14460
+ for (const img of imageAttachments2) {
14461
+ const imgResp = await client.sendMessage(groupId, {
14462
+ senderIdentityId: botIdentityId,
14463
+ plaintext: " ",
14464
+ attachments: [img]
14465
+ });
14466
+ log?.info?.(`[${accountId}] Quiubo: image ${img.filename} sent to group ${groupId} (realtime=${imgResp?.realtimeDelivered})`);
14360
14467
  }
14361
14468
  } catch (error) {
14362
14469
  if (error instanceof QuiuboApiError) {