@newbase-clawchat/openclaw-clawchat 2026.5.4-1 → 2026.5.4-2

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
@@ -21,7 +21,8 @@ npm i @newbase-clawchat/openclaw-clawchat
21
21
 
22
22
  Requires `openclaw >= 2026.5.4` as a peer host.
23
23
 
24
- For the OpenClaw plugin install/update flow, see [`INSTALL.md`](./INSTALL.md).
24
+ For the OpenClaw plugin install/update flow, use the R2-hosted tarball install
25
+ command documented in [`INSTALL.md`](./INSTALL.md).
25
26
 
26
27
  Example LLM prompt:
27
28
 
@@ -176,6 +177,38 @@ npm run dev:openclaw-source
176
177
  This checkout is local-only. It is ignored by git and is not required to run the
177
178
  plugin tests or publish the package.
178
179
 
180
+ ## R2 package scripts
181
+
182
+ Create and upload the OpenClaw plugin tarball to the R2 `openclaw/` prefix:
183
+
184
+ ```bash
185
+ ./package_openclaw_plugin.sh
186
+ ```
187
+
188
+ The script runs `npm pack`, uploads the generated `.tgz` to the configured R2
189
+ bucket, and prints the public URL. R2 credentials are read from `.env.r2`, which
190
+ is ignored by git. Use `--no-upload` to build the tarball without uploading it.
191
+
192
+ ```bash
193
+ AWS_ACCESS_KEY_ID=...
194
+ AWS_SECRET_ACCESS_KEY=...
195
+ AWS_DEFAULT_REGION=auto
196
+ R2_ENDPOINT=https://...
197
+ R2_BUCKET=...
198
+ ```
199
+
200
+ Install the R2-hosted tarball on a device or container with OpenClaw available:
201
+
202
+ ```bash
203
+ ./install_openclaw.sh
204
+ ```
205
+
206
+ To install a specific uploaded tarball, pass its URL explicitly:
207
+
208
+ ```bash
209
+ ./install_openclaw.sh https://dddddddddddddtest.clawling.chat/openclaw/newbase-clawchat-openclaw-clawchat-2026.5.4-2.tgz
210
+ ```
211
+
179
212
  ## License
180
213
 
181
214
  See the repository root.
@@ -1,6 +1,6 @@
1
1
  import { createChatChannelPlugin } from "openclaw/plugin-sdk/core";
2
2
  import { createEmptyChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-runtime";
3
- import { resolveOpenclawClawlingAccount, } from "./config.js";
3
+ import { CHANNEL_ID, resolveOpenclawClawlingAccount, } from "./config.js";
4
4
  import { openclawClawlingOutbound } from "./outbound.js";
5
5
  import { getOpenclawClawlingRuntime, startOpenclawClawlingGateway } from "./runtime.js";
6
6
  import { openclawClawlingSetupPlugin } from "./channel.setup.js";
@@ -53,6 +53,7 @@ export const openclawClawlingPlugin = createChatChannelPlugin({
53
53
  ],
54
54
  },
55
55
  messaging: {
56
+ targetPrefixes: ["cc", "clawchat", CHANNEL_ID],
56
57
  normalizeTarget: (target) => target
57
58
  .trim()
58
59
  .replace(/^openclaw-clawchat:/i, "")
@@ -44,12 +44,19 @@ function normalizeRouting(params) {
44
44
  * Emit a raw v2 envelope directly over the transport so we can carry top-level
45
45
  * `chat_id` routing without SDK-injected `to` metadata.
46
46
  */
47
- function emitEnvelope(client, event, payload, routing) {
47
+ function emitEnvelope(client, event, payload, routing, options = {}) {
48
48
  const inner = client;
49
- if (!inner.opts?.transport) {
50
- inner.emitRaw?.(event, payload, { to: { id: routing.chatId, type: routing.chatType } });
49
+ if (!options.forceRawTransport && inner.emitRaw) {
50
+ inner.emitRaw(event, payload, { chat_id: routing.chatId });
51
51
  return;
52
52
  }
53
+ if (!inner.opts?.transport) {
54
+ if (!options.forceRawTransport && inner.emitRaw) {
55
+ inner.emitRaw(event, payload, { chat_id: routing.chatId });
56
+ return;
57
+ }
58
+ throw new Error("openclaw-clawchat streaming emit requires SDK raw transport");
59
+ }
53
60
  const env = {
54
61
  version: "2",
55
62
  event,
@@ -150,7 +157,7 @@ export function emitFinalStreamReply(client, params) {
150
157
  },
151
158
  },
152
159
  },
153
- }, routing);
160
+ }, routing, { forceRawTransport: true });
154
161
  }
