openclaw-quiubo 2.6.16 → 2.6.18

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
@@ -10,34 +10,26 @@ npm install openclaw-quiubo
10
10
 
11
11
  ## Setup
12
12
 
13
- Quiubo is configured with:
14
-
15
13
  ```bash
16
14
  openclaw channels add --channel quiubo
17
15
  ```
18
16
 
19
- This launches the interactive setup wizard which will:
17
+ The interactive wizard will:
20
18
 
21
19
  1. Prompt for your **SDK API Key** (starts with `qub_`)
22
20
  2. Authenticate against the Quiubo API
23
- 3. List your existing bot identities or create a new one
21
+ 3. List existing bot identities or create a new one
24
22
  4. Save the configuration
25
23
 
26
- ## Prerequisites
27
-
28
- You need a Quiubo SDK app with an API key. Get one from the Developer section in the Quiubo app settings.
29
-
30
- ### What you'll need
24
+ ### Prerequisites
31
25
 
32
26
  | Item | Where to get it |
33
27
  |------|----------------|
34
- | SDK API Key | Quiubo app Settings Developer API Keys |
28
+ | SDK API Key | Quiubo app > Settings > Developer > API Keys |
35
29
  | Bot Identity | Created during setup wizard, or pre-create in Developer console |
36
30
 
37
31
  ## Configuration
38
32
 
39
- After running `channels add`, your OpenClaw config will contain:
40
-
41
33
  ```yaml
42
34
  channels:
43
35
  quiubo:
@@ -50,8 +42,6 @@ channels:
50
42
  pollIntervalMs: 5000
51
43
  ```
52
44
 
53
- ### Config fields
54
-
55
45
  | Field | Required | Default | Description |
56
46
  |-------|----------|---------|-------------|
57
47
  | `apiKey` | Yes | — | SDK API key (starts with `qub_`) |
@@ -62,21 +52,96 @@ channels:
62
52
 
63
53
  ### Multiple accounts
64
54
 
65
- You can configure multiple accounts by running the setup wizard again:
55
+ Run the setup wizard again and enter a different Account ID when prompted (e.g., `support-bot`, `sales-bot`). Each account gets its own gateway instance, cursor file, and bot config cache.
66
56
 
