@openclaw/clickclack 2026.7.1-beta.6 → 2026.7.2-beta.1

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/api.js CHANGED
@@ -1,2 +1,2 @@
1
- import { a as getClickClackRuntime, c as DEFAULT_ACCOUNT_ID, d as resolveClickClackAccount, f as resolveDefaultClickClackAccountId, i as createClickClackClient, l as listClickClackAccountIds, n as buildClickClackTarget, o as setClickClackRuntime, r as parseClickClackTarget, s as clickClackConfigSchema, t as clickClackPlugin, u as listEnabledClickClackAccounts } from "./channel-1ZnJZTPl.js";
1
+ import { a as getClickClackRuntime, c as DEFAULT_ACCOUNT_ID, d as resolveClickClackAccount, f as resolveDefaultClickClackAccountId, i as createClickClackClient, l as listClickClackAccountIds, n as buildClickClackTarget, o as setClickClackRuntime, r as parseClickClackTarget, s as clickClackConfigSchema, t as clickClackPlugin, u as listEnabledClickClackAccounts } from "./channel-Ds6d_3nx.js";
2
2
  export { DEFAULT_ACCOUNT_ID, buildClickClackTarget, clickClackConfigSchema, clickClackPlugin, createClickClackClient, getClickClackRuntime, listClickClackAccountIds, listEnabledClickClackAccounts, parseClickClackTarget, resolveClickClackAccount, resolveDefaultClickClackAccountId, setClickClackRuntime };
@@ -6,7 +6,7 @@ import { resolveDefaultSecretProviderAlias } from "openclaw/plugin-sdk/provider-
6
6
  import { buildSecretInputSchema, normalizeResolvedSecretInputString, normalizeSecretInputString, resolveSecretInputString } from "openclaw/plugin-sdk/secret-input";
7
7
  import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
