@newbase-clawchat/openclaw-clawchat 2026.4.21 → 2026.4.23

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/src/outbound.ts CHANGED
@@ -1,8 +1,19 @@
1
1
  import type { ClawlingChatClient, Envelope, Fragment, MessageAckPayload } from "@newbase-clawchat/sdk";
2
+ import {
3
+ createAttachedChannelResultAdapter,
4
+ type ChannelOutboundAdapter,
5
+ } from "openclaw/plugin-sdk/channel-send-result";
6
+ import { chunkMarkdownText } from "openclaw/plugin-sdk/reply-runtime";
2
7
  import type { ChatType } from "./client.ts";
3
- import type { ResolvedOpenclawClawlingAccount } from "./config.ts";
4
- import type { ClawlingMediaFragment } from "./media-runtime.ts";
8
+ import { createOpenclawClawlingApiClient } from "./api-client.ts";
9
+ import { CHANNEL_ID, resolveOpenclawClawlingAccount, type ResolvedOpenclawClawlingAccount } from "./config.ts";
5
10
  import { textToFragments } from "./message-mapper.ts";
11
+ import { uploadOutboundMedia, type ClawlingMediaFragment } from "./media-runtime.ts";
12
+ import {
13
+ getOpenclawClawlingClient,
14
+ getOpenclawClawlingRuntime,
15
+ waitForOpenclawClawlingClient,
16
+ } from "./runtime.ts";
6
17
 
7
18
  export interface OutboundTarget {
8
19
  chatId: string;
@@ -37,6 +48,48 @@ export interface SendResult {
37
48
  acceptedAt: number;
38
49
  }
39
50
 
51
+ /**
52
+ * Parse an agent-initiated outbound recipient string into the new-protocol
53
+ * `chat_id` + `chat_type` pair.
54
+ *
55
+ * Accepted forms (case-insensitive prefix):
56
+ * - `cc:{chat_id}` → direct
57
+ * - `clawchat:{chat_id}` → direct
58
+ * - `openclaw-clawchat:{chat_id}` → direct
59
+ * - `cc:direct:{chat_id}` → direct
60
+ * - `cc:group:{chat_id}` → group
61
+ * - `clawchat:direct:{chat_id}` → direct
62
+ * - `clawchat:group:{chat_id}` → group
63
+ * - `openclaw-clawchat:direct:{chat_id}` → direct
64
+ * - `openclaw-clawchat:group:{chat_id}` → group
65
+ * - bare `{chat_id}` → direct (backward compat)
66
+ */
67
+ export function parseOpenclawRecipient(to: string): { chatId: string; chatType: ChatType } {
68
+ const raw = (to ?? "").trim();
69
+ if (!raw) throw new Error("openclaw-clawchat: outbound `to` is empty");
70
+
71
+ const firstColon = raw.indexOf(":");
72
+ if (firstColon < 0) return { chatId: raw, chatType: "direct" };
73
+
74
+ const scheme = raw.slice(0, firstColon).toLowerCase();
75
+ const rest = raw.slice(firstColon + 1);
76
+ if (scheme !== "cc" && scheme !== "clawchat" && scheme !== CHANNEL_ID) {
77
+ return { chatId: raw, chatType: "direct" };
78
+ }
79
+
80
+ const secondColon = rest.indexOf(":");
81
+ if (secondColon >= 0) {
82
+ const typeToken = rest.slice(0, secondColon).toLowerCase();
83
+ const chatId = rest.slice(secondColon + 1).trim();
84
+ if ((typeToken === "direct" || typeToken === "group") && chatId) {
85
+ return { chatId, chatType: typeToken };
86
+ }
87
+ }
88
+ const chatId = rest.trim();
89
+ if (!chatId) throw new Error(`openclaw-clawchat: missing chat_id in "${to}"`);
90
+ return { chatId, chatType: "direct" };
91
+ }
92
+
40
93
  export async function sendOpenclawClawlingText(params: SendParams): Promise<SendResult | null> {
41
94
  const text = (params.text ?? "").trim();
42
95
  const mediaFragments = params.mediaFragments ?? [];
@@ -139,3 +192,63 @@ export async function sendOpenclawClawlingMedia(
139
192
  ...(params.log ? { log: params.log } : {}),
140
193
  });
141
194
  }