67
- ```bash
68
- openclaw channels add --channel quiubo
57
+ ## Architecture
58
+
59
+ ### Message delivery
60
+
61
+ The plugin uses a dual-mode real-time gateway:
62
+
63
+ ```
64
+ Pusher WebSocket (primary) ─┐
65
+ ├──→ dedup ──→ onMessage() ──→ OpenClaw agent pipeline
66
+ Polling fallback (safety) ─┘
69
67
  ```
70
68
 
71
- When prompted for Account ID, enter a new name (e.g., `support-bot`, `sales-bot`).
69
+ - **Pusher WebSocket**: Real-time push delivery via `private-user-{botIdentityId}` channel
70
+ - **Polling**: Runs concurrently as a safety net (catches messages Pusher may silently miss)
71
+ - **Dedup**: Both paths pass through cursor + in-memory dedup — no double-processing
72
+
73
+ Outbound messages are sent via the Quiubo SDK REST API.
74
+
75
+ ### Replay prevention
76
+
77
+ Four layers prevent old messages from being reprocessed on restart:
78
+
79
+ 1. **Cursor persistence**: Per-account cursor files at `~/.openclaw/cron/quiubo-cursors-{accountId}.json` survive restarts
80
+ 2. **seekToLatest()**: On cold start (no cursor for a group), paginates to the latest message without processing — only subsequent messages are treated as new
81
+ 3. **FIFO dedup set**: In-memory `dispatched` set (capped at 500 entries with FIFO eviction) catches duplicates across Pusher and poll paths within a session
82
+ 4. **Dedup guard on all delivery paths**: Pusher plaintext, Pusher E2EE, and poll paths all check the dedup set before dispatching
83
+
84
+ ### Resilience
85
+
86
+ - **Exponential backoff**: Poll failures increase delay (5s > 10s > 20s > 40s > 60s cap), resets on success
87
+ - **Poll concurrency limiter**: Groups polled in batches of 5 via `Promise.allSettled` to avoid API burst
88
+ - **Gateway lifecycle safety**: `startAccount()` stops any existing gateway before creating a new one (prevents orphaned timers on config reload)
89
+ - **Pusher timeout management**: 15s connection timeout is properly cancelled on stop/restart
90
+
91
+ ### End-to-end encryption (E2EE)
92
+
93
+ The plugin supports Quiubo's E2EE protocol for groups that require it:
94
+
95
+ - **Key generation**: Deterministic Ed25519 + X25519 keypairs derived from a 32-byte seed (persisted in config)
96
+ - **Auto-enrollment**: On first startup, generates keypair and enrolls via challenge-response (`requestKeyChallenge` > `signChallenge` > `verifyKeyChallenge`)
97
+ - **Inbound decryption**: GroupEnvelopeV2 messages decrypted using XChaCha20-Poly1305 with epoch keys
98
+ - **Outbound encryption**: Plaintext encrypted before sending when E2EE is granted for the group
99
+ - **Epoch key management**: Dual-layer cache (active key + historical keys per epoch) with 30-min TTL and automatic refresh
100
+ - **Pusher events**: Handles `e2ee-granted`, `e2ee-revoked`, `epoch-rotated` for real-time key lifecycle
101
+
102
+ ### Directory agents
103
+
104
+ Agents can be listed in Quiubo's public agent directory, allowing any group to add them:
105
+
106
+ - **Auto-registration**: If no agent record exists for the bot identity, one is created automatically on startup
107
+ - **Directory groups**: Discovered via `listAgentGroups()` (refreshed every 60s) and polled alongside partner groups
108
+ - **Mention filtering**: In directory groups, polled messages are only processed if they contain an `@mention` of the agent (Pusher path is pre-filtered server-side by `triggerMode`)
109
+ - **Scope enforcement**: Outbound messages check `grantedScopes` — agent won't attempt to send if `send_messages` isn't granted
110
+ - **Real-time membership**: Pusher events `agent:group-added` and `agent:group-removed` update the cache immediately
111
+
112
+ ### Bot-enabled gating
113
+
114
+ Every incoming message passes through a multi-step gating pipeline:
115
+
116
+ 1. **Self-echo skip** — Bot's own messages ignored
117
+ 2. **Agent channel bypass** — `agent_channel` groups always process (1:1 bot channels)
118
+ 3. **Cache miss resolution** — Unknown groups fetched via API with 5s timeout (fail-closed)
119
+ 4. **Bot-enabled check** — `settings.bot.enabled` must be `true`
120
+ 5. **Security mode check** — `PLAINTEXT_SDK` or E2EE-granted groups only
121
+ 6. **Owner takeover** — Owner sends a message > bot suppressed for `suppressionMinutes`
122
+ 7. **Suppression check** — Skipped while suppressed (stale entries evicted after 1 hour)
123
+
124
+ ### Inbound routing
125
+
126
+ Messages are routed through OpenClaw's agent pipeline:
127
+
128
+ ```
129
+ finalizeInboundContext() → dispatchReplyWithBufferedBlockDispatcher()
130
+ ```
131
+
132
+ Session keys: `quiubo:{groupId}` for the main agent, `agent:{agentId}:quiubo:{groupId}` for non-main agents.
133
+
134
+ ### Auto-provisioning
135
+
136
+ On first gateway startup, if the bot has no groups, the plugin automatically creates a welcome `agent_channel` group, adds the bot as a member, and sends a welcome message.
72
137
 
73
138
  ## Multi-Agent Setup
74
139
 
75
- Run multiple AI agents on a single OpenClaw gateway — each with their own Quiubo chat, workspace, model, and cron jobs. For example: a personal assistant on Opus and an autonomous project manager on Sonnet, each in their own chat.
140
+ Run multiple AI agents on a single OpenClaw gateway — each with their own Quiubo chat, workspace, model, and cron jobs.
76
141
 
77
142
  ### Step 1: Add the agent
78
143
 
79
- In `~/.openclaw/openclaw.json`, add a new agent to the list:
144
+ In `~/.openclaw/openclaw.json`:
80
145
 
81
146
  ```json