8
8
  import { buildChannelOutboundSessionRoute, buildThreadAwareOutboundSessionRoute, createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
9
- import { buildChannelProgressDraftLine, createMessageReceiptFromOutboundResults, defineChannelMessageAdapter } from "openclaw/plugin-sdk/channel-outbound";
9
+ import { buildChannelProgressDraftLine, createMessageReceiptFromOutboundResults, defineChannelMessageAdapter, deriveDurableFinalDeliveryRequirements } from "openclaw/plugin-sdk/channel-outbound";
10
10
  import { getChatChannelMeta } from "openclaw/plugin-sdk/channel-plugin-common";
11
11
  import { createComputedAccountStatusAdapter, createDefaultChannelRuntimeState } from "openclaw/plugin-sdk/status-helpers";
12
12
  import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
@@ -17,6 +17,9 @@ import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
17
17
  import { readProviderJsonResponse, readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
18
18
  import { WebSocket } from "ws";
19
19
  import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
20
+ import { createHash } from "node:crypto";
21
+ import { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/outbound-media";
22
+ import { sanitizeAssistantVisibleText } from "openclaw/plugin-sdk/text-chunking";
20
23
  //#region extensions/clickclack/src/accounts.ts
21
24
  /**
22
25
  * Resolves ClickClack account configuration from root channel config, named
@@ -230,12 +233,31 @@ function provenanceFields(provenance) {
230
233
  return fields;
231
234
  }
232
235
  const CLICKCLACK_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
236
+ const CLICKCLACK_CORRELATION_ID_MAX_LENGTH = 128;
237
+ const CLICKCLACK_CORRELATION_ID_PATTERN = /^[A-Za-z0-9._:-]+$/u;
238
+ const CLICKCLACK_CORRELATION_ID_HEADER = "X-Correlation-ID";
239
+ const CLICKCLACK_INBOUND_JSON_LIMIT_BYTES = 16 * 1024 * 1024;
240
+ var ClickClackHttpError = class extends Error {
241
+ constructor(status, detail, headers) {
242
+ super(`ClickClack ${status}: ${detail}`);
243
+ this.status = status;
244
+ this.headers = headers;
245
+ }
246
+ };
247
+ /** Accepts the same bounded request-correlation shape as the ClickClack API. */
248
+ function normalizeClickClackCorrelationId(value) {
249
+ if (typeof value !== "string") return;
250
+ const normalized = value.trim();
251
+ if (!normalized || normalized.length > CLICKCLACK_CORRELATION_ID_MAX_LENGTH || !CLICKCLACK_CORRELATION_ID_PATTERN.test(normalized)) return;
252
+ return normalized;
253
+ }
233
254
  /**
234
255
  * Creates a typed client for the ClickClack API using bearer-token auth.
235
256
  */
236
257
  function createClickClackClient(options) {
237
258
  const baseUrl = options.baseUrl.replace(/\/$/, "");
238
259
  const fetcher = options.fetch ?? fetch;
260
+ const correlationId = normalizeClickClackCorrelationId(options.correlationId);
239
261
  const headers = {
240
262
  Authorization: `Bearer ${options.token}`,
241
263
  Accept: "application/json"
@@ -243,6 +265,7 @@ function createClickClackClient(options) {
243
265
  async function request(path, init = {}) {
244
266
  const requestHeaders = new Headers(init.headers);
245
267
  for (const [key, value] of Object.entries(headers)) requestHeaders.set(key, value);
268
+ if (correlationId) requestHeaders.set(CLICKCLACK_CORRELATION_ID_HEADER, correlationId);
246
269
  if (init.body && !(init.body instanceof FormData)) requestHeaders.set("Content-Type", "application/json");
247
270
  const response = await fetcher(`${baseUrl}${path}`, {
248
271
  ...init,
@@ -250,9 +273,20 @@ function createClickClackClient(options) {
250
273
  });
251
274
  if (!response.ok) {
252
275
  const detail = await readResponseTextLimited(response, CLICKCLACK_ERROR_BODY_LIMIT_BYTES);
253
- throw new Error(`ClickClack ${response.status}: ${detail}`);
276
+ throw new ClickClackHttpError(response.status, detail, new Headers(response.headers));
254
277
  }
255
- return await readProviderJsonResponse(response, "ClickClack response");
278
+ return await readProviderJsonResponse(response, "ClickClack response", { maxBytes: CLICKCLACK_INBOUND_JSON_LIMIT_BYTES });
279
+ }
280
+ async function fetchEventPage(workspaceId, pageOptions = {}) {
281
+ const query = new URLSearchParams({ workspace_id: workspaceId });
282
+ if (pageOptions.afterCursor) query.set("after_cursor", pageOptions.afterCursor);
283
+ if (pageOptions.limit !== void 0) query.set("limit", String(pageOptions.limit));
284
+ if (pageOptions.includeTail) query.set("include_tail", "true");
285
+ const data = await request(`/api/realtime/events?${query.toString()}`);
286
+ return {
287
+ events: data.events,
288
+ ...typeof data.tail_cursor === "string" ? { tailCursor: data.tail_cursor } : {}
289
+ };
256
290
  }
257
291
  return {
258
292
  me: async () => {
@@ -271,12 +305,31 @@ function createClickClackClient(options) {
271
305
  return (await request(`/api/dms/${encodeURIComponent(conversationId)}/messages?after_seq=${afterSeq}&limit=${limit}`)).messages;
272
306
  },
273
307
  thread: async (messageId) => await request(`/api/messages/${encodeURIComponent(messageId)}/thread`),
308
+ message: async (messageId) => {
309
+ return (await request(`/api/messages/${encodeURIComponent(messageId)}`)).message;
310
+ },
311
+ findMessageByNonce: async (params) => {
312
+ const query = new URLSearchParams({
313
+ workspace_id: params.workspaceId,
314
+ nonce: params.nonce
315
+ });
316
+ try {
317
+ return (await request(`/api/messages/by-nonce?${query.toString()}`)).message;
318
+ } catch (error) {
319
+ if (error instanceof ClickClackHttpError && error.status === 404) {
320
+ if (error.headers.get("X-ClickClack-Message-Nonce") === "supported") return;
321
+ throw new Error("ClickClack server does not support durable message nonce lookup", { cause: error });
322
+ }
323
+ throw error;
324
+ }
325
+ },
274
326
  createChannelMessage: async (channelId, body, opts) => {
275
327
  return (await request(`/api/channels/${encodeURIComponent(channelId)}/messages`, {
276
328
  method: "POST",
277
329
  body: JSON.stringify({
278
330
  body,
279
331
  ...opts?.quotedMessageId ? { quoted_message_id: opts.quotedMessageId } : {},
332
+ ...opts?.nonce ? { nonce: opts.nonce } : {},
280
333
  ...provenanceFields(opts?.provenance)
281
334
  })
282
335
  })).message;
@@ -286,6 +339,7 @@ function createClickClackClient(options) {
286
339
  method: "POST",
287
340
  body: JSON.stringify({
288
341
  body,
342
+ ...opts?.nonce ? { nonce: opts.nonce } : {},
289
343
  ...provenanceFields(opts?.provenance)
290
344
  })
291
345
  })).message;
@@ -299,6 +353,38 @@ function createClickClackClient(options) {
299
353
  })
300
354
  })).conversation;
301
355
  },
356
+ createUpload: async (params) => {
357
+ const form = new FormData();
358
+ const bytes = new Uint8Array(params.buffer);
359
+ form.append("file", new Blob([bytes], { type: params.contentType }), params.filename);
360
+ const query = new URLSearchParams({ workspace_id: params.workspaceId });
361
+ if (params.nonce) query.set("nonce", params.nonce);
362
+ return (await request(`/api/uploads?${query.toString()}`, {
363
+ method: "POST",
364
+ body: form
365
+ })).upload;
366
+ },
367
+ findUploadByNonce: async (params) => {
368
+ const query = new URLSearchParams({
369
+ workspace_id: params.workspaceId,
370
+ nonce: params.nonce
371
+ });
372
+ try {
373
+ return (await request(`/api/uploads/by-nonce?${query.toString()}`)).upload;
374
+ } catch (error) {
375
+ if (error instanceof ClickClackHttpError && error.status === 404) {
376
+ if (error.headers.get("X-ClickClack-Upload-Nonce") === "supported") return;
377
+ throw new Error("ClickClack server does not support durable upload nonce lookup", { cause: error });
378
+ }
379
+ throw error;
380
+ }
381
+ },
382
+ attachUpload: async (messageId, uploadId) => {
383
+ await request(`/api/messages/${encodeURIComponent(messageId)}/attachments`, {
384
+ method: "POST",
385
+ body: JSON.stringify({ upload_id: uploadId })
386
+ });
387
+ },
302
388
  /**
303
389
  * POSTs a durable agent activity row (agent_commentary / agent_tool)
304
390
  * through the normal message create path. Requires a bot token carrying
@@ -328,21 +414,22 @@ function createClickClackClient(options) {
328
414
  method: "POST",
329
415
  body: JSON.stringify({
330
416
  body,
331
- ...opts?.quotedMessageId ? { quoted_message_id: opts.quotedMessageId } : {}
417
+ ...opts?.quotedMessageId ? { quoted_message_id: opts.quotedMessageId } : {},
418
+ ...opts?.nonce ? { nonce: opts.nonce } : {}
332
419
  })
333
420
  })).message;
334
421
  },
335
- events: async (workspaceId, afterCursor) => {
336
- const query = new URLSearchParams({ workspace_id: workspaceId });
337
- if (afterCursor) query.set("after_cursor", afterCursor);
338
- return (await request(`/api/realtime/events?${query.toString()}`)).events;
339
- },
422
+ events: async (workspaceId, afterCursor) => (await fetchEventPage(workspaceId, { afterCursor })).events,
423
+ eventPage: fetchEventPage,
340
424
  websocket: (workspaceId, afterCursor) => {
341
425
  const url = new URL(`${baseUrl}/api/realtime/ws`);
342
426
  url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
343
427
  url.searchParams.set("workspace_id", workspaceId);
344
428
  if (afterCursor) url.searchParams.set("after_cursor", afterCursor);
345
- return new WebSocket(url, { headers: { Authorization: `Bearer ${options.token}` } });
429
+ return new WebSocket(url, {
430
+ headers: { Authorization: `Bearer ${options.token}` },
431
+ maxPayload: CLICKCLACK_INBOUND_JSON_LIMIT_BYTES
432
+ });
346
433
  }
347
434
  };
348
435
  }
@@ -612,52 +699,285 @@ function looksLikeClickClackTarget(raw) {
612
699
  * Outbound ClickClack delivery helpers for channel messages, thread replies,
613
700
  * and direct messages.
614
701
  */
615
- /**
616
- * Sends text to a normalized ClickClack target and returns the created message
617
- * id for receipt/session tracking.
618
- */
619
- async function sendClickClackText(params) {
702
+ const CLICKCLACK_MAX_UPLOAD_BYTES = 64 * 1024 * 1024;
703
+ async function createTargetMessage(params) {
704
+ const parsed = parseClickClackTarget(params.to);
705
+ const explicitThreadId = params.threadId == null ? "" : String(params.threadId);
706
+ const replyToId = params.replyToId == null ? "" : String(params.replyToId);
707
+ if (explicitThreadId || parsed.kind === "thread") {
708
+ const rootId = explicitThreadId || parsed.id;
709
+ await params.onPlatformSendDispatch?.();
710
+ return await params.client.createThreadReply(rootId, params.text, {
711
+ provenance: params.provenance,
712
+ nonce: params.nonce
713
+ });
714
+ }
715
+ if (parsed.kind === "dm") {
716
+ await params.onPlatformSendDispatch?.();
717
+ const dm = await params.client.createDirectConversation(params.workspaceId, [parsed.id]);
718
+ return await params.client.createDirectMessage(dm.id, params.text, {
719
+ quotedMessageId: replyToId || void 0,
720
+ nonce: params.nonce
721
+ });
722
+ }
723
+ const channelId = await resolveChannelId(params.client, params.workspaceId, parsed.id);
724
+ await params.onPlatformSendDispatch?.();
725
+ return await params.client.createChannelMessage(channelId, params.text, {
726
+ provenance: params.provenance,
727
+ quotedMessageId: replyToId || void 0,
728
+ nonce: params.nonce
729
+ });
730
+ }
731
+ function durableDeliveryDigest(params) {
732
+ if (!params.deliveryQueueId) return;
733
+ if (!Number.isSafeInteger(params.deliveryPartIndex) || (params.deliveryPartIndex ?? -1) < 0) throw new Error("ClickClack durable delivery requires a stable delivery part index");
734
+ return createHash("sha256").update(`${params.deliveryQueueId}\n${params.deliveryPartIndex}`).digest("hex");
735
+ }
736
+ function mediaDeliveryNonces(params) {
737
+ const digest = durableDeliveryDigest(params);
738
+ if (!digest) return {};
739
+ return {
740
+ message: `openclaw-media:${digest}`,
741
+ upload: `openclaw-upload:${digest}`
742
+ };
743
+ }
744
+ function textDeliveryNonce(params) {
745
+ const digest = durableDeliveryDigest(params);
746
+ return digest ? `openclaw-text:${digest}` : void 0;
747
+ }
748
+ function createDispatchOnce(onPlatformSendDispatch) {
749
+ let dispatched = false;
750
+ return async () => {
751
+ if (dispatched) return;
752
+ await onPlatformSendDispatch?.();
753
+ dispatched = true;
754
+ };
755
+ }
756
+ async function attachUploadRetrySafe(params) {
757
+ try {
758
+ await params.client.attachUpload(params.messageId, params.uploadId);
759
+ } catch (firstError) {
760
+ try {
761
+ if ((await params.client.message(params.messageId)).attachments?.some((attachment) => attachment.id === params.uploadId)) return;
762
+ } catch {}
763
+ try {
764
+ await params.client.attachUpload(params.messageId, params.uploadId);
765
+ } catch {
766
+ throw firstError;
767
+ }
768
+ }
769
+ }
770
+ function createOutboundContext(params) {
620
771
  const account = resolveClickClackAccount({
621
772
  cfg: params.cfg,
622
773
  accountId: params.accountId
623
774
  });
624
- const client = createClickClackClient({
625
- baseUrl: account.baseUrl,
626
- token: account.token
775
+ return {
776
+ account,
777
+ client: createClickClackClient({
778
+ baseUrl: account.baseUrl,
779
+ token: account.token,
780
+ correlationId: params.correlationId
781
+ })
782
+ };
783
+ }
784
+ /**
785
+ * Sends visible text to a normalized ClickClack target and returns the created
786
+ * message id, or undefined when sanitization removes all content.
787
+ */
788
+ async function sendClickClackText(params) {
789
+ const text = sanitizeAssistantVisibleText(params.text);
790
+ if (!text) return;
791
+ const { account, client } = createOutboundContext(params);
792
+ const workspaceId = await resolveWorkspaceId(client, account.workspace);
793
+ const dispatch = createDispatchOnce(params.onPlatformSendDispatch);
794
+ return (await createTargetMessage({
795
+ client,
796
+ workspaceId,
797
+ to: params.to,
798
+ text,
799
+ threadId: params.threadId,
800
+ replyToId: params.replyToId,
801
+ provenance: params.provenance,
802
+ nonce: textDeliveryNonce({
803
+ deliveryQueueId: params.deliveryQueueId,
804
+ deliveryPartIndex: params.deliveryPartIndex
805
+ }),
806
+ onPlatformSendDispatch: dispatch
807
+ })).id;
808
+ }
809
+ /** Resolves, uploads, sends, then attaches one file to a ClickClack message. */
810
+ async function sendClickClackMedia(params) {
811
+ const nonces = mediaDeliveryNonces({
812
+ deliveryQueueId: params.deliveryQueueId,
813
+ deliveryPartIndex: params.deliveryPartIndex
814
+ });
815
+ const preloadedMedia = nonces.upload ? void 0 : await loadOutboundMediaFromUrl(params.mediaUrl, {
816
+ maxBytes: CLICKCLACK_MAX_UPLOAD_BYTES,
817
+ mediaAccess: params.mediaAccess,
818
+ mediaLocalRoots: params.mediaLocalRoots,
819
+ mediaReadFile: params.mediaReadFile
627
820
  });
821
+ const { account, client } = createOutboundContext(params);
628
822
  const workspaceId = await resolveWorkspaceId(client, account.workspace);
629
- const parsed = parseClickClackTarget(params.to);
630
- const explicitThreadId = params.threadId == null ? "" : String(params.threadId);
631
- const replyToId = params.replyToId == null ? "" : String(params.replyToId);
632
- if (explicitThreadId || parsed.kind === "thread") {
633
- const rootId = explicitThreadId || parsed.id;
634
- const message = await client.createThreadReply(rootId, params.text, { provenance: params.provenance });
823
+ const persistedUpload = nonces.upload ? await client.findUploadByNonce({
824
+ workspaceId,
825
+ nonce: nonces.upload
826
+ }) : void 0;
827
+ const dispatch = createDispatchOnce(params.onPlatformSendDispatch);
828
+ let upload = persistedUpload;
829
+ let mediaFilename = preloadedMedia?.fileName?.trim();
830
+ if (!upload) {
831
+ const media = preloadedMedia ?? await loadOutboundMediaFromUrl(params.mediaUrl, {
832
+ maxBytes: CLICKCLACK_MAX_UPLOAD_BYTES,
833
+ mediaAccess: params.mediaAccess,
834
+ mediaLocalRoots: params.mediaLocalRoots,
835
+ mediaReadFile: params.mediaReadFile
836
+ });
837
+ const filename = media.fileName?.trim() || "attachment";
838
+ mediaFilename = filename;
839
+ const contentType = media.contentType?.trim() || "application/octet-stream";
840
+ await dispatch();
841
+ upload = await client.createUpload({
842
+ workspaceId,
843
+ buffer: media.buffer,
844
+ filename,
845
+ contentType,
846
+ ...nonces.upload ? { nonce: nonces.upload } : {}
847
+ });
848
+ }
849
+ const text = sanitizeAssistantVisibleText(params.text) || mediaFilename || upload.filename || "attachment";
850
+ const message = await createTargetMessage({
851
+ client,
852
+ workspaceId,
853
+ to: params.to,
854
+ text,
855
+ threadId: params.threadId,
856
+ replyToId: params.replyToId,
857
+ nonce: nonces.message,
858
+ onPlatformSendDispatch: dispatch
859
+ });
860
+ await attachUploadRetrySafe({
861
+ client,
862
+ messageId: message.id,
863
+ uploadId: upload.id
864
+ });
865
+ return message.id;
866
+ }
867
+ function collectReconciliationMediaUrls(ctx) {
868
+ const planned = ctx.renderedBatchPlan?.items[0]?.mediaUrls;
869
+ if (planned?.length) return planned.map((url) => url.trim()).filter(Boolean);
870
+ const payload = ctx.payloads[0];
871
+ return [payload?.mediaUrl, ...payload?.mediaUrls ?? []].map((url) => url?.trim()).filter((url) => Boolean(url));
872
+ }
873
+ /**
874
+ * Completes an unknown durable send through ClickClack's message/upload nonces.
875
+ * Media recovery never rereads the original source after restart.
876
+ */
877
+ async function reconcileClickClackUnknownSend(ctx) {
878
+ if (ctx.payloads.length !== 1 || (ctx.renderedBatchPlan?.items.length ?? 1) !== 1) return {
879
+ status: "unresolved",
880
+ error: "ClickClack reconciliation requires exactly one payload"
881
+ };
882
+ const mediaUrls = collectReconciliationMediaUrls(ctx);
883
+ const { account, client } = createOutboundContext({
884
+ cfg: ctx.cfg,
885
+ accountId: ctx.accountId
886
+ });
887
+ const workspaceId = await resolveWorkspaceId(client, account.workspace);
888
+ const effectiveReplyToId = ctx.effectiveReplyToId !== void 0 ? ctx.effectiveReplyToId : ctx.replyToMode === "off" ? void 0 : ctx.replyToId;
889
+ const payload = ctx.payloads[0];
890
+ const caption = ctx.renderedBatchPlan?.items[0]?.text ?? payload?.text ?? "";
891
+ if (mediaUrls.length === 0) {
892
+ const nonce = textDeliveryNonce({
893
+ deliveryQueueId: ctx.queueId,
894
+ deliveryPartIndex: 0
895
+ });
896
+ if (!nonce || !sanitizeAssistantVisibleText(caption)) return { status: "not_sent" };
897
+ const message = await client.findMessageByNonce({
898
+ workspaceId,
899
+ nonce
900
+ });
901
+ if (!message) return { status: "not_sent" };
902
+ const receipt = createMessageReceiptFromOutboundResults({
903
+ results: [{
904
+ channel: "clickclack",
905
+ messageId: message.id
906
+ }],
907
+ threadId: ctx.threadId == null ? void 0 : String(ctx.threadId),
908
+ replyToId: effectiveReplyToId ?? void 0,
909
+ kind: "text"
910
+ });
635
911
  return {
636
- to: params.to,
637
- messageId: message.id
912
+ status: "sent",
913
+ messageId: message.id,
914
+ receipt
638
915
  };
639
916
  }
640
- if (parsed.kind === "dm") {
641
- const dm = await client.createDirectConversation(workspaceId, [parsed.id]);
642
- const message = await client.createDirectMessage(dm.id, params.text, { quotedMessageId: replyToId || void 0 });
917
+ const parts = await Promise.all(mediaUrls.map(async (_mediaUrl, index) => {
918
+ const nonces = mediaDeliveryNonces({
919
+ deliveryQueueId: ctx.queueId,
920
+ deliveryPartIndex: index
921
+ });
922
+ if (!nonces.upload || !nonces.message) throw new Error("ClickClack durable media nonces were not derived");
923
+ const [upload, message] = await Promise.all([client.findUploadByNonce({
924
+ workspaceId,
925
+ nonce: nonces.upload
926
+ }), client.findMessageByNonce({
927
+ workspaceId,
928
+ nonce: nonces.message
929
+ })]);
643
930
  return {
644
- to: params.to,
645
- messageId: message.id
931
+ upload,
932
+ message
933
+ };
934
+ }));
935
+ for (const part of parts) {
936
+ if (part.message && !part.upload) return {
937
+ status: "unresolved",
938
+ error: `ClickClack message ${part.message.id} exists without its nonce-keyed upload`,
939
+ retryable: false
646
940
  };
941
+ if (!part.message) return { status: "not_sent" };
647
942
  }
648
- const channelId = await resolveChannelId(client, workspaceId, parsed.id);
649
- const message = await client.createChannelMessage(channelId, params.text, {
650
- provenance: params.provenance,
651
- quotedMessageId: replyToId || void 0
943
+ const messageIds = [];
944
+ for (const part of parts) {
945
+ const message = part.message;
946
+ const upload = part.upload;
947
+ if (!message || !upload) throw new Error("ClickClack reconciliation state changed unexpectedly");
948
+ if (!message.attachments?.some((attachment) => attachment.id === upload.id)) await attachUploadRetrySafe({
949
+ client,
950
+ messageId: message.id,
951
+ uploadId: upload.id
952
+ });
953
+ messageIds.push(message.id);
954
+ }
955
+ const receipt = createMessageReceiptFromOutboundResults({
956
+ results: messageIds.map((messageId) => ({
957
+ channel: "clickclack",
958
+ messageId
959
+ })),
960
+ threadId: ctx.threadId == null ? void 0 : String(ctx.threadId),
961
+ replyToId: effectiveReplyToId ?? void 0,
962
+ kind: "media"
652
963
  });
964
+ const messageId = messageIds.at(-1);
653
965
  return {
654
- to: params.to,
655
- messageId: message.id
966
+ status: "sent",
967
+ ...messageId ? { messageId } : {},
968
+ receipt
656
969
  };
657
970
  }
658
971
  //#endregion
659
972
  //#region extensions/clickclack/src/inbound.ts
660
973
  const CHANNEL_ID$1 = "clickclack";
974
+ const CLICKCLACK_MESSAGE_ID_PATTERN = /^msg_[0-9a-hjkmnp-tv-z]{26}$/u;
975
+ function hasClickClackReplyMedia(payload) {
976
+ return Boolean(payload.mediaUrl?.trim() || payload.mediaUrls?.some((mediaUrl) => typeof mediaUrl === "string" && mediaUrl.trim()));
977
+ }
978
+ function resolveClickClackAgentRunId(messageId) {
979
+ return CLICKCLACK_MESSAGE_ID_PATTERN.test(messageId) ? `${CHANNEL_ID$1}:${messageId}` : void 0;
980
+ }
661
981
  function resolveAccountAgentRoute(params) {
662
982
  const runtime = getClickClackRuntime();
663
983
  const route = runtime.channel.routing.resolveAgentRoute({
@@ -702,10 +1022,10 @@ function resolveAccountAgentRoute(params) {
702
1022
  };
703
1023
  }
704
1024
  async function dispatchModelReply(params) {
705
- const text = (await getClickClackRuntime().llm.complete({
1025
+ const runtime = getClickClackRuntime();
1026
+ const text = (await runtime.llm.complete({
706
1027
  agentId: params.route.agentId,
707
1028
  model: params.account.model,
708
- maxTokens: 96,
709
1029
  purpose: "clickclack bot reply",
710
1030
  systemPrompt: params.account.systemPrompt,
711
1031
  messages: [{
@@ -713,14 +1033,21 @@ async function dispatchModelReply(params) {
713
1033
  content: params.message.body
714
1034
  }]
715
1035
  })).text.trim();
716
- if (!text) return;
1036
+ if (!text) {
1037
+ runtime.logging.getChildLogger({
1038
+ plugin: "clickclack",
1039
+ feature: "model-reply"
1040
+ }).warn(`[${params.account.accountId}] ClickClack model reply produced no sendable text`);
1041
+ return;
1042
+ }
717
1043
  await sendClickClackText({
718
1044
  cfg: params.cfg,
719
1045
  accountId: params.account.accountId,
720
1046
  to: params.target,
721
1047
  text,
722
1048
  threadId: params.message.parent_message_id ? params.message.thread_root_id : void 0,
723
- replyToId: params.message.id
1049
+ replyToId: params.message.id,
1050
+ correlationId: params.correlationId
724
1051
  });
725
1052
  }
726
1053
  /**
@@ -758,7 +1085,8 @@ async function handleClickClackInbound(params) {
758
1085
  cfg: params.config,
759
1086
  message,
760
1087
  route,
761
- target
1088
+ target,
1089
+ correlationId: params.correlationId
762
1090
  });
763
1091
  return;
764
1092
  }
@@ -767,7 +1095,8 @@ async function handleClickClackInbound(params) {
767
1095
  if (params.account.agentActivity && (message.channel_id || message.direct_conversation_id)) activity = createClickClackActivityPublisher({
768
1096
  client: createClickClackClient({
769
1097
  baseUrl: params.account.baseUrl,
770
- token: params.account.token
1098
+ token: params.account.token,
1099
+ correlationId: params.correlationId
771
1100
  }),
772
1101
  target: message.channel_id ? { channelId: message.channel_id } : { conversationId: message.direct_conversation_id },
773
1102
  turnId: message.id,
@@ -820,6 +1149,20 @@ async function handleClickClackInbound(params) {
820
1149
  OriginatingTo: target,
821
1150
  CommandAuthorized: access.commandAuthorized
822
1151
  });
1152
+ const runId = resolveClickClackAgentRunId(message.id);
1153
+ const activityReplyOptions = activity ? {
1154
+ onModelSelected: (ctx) => {
1155
+ turnProvenance = {
1156
+ model: ctx.provider && ctx.model ? `${ctx.provider}/${ctx.model}` : ctx.model,
1157
+ thinking: ctx.thinkLevel
1158
+ };
1159
+ activity?.setProvenance(turnProvenance);
1160
+ },
1161
+ onItemEvent: activity.onItemEvent,
1162
+ commentaryProgressEnabled: true,
1163
+ suppressDefaultToolProgressMessages: true,
1164
+ allowProgressCallbacksWhenSourceDeliverySuppressed: true
1165
+ } : void 0;
823
1166
  const dispatchPromise = runtime.channel.inbound.dispatchReply({
824
1167
  cfg: params.config,
825
1168
  channel: CHANNEL_ID$1,
@@ -831,21 +1174,13 @@ async function handleClickClackInbound(params) {
831
1174
  recordInboundSession: runtime.channel.session.recordInboundSession,
832
1175
  dispatchReplyWithBufferedBlockDispatcher: runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
833
1176
  toolsAllow: params.account.toolsAllow,
834
- replyOptions: activity ? {
835
- onModelSelected: (ctx) => {
836
- turnProvenance = {
837
- model: ctx.provider && ctx.model ? `${ctx.provider}/${ctx.model}` : ctx.model,
838
- thinking: ctx.thinkLevel
839
- };
840
- activity?.setProvenance(turnProvenance);
841
- },
842
- onItemEvent: activity.onItemEvent,
843
- commentaryProgressEnabled: true,
844
- suppressDefaultToolProgressMessages: true,
845
- allowProgressCallbacksWhenSourceDeliverySuppressed: true
1177
+ replyOptions: runId || activityReplyOptions ? {
1178
+ ...runId ? { runId } : {},
1179
+ ...activityReplyOptions
846
1180
  } : void 0,
847
1181
  delivery: {
848
1182
  deliver: async (payload) => {
1183
+ if (hasClickClackReplyMedia(payload)) throw new Error("ClickClack media reply requires durable delivery");
849
1184
  const text = payload && typeof payload === "object" && "text" in payload ? payload.text ?? "" : "";
850
1185
  if (!text.trim()) return;
851
1186
  await sendClickClackText({
@@ -855,9 +1190,25 @@ async function handleClickClackInbound(params) {
855
1190
  text,
856
1191
  threadId: message.parent_message_id ? message.thread_root_id : void 0,
857
1192
  replyToId: message.id,
858
- provenance: turnProvenance
1193
+ provenance: turnProvenance,
1194
+ correlationId: params.correlationId
859
1195
  });
860
1196
  },
1197
+ durable: (payload) => {
1198
+ if (!hasClickClackReplyMedia(payload)) return false;
1199
+ const threadId = message.parent_message_id ? message.thread_root_id : void 0;
1200
+ return {
1201
+ to: target,
1202
+ threadId,
1203
+ replyToId: message.id,
1204
+ requiredCapabilities: deriveDurableFinalDeliveryRequirements({
1205
+ payload,
1206
+ threadId,
1207
+ replyToId: message.id,
1208
+ reconcileUnknownSend: true
1209
+ })
1210
+ };
1211
+ },
861
1212
  onError: (error) => {
862
1213
  throw error instanceof Error ? error : /* @__PURE__ */ new Error(`clickclack dispatch failed: ${String(error)}`);
863
1214
  }
@@ -875,10 +1226,14 @@ async function handleClickClackInbound(params) {
875
1226
  }
876
1227
  //#endregion
877
1228
  //#region extensions/clickclack/src/gateway.ts
1229
+ const CLICKCLACK_EVENT_PAGE_LIMIT = 500;
878
1230
  function payloadString(event, key) {
879
1231
  const value = event.payload?.[key];
880
1232
  return typeof value === "string" ? value : "";
881
1233
  }
1234
+ function eventCorrelationId(event) {
1235
+ return normalizeClickClackCorrelationId(event.payload?.correlation_id);
1236
+ }
882
1237
  async function resolveEventMessage(params) {
883
1238
  const messageId = payloadString(params.event, "message_id");
884
1239
  if (!messageId) return null;
@@ -908,8 +1263,13 @@ function parseSocketEvent(data) {
908
1263
  async function processEvent(params) {
909
1264
  if (params.event.type !== "message.created" && params.event.type !== "thread.reply_created") return;
910
1265
  if (payloadString(params.event, "author_id") === params.botUserId) return;
1266
+ const correlationId = eventCorrelationId(params.event);
911
1267
  const message = await resolveEventMessage({
912
- client: params.client,
1268
+ client: correlationId ? createClickClackClient({
1269
+ baseUrl: params.account.baseUrl,
1270
+ token: params.account.token,
1271
+ correlationId
1272
+ }) : params.client,
913
1273
  event: params.event
914
1274
  });
915
1275
  if (!message || message.author_id === params.botUserId) return;
@@ -924,9 +1284,27 @@ async function processEvent(params) {
924
1284
  account: params.account,
925
1285
  config: params.config,
926
1286
  message,
927
- access
1287
+ access,
1288
+ ...correlationId ? { correlationId } : {}
928
1289
  });
929
1290
  }
1291
+ async function drainEventBacklog(params) {
1292
+ let afterCursor = params.afterCursor;
1293
+ while (!params.abortSignal.aborted) {
1294
+ const events = (await params.client.eventPage(params.workspaceId, {
1295
+ afterCursor,
1296
+ limit: CLICKCLACK_EVENT_PAGE_LIMIT
1297
+ })).events;
1298
+ for (const event of events) {
1299
+ if (params.abortSignal.aborted) return afterCursor;
1300
+ if (!event.cursor || event.cursor === afterCursor) throw new Error("ClickClack event backlog returned a non-advancing cursor");
1301
+ await params.onEvent(event);
1302
+ afterCursor = event.cursor;
1303
+ }
1304
+ if (events.length === 0) return afterCursor;
1305
+ }
1306
+ return afterCursor;
1307
+ }
930
1308
  async function startClickClackGatewayAccount(ctx) {
931
1309
  const configuredAccount = resolveClickClackAccount({
932
1310
  cfg: ctx.cfg,
@@ -955,20 +1333,27 @@ async function startClickClackGatewayAccount(ctx) {
955
1333
  let initialized = false;
956
1334
  try {
957
1335
  while (!ctx.abortSignal.aborted) {
958
- const backlog = await client.events(workspaceId, afterCursor);
959
1336
  if (!initialized) {
960
- for (const event of backlog) afterCursor = event.cursor || afterCursor;
1337
+ const page = await client.eventPage(workspaceId, { includeTail: true });
1338
+ if (page.tailCursor !== void 0) afterCursor = page.tailCursor;
1339
+ else for (const event of page.events) afterCursor = event.cursor || afterCursor;
961
1340
  initialized = true;
962
- } else for (const event of backlog) {
963
- afterCursor = event.cursor || afterCursor;
964
- await processEvent({
965
- account,
966
- config: ctx.cfg,
967
- client,
968
- event,
969
- botUserId: account.botUserId
970
- });
971
- }
1341
+ } else afterCursor = await drainEventBacklog({
1342
+ client,
1343
+ workspaceId,
1344
+ afterCursor,
1345
+ abortSignal: ctx.abortSignal,
1346
+ onEvent: async (event) => {
1347
+ await processEvent({
1348
+ account,
1349
+ config: ctx.cfg,
1350
+ client,
1351
+ event,
1352
+ botUserId: account.botUserId
1353
+ });
1354
+ }
1355
+ });
1356
+ if (ctx.abortSignal.aborted) break;
972
1357
  const socket = client.websocket(workspaceId, afterCursor);
973
1358
  await new Promise((resolve, reject) => {
974
1359
  let settled = false;
@@ -1035,36 +1420,81 @@ const CHANNEL_ID = "clickclack";
1035
1420
  const meta = { ...getChatChannelMeta(CHANNEL_ID) };
1036
1421
  const clickClackMessageAdapter = defineChannelMessageAdapter({
1037
1422
  id: CHANNEL_ID,
1038
- durableFinal: { capabilities: {
1039
- text: true,
1040
- replyTo: true,
1041
- thread: true,
1042
- messageSendingHooks: true
1043
- } },
1044
- send: { text: async (ctx) => {
1045
- const result = await sendClickClackText({
1046
- cfg: ctx.cfg,
1047
- accountId: ctx.accountId,
1048
- to: ctx.to,
1049
- text: ctx.text,
1050
- threadId: ctx.threadId,
1051
- replyToId: ctx.replyToId
1052
- });
1053
- const threadId = ctx.threadId == null ? void 0 : String(ctx.threadId);
1054
- const replyToId = ctx.replyToId ?? void 0;
1055
- return {
1056
- messageId: result.messageId,
1057
- receipt: createMessageReceiptFromOutboundResults({
1058
- results: [{
1059
- channel: CHANNEL_ID,
1060
- messageId: result.messageId
1061
- }],
1062
- threadId,
1063
- replyToId,
1064
- kind: "text"
1065
- })
1066
- };
1067
- } }
1423
+ durableFinal: {
1424
+ capabilities: {
1425
+ text: true,
1426
+ media: true,
1427
+ replyTo: true,
1428
+ thread: true,
1429
+ messageSendingHooks: true,
1430
+ reconcileUnknownSend: true
1431
+ },
1432
+ reconcileUnknownSendKinds: {
1433
+ text: true,
1434
+ media: true
1435
+ },
1436
+ reconcileUnknownSend: reconcileClickClackUnknownSend
1437
+ },
1438
+ send: {
1439
+ text: async (ctx) => {
1440
+ const messageId = await sendClickClackText({
1441
+ cfg: ctx.cfg,
1442
+ accountId: ctx.accountId,
1443
+ to: ctx.to,
1444
+ text: ctx.text,
1445
+ threadId: ctx.threadId,
1446
+ replyToId: ctx.replyToId,
1447
+ deliveryQueueId: ctx.deliveryQueueId,
1448
+ deliveryPartIndex: ctx.deliveryPartIndex,
1449
+ onPlatformSendDispatch: ctx.onPlatformSendDispatch
1450
+ });
1451
+ const threadId = ctx.threadId == null ? void 0 : String(ctx.threadId);
1452
+ const replyToId = ctx.replyToId ?? void 0;
1453
+ return {
1454
+ ...messageId ? { messageId } : {},
1455
+ receipt: createMessageReceiptFromOutboundResults({
1456
+ results: messageId ? [{
1457
+ channel: CHANNEL_ID,
1458
+ messageId
1459
+ }] : [],
1460
+ threadId,
1461
+ replyToId,
1462
+ kind: "text"
1463
+ })
1464
+ };
1465
+ },
1466
+ media: async (ctx) => {
1467
+ const messageId = await sendClickClackMedia({
1468
+ cfg: ctx.cfg,
1469
+ accountId: ctx.accountId,
1470
+ to: ctx.to,
1471
+ text: ctx.text,
1472
+ mediaUrl: ctx.mediaUrl,
1473
+ mediaAccess: ctx.mediaAccess,
1474
+ mediaLocalRoots: ctx.mediaLocalRoots,
1475
+ mediaReadFile: ctx.mediaReadFile,
1476
+ threadId: ctx.threadId,
1477
+ replyToId: ctx.replyToId,
1478
+ deliveryQueueId: ctx.deliveryQueueId,
1479
+ deliveryPartIndex: ctx.deliveryPartIndex,
1480
+ onPlatformSendDispatch: ctx.onPlatformSendDispatch
1481
+ });
1482
+ const threadId = ctx.threadId == null ? void 0 : String(ctx.threadId);
1483
+ const replyToId = ctx.replyToId ?? void 0;
1484
+ return {
1485
+ messageId,
1486
+ receipt: createMessageReceiptFromOutboundResults({
1487
+ results: [{
1488
+ channel: CHANNEL_ID,
1489
+ messageId
1490
+ }],
1491
+ threadId,
1492
+ replyToId,
1493
+ kind: "media"
1494
+ })
1495
+ };
1496
+ }
1497
+ }
1068
1498
  });
1069
1499
  /**
1070
1500
  * Channel plugin instance registered by the bundled ClickClack entry.
@@ -1162,14 +1592,37 @@ const clickClackPlugin = createChatChannelPlugin({
1162
1592
  base: { deliveryMode: "direct" },
1163
1593
  attachedResults: {
1164
1594
  channel: CHANNEL_ID,
1165
- sendText: async ({ cfg, to, text, accountId, threadId, replyToId }) => await sendClickClackText({
1166
- cfg,
1167
- accountId,
1168
- to,
1169
- text,
1170
- threadId,
1171
- replyToId
1172
- })
1595
+ sendText: async ({ cfg, to, text, accountId, threadId, replyToId, deliveryQueueId, deliveryPartIndex, onPlatformSendDispatch }) => {
1596
+ return { messageId: await sendClickClackText({
1597
+ cfg,
1598
+ accountId,
1599
+ to,
1600
+ text,
1601
+ threadId,
1602
+ replyToId,
1603
+ deliveryQueueId,
1604
+ deliveryPartIndex,
1605
+ onPlatformSendDispatch
1606
+ }) ?? "" };
1607
+ },
1608
+ sendMedia: async ({ cfg, to, text, mediaUrl, mediaAccess, mediaLocalRoots, mediaReadFile, accountId, threadId, replyToId, deliveryQueueId, deliveryPartIndex, onPlatformSendDispatch }) => {
1609
+ if (!mediaUrl) throw new Error("ClickClack media send requires mediaUrl");
1610
+ return { messageId: await sendClickClackMedia({
1611
+ cfg,
1612
+ accountId,
1613
+ to,
1614
+ text,
1615
+ mediaUrl,
1616
+ mediaAccess,
1617
+ mediaLocalRoots,
1618
+ mediaReadFile,
1619
+ threadId,
1620
+ replyToId,
1621
+ deliveryQueueId,
1622
+ deliveryPartIndex,
1623
+ onPlatformSendDispatch
1624
+ }) };
1625
+ }
1173
1626
  }
1174
1627
  }
1175
1628
  });
@@ -1,2 +1,2 @@
1
- import { t as clickClackPlugin } from "./channel-1ZnJZTPl.js";
1
+ import { t as clickClackPlugin } from "./channel-Ds6d_3nx.js";
2
2
  export { clickClackPlugin };
@@ -1,3 +1,3 @@
1
- import { d as resolveClickClackAccount, i as createClickClackClient, o as setClickClackRuntime, r as parseClickClackTarget } from "./channel-1ZnJZTPl.js";
1
+ import { d as resolveClickClackAccount, i as createClickClackClient, o as setClickClackRuntime, r as parseClickClackTarget } from "./channel-Ds6d_3nx.js";
2
2
  import "./api.js";
3
3
  export { createClickClackClient, parseClickClackTarget, resolveClickClackAccount, setClickClackRuntime };
@@ -1,18 +1,18 @@
1
1
  {
2
2
  "name": "@openclaw/clickclack",
3
- "version": "2026.7.1-beta.6",
3
+ "version": "2026.7.2-beta.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@openclaw/clickclack",
9
- "version": "2026.7.1-beta.6",
9
+ "version": "2026.7.2-beta.1",
10
10
  "dependencies": {
11
11
  "ws": "8.21.0",
12
12
  "zod": "4.4.3"
13
13
  },
14
14
  "peerDependencies": {
15
- "openclaw": ">=2026.7.1-beta.6"
15
+ "openclaw": ">=2026.7.2-beta.1"
16
16
  },
17
17
  "peerDependenciesMeta": {
18
18
  "openclaw": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/clickclack",
3
- "version": "2026.7.1-beta.6",
3
+ "version": "2026.7.2-beta.1",
4
4
  "description": "OpenClaw ClickClack channel plugin",
5
5
  "type": "module",
6
6
  "exports": {
@@ -13,7 +13,7 @@
13
13
  "zod": "4.4.3"
14
14
  },
15
15
  "peerDependencies": {
16
- "openclaw": ">=2026.7.1-beta.6"
16
+ "openclaw": ">=2026.7.2-beta.1"
17
17
  },
18
18
  "peerDependenciesMeta": {
19
19
  "openclaw": {
@@ -49,10 +49,10 @@
49
49
  "allowInvalidConfigRecovery": true
50
50
  },
51
51
  "compat": {
52
- "pluginApi": ">=2026.7.1-beta.6"
52
+ "pluginApi": ">=2026.7.2-beta.1"
53
53
  },
54
54
  "build": {
55
- "openclawVersion": "2026.7.1-beta.6",
55
+ "openclawVersion": "2026.7.2-beta.1",
56
56
  "bundledDist": false
57
57
  },
58
58
  "release": {