195
+
196
+ export const openclawClawlingOutbound: ChannelOutboundAdapter = {
197
+ deliveryMode: "direct",
198
+ chunker: (text, limit) => chunkMarkdownText(text, limit),
199
+ chunkerMode: "markdown",
200
+ textChunkLimit: 4000,
201
+ ...createAttachedChannelResultAdapter({
202
+ channel: CHANNEL_ID,
203
+ sendText: async ({ cfg, to, text }) => {
204
+ const account = resolveOpenclawClawlingAccount(cfg);
205
+ const client =
206
+ getOpenclawClawlingClient(account.accountId) ??
207
+ (await waitForOpenclawClawlingClient(account.accountId));
208
+ const result = await sendOpenclawClawlingText({
209
+ client,
210
+ account,
211
+ to: parseOpenclawRecipient(to),
212
+ text,
213
+ });
214
+ return {
215
+ to,
216
+ messageId: result?.messageId ?? `${account.userId}-${Date.now()}`,
217
+ };
218
+ },
219
+ sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots }) => {
220
+ const account = resolveOpenclawClawlingAccount(cfg);
221
+ const client =
222
+ getOpenclawClawlingClient(account.accountId) ??
223
+ (await waitForOpenclawClawlingClient(account.accountId));
224
+ if (!mediaUrl?.trim()) {
225
+ throw new Error("openclaw-clawchat sendMedia requires mediaUrl");
226
+ }
227
+ const runtime = getOpenclawClawlingRuntime();
228
+ const apiClient = createOpenclawClawlingApiClient({
229
+ baseUrl: account.baseUrl,
230
+ token: account.token,
231
+ userId: account.userId,
232
+ });
233
+ const mediaFragments = await uploadOutboundMedia([mediaUrl.trim()], {
234
+ apiClient,
235
+ runtime,
236
+ ...(mediaLocalRoots ? { mediaLocalRoots } : {}),
237
+ });
238
+ if (mediaFragments.length === 0) {
239
+ throw new Error(`openclaw-clawchat failed to upload media: ${mediaUrl}`);
240
+ }
241
+ const result = await sendOpenclawClawlingMedia({
242
+ client,
243
+ account,
244
+ to: parseOpenclawRecipient(to),
245
+ text,
246
+ mediaFragments,
247
+ });
248
+ return {
249
+ to,
250
+ messageId: result?.messageId ?? `${account.userId}-${Date.now()}`,
251
+ };
252
+ },
253
+ }),
254
+ };
@@ -36,7 +36,9 @@ describe("openclaw-clawchat protocol guards", () => {
36
36
 
37
37
  it("detects renderable text", () => {
38
38
  expect(hasRenderableText({ body: { fragments: [{ kind: "text", text: "hi" }] } })).toBe(true);
39
- expect(hasRenderableText({ body: { fragments: [{ kind: "image", url: "x" }] } })).toBe(false);
39
+ expect(hasRenderableText({ body: { fragments: [{ kind: "image", url: "x" }] } })).toBe(true);
40
+ expect(hasRenderableText({ body: { fragments: [{ kind: "file", url: "x" }] } })).toBe(true);
40
41
  expect(hasRenderableText({ body: { fragments: [{ kind: "text", text: " " }] } })).toBe(false);
42
+ expect(hasRenderableText({ body: { fragments: [{ kind: "image" }] } })).toBe(false);
41
43
  });
42
44
  });