82
147
  {
@@ -93,11 +158,9 @@ In `~/.openclaw/openclaw.json`, add a new agent to the list:
93
158
  }
94
159
  ```
95
160
 
96
- Each agent can use a different model — useful for cost control when running autonomous agents.
97
-
98
161
  ### Step 2: Add a Quiubo account for the agent
99
162
 
100
- Add a second account under the Quiubo plugin config. You can reuse the same SDK API key — each account just needs its own bot identity:
163
+ Each account needs its own bot identity (can share the same API key):
101
164
 
102
165
  ```json
103
166
  {
@@ -118,12 +181,8 @@ Add a second account under the Quiubo plugin config. You can reuse the same SDK
118
181
  }
119
182
  ```
120
183
 
121
- Create additional bot identities through the Quiubo developer console or the setup wizard.
122
-
123
184
  ### Step 3: Bind accounts to agents
124
185
 
125
- Bindings tell OpenClaw which Quiubo account routes to which agent:
126
-
127
186
  ```json
128
187
  {
129
188
  "bindings": [
@@ -139,7 +198,7 @@ Bindings tell OpenClaw which Quiubo account routes to which agent:
139
198
  }
140
199
  ```
141
200
 
142
- Without bindings, all messages route to the first agent. This is the most common setup issue — don't skip this step.
201
+ Without bindings, all messages route to the first agent.
143
202
 
144
203
  ### Step 4: Create the agent's workspace
145
204
 
@@ -147,31 +206,14 @@ Without bindings, all messages route to the first agent. This is the most common
147
206
  mkdir -p ~/.openclaw/workspace-project-manager/memory
148
207
  ```
149
208
 
150
- Seed it with the standard OpenClaw agent files:
151
-
152
- - `SOUL.md` — personality and purpose
153
- - `AGENTS.md` — operating instructions
154
- - `USER.md` — info about the human
155
- - `IDENTITY.md` — name, emoji, avatar
156
- - `MEMORY.md` — long-term memory (starts empty)
157
-
158
- ### Step 5: Copy auth profiles (optional)
159
-
160
- If the new agent needs access to the same services (web search, APIs, etc.):
161
-
162
- ```bash
163
- mkdir -p ~/.openclaw/agents/project-manager/agent/
164
- cp ~/.openclaw/agents/main/agent/auth-profiles.json ~/.openclaw/agents/project-manager/agent/
165
- ```
209
+ Seed it with: `SOUL.md`, `AGENTS.md`, `USER.md`, `IDENTITY.md`, `MEMORY.md`.
166
210
 
167
- ### Step 6: Restart
211
+ ### Step 5: Restart
168
212
 
169
213
  ```bash
170
214
  openclaw gateway restart
171
215
  ```
172
216
 
173
- The new agent is live. Messages to the PM bot identity route to `project-manager`, use Sonnet, and read from its own workspace.
174
-
175
217
  ### How routing works
176
218
 
177
219
  ```
@@ -179,72 +221,21 @@ Quiubo Chat (You + Main Bot) → account: default → binding → agent: main
179
221
  Quiubo Chat (You + PM Bot) → account: pm → binding → agent: project-manager
180
222
  ```
181
223
 
182
- - **Messages:** The plugin passes `AccountId` to OpenClaw, which resolves the agent from bindings
183
- - **Cron jobs:** Filtered by `agentId` — each agent's chat only shows its own jobs
184
- - **Sessions:** Each agent gets its own session namespace, no cross-talk
185
- - **Workspaces:** Fully isolated — agents can't see or edit each other's files
186
-
187
- ### Full config example
188
-
189
- ```json
190
- {
191
- "agents": {
192
- "list": [
193
- { "id": "main" },
194
- {
195
- "id": "project-manager",
196
- "model": "anthropic/claude-sonnet-4-5",
197
- "workspace": "~/.openclaw/workspace-project-manager"
198
- }
199
- ]
200
- },
201
- "bindings": [
202
- {
203
- "match": { "channel": "quiubo", "accountId": "default" },
204
- "agentId": "main"
205
- },
206
- {
207
- "match": { "channel": "quiubo", "accountId": "pm" },
208
- "agentId": "project-manager"
209
- }
210
- ],
211
- "plugins": {
212
- "quiubo": {
213
- "accounts": {
214
- "default": {
215
- "sdkApiKey": "qub_...",
216
- "botIdentityId": "<main-bot-uuid>"
217
- },
218
- "pm": {
219
- "sdkApiKey": "qub_...",
220
- "botIdentityId": "<pm-bot-uuid>"
221
- }
222
- }
223
- }
224
- }
225
- }
226
- ```
227
-
228
- ### Tips
229
-
230
- - **Cost control:** Use cheaper models for autonomous agents that run frequently
231
- - **Workspace isolation:** Agents should never edit each other's files — enforce this in their `AGENTS.md`
232
- - **Permissions:** Add rules about what agents can/can't do (e.g., "ask before spending money")
233
- - **Scaling:** Same pattern for any number of agents — new ID, new bot identity, new binding, new workspace
224
+ - **Messages**: AccountId resolves the agent via bindings
225
+ - **Cron jobs**: Filtered by agentId — each agent's chat shows only its own
226
+ - **Sessions**: Isolated session namespaces per agent, no cross-talk
227
+ - **Workspaces**: Fully isolated — agents can't see each other's files
228
+ - **Cursors**: Each account has its own cursor file — no clobbering
234
229
 
235
230
  ## Markdown Attachments
236
231
 
237
- Agents can send `.md` file attachments alongside their messages. Attachments appear as tappable cards in the Quiubo app with a full markdown viewer.
238
-
239
- ### How it works
232
+ Agents can send `.md` file attachments alongside messages. Attachments appear as tappable cards in the Quiubo app with a full markdown viewer.
240
233
 
241
- OpenClaw uses the `MEDIA:` token protocol. When an agent writes a file and includes a `MEDIA: /path/to/file.md` line in its response text, the dispatcher extracts it and passes the path to the plugin. The plugin reads the file from disk and sends it as an attachment via the Quiubo API.
234
+ OpenClaw's `MEDIA:` token protocol is used: when an agent writes a file and includes `MEDIA: /path/to/file.md` in its response, the plugin reads the file and sends it as a structured attachment.
242
235
 
243
- **Supported:** `.md` files only, max 1MB each, max 5 per message.
236
+ **Supported:** `.md` files only, max 1MB each. Source tracking distinguishes `agent` vs `subagent` attachments.
244
237
 
245
- ### Agent instructions
246
-
247
- Add this to your agent's `AGENTS.md` so it knows how to send attachments:
238
+ Add this to your agent's `AGENTS.md`:
248
239
 
249
240
  ```markdown
250
241
  ## Sending File Attachments
@@ -262,133 +253,69 @@ The file will be delivered as a tappable attachment card in the chat.
262
253
  Only `.md` files are supported. Files over 1MB are skipped.
263
254
  ```
264
255
 
265
- ### Cron job attachments
266
-
267
- Cron jobs with `delivery.channel` already target the correct group. To send attachments from a cron job, the cron agent just needs to follow the same `MEDIA:` token protocol — write the `.md` file, then include the `MEDIA:` line in the output.
268
-
269
- ### What the user sees
256
+ ## Operational Notes
270
257
 
271
- - **In chat:** A compact card below the message text showing the filename, size, and source badge (agent/subagent/cron)
272
- - **On tap:** Full-screen markdown viewer with rendered content
273
- - **In group details:** A "Documents" tab listing all attachments in the group, filterable by source
258
+ ### Cursor files
274
259
 
275
- ## How it works
260
+ Cursors are persisted at:
276
261
 
277
- ### Message delivery
278
-
279
- The plugin uses a dual-mode gateway for receiving messages:
280
-
281
- - **Primary:** Pusher WebSocket — real-time push delivery
282
- - **Fallback:** Polling — automatic fallback when Pusher is unavailable
283
-
284
- Outbound messages are sent via the Quiubo SDK REST API.
285
-
286
- ### Auto-provisioning
287
-
288
- On first gateway startup, if the bot has no groups, the plugin automatically:
289
-
290
- 1. Creates a welcome group (type: `agent_channel`)
291
- 2. Adds the bot as a member
292
- 3. Sends a welcome message
293
-
294
- This means users can start chatting immediately after setup — no manual group creation needed.
295
-
296
- ### Inbound routing
297
-
298
- Messages are routed through OpenClaw's agent pipeline using the standard `finalizeInboundContext()` → `dispatchReplyWithBufferedBlockDispatcher()` pattern (same as openclaw-mqtt).
299
-
300
- Session keys use the format `quiubo:<groupId>`. The `AccountId` field in the context payload tells OpenClaw which agent to route to via the bindings config.
301
-
302
- ## Bot-Enabled Gating
303
-
304
- The extension implements a multi-step gating pipeline to determine whether to respond to a message. This runs for every incoming message (both Pusher and polling paths).
305
-
306
- ### Gating Pipeline
307
-
308
- 1. **Self-echo skip** — Messages from the bot's own identity are ignored
309
- 2. **Agent channel bypass** — `agent_channel` groups always process messages (1:1 bot channels)
310
- 3. **Security mode check** — Only `PLAINTEXT_SDK` groups are processed; E2EE groups are skipped
311
- 4. **Bot-enabled check** — `settings.bot.enabled` must be `true` (with cache-miss fallback)
312
- 5. **Owner takeover** — If the sender is the group owner, suppress bot for `suppressionMinutes`
313
- 6. **Suppression check** — If currently suppressed, skip the message
314
-
315
- ### Bot Config Cache
316
-
317
- The extension maintains an in-memory cache of bot configuration per group:
318
-
319
- ```typescript
320
- interface BotConfig {
321
- enabled: boolean;
322
- suppressionMinutes: number;
323
- ownerIdentityId: string;
324
- groupType: string; // 'standard' | 'agent_channel'
325
- }
262
+ ```
263
+ ~/.openclaw/cron/quiubo-cursors-{accountId}.json
326
264
  ```
327
265
 
328
- - **Populated at startup** from `listGroups()` + `listGroupMembers()` for each group
329
- - **Updated on cache miss** via `onCacheMiss` callback (5s timeout, fail-closed)
330
- - **Hydrated from polls** when Pusher is unavailable
331
-
332
- ### Suppression Map
333
-
334
- Owner takeover suppression uses a `Map<groupId, suppressedUntil>`:
335
- - When the group owner sends a message, `suppressedUntil` is set to `now + suppressionMinutes * 60_000`
336
- - Entries older than 1 hour are periodically evicted via `clearStaleSuppression()`
266
+ Each account writes to its own file to avoid clobbering in multi-account setups. Safe to delete the gateway will `seekToLatest()` on next start (no replay).
337
267
 
338
- ### Cache-Miss Strategy
268
+ ### Heartbeat
339
269
 
340
- When a message arrives for a group not in the cache (e.g., newly created):
341
- - Extension calls `onCacheMiss(groupId)` which fetches group details + members from the API
342
- - 5-second timeout — if the fetch fails or times out, the message is skipped (fail-closed)
343
- - This is safer than fail-open: better to miss a message than respond incorrectly
270
+ If an agent is registered, the gateway sends a heartbeat every 60s to report `connectionStatus: online`. Heartbeat stops on Pusher disconnect and resumes on reconnect.
344
271
 
345
- ---
272
+ ### Typing indicators
346
273
 
347
- ## Managing accounts
274
+ A typing indicator is sent immediately when processing begins, then repeated every 4s until the response is delivered.
348
275
 
349
- ### Disable an account
276
+ ## Managing Accounts
350
277
 
351
278
  ```bash
352
- openclaw channels disable --channel quiubo
279
+ openclaw channels disable --channel quiubo # Disable
280
+ openclaw channels add --channel quiubo # Re-configure
353
281
  ```
354
282
 
355
- ### Remove an account
356
-
357
- Delete the account entry from your OpenClaw config, or use the config editor.
358
-
359
283
  ## Development
360
284
 
361
285
  ```bash
362
286
  npm run build # Compile TypeScript
363
287
  npm run typecheck # Type-check without emitting
288
+ npm run release # Bump version + publish
364
289
  ```
365
290
 
366
291
  ### Project structure
367
292
 
368
293
  ```
294
+ index.ts # Entry point + exports
369
295
  src/
370
- api.ts # REST API client (QuiuboApiClient)
371
- channel.ts # Channel plugin (config, setup, onboarding, outbound, gateway)
372
- realtime-gateway.ts # Pusher WebSocket + polling fallback
373
- polling-gateway.ts # Polling-only gateway
374
- webhook-handler.ts # HTTP webhook server (alternative inbound)
375
- runtime.ts # Plugin runtime singleton
376
- types.ts # TypeScript type definitions
296
+ api.ts # REST API client (QuiuboApiClient)
297
+ channel.ts # Channel plugin (config, setup, onboarding, outbound, gateway)
298
+ realtime-gateway.ts # Pusher WebSocket + polling fallback + replay prevention
299
+ crypto.ts # E2EE: key generation, envelope decrypt/encrypt, Ed25519 signing
300
+ key-manager.ts # Dual-layer epoch key cache with auto-fetch
301
+ runtime.ts # Plugin runtime singleton
302
+ types.ts # TypeScript type definitions
377
303
  ```
378
304
 
379
- ## API coverage
380
-
381
- The `QuiuboApiClient` wraps these SDK endpoints:
305
+ ### API coverage
382
306
 
383
307
  | Category | Methods |
384
308
  |----------|---------|
385
309
  | Auth | `authenticate()` |
386
- | Messages | `sendMessage()`, `listMessages()` |
310
+ | Messages | `sendMessage()`, `listMessages()`, `sendTypingIndicator()` |
387
311
  | Groups | `createGroup()`, `listGroups()`, `getGroup()`, `addMembers()`, `removeMembers()`, `listGroupMembers()`, `updateGroupSettings()` |
388
312
  | Identities | `createIdentity()`, `listIdentities()`, `deleteIdentity()` |
389
313
  | Join Tokens | `createJoinToken()`, `listJoinTokens()`, `deleteJoinToken()` |
390
- | Webhooks | `configureWebhook()` |
314
+ | Agents | `listAgents()`, `createAgent()`, `updateAgent()`, `sendHeartbeat()`, `listAgentGroups()`, `getGroupAgent()` |
315
+ | E2EE | `requestKeyChallenge()`, `verifyKeyChallenge()`, `getEpochKey()`, `getEpochKeys()` |
391
316
  | Pusher | `pusherAuth()` |
317
+ | OpenClaw | `sendOpenclawResponse()` |
318
+ | Webhooks | `configureWebhook()` |
392
319
 
393
320
  ## License
394
321
 
package/dist/index.js CHANGED
@@ -13289,6 +13289,7 @@ async function readMdAttachments(mediaUrls, source, log, accountId) {
13289
13289
  var gateways = /* @__PURE__ */ new Map();
13290
13290
  var clients = /* @__PURE__ */ new Map();
13291
13291
  var accounts = /* @__PURE__ */ new Map();
13292
+ var loggers = /* @__PURE__ */ new Map();
13292
13293
  var CHANNEL_ID = "quiubo";
13293
13294
  var DEFAULT_API_URL = "https://api.quiubo.io";
13294
13295
  var DEFAULT_ACCOUNT_ID = "default";
@@ -13585,11 +13586,13 @@ var quiuboPlugin = {
13585
13586
  async sendText(ctx) {
13586
13587
  const { text, cfg } = ctx;
13587
13588
  const accountId = ctx.accountId ?? DEFAULT_ACCOUNT_ID;
13589
+ const log = loggers.get(accountId);
13588
13590
  let client = clients.get(accountId);
13589
13591
  let account = accounts.get(accountId);
13590
13592
  if (!client || !account) {
13591
13593
  const acct = getChannelConfig(cfg)?.accounts?.[accountId];
13592
13594
  if (!acct?.apiKey) {
13595
+ log?.warn?.(`[${accountId}] [outbound:sendText] no account config found`);
13593
13596
  return { ok: false, error: "No account config found" };
13594
13597
  }
13595
13598
  const apiUrl = acct.apiUrl ?? DEFAULT_API_URL;
@@ -13598,8 +13601,13 @@ var quiuboPlugin = {
13598
13601
  clients.set(accountId, client);
13599
13602
  accounts.set(accountId, account);
13600
13603
  }
13601
- const groupId = resolveOutboundGroupId(ctx);
13604
+ let groupId = resolveOutboundGroupId(ctx);
13602
13605
  if (!groupId) {
13606
+ groupId = await resolveAnnounceGroupId(accountId, log);
13607
+ }
13608
+ log?.info?.(`[${accountId}] [outbound:sendText] groupId=${groupId}, text=${text?.length ?? 0} chars, ctx.to=${ctx.to}, ConversationLabel=${ctx.target?.raw?.ConversationLabel ?? ctx.ConversationLabel}, SessionKey=${ctx.target?.raw?.SessionKey ?? ctx.SessionKey}`);
13609
+ if (!groupId) {
13610
+ log?.error?.(`[${accountId}] [outbound:sendText] no groupId \u2014 ctx keys=${Object.keys(ctx).join(",")}, target=${ctx.target ? JSON.stringify(Object.keys(ctx.target)) : "none"}, raw=${ctx.target?.raw ? JSON.stringify(Object.keys(ctx.target.raw)) : "none"}`);
13603
13611
  return { ok: false, error: "No groupId in outbound context" };
13604
13612
  }
13605
13613
  const senderId = account.botIdentityId;
@@ -13607,14 +13615,16 @@ var quiuboPlugin = {
13607
13615
  return { ok: false, error: "No botIdentityId configured" };
13608
13616
  }
13609
13617
  try {
13610
- await client.sendMessage(groupId, {
13618
+ const apiResp = await client.sendMessage(groupId, {
13611
13619
  senderIdentityId: senderId,
13612
13620
  plaintext: text,
13613
13621
  metadata: { format: "markdown" }
13614
13622
  });
13623
+ log?.info?.(`[${accountId}] [outbound:sendText] sent to group ${groupId} (realtime=${apiResp?.realtimeDelivered})`);
13615
13624
  return { ok: true };
13616
13625
  } catch (error) {
13617
13626
  const msg = error instanceof Error ? error.message : String(error);
13627
+ log?.error?.(`[${accountId}] [outbound:sendText] failed: ${msg}`);
13618
13628
  return { ok: false, error: msg };
13619
13629
  }
13620
13630
  },
@@ -13624,14 +13634,16 @@ var quiuboPlugin = {
13624
13634
  const urls = [];
13625
13635
  if (ctx.mediaUrl) urls.push(ctx.mediaUrl);
13626
13636
  if (Array.isArray(ctx.mediaUrls)) urls.push(...ctx.mediaUrls);
13627
- const mdAttachments = await readMdAttachments(urls, "agent", void 0, "sendMedia");
13628
- const plaintext = text || (mdAttachments.length === 0 ? "[Media attachment]" : "");
13629
13637
  const accountId = ctx.accountId ?? DEFAULT_ACCOUNT_ID;
13638
+ const log = loggers.get(accountId);
13639
+ const mdAttachments = await readMdAttachments(urls, "agent", log, accountId);
13640
+ const plaintext = text || (mdAttachments.length === 0 ? "[Media attachment]" : "");
13630
13641
  let client = clients.get(accountId);
13631
13642
  let account = accounts.get(accountId);
13632
13643
  if (!client || !account) {
13633
13644
  const acct = getChannelConfig(ctx.cfg)?.accounts?.[accountId];
13634
13645
  if (!acct?.apiKey) {
13646
+ log?.warn?.(`[${accountId}] [outbound:sendMedia] no account config found`);
13635
13647
  return { ok: false, error: "No account config found" };
13636
13648
  }
13637
13649
  const apiUrl = acct.apiUrl ?? DEFAULT_API_URL;
@@ -13640,8 +13652,13 @@ var quiuboPlugin = {
13640
13652
  clients.set(accountId, client);
13641
13653
  accounts.set(accountId, account);
13642
13654
  }
13643
- const groupId = resolveOutboundGroupId(ctx);
13655
+ let groupId = resolveOutboundGroupId(ctx);
13656
+ if (!groupId) {
13657
+ groupId = await resolveAnnounceGroupId(accountId, log);
13658
+ }
13659
+ log?.info?.(`[${accountId}] [outbound:sendMedia] groupId=${groupId}, urls=${urls.length}, attachments=${mdAttachments.length}`);
13644
13660
  if (!groupId) {
13661
+ log?.error?.(`[${accountId}] [outbound:sendMedia] no groupId \u2014 ctx keys=${Object.keys(ctx).join(",")}`);
13645
13662
  return { ok: false, error: "No groupId in outbound context" };
13646
13663
  }
13647
13664
  const senderId = account.botIdentityId;
@@ -13649,15 +13666,17 @@ var quiuboPlugin = {
13649
13666
  return { ok: false, error: "No botIdentityId configured" };
13650
13667
  }
13651
13668
  try {
13652
- await client.sendMessage(groupId, {
13669
+ const apiResp = await client.sendMessage(groupId, {
13653
13670
  senderIdentityId: senderId,
13654
13671
  plaintext,
13655
13672
  metadata: { format: "markdown" },
13656
13673
  ...mdAttachments.length > 0 ? { attachments: mdAttachments } : {}
13657
13674
  });
13675
+ log?.info?.(`[${accountId}] [outbound:sendMedia] sent to group ${groupId} (realtime=${apiResp?.realtimeDelivered})`);
13658
13676
  return { ok: true };
13659
13677
  } catch (error) {
13660
13678
  const msg = error instanceof Error ? error.message : String(error);
13679
+ log?.error?.(`[${accountId}] [outbound:sendMedia] failed: ${msg}`);
13661
13680
  return { ok: false, error: msg };
13662
13681
  }
13663
13682
  }
@@ -13693,6 +13712,7 @@ var quiuboPlugin = {
13693
13712
  const client = new QuiuboApiClient(apiUrl, apiKey);
13694
13713
  clients.set(accountId, client);
13695
13714
  accounts.set(accountId, { ...quiuboConfig, accountId });
13715
+ loggers.set(accountId, log);
13696
13716
  let pusherConfig;
13697
13717
  let auth;
13698
13718
  try {
@@ -14024,6 +14044,7 @@ var quiuboPlugin = {
14024
14044
  gateways.delete(accountId);
14025
14045
  clients.delete(accountId);
14026
14046
  accounts.delete(accountId);
14047
+ loggers.delete(accountId);
14027
14048
  resolve();
14028
14049
  };
14029
14050
  if (abortSignal) {
@@ -14049,6 +14070,30 @@ function resolveOutboundGroupId(ctx) {
14049
14070
  if (targetGroupId) return targetGroupId;
14050
14071
  return void 0;
14051
14072
  }
14073
+ async function resolveAnnounceGroupId(accountId, log) {
14074
+ try {
14075
+ const { readFile: readFile3 } = await import("node:fs/promises");
14076
+ const { join: join2 } = await import("node:path");
14077
+ const homeDir = process.env.HOME ?? process.env.USERPROFILE ?? "";
14078
+ const cronPath = join2(homeDir, ".openclaw", "cron", "jobs.json");
14079
+ const raw = await readFile3(cronPath, "utf-8");
14080
+ const parsed = JSON.parse(raw);
14081
+ const jobs = parsed?.jobs ?? [];
14082
+ for (const job of jobs) {
14083
+ if (job.enabled === false) continue;
14084
+ const delivery = job.delivery;
14085
+ if (!delivery) continue;
14086
+ if (delivery.channel !== "quiubo") continue;
14087
+ if (delivery.to) {
14088
+ log?.info?.(`[${accountId}] [resolveAnnounceGroupId] found cron job ${job.id} \u2192 delivery.to=${delivery.to}`);
14089
+ return delivery.to;
14090
+ }
14091
+ }
14092
+ } catch (err) {
14093
+ log?.warn?.(`[${accountId}] [resolveAnnounceGroupId] failed to read cron jobs: ${err}`);
14094
+ }
14095
+ return void 0;
14096
+ }
14052
14097
  async function getActivityData(runtime2, log, agentId) {
14053
14098
  try {
14054
14099
  const { readFile: readFile3 } = await import("node:fs/promises");