155
162
  export function emitStreamFailed(client, params) {
156
163
  const now = Date.now();
@@ -1,3 +1,4 @@
1
+ import { buildOutboundMediaLoadOptions, } from "openclaw/plugin-sdk/media-runtime";
1
2
  export function inferMediaKindFromMime(mime) {
2
3
  if (!mime)
3
4
  return "file";
@@ -56,12 +57,12 @@ export async function uploadOutboundMedia(urls, ctx) {
56
57
  const out = [];
57
58
  for (const url of urls) {
58
59
  try {
59
- const loaded = await ctx.runtime.media.loadWebMedia(url, {
60
+ const loaded = await ctx.runtime.media.loadWebMedia(url, buildOutboundMediaLoadOptions({
60
61
  maxBytes,
61
- ...(ctx.mediaLocalRoots && ctx.mediaLocalRoots.length > 0
62
- ? { localRoots: ctx.mediaLocalRoots }
63
- : {}),
64
- });
62
+ ...(ctx.mediaAccess ? { mediaAccess: ctx.mediaAccess } : {}),
63
+ ...(ctx.mediaLocalRoots ? { mediaLocalRoots: ctx.mediaLocalRoots } : {}),
64
+ ...(ctx.mediaReadFile ? { mediaReadFile: ctx.mediaReadFile } : {}),
65
+ }));
65
66
  const uploaded = await ctx.apiClient.uploadMedia({
66
67
  buffer: loaded.buffer,
67
68
  filename: loaded.fileName ?? "upload.bin",
@@ -19,6 +19,8 @@ import { getOpenclawClawlingClient, getOpenclawClawlingRuntime, waitForOpenclawC
19
19
  * - `clawchat:group:{chat_id}` → group
20
20
  * - `openclaw-clawchat:direct:{chat_id}` → direct
21
21
  * - `openclaw-clawchat:group:{chat_id}` → group
22
+ * - `direct:{chat_id}` → direct (host-normalized)
23
+ * - `group:{chat_id}` → group (host-normalized)
22
24
  * - bare `{chat_id}` → direct (backward compat)
23
25
  */
24
26
  export function parseOpenclawRecipient(to) {
@@ -30,6 +32,12 @@ export function parseOpenclawRecipient(to) {
30
32
  return { chatId: raw, chatType: "direct" };
31
33
  const scheme = raw.slice(0, firstColon).toLowerCase();
32
34
  const rest = raw.slice(firstColon + 1);
35
+ if (scheme === "direct" || scheme === "group") {
36
+ const chatId = rest.trim();
37
+ if (!chatId)
38
+ throw new Error(`openclaw-clawchat: missing chat_id in "${to}"`);
39
+ return { chatId, chatType: scheme };
40
+ }
33
41
  if (scheme !== "cc" && scheme !== "clawchat" && scheme !== CHANNEL_ID) {
34
42
  return { chatId: raw, chatType: "direct" };
35
43
  }
@@ -74,7 +82,7 @@ export async function sendOpenclawClawlingText(params) {
74
82
  mode: "normal",
75
83
  replyTo: {
76
84
  msgId: params.replyCtx.replyToMessageId,
77
- senderId: params.replyCtx.replyPreviewChatId ?? params.replyCtx.replyPreviewSenderId,
85
+ senderId: params.replyCtx.replyPreviewSenderId,
78
86
  nickName: params.replyCtx.replyPreviewNickName,
79
87
  fragments: [{ kind: "text", text: params.replyCtx.replyPreviewText }],
80
88
  },
@@ -144,7 +152,7 @@ export const openclawClawlingOutbound = {
144
152
  messageId: result?.messageId ?? `${account.userId}-${Date.now()}`,
145
153
  };
146
154
  },
147
- sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots }) => {
155
+ sendMedia: async ({ cfg, to, text, mediaUrl, mediaAccess, mediaLocalRoots, mediaReadFile }) => {
148
156
  const account = resolveOpenclawClawlingAccount(cfg);
149
157
  const client = getOpenclawClawlingClient(account.accountId) ??
150
158
  (await waitForOpenclawClawlingClient(account.accountId));
@@ -160,7 +168,9 @@ export const openclawClawlingOutbound = {
160
168
  const mediaFragments = await uploadOutboundMedia([mediaUrl.trim()], {
161
169
  apiClient,
162
170
  runtime,
171
+ ...(mediaAccess ? { mediaAccess } : {}),
163
172
  ...(mediaLocalRoots ? { mediaLocalRoots } : {}),
173
+ ...(mediaReadFile ? { mediaReadFile } : {}),
164
174
  });
165
175
  if (mediaFragments.length === 0) {
166
176
  throw new Error(`openclaw-clawchat failed to upload media: ${mediaUrl}`);
@@ -1,4 +1,5 @@
1
1
  import { interactiveReplyToPresentation, renderMessagePresentationFallbackText, } from "openclaw/plugin-sdk/interactive-runtime";
2
+ import { resolveOutboundMediaUrls } from "openclaw/plugin-sdk/reply-payload";
2
3
  import { createOpenclawClawlingApiClient } from "./api-client.js";
3
4
  import { openBufferedStreamingSession, mergeStreamingText, } from "./buffered-stream.js";
4
5
  import { emitFinalStreamReply } from "./client.js";
@@ -256,7 +257,7 @@ export function createOpenclawClawlingReplyDispatcher(options) {
256
257
  routing,
257
258
  replyTo: {
258
259
  msgId: inboundMessageId ?? streamingMessageId,
259
- previewId: inboundForFinalReply?.chatId ?? target.chatId,
260
+ previewId: inboundForFinalReply?.senderId ?? target.chatId,
260
261
  nickName: inboundForFinalReply?.senderNickName ?? inboundForFinalReply?.senderId ?? target.chatId,
261
262
  fragments: inboundForFinalReply?.bodyText
262
263
  ? [{ kind: "text", text: inboundForFinalReply.bodyText }]
@@ -271,10 +272,7 @@ export function createOpenclawClawlingReplyDispatcher(options) {
271
272
  }
272
273
  if (text)
273
274
  streamText = mergeStreamingText(streamText, text);
274
- const urls = [
275
- ...(payload.mediaUrl ? [payload.mediaUrl] : []),
276
- ...(payload.mediaUrls ?? []),
277
- ].filter((u) => Boolean(u));
275
+ const urls = resolveOutboundMediaUrls(payload).filter(Boolean);
278
276
  for (const url of urls) {
279
277
  if (!accumulatedMediaUrls.includes(url))
280
278
  accumulatedMediaUrls.push(url);
@@ -313,10 +311,7 @@ export function createOpenclawClawlingReplyDispatcher(options) {
313
311
  deliver: async (payload, info) => {
314
312
  const richFragment = buildRichInteractionFragment(payload);
315
313
  const text = richFragment && account.richInteractions ? "" : resolvePayloadText(payload);
316
- const urls = [
317
- ...(payload.mediaUrl ? [payload.mediaUrl] : []),
318
- ...(payload.mediaUrls ?? []),
319
- ].filter((u) => Boolean(u));
314
+ const urls = resolveOutboundMediaUrls(payload).filter(Boolean);
320
315
  log?.info?.(`[${account.accountId}] openclaw-clawchat deliver kind=${info?.kind ?? "unknown"} text_len=${text.length} media_urls=${urls.length} reasoning=${payload.isReasoning === true}`);
321
316
  if (payload.isReasoning) {
322
317
  if (!account.forwardThinking)
@@ -140,16 +140,16 @@ export async function startOpenclawClawlingGateway(params) {
140
140
  timestamp: turn.timestamp,
141
141
  ...rt.reply.resolveEnvelopeFormatOptions(cfg),
142
142
  });
143
+ const conversationTarget = `${CHANNEL_ID}:${formatConversationSubject(turn.peer)}`;
143
144
  const ctxPayload = rt.reply.finalizeInboundContext({
144
145
  Body: body,
145
146
  BodyForAgent: turn.rawBody,
146
147
  RawBody: turn.rawBody,
147
148
  CommandBody: turn.rawBody,
148
- // Clawling v2 routes by chat_id. `senderId` is still preserved as
149
- // structured metadata, but the conversation target must be based on
150
- // `peer.id` so follow-up sends address the active chat, not merely
151
- // the human sender identity.
152
- From: `${CHANNEL_ID}:${formatConversationSubject(turn.peer)}`,
149
+ // Clawling v2 routes by chat_id. `OriginatingTo` is what the
150
+ // message tool uses as the implicit current-chat target, so keep it
151
+ // on the conversation id rather than the agent account user id.
152
+ From: conversationTarget,
153
153
  To: `${CHANNEL_ID}:${account.userId}`,
154
154
  SessionKey: route.sessionKey,
155
155
  AccountId: route.accountId ?? accountId,
@@ -162,7 +162,7 @@ export async function startOpenclawClawlingGateway(params) {
162
162
  MessageSidFull: turn.messageId,
163
163
  Timestamp: turn.timestamp,
164
164
  OriginatingChannel: CHANNEL_ID,
165
- OriginatingTo: `${CHANNEL_ID}:${account.userId}`,
165
+ OriginatingTo: conversationTarget,
166
166
  });
167
167
  // Fetch any inbound media attachments and populate MediaPath/MediaPaths in context.
168
168
  const inboundPaths = turn.mediaItems.length > 0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@newbase-clawchat/openclaw-clawchat",
3
- "version": "2026.5.4-1",
3
+ "version": "2026.5.4-2",
4
4
  "description": "OpenClaw ClawChat channel plugin",
5
5
  "files": [
6
6
  "dist",
@@ -88,6 +88,8 @@ describe("openclaw-clawchat channel outbound", () => {
88
88
  uploadOutboundMediaMock.mockResolvedValue([
89
89
  { kind: "image", url: "https://cdn/uploaded.png", mime: "image/png" },
90
90
  ]);
91
+ const mediaReadFile = vi.fn(async () => Buffer.from("host-read"));
92
+ const mediaAccess = { localRoots: ["/tmp"], workspaceDir: "/workspace" };
91
93
 
92
94
  const { openclawClawlingOutbound } = await import("./outbound.ts");
93
95
  const result = await openclawClawlingOutbound.sendMedia!({
@@ -105,7 +107,9 @@ describe("openclaw-clawchat channel outbound", () => {
105
107
  to: "cc:group:room-1",
106
108
  text: "caption",
107
109
  mediaUrl: "/tmp/photo.png",
110
+ mediaAccess,
108
111
  mediaLocalRoots: ["/tmp"],
112
+ mediaReadFile,
109
113
  });
110
114
 
111
115
  expect(createApiClientMock).toHaveBeenCalledWith({
@@ -116,7 +120,9 @@ describe("openclaw-clawchat channel outbound", () => {
116
120
  expect(uploadOutboundMediaMock).toHaveBeenCalledWith(["/tmp/photo.png"], {
117
121
  apiClient,
118
122
  runtime,
123
+ mediaAccess,
119
124
  mediaLocalRoots: ["/tmp"],
125
+ mediaReadFile,
120
126
  });
121
127
  expect(client.sendMessage).toHaveBeenCalledWith(
122
128
  expect.objectContaining({
@@ -123,10 +123,32 @@ describe("openclaw-clawchat plugin", () => {
123
123
  expect(normalized).toBe("usr_01KPN6SQFQEGM9HR11CHRHPMMT");
124
124
  });
125
125
 
126
+ it("declares ClawChat target prefixes for OpenClaw channel selection", () => {
127
+ expect(openclawClawlingPlugin.messaging?.targetPrefixes).toEqual([
128
+ "cc",
129
+ "clawchat",
130
+ "openclaw-clawchat",
131
+ ]);
132
+ });
133
+
126
134
  it("parses openclaw-clawchat target prefix as a direct recipient", () => {
127
135
  expect(parseOpenclawRecipient("openclaw-clawchat:usr_01KPN6SQFQEGM9HR11CHRHPMMT")).toEqual({
128
136
  chatId: "usr_01KPN6SQFQEGM9HR11CHRHPMMT",
129
137
  chatType: "direct",
130
138
  });
131
139
  });
140
+
141
+ it("parses host-normalized group targets as group recipients", () => {
142
+ expect(parseOpenclawRecipient("group:cnv_01KR2NBGTKEQ0S0CAYCEQP3YPW")).toEqual({
143
+ chatId: "cnv_01KR2NBGTKEQ0S0CAYCEQP3YPW",
144
+ chatType: "group",
145
+ });
146
+ });
147
+
148
+ it("parses host-normalized direct targets as direct recipients", () => {
149
+ expect(parseOpenclawRecipient("direct:cnv_01KR2NBGTKEQ0S0CAYCEQP3YPW")).toEqual({
150
+ chatId: "cnv_01KR2NBGTKEQ0S0CAYCEQP3YPW",
151
+ chatType: "direct",
152
+ });
153
+ });
132
154
  });
package/src/channel.ts CHANGED
@@ -67,6 +67,7 @@ export const openclawClawlingPlugin: ChannelPlugin<ResolvedOpenclawClawlingAccou
67
67
  ],
68
68
  },
69
69
  messaging: {
70
+ targetPrefixes: ["cc", "clawchat", CHANNEL_ID],
70
71
  normalizeTarget: (target) =>
71
72
  target
72
73
  .trim()
@@ -1,12 +1,13 @@
1
- import { MockTransport } from "@newbase-clawchat/sdk";
1
+ import { MockTransport, type ClawlingChatClient } from "@newbase-clawchat/sdk";
2
2
  import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup";
3
- import { describe, expect, it } from "vitest";
3
+ import { describe, expect, it, vi } from "vitest";
4
4
  import {
5
5
  createOpenclawClawlingClient,
6
6
  emitStreamCreated,
7
7
  emitStreamAdd,
8
8
  emitStreamDone,
9
9
  emitStreamFailed,
10
+ emitFinalStreamReply,
10
11
  } from "./client.ts";
11
12
  import type { ResolvedOpenclawClawlingAccount } from "./config.ts";
12
13
 
@@ -100,6 +101,66 @@ describe("openclaw-clawchat client", () => {
100
101
  client.close();
101
102
  });
102
103
 
104
+ it("uses SDK emitRaw with chat_id routing for stream lifecycle frames", () => {
105
+ const transportSend = vi.fn();
106
+ const emitRaw = vi.fn();
107
+ const client = {
108
+ opts: {
109
+ transport: { send: transportSend },
110
+ traceIdFactory: () => "trace-raw",
111
+ },
112
+ emitRaw,
113
+ } as unknown as ClawlingChatClient;
114
+
115
+ emitStreamCreated(client, {
116
+ messageId: "msg-1",
117
+ routing: { chatId: "chat-1", chatType: "group" },
118
+ });
119
+
120
+ expect(emitRaw).toHaveBeenCalledWith(
121
+ "message.created",
122
+ { message_id: "msg-1" },
123
+ { chat_id: "chat-1" },
124
+ );
125
+ expect(transportSend).not.toHaveBeenCalled();
126
+ });
127
+
128
+ it("falls back to SDK emitRaw with chat_id routing when raw transport is unavailable", () => {
129
+ const emitRaw = vi.fn();
130
+ const client = { emitRaw } as unknown as ClawlingChatClient;
131
+
132
+ emitStreamCreated(client, {
133
+ messageId: "msg-1",
134
+ routing: { chatId: "chat-1", chatType: "direct" },
135
+ });
136
+
137
+ expect(emitRaw).toHaveBeenCalledWith(
138
+ "message.created",
139
+ { message_id: "msg-1" },
140
+ { chat_id: "chat-1" },
141
+ );
142
+ });
143
+
144
+ it("requires raw transport for final stream replies that carry a client message_id", () => {
145
+ const emitRaw = vi.fn();
146
+ const client = { emitRaw } as unknown as ClawlingChatClient;
147
+
148
+ expect(() =>
149
+ emitFinalStreamReply(client, {
150
+ messageId: "agent-stream-1",
151
+ routing: { chatId: "chat-1", chatType: "direct" },
152
+ replyTo: {
153
+ msgId: "user-msg-1",
154
+ previewId: "user-1",
155
+ nickName: "User 1",
156
+ fragments: [{ kind: "text", text: "hello" }],
157
+ },
158
+ body: { fragments: [{ kind: "text", text: "reply" }] },
159
+ }),
160
+ ).toThrow(/raw transport/);
161
+ expect(emitRaw).not.toHaveBeenCalled();
162
+ });
163
+
103
164
  it("emitStreamAdd emits message.add with fragments: [{ text: full, delta: new }]", async () => {
104
165
  const transport = new MockTransport();
105
166
  const client = createOpenclawClawlingClient(baseAccount(), { transport });
package/src/client.ts CHANGED
@@ -84,18 +84,26 @@ function emitEnvelope(
84
84
  event: string,
85
85
  payload: object,
86
86
  routing: EnvelopeRouting,
87
+ options: { forceRawTransport?: boolean } = {},
87
88
  ): void {
88
89
  const inner = client as unknown as {
89
90
  opts?: {
90
91
  transport: { send: (data: string) => void };
91
92
  traceIdFactory: () => string;
92
93
  };
93
- emitRaw?: (event: string, payload: object, routing?: { to?: { id: string; type: ChatType } }) => void;
94
+ emitRaw?: (event: string, payload: object, routing?: { chat_id?: string; chat_type?: ChatType }) => void;
94
95
  };
95
- if (!inner.opts?.transport) {
96
- inner.emitRaw?.(event, payload, { to: { id: routing.chatId, type: routing.chatType } });
96
+ if (!options.forceRawTransport && inner.emitRaw) {
97
+ inner.emitRaw(event, payload, { chat_id: routing.chatId });
97
98
  return;
98
99
  }
100
+ if (!inner.opts?.transport) {
101
+ if (!options.forceRawTransport && inner.emitRaw) {
102
+ inner.emitRaw(event, payload, { chat_id: routing.chatId });
103
+ return;
104
+ }
105
+ throw new Error("openclaw-clawchat streaming emit requires SDK raw transport");
106
+ }
99
107
  const env = {
100
108
  version: "2" as const,
101
109
  event,
@@ -265,6 +273,7 @@ export function emitFinalStreamReply(
265
273
  },
266
274
  },
267
275
  routing,
276
+ { forceRawTransport: true },
268
277
  );
269
278
  }
270
279
 
@@ -248,51 +248,41 @@ describe("openclaw-clawchat manifest", () => {
248
248
  expect(docs).toMatch(/Natural-language activation requests should call `clawchat_activate`/i);
249
249
  });
250
250
 
251
- it("documents gateway restart as the required next step after plugin install or update", () => {
251
+ it("documents the numbered install restart step", () => {
252
252
  const install = fs.readFileSync(new URL("../INSTALL.md", import.meta.url), "utf8");
253
- const restart = install.indexOf("## Restart the Gateway");
254
- const activate = install.indexOf("## Activate");
253
+ const restart = install.indexOf("## 3. Restart");
254
+ const activate = install.indexOf("## 4. Activate");
255
255
  const installSection = install.slice(restart, activate);
256
256
 
257
- expect(installSection).toMatch(/perform a\s+real OpenClaw Gateway restart/i);
258
- expect(installSection).toMatch(/Do not rely on hot reload or channel reload/i);
259
257
  expect(installSection).toMatch(/openclaw gateway restart/);
260
- expect(installSection).toMatch(/inside an OpenClaw chat\/agent turn/i);
261
- expect(installSection).toMatch(/send a new message to continue/i);
258
+ expect(installSection).toMatch(/First restart completed/);
262
259
  expect(installSection).not.toMatch(/kill -TERM 1/);
263
260
  expect(installSection).not.toMatch(/docker restart <container>/);
264
- expect(installSection).not.toMatch(/runtime imports the plugin/i);
265
- expect(installSection).not.toMatch(/If the Gateway is already running/i);
266
261
  });
267
262
 
268
- it("documents activation after restart through runtime tools before channels-add CLI", () => {
263
+ it("documents numbered CLI activation, second restart, and verification", () => {
269
264
  const install = fs.readFileSync(new URL("../INSTALL.md", import.meta.url), "utf8");
270
- const activate = install.indexOf("## Activate");
271
- const restartAgain = install.indexOf("## Restart Again");
272
- const verify = install.indexOf("## Verify");
265
+ const activate = install.indexOf("## 4. Activate");
266
+ const restartAgain = install.indexOf("## 5. Restart Again");
267
+ const verify = install.indexOf("## 6. Verify");
273
268
  const activateSection = install.slice(activate, restartAgain);
274
269
 
275
- expect(activateSection).toMatch(/After the Gateway has restarted and is reachable/i);
276
- expect(activateSection).toMatch(/clawchat_activate/);
277
- expect(activateSection).toMatch(/\/clawchat-login XXXXXX/);
278
270
  expect(activateSection).toMatch(/openclaw channels add --channel openclaw-clawchat --token "\$CLAWCHAT_INVITE_CODE"/);
279
- expect(activateSection).toMatch(/Do not use `openclaw channels login --channel openclaw-clawchat` for first-time\s+activation/i);
280
- expect(activateSection).not.toMatch(/OpenClaw 2026\.5\.5/i);
281
- expect(activateSection).not.toMatch(/Unknown channel: openclaw-clawchat/i);
271
+ expect(activateSection).toMatch(/Activation completed/);
282
272
  expect(activateSection).not.toMatch(/openclaw channels status --probe/i);
283
- expect(activateSection).not.toMatch(/verify the channel/i);
284
273
 
285
274
  const restartAgainSection = install.slice(
286
275
  restartAgain,
287
- install.indexOf("## Verify"),
276
+ verify,
288
277
  );
289
- expect(restartAgainSection).toMatch(/After activation succeeds/i);
290
- expect(restartAgainSection).toMatch(/one more real Gateway restart/i);
291
278
  expect(restartAgainSection).toMatch(/openclaw gateway restart/i);
279
+ expect(restartAgainSection).toMatch(/Second restart completed/);
292
280
 
293
281
  const verifySection = install.slice(verify);
282
+ expect(verifySection).toMatch(/sleep 5/);
294
283
  expect(verifySection).toMatch(/openclaw channels status --probe/i);
284
+ expect(verifySection).toMatch(/Verification completed/);
295
285
  expect(verifySection).toMatch(/enabled, configured, running, and\s+connected/i);
296
- expect(verifySection).toMatch(/startAccount invoked/i);
286
+ expect(verifySection).toMatch(/installation flow is complete/i);
297
287
  });
298
288
  });
@@ -130,6 +130,32 @@ describe("uploadOutboundMedia", () => {
130
130
  ]);
131
131
  });
132
132
 
133
+ it("passes host media access options to loadWebMedia", async () => {
134
+ const { runtime, loadWebMedia } = buildRuntime();
135
+ const apiClient = buildApiClient();
136
+ const readFile = vi.fn(async () => Buffer.from("host-read"));
137
+
138
+ await uploadOutboundMedia(["relative/image.png"], {
139
+ apiClient,
140
+ runtime,
141
+ mediaAccess: {
142
+ localRoots: ["/workspace"],
143
+ readFile,
144
+ workspaceDir: "/workspace",
145
+ },
146
+ });
147
+
148
+ expect(loadWebMedia).toHaveBeenCalledWith(
149
+ "relative/image.png",
150
+ expect.objectContaining({
151
+ localRoots: ["/workspace"],
152
+ readFile,
153
+ hostReadCapability: true,
154
+ workspaceDir: "/workspace",
155
+ }),
156
+ );
157
+ });
158
+
133
159
  it("drops a single failed upload, returns the rest", async () => {
134
160
  const { runtime } = buildRuntime();
135
161
  const apiClient = buildApiClient();
@@ -1,4 +1,9 @@
1
1
  import type { PluginRuntime } from "openclaw/plugin-sdk/core";
2
+ import {
3
+ buildOutboundMediaLoadOptions,
4
+ type OutboundMediaAccess,
5
+ type OutboundMediaReadFile,
6
+ } from "openclaw/plugin-sdk/media-runtime";
2
7
  import type { OpenclawClawlingApiClient } from "./api-client.ts";
3
8
 
4
9
  /**
@@ -42,8 +47,12 @@ export interface UploadOutboundCtx {
42
47
  runtime: PluginRuntime;
43
48
  log?: LogSink;
44
49
  maxBytes?: number;
50
+ /** Host-authorized outbound media access from OpenClaw message delivery. */
51
+ mediaAccess?: OutboundMediaAccess;
45
52
  /** Allowed local roots for path-based uploads. Empty/undefined = use loadWebMedia defaults. */
46
- mediaLocalRoots?: readonly string[];
53
+ mediaLocalRoots?: readonly string[] | "any";
54
+ /** Host-provided read bridge for sandbox/allowed local media paths. */
55
+ mediaReadFile?: OutboundMediaReadFile;
47
56
  }
48
57
 
49
58
  export function inferMediaKindFromMime(mime: string | undefined): MediaItem["kind"] {
@@ -114,12 +123,15 @@ export async function uploadOutboundMedia(
114
123
  const out: ClawlingMediaFragment[] = [];
115
124
  for (const url of urls) {
116
125
  try {
117
- const loaded = await ctx.runtime.media.loadWebMedia(url, {
118
- maxBytes,
119
- ...(ctx.mediaLocalRoots && ctx.mediaLocalRoots.length > 0
120
- ? { localRoots: ctx.mediaLocalRoots }
121
- : {}),
122
- });
126
+ const loaded = await ctx.runtime.media.loadWebMedia(
127
+ url,
128
+ buildOutboundMediaLoadOptions({
129
+ maxBytes,
130
+ ...(ctx.mediaAccess ? { mediaAccess: ctx.mediaAccess } : {}),
131
+ ...(ctx.mediaLocalRoots ? { mediaLocalRoots: ctx.mediaLocalRoots } : {}),
132
+ ...(ctx.mediaReadFile ? { mediaReadFile: ctx.mediaReadFile } : {}),
133
+ }),
134
+ );
123
135
  const uploaded = await ctx.apiClient.uploadMedia({
124
136
  buffer: loaded.buffer,
125
137
  filename: loaded.fileName ?? "upload.bin",
@@ -92,7 +92,7 @@ describe("openclaw-clawchat outbound", () => {
92
92
  chat_id: "chat-1",
93
93
  replyTo: {
94
94
  msgId: "m-orig",
95
- senderId: "chat-1",
95
+ senderId: "user-2",
96
96
  nickName: "Sender",
97
97
  fragments: [{ kind: "text", text: "original" }],
98
98
  },
package/src/outbound.ts CHANGED
@@ -64,6 +64,8 @@ export interface SendResult {
64
64
  * - `clawchat:group:{chat_id}` → group
65
65
  * - `openclaw-clawchat:direct:{chat_id}` → direct
66
66
  * - `openclaw-clawchat:group:{chat_id}` → group
67
+ * - `direct:{chat_id}` → direct (host-normalized)
68
+ * - `group:{chat_id}` → group (host-normalized)
67
69
  * - bare `{chat_id}` → direct (backward compat)
68
70
  */
69
71
  export function parseOpenclawRecipient(to: string): { chatId: string; chatType: ChatType } {
@@ -75,6 +77,11 @@ export function parseOpenclawRecipient(to: string): { chatId: string; chatType:
75
77
 
76
78
  const scheme = raw.slice(0, firstColon).toLowerCase();
77
79
  const rest = raw.slice(firstColon + 1);
80
+ if (scheme === "direct" || scheme === "group") {
81
+ const chatId = rest.trim();
82
+ if (!chatId) throw new Error(`openclaw-clawchat: missing chat_id in "${to}"`);
83
+ return { chatId, chatType: scheme };
84
+ }
78
85
  if (scheme !== "cc" && scheme !== "clawchat" && scheme !== CHANNEL_ID) {
79
86
  return { chatId: raw, chatType: "direct" };
80
87
  }
@@ -127,7 +134,7 @@ export async function sendOpenclawClawlingText(params: SendParams): Promise<Send
127
134
  mode: "normal",
128
135
  replyTo: {
129
136
  msgId: params.replyCtx.replyToMessageId,
130
- senderId: params.replyCtx.replyPreviewChatId ?? params.replyCtx.replyPreviewSenderId,
137
+ senderId: params.replyCtx.replyPreviewSenderId,
131
138
  nickName: params.replyCtx.replyPreviewNickName,
132
139
  fragments: [{ kind: "text", text: params.replyCtx.replyPreviewText }],
133
140
  },
@@ -217,7 +224,7 @@ export const openclawClawlingOutbound: ChannelOutboundAdapter = {
217
224
  messageId: result?.messageId ?? `${account.userId}-${Date.now()}`,
218
225
  };
219
226
  },
220
- sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots }) => {
227
+ sendMedia: async ({ cfg, to, text, mediaUrl, mediaAccess, mediaLocalRoots, mediaReadFile }) => {
221
228
  const account = resolveOpenclawClawlingAccount(cfg);
222
229
  const client =
223
230
  getOpenclawClawlingClient(account.accountId) ??
@@ -234,7 +241,9 @@ export const openclawClawlingOutbound: ChannelOutboundAdapter = {
234
241
  const mediaFragments = await uploadOutboundMedia([mediaUrl.trim()], {
235
242
  apiClient,
236
243
  runtime,
244
+ ...(mediaAccess ? { mediaAccess } : {}),
237
245
  ...(mediaLocalRoots ? { mediaLocalRoots } : {}),
246
+ ...(mediaReadFile ? { mediaReadFile } : {}),
238
247
  });
239
248
  if (mediaFragments.length === 0) {
240
249
  throw new Error(`openclaw-clawchat failed to upload media: ${mediaUrl}`);
@@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest";
3
3
  import { createOpenclawClawlingReplyDispatcher } from "./reply-dispatcher.ts";
4
4
 
5
5
  describe("openclaw-clawchat reply-dispatcher", () => {
6
- it("uses chat_id, not sender_id, as the consolidated streaming reply marker", async () => {
6
+ it("uses the inbound sender id in consolidated streaming reply previews", async () => {
7
7
  let hooks:
8
8
  | {
9
9
  deliver?: (payload: { text?: string }, info?: { kind: "tool" | "block" | "final" }) => Promise<void>;
@@ -70,7 +70,7 @@ describe("openclaw-clawchat reply-dispatcher", () => {
70
70
  context: {
71
71
  reply: {
72
72
  reply_preview: {
73
- id: "chat-1",
73
+ id: "user-1",
74
74
  nick_name: "User 1",
75
75
  },
76
76
  },
@@ -489,6 +489,104 @@ describe("openclaw-clawchat reply-dispatcher", () => {
489
489
  );
490
490
  });
491
491
 
492
+ it("prefers mediaUrls over legacy mediaUrl so one image is not sent twice", async () => {
493
+ let hooks:
494
+ | {
495
+ deliver?: (payload: {
496
+ text?: string;
497
+ mediaUrl?: string;
498
+ mediaUrls?: string[];
499
+ }, info?: { kind: "tool" | "block" | "final" }) => Promise<void>;
500
+ }
501
+ | undefined;
502
+ const loadWebMedia = vi.fn(async (url: string) => ({
503
+ buffer: Buffer.from(`bytes:${url}`),
504
+ contentType: "image/png",
505
+ fileName: "image.png",
506
+ }));
507
+ const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
508
+ new Response(
509
+ JSON.stringify({
510
+ code: 0,
511
+ msg: "ok",
512
+ data: { url: "https://cdn/uploaded.png", size: 12, mime: "image/png" },
513
+ }),
514
+ { status: 200, headers: { "content-type": "application/json" } },
515
+ ),
516
+ );
517
+ const client = {
518
+ sendMessage: vi.fn().mockResolvedValue({
519
+ payload: { message_id: "server-m1", accepted_at: 1234 },
520
+ trace_id: "trace-media",
521
+ }),
522
+ replyMessage: vi.fn(),
523
+ typing: vi.fn(),
524
+ } as unknown as ClawlingChatClient;
525
+
526
+ try {
527
+ createOpenclawClawlingReplyDispatcher({
528
+ cfg: {} as never,
529
+ runtime: {
530
+ media: { loadWebMedia },
531
+ channel: {
532
+ reply: {
533
+ resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
534
+ createReplyDispatcherWithTyping: vi.fn((options) => {
535
+ hooks = options;
536
+ return { dispatcher: {}, replyOptions: {}, markDispatchIdle: vi.fn() };
537
+ }),
538
+ },
539
+ },
540
+ } as never,
541
+ account: {
542
+ accountId: "default",
543
+ baseUrl: "https://api.example.com",
544
+ token: "tk",
545
+ userId: "agent-1",
546
+ replyMode: "static",
547
+ forwardThinking: true,
548
+ forwardToolCalls: false,
549
+ stream: { flushIntervalMs: 250, minChunkChars: 40, maxBufferChars: 2000 },
550
+ } as never,
551
+ client,
552
+ target: { chatId: "chat-1", chatType: "direct" },
553
+ log: { info: vi.fn(), error: vi.fn() },
554
+ });
555
+
556
+ await hooks?.deliver?.(
557
+ {
558
+ text: "look",
559
+ mediaUrl: "https://cdn/legacy.png",
560
+ mediaUrls: ["https://cdn/image.png"],
561
+ },
562
+ { kind: "final" },
563
+ );
564
+
565
+ expect(loadWebMedia).toHaveBeenCalledTimes(1);
566
+ expect(loadWebMedia).toHaveBeenCalledWith("https://cdn/image.png", expect.any(Object));
567
+ expect(fetchMock).toHaveBeenCalledTimes(1);
568
+ expect(client.sendMessage).toHaveBeenCalledWith(
569
+ expect.objectContaining({
570
+ chat_id: "chat-1",
571
+ body: {
572
+ fragments: [
573
+ { kind: "text", text: "look" },
574
+ {
575
+ kind: "image",
576
+ url: "https://cdn/uploaded.png",
577
+ mime: "image/png",
578
+ size: 12,
579
+ name: "image.png",
580
+ },
581
+ ],
582
+ },
583
+ }),
584
+ );
585
+ } finally {
586
+ fetchMock.mockRestore();
587
+ }
588
+ });
589
+
492
590
  it("includes rich interaction fragments in the consolidated streaming final reply", async () => {
493
591
  let hooks:
494
592
  | {
@@ -8,6 +8,7 @@ import {
8
8
  } from "openclaw/plugin-sdk/interactive-runtime";
9
9
  import type { PluginRuntime } from "openclaw/plugin-sdk/core";
10
10
  import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
11
+ import { resolveOutboundMediaUrls } from "openclaw/plugin-sdk/reply-payload";
11
12
  import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
12
13
  import { createOpenclawClawlingApiClient } from "./api-client.ts";
13
14
  import {
@@ -363,7 +364,7 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
363
364
  routing,
364
365
  replyTo: {
365
366
  msgId: inboundMessageId ?? streamingMessageId,
366
- previewId: inboundForFinalReply?.chatId ?? target.chatId,
367
+ previewId: inboundForFinalReply?.senderId ?? target.chatId,
367
368
  nickName:
368
369
  inboundForFinalReply?.senderNickName ?? inboundForFinalReply?.senderId ?? target.chatId,
369
370
  fragments: inboundForFinalReply?.bodyText
@@ -379,10 +380,7 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
379
380
  finalRichFragments.push(richFragment);
380
381
  }
381
382
  if (text) streamText = mergeStreamingText(streamText, text);
382
- const urls = [
383
- ...(payload.mediaUrl ? [payload.mediaUrl] : []),
384
- ...(payload.mediaUrls ?? []),
385
- ].filter((u): u is string => Boolean(u));
383
+ const urls = resolveOutboundMediaUrls(payload).filter(Boolean);
386
384
  for (const url of urls) {
387
385
  if (!accumulatedMediaUrls.includes(url)) accumulatedMediaUrls.push(url);
388
386
  }
@@ -422,10 +420,7 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
422
420
  deliver: async (payload: ReplyPayload, info?: { kind: "tool" | "block" | "final" }) => {
423
421
  const richFragment = buildRichInteractionFragment(payload);
424
422
  const text = richFragment && account.richInteractions ? "" : resolvePayloadText(payload);
425
- const urls = [
426
- ...(payload.mediaUrl ? [payload.mediaUrl] : []),
427
- ...(payload.mediaUrls ?? []),
428
- ].filter((u): u is string => Boolean(u));
423
+ const urls = resolveOutboundMediaUrls(payload).filter(Boolean);
429
424
  log?.info?.(
430
425
  `[${account.accountId}] openclaw-clawchat deliver kind=${info?.kind ?? "unknown"} text_len=${text.length} media_urls=${urls.length} reasoning=${payload.isReasoning === true}`,
431
426
  );
@@ -205,6 +205,7 @@ describe("openclaw-clawchat runtime media ingest", () => {
205
205
  expect(capturedCtx?.MediaPath).toBe("/cache/1.png");
206
206
  expect(capturedCtx?.MediaPaths).toEqual(["/cache/1.png"]);
207
207
  expect(capturedCtx?.From).toBe("openclaw-clawchat:chat-1");
208
+ expect(capturedCtx?.OriginatingTo).toBe("openclaw-clawchat:chat-1");
208
209
  expect(capturedCtx?.ConversationLabel).toBe("chat-1");
209
210
  expect(capturedCtx?.SenderId).toBe("user-1");
210
211
  expect(resolveAgentRoute).toHaveBeenCalledWith(
@@ -359,6 +360,7 @@ describe("openclaw-clawchat runtime media ingest", () => {
359
360
  await startPromise;
360
361
 
361
362
  expect(capturedCtx?.From).toBe("openclaw-clawchat:group:grp-1");
363
+ expect(capturedCtx?.OriginatingTo).toBe("openclaw-clawchat:group:grp-1");
362
364
  expect(capturedCtx?.ConversationLabel).toBe("group:grp-1");
363
365
  expect(capturedCtx?.SenderId).toBe("user-1");
364
366
  expect(capturedCtx?.ChatType).toBe("group");
package/src/runtime.ts CHANGED
@@ -200,16 +200,16 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
200
200
  timestamp: turn.timestamp,
201
201
  ...rt.reply.resolveEnvelopeFormatOptions(cfg),
202
202
  });
203
+ const conversationTarget = `${CHANNEL_ID}:${formatConversationSubject(turn.peer)}`;
203
204
  const ctxPayload = rt.reply.finalizeInboundContext({
204
205
  Body: body,
205
206
  BodyForAgent: turn.rawBody,
206
207
  RawBody: turn.rawBody,
207
208
  CommandBody: turn.rawBody,
208
- // Clawling v2 routes by chat_id. `senderId` is still preserved as
209
- // structured metadata, but the conversation target must be based on
210
- // `peer.id` so follow-up sends address the active chat, not merely
211
- // the human sender identity.
212
- From: `${CHANNEL_ID}:${formatConversationSubject(turn.peer)}`,
209
+ // Clawling v2 routes by chat_id. `OriginatingTo` is what the
210
+ // message tool uses as the implicit current-chat target, so keep it
211
+ // on the conversation id rather than the agent account user id.
212
+ From: conversationTarget,
213
213
  To: `${CHANNEL_ID}:${account.userId}`,
214
214
  SessionKey: route.sessionKey,
215
215
  AccountId: route.accountId ?? accountId,
@@ -222,7 +222,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
222
222
  MessageSidFull: turn.messageId,
223
223
  Timestamp: turn.timestamp,
224
224
  OriginatingChannel: CHANNEL_ID,
225
- OriginatingTo: `${CHANNEL_ID}:${account.userId}`,
225
+ OriginatingTo: conversationTarget,
226
226
  });
227
227
  // Fetch any inbound media attachments and populate MediaPath/MediaPaths in context.
228
228
  const inboundPaths =
@@ -0,0 +1,42 @@
1
+ import fs from "node:fs";
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ const root = new URL("../", import.meta.url);
5
+
6
+ function readRootFile(name: string): string {
7
+ return fs.readFileSync(new URL(name, root), "utf8");
8
+ }
9
+
10
+ describe("OpenClaw packaging scripts", () => {
11
+ it("packages the npm tarball and uploads it to the R2 openclaw prefix", () => {
12
+ const script = readRootFile("package_openclaw_plugin.sh");
13
+
14
+ expect(script).toMatch(/npm pack --silent/);
15
+ expect(script).toContain('R2_ENV_FILE="${R2_ENV_FILE:-.env.r2}"');
16
+ expect(script).toContain('set -a; source "$R2_ENV_FILE"; set +a');
17
+ expect(script).toContain(': "${AWS_ACCESS_KEY_ID:?missing in $R2_ENV_FILE}"');
18
+ expect(script).toContain(': "${AWS_SECRET_ACCESS_KEY:?missing in $R2_ENV_FILE}"');
19
+ expect(script).toContain(': "${R2_ENDPOINT:?missing in $R2_ENV_FILE}"');
20
+ expect(script).toContain(': "${R2_BUCKET:?missing in $R2_ENV_FILE}"');
21
+ expect(script).not.toContain("f8eeaffb6f81ffd82b59c24d4ff797c9");
22
+ expect(script).not.toContain("587f8d4aa485a70f6e984a9efa7e609a86e4ef02d56a6682d9d7a9509913b8cb");
23
+ expect(script).toContain("OBJECT_KEY=\"openclaw/${TARGET_NAME}\"");
24
+ expect(script).toContain("https://dddddddddddddtest.clawling.chat/${OBJECT_KEY}");
25
+ expect(script).toMatch(/aws s3 cp[\s\S]*--content-type application\/gzip/);
26
+ });
27
+
28
+ it("keeps the local R2 env file out of git", () => {
29
+ const gitignore = readRootFile(".gitignore");
30
+
31
+ expect(gitignore).toMatch(/^\.env\.r2$/m);
32
+ });
33
+
34
+ it("downloads the R2 tarball and installs it through openclaw", () => {
35
+ const script = readRootFile("install_openclaw.sh");
36
+
37
+ expect(script).toContain("PUBLIC_BASE_URL=\"https://dddddddddddddtest.clawling.chat\"");
38
+ expect(script).toContain("CLAWCHAT_PLUGIN_URL=\"${1:-${PUBLIC_BASE_URL}/openclaw/${DEFAULT_TGZ}}\"");
39
+ expect(script).toContain("curl -fL \"$CLAWCHAT_PLUGIN_URL\" -o \"$CLAWCHAT_PLUGIN_TGZ\"");
40
+ expect(script).toContain("openclaw plugins install \"$CLAWCHAT_PLUGIN_TGZ\" --force");
41
+ });
42
+ });