package/src/protocol.ts CHANGED
@@ -31,8 +31,12 @@ export function hasRenderableText(message: {
31
31
  const fragments = message?.body?.fragments ?? [];
32
32
  return fragments.some(
33
33
  (f) =>
34
- (f as { kind?: string }).kind === "text" &&
35
- typeof (f as { text?: unknown }).text === "string" &&
36
- (f as { text: string }).text.trim().length > 0,
34
+ (((f as { kind?: string }).kind === "text" &&
35
+ typeof (f as { text?: unknown }).text === "string" &&
36
+ (f as { text: string }).text.trim().length > 0) ||
37
+ (typeof (f as { kind?: unknown }).kind === "string" &&
38
+ ["image", "file", "audio", "video"].includes((f as { kind: string }).kind) &&
39
+ typeof (f as { url?: unknown }).url === "string" &&
40
+ (f as { url: string }).url.trim().length > 0)),
37
41
  );
38
42
  }
@@ -0,0 +1,252 @@
1
+ import type { ClawlingChatClient } from "@newbase-clawchat/sdk";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { createOpenclawClawlingReplyDispatcher } from "./reply-dispatcher.ts";
4
+
5
+ describe("openclaw-clawchat reply-dispatcher", () => {
6
+ it("emits message.failed in stream mode even if execution errors before any stream chunk", async () => {
7
+ let hooks:
8
+ | {
9
+ deliver?: (payload: { text?: string }, info?: { kind: "tool" | "block" | "final" }) => Promise<void>;
10
+ onIdle?: () => Promise<void>;
11
+ onError?: (error: unknown, info: { kind: string }) => void;
12
+ }
13
+ | undefined;
14
+ const sent: Array<{ event: string; payload: Record<string, unknown> }> = [];
15
+ const client = {
16
+ opts: {
17
+ transport: {
18
+ send: (data: string) => {
19
+ const env = JSON.parse(data) as { event: string; payload: Record<string, unknown> };
20
+ sent.push({ event: env.event, payload: env.payload });
21
+ },
22
+ },
23
+ traceIdFactory: () => "trace-1",
24
+ },
25
+ typing: vi.fn(),
26
+ } as unknown as ClawlingChatClient;
27
+
28
+ createOpenclawClawlingReplyDispatcher({
29
+ cfg: {} as never,
30
+ runtime: {
31
+ channel: {
32
+ reply: {
33
+ resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
34
+ createReplyDispatcherWithTyping: vi.fn((options) => {
35
+ hooks = options;
36
+ return {
37
+ dispatcher: {},
38
+ replyOptions: {},
39
+ markDispatchIdle: vi.fn(),
40
+ };
41
+ }),
42
+ },
43
+ },
44
+ } as never,
45
+ account: {
46
+ accountId: "default",
47
+ userId: "agent-1",
48
+ replyMode: "stream",
49
+ forwardThinking: true,
50
+ forwardToolCalls: false,
51
+ stream: { flushIntervalMs: 250, minChunkChars: 40, maxBufferChars: 2000 },
52
+ } as never,
53
+ client,
54
+ target: { chatId: "chat-1", chatType: "direct" },
55
+ log: { info: vi.fn(), error: vi.fn() },
56
+ });
57
+
58
+ hooks?.onError?.(new Error("boom"), { kind: "dispatch" });
59
+
60
+ expect(sent).toHaveLength(1);
61
+ expect(sent[0]!.event).toBe("message.failed");
62
+ expect(sent[0]!.payload.reason).toBe("Error: boom");
63
+ expect((client.typing as ReturnType<typeof vi.fn>).mock.calls).toEqual([
64
+ ["chat-1", false, "direct"],
65
+ ]);
66
+ });
67
+
68
+ it("does not emit consolidated final after a streaming failure", async () => {
69
+ let hooks:
70
+ | {
71
+ deliver?: (payload: { text?: string }, info?: { kind: "tool" | "block" | "final" }) => Promise<void>;
72
+ onIdle?: () => Promise<void>;
73
+ onError?: (error: unknown, info: { kind: string }) => void;
74
+ }
75
+ | undefined;
76
+ const sent: Array<{ event: string; payload: Record<string, unknown> }> = [];
77
+ const client = {
78
+ opts: {
79
+ transport: {
80
+ send: (data: string) => {
81
+ const env = JSON.parse(data) as { event: string; payload: Record<string, unknown> };
82
+ sent.push({ event: env.event, payload: env.payload });
83
+ },
84
+ },
85
+ traceIdFactory: () => "trace-2",
86
+ },
87
+ typing: vi.fn(),
88
+ } as unknown as ClawlingChatClient;
89
+
90
+ createOpenclawClawlingReplyDispatcher({
91
+ cfg: {} as never,
92
+ runtime: {
93
+ channel: {
94
+ reply: {
95
+ resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
96
+ createReplyDispatcherWithTyping: vi.fn((options) => {
97
+ hooks = options;
98
+ return {
99
+ dispatcher: {},
100
+ replyOptions: {},
101
+ markDispatchIdle: vi.fn(),
102
+ };
103
+ }),
104
+ },
105
+ },
106
+ } as never,
107
+ account: {
108
+ accountId: "default",
109
+ userId: "agent-1",
110
+ replyMode: "stream",
111
+ forwardThinking: true,
112
+ forwardToolCalls: false,
113
+ stream: { flushIntervalMs: 250, minChunkChars: 1, maxBufferChars: 2000 },
114
+ } as never,
115
+ client,
116
+ target: { chatId: "chat-1", chatType: "direct" },
117
+ inboundMessageId: "inbound-1",
118
+ inboundForFinalReply: {
119
+ senderId: "user-1",
120
+ senderNickName: "User 1",
121
+ bodyText: "hello",
122
+ },
123
+ log: { info: vi.fn(), error: vi.fn() },
124
+ });
125
+
126
+ await hooks?.deliver?.({ text: "partial reply" }, { kind: "block" });
127
+ hooks?.onError?.(new Error("boom"), { kind: "dispatch" });
128
+ await hooks?.onIdle?.();
129
+
130
+ expect(sent.map((entry) => entry.event)).toEqual([
131
+ "message.created",
132
+ "message.add",
133
+ "message.failed",
134
+ ]);
135
+ expect(sent.find((entry) => entry.event === "message.reply")).toBeUndefined();
136
+ expect(sent.find((entry) => entry.event === "message.done")).toBeUndefined();
137
+ });
138
+
139
+ it("sends static error text when non-streaming reply execution fails", async () => {
140
+ let hooks:
141
+ | {
142
+ onError?: (error: unknown, info: { kind: string }) => void;
143
+ }
144
+ | undefined;
145
+ const client = {
146
+ sendMessage: vi.fn().mockResolvedValue({
147
+ payload: { message_id: "server-m1", accepted_at: 1234 },
148
+ }),
149
+ replyMessage: vi.fn(),
150
+ typing: vi.fn(),
151
+ } as unknown as ClawlingChatClient;
152
+
153
+ createOpenclawClawlingReplyDispatcher({
154
+ cfg: {} as never,
155
+ runtime: {
156
+ channel: {
157
+ reply: {
158
+ resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
159
+ createReplyDispatcherWithTyping: vi.fn((options) => {
160
+ hooks = options;
161
+ return {
162
+ dispatcher: {},
163
+ replyOptions: {},
164
+ markDispatchIdle: vi.fn(),
165
+ };
166
+ }),
167
+ },
168
+ },
169
+ } as never,
170
+ account: {
171
+ accountId: "default",
172
+ userId: "agent-1",
173
+ replyMode: "static",
174
+ forwardThinking: true,
175
+ forwardToolCalls: false,
176
+ stream: { flushIntervalMs: 250, minChunkChars: 40, maxBufferChars: 2000 },
177
+ } as never,
178
+ client,
179
+ target: { chatId: "chat-1", chatType: "direct" },
180
+ log: { info: vi.fn(), error: vi.fn() },
181
+ });
182
+
183
+ hooks?.onError?.(new Error("boom"), { kind: "dispatch" });
184
+ await new Promise((resolve) => setTimeout(resolve, 0));
185
+
186
+ expect(client.sendMessage).toHaveBeenCalledWith(
187
+ expect.objectContaining({
188
+ to: { id: "chat-1", type: "direct" },
189
+ body: { fragments: [{ kind: "text", text: "Error: boom" }] },
190
+ }),
191
+ );
192
+ expect(client.replyMessage).not.toHaveBeenCalled();
193
+ });
194
+
195
+ it("strips delivery retry wrapper text before sending non-streaming errors", async () => {
196
+ let hooks:
197
+ | {
198
+ onError?: (error: unknown, info: { kind: string }) => void;
199
+ }
200
+ | undefined;
201
+ const client = {
202
+ sendMessage: vi.fn().mockResolvedValue({
203
+ payload: { message_id: "server-m1", accepted_at: 1234 },
204
+ }),
205
+ replyMessage: vi.fn(),
206
+ typing: vi.fn(),
207
+ } as unknown as ClawlingChatClient;
208
+
209
+ createOpenclawClawlingReplyDispatcher({
210
+ cfg: {} as never,
211
+ runtime: {
212
+ channel: {
213
+ reply: {
214
+ resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
215
+ createReplyDispatcherWithTyping: vi.fn((options) => {
216
+ hooks = options;
217
+ return {
218
+ dispatcher: {},
219
+ replyOptions: {},
220
+ markDispatchIdle: vi.fn(),
221
+ };
222
+ }),
223
+ },
224
+ },
225
+ } as never,
226
+ account: {
227
+ accountId: "default",
228
+ userId: "agent-1",
229
+ replyMode: "static",
230
+ forwardThinking: true,
231
+ forwardToolCalls: false,
232
+ stream: { flushIntervalMs: 250, minChunkChars: 40, maxBufferChars: 2000 },
233
+ } as never,
234
+ client,
235
+ target: { chatId: "chat-1", chatType: "direct" },
236
+ log: { info: vi.fn(), error: vi.fn() },
237
+ });
238
+
239
+ hooks?.onError?.(
240
+ new Error("Retry failed for delivery 123: Error: boom"),
241
+ { kind: "dispatch" },
242
+ );
243
+ await new Promise((resolve) => setTimeout(resolve, 0));
244
+
245
+ expect(client.sendMessage).toHaveBeenCalledWith(
246
+ expect.objectContaining({
247
+ to: { id: "chat-1", type: "direct" },
248
+ body: { fragments: [{ kind: "text", text: "Error: boom" }] },
249
+ }),
250
+ );
251
+ });
252
+ });
@@ -13,6 +13,7 @@ import type { ResolvedOpenclawClawlingAccount } from "./config.ts";
13
13
  import { textToFragments } from "./message-mapper.ts";
14
14
  import { uploadOutboundMedia, type ClawlingMediaFragment } from "./media-runtime.ts";
15
15
  import { sendOpenclawClawlingText, type OutboundReplyCtx } from "./outbound.ts";
16
+ import { sendStreamingFailure } from "./streaming.ts";
16
17
 
17
18
  export interface ReplyDispatcherOptions {
18
19
  cfg: OpenClawConfig;
@@ -54,6 +55,15 @@ type StreamingReplyHooks = {
54
55
  onReasoningStream?: (payload: ReplyPayload) => Promise<void>;
55
56
  };
56
57
 
58
+ function normalizeReplyErrorText(error: unknown): string {
59
+ const raw = String(error);
60
+ const retryWrapped = raw.match(/^Error: Retry failed for delivery [^:]+:\s*(.+)$/s);
61
+ if (retryWrapped?.[1]?.trim()) return retryWrapped[1].trim();
62
+ const retryWrappedBare = raw.match(/^Retry failed for delivery [^:]+:\s*(.+)$/s);
63
+ if (retryWrappedBare?.[1]?.trim()) return retryWrappedBare[1].trim();
64
+ return raw;
65
+ }
66
+
57
67
  /**
58
68
  * Reply dispatcher for openclaw-clawchat.
59
69
  *
@@ -126,6 +136,7 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
126
136
  const accumulatedMediaUrls: string[] = [];
127
137
  let finalEmitted = false;
128
138
  let streamingClosed = false;
139
+ let runFailed = false;
129
140
  let runDone = false;
130
141
  // `streamCreatedEmitted` is the authoritative guard: once a `message.created`
131
142
  // has been emitted for this dispatcher instance, never emit another — even
@@ -133,6 +144,9 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
133
144
  // raced the lazy open path.
134
145
  let streamCreatedEmitted = false;
135
146
 
147
+ const mintStreamingMessageId = () =>
148
+ `${account.userId}-stream-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
149
+
136
150
  const openSessionIfNeeded = () => {
137
151
  if (!streamingEnabled || streamingSession || streamCreatedEmitted) return;
138
152
  streamCreatedEmitted = true;
@@ -143,7 +157,7 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
143
157
  // which mints its own id. The inbound user message_id lives on
144
158
  // `replyTo.msgId`; keeping the two distinct avoids the agent's reply
145
159
  // frames shadowing the user turn they answer.
146
- streamingMessageId = `${account.userId}-stream-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
160
+ streamingMessageId = mintStreamingMessageId();
147
161
  streamingSession = openBufferedStreamingSession({
148
162
  client,
149
163
  routing,
@@ -352,15 +366,35 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
352
366
  }
353
367
  },
354
368
  onError: (error: unknown, info: { kind: string }) => {
369
+ const errorText = normalizeReplyErrorText(error);
355
370
  log?.error?.(
356
- `[${account.accountId}] openclaw-clawchat ${info.kind} reply failed: ${String(error)}`,
371
+ `[${account.accountId}] openclaw-clawchat ${info.kind} reply failed: ${errorText}`,
357
372
  );
358
- void closeStreamingSession("fail", String(error));
373
+ if (!streamingEnabled) {
374
+ void sendStatic(errorText);
375
+ return;
376
+ }
377
+ runFailed = true;
378
+ if (streamingSession && !streamingClosed) {
379
+ void closeStreamingSession("fail", errorText);
380
+ return;
381
+ }
382
+ if (streamingClosed) return;
383
+ streamingClosed = true;
384
+ if (!streamingMessageId) streamingMessageId = mintStreamingMessageId();
385
+ void sendStreamingFailure({
386
+ client,
387
+ routing,
388
+ messageId: streamingMessageId,
389
+ currentSequence: 0,
390
+ reason: errorText,
391
+ });
359
392
  },
360
393
  onIdle: async () => {
361
394
  if (runDone) return;
362
395
  runDone = true;
363
396
  if (!streamingEnabled) return;
397
+ if (runFailed) return;
364
398
  await closeStreamingSession("done");
365
399
  await emitFinalConsolidatedMessage();
366
400
  },