@openbrt/weclawbotctl 0.1.14 → 0.1.16
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 +20 -2
- package/bin/weclawbotctl.mjs +72 -3
- package/index.mjs +284 -2
- package/lib/openclaw-preview.mjs +101 -0
- package/lib/screen-preview.mjs +166 -0
- package/openclaw.plugin.json +10 -1
- package/package.json +3 -2
- package/skills/weclawbot-curator/SKILL.md +16 -7
- package/test/openclaw-preview-hook.test.mjs +41 -0
- package/test/screen-preview.test.mjs +46 -0
- package/workspace/AGENTS.md +8 -0
package/README.md
CHANGED
|
@@ -57,12 +57,19 @@ rejects stale or unrelated `idle` messages and keeps the visible thinking state.
|
|
|
57
57
|
The bundled OpenClaw bridge publishes `thinking` before every curator job and
|
|
58
58
|
`idle` after it finishes, so WeChat-origin official-mode work also gets a visible
|
|
59
59
|
processing state without relying on the model to remember it.
|
|
60
|
+
In OpenClaw itself, the plugin also registers hooks for direct Telegram/UI agent
|
|
61
|
+
turns that mention WeClawBot or the physical screen. Those hooks show the
|
|
62
|
+
thinking pet while the turn runs and clear it before the final answer. The
|
|
63
|
+
installer enables `plugins.entries.weclawbot.hooks.allowConversationAccess`
|
|
64
|
+
because OpenClaw blocks conversation hooks for third-party plugins unless the
|
|
65
|
+
user explicitly grants that permission.
|
|
60
66
|
|
|
61
67
|
To put text, status, diagrams, or images on the screen, render them into a
|
|
62
68
|
pre-rendered `weclawbot.screen_document.v1` first. The firmware receives pixels;
|
|
63
69
|
it does not lay out text, choose fonts, or split pages for agents.
|
|
64
70
|
|
|
65
71
|
```bash
|
|
72
|
+
weclawbotctl preview /path/to/screen-document.json
|
|
66
73
|
weclawbotctl screen /path/to/screen-document.json
|
|
67
74
|
```
|
|
68
75
|
|
|
@@ -70,6 +77,14 @@ weclawbotctl screen /path/to/screen-document.json
|
|
|
70
77
|
only after the firmware reports `applied`; a firmware `rejected` status or a
|
|
71
78
|
timeout is a failed delivery. Use `--no-wait` only for diagnostics where MQTT
|
|
72
79
|
publish acknowledgement is enough.
|
|
80
|
+
`preview` renders the exact mono1 pages into PNG files. `screen` also emits a
|
|
81
|
+
`preview.pages[].path` list by default. Agents should inspect those images when
|
|
82
|
+
possible and send them through their normal chat/media channel so the user sees
|
|
83
|
+
the actual effect, not just "published".
|
|
84
|
+
When the OpenClaw tool `weclawbot_publish_screen_document` is used, its return
|
|
85
|
+
value contains the same `preview.pages[].path` list. The agent owns delivering
|
|
86
|
+
that preview to the user; the plugin's automatic preview hook is only a
|
|
87
|
+
best-effort fallback.
|
|
73
88
|
|
|
74
89
|
To clear the current note, use the firmware clear command:
|
|
75
90
|
|
|
@@ -125,7 +140,7 @@ openclaw plugins enable weclawbot
|
|
|
125
140
|
|
|
126
141
|
Restart the OpenClaw gateway or app after installation so it reloads plugin
|
|
127
142
|
tools. The doctor checks the OpenClaw version, plugin installation, plugin
|
|
128
|
-
diagnostics, and local gateway reachability. If a local WSS gateway uses a
|
|
143
|
+
diagnostics, hook permission, and local gateway reachability. If a local WSS gateway uses a
|
|
129
144
|
self-signed certificate, the doctor will suggest `NODE_EXTRA_CA_CERTS`. If the
|
|
130
145
|
certificate lacks `localhost` or `127.0.0.1` SANs, fix the gateway certificate
|
|
131
146
|
or use a certificate trusted by Node. The package does not rewrite other
|
|
@@ -199,6 +214,7 @@ pre-rendered screen document immediately:
|
|
|
199
214
|
|
|
200
215
|
```bash
|
|
201
216
|
weclawbotctl doctor --online
|
|
217
|
+
weclawbotctl preview /path/to/screen-document.json
|
|
202
218
|
weclawbotctl screen /path/to/screen-document.json
|
|
203
219
|
```
|
|
204
220
|
|
|
@@ -280,9 +296,11 @@ screen has exactly one page.
|
|
|
280
296
|
Before publishing, agents should inspect or otherwise self-evaluate the rendered
|
|
281
297
|
pages against the user's preferences and their own learned standards when their
|
|
282
298
|
runtime supports it. Regenerate the document if the bitmap does not satisfy those
|
|
283
|
-
standards.
|
|
299
|
+
standards. After a successful publish, send the preview PNGs to the user through
|
|
300
|
+
the current chat/media channel so the user can see the effect.
|
|
284
301
|
|
|
285
302
|
```bash
|
|
303
|
+
weclawbotctl preview /path/to/screen-document.json
|
|
286
304
|
weclawbotctl screen /path/to/screen-document.json
|
|
287
305
|
```
|
|
288
306
|
|
package/bin/weclawbotctl.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import process from "node:process";
|
|
|
10
10
|
import { validateActivity } from "../lib/activity.mjs";
|
|
11
11
|
import { validateScreenDocument } from "../lib/direct-control.mjs";
|
|
12
12
|
import { normalizeCredentials, publishControl, publishControlAndWaitStatus, testConnection } from "../lib/mqtt-control.mjs";
|
|
13
|
+
import { writeScreenDocumentPreviewFiles } from "../lib/screen-preview.mjs";
|
|
13
14
|
|
|
14
15
|
const DEFAULT_ENDPOINT = "https://weclawbot.link/byoa";
|
|
15
16
|
const DEFAULT_CREDENTIALS_PATH = path.join(os.homedir(), ".config", "weclawbot", "agent-mqtt.json");
|
|
@@ -17,7 +18,7 @@ const DEFAULT_OPENCLAW_PLUGIN_SPEC = "@openbrt/weclawbotctl";
|
|
|
17
18
|
const MIN_OPENCLAW_VERSION = "2026.6.9";
|
|
18
19
|
|
|
19
20
|
const [command, ...args] = process.argv.slice(2);
|
|
20
|
-
const commands = new Set(["bind", "status", "doctor", "export", "unbind", "thinking", "idle", "screen", "clear", "openclaw"]);
|
|
21
|
+
const commands = new Set(["bind", "status", "doctor", "export", "unbind", "thinking", "idle", "screen", "preview", "clear", "openclaw"]);
|
|
21
22
|
if (!commands.has(command)) {
|
|
22
23
|
usage();
|
|
23
24
|
process.exit(64);
|
|
@@ -30,6 +31,7 @@ try {
|
|
|
30
31
|
else if (command === "export") await commandExport(args);
|
|
31
32
|
else if (command === "unbind") await commandUnbind(args);
|
|
32
33
|
else if (command === "screen") await commandScreen(args);
|
|
34
|
+
else if (command === "preview") await commandPreview(args);
|
|
33
35
|
else if (command === "clear") await commandClear(args);
|
|
34
36
|
else if (command === "openclaw") await commandOpenClaw(args);
|
|
35
37
|
else await commandActivity(command, args);
|
|
@@ -208,10 +210,13 @@ async function commandScreen(values) {
|
|
|
208
210
|
force: false,
|
|
209
211
|
wait: true,
|
|
210
212
|
timeout: 12,
|
|
213
|
+
preview: true,
|
|
214
|
+
"preview-dir": "",
|
|
215
|
+
scale: 2,
|
|
211
216
|
});
|
|
212
217
|
const file = String(options._[0] || "").trim();
|
|
213
218
|
if (!file || options._.length !== 1) {
|
|
214
|
-
throw new Error("Usage: weclawbotctl screen <document.json> [--force] [--no-wait] [--timeout seconds]");
|
|
219
|
+
throw new Error("Usage: weclawbotctl screen <document.json> [--force] [--no-wait] [--timeout seconds] [--preview-dir dir]");
|
|
215
220
|
}
|
|
216
221
|
const document = JSON.parse(await fs.readFile(file, "utf8"));
|
|
217
222
|
if (options.force) {
|
|
@@ -224,6 +229,12 @@ async function commandScreen(values) {
|
|
|
224
229
|
if (!validation.ok) {
|
|
225
230
|
throw new Error(`Invalid screen document: ${validation.errors.join("; ")}`);
|
|
226
231
|
}
|
|
232
|
+
const preview = options.preview
|
|
233
|
+
? await writeScreenDocumentPreviewFiles(document, {
|
|
234
|
+
outputDir: options["preview-dir"] || "",
|
|
235
|
+
scale: Number(options.scale) || 2,
|
|
236
|
+
})
|
|
237
|
+
: { available: false, pages: [] };
|
|
227
238
|
const control = {
|
|
228
239
|
schema: "weclawbot.control.v1",
|
|
229
240
|
id: `screen_${crypto.randomUUID()}`,
|
|
@@ -248,6 +259,7 @@ async function commandScreen(values) {
|
|
|
248
259
|
force_replace: document.force_replace === true,
|
|
249
260
|
warnings: validation.warnings,
|
|
250
261
|
layout_guidance: validation.layout_guidance,
|
|
262
|
+
preview,
|
|
251
263
|
status: delivery.status,
|
|
252
264
|
}));
|
|
253
265
|
return;
|
|
@@ -262,6 +274,39 @@ async function commandScreen(values) {
|
|
|
262
274
|
force_replace: document.force_replace === true,
|
|
263
275
|
warnings: validation.warnings,
|
|
264
276
|
layout_guidance: validation.layout_guidance,
|
|
277
|
+
preview,
|
|
278
|
+
}));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function commandPreview(values) {
|
|
282
|
+
const options = parseOptions(values, {
|
|
283
|
+
output: "",
|
|
284
|
+
"output-dir": "",
|
|
285
|
+
scale: 2,
|
|
286
|
+
});
|
|
287
|
+
const file = String(options._[0] || "").trim();
|
|
288
|
+
if (!file || options._.length !== 1) {
|
|
289
|
+
throw new Error("Usage: weclawbotctl preview <document.json> [--output-dir dir] [--scale 1..4]");
|
|
290
|
+
}
|
|
291
|
+
const document = JSON.parse(await fs.readFile(file, "utf8"));
|
|
292
|
+
const validation = validateScreenDocument(document, {
|
|
293
|
+
agent_transport: { available: true, screen_document_available: true },
|
|
294
|
+
});
|
|
295
|
+
if (!validation.ok) {
|
|
296
|
+
throw new Error(`Invalid screen document: ${validation.errors.join("; ")}`);
|
|
297
|
+
}
|
|
298
|
+
const outputDir = options["output-dir"] || options.output || "";
|
|
299
|
+
const preview = await writeScreenDocumentPreviewFiles(document, {
|
|
300
|
+
outputDir,
|
|
301
|
+
scale: Number(options.scale) || 2,
|
|
302
|
+
});
|
|
303
|
+
console.log(JSON.stringify({
|
|
304
|
+
ok: true,
|
|
305
|
+
id: document.id,
|
|
306
|
+
pages: document.pages.length,
|
|
307
|
+
warnings: validation.warnings,
|
|
308
|
+
layout_guidance: validation.layout_guidance,
|
|
309
|
+
preview,
|
|
265
310
|
}));
|
|
266
311
|
}
|
|
267
312
|
|
|
@@ -361,6 +406,13 @@ async function commandOpenClawInstall(values) {
|
|
|
361
406
|
if (options.force) installArgs.push("--force");
|
|
362
407
|
await runRequired(openclaw, installArgs);
|
|
363
408
|
await runRequired(openclaw, ["plugins", "enable", "weclawbot"]);
|
|
409
|
+
await runRequired(openclaw, [
|
|
410
|
+
"config",
|
|
411
|
+
"set",
|
|
412
|
+
"plugins.entries.weclawbot.hooks.allowConversationAccess",
|
|
413
|
+
"true",
|
|
414
|
+
"--strict-json",
|
|
415
|
+
]);
|
|
364
416
|
console.log("OpenClaw plugin installed and enabled. Restart the OpenClaw gateway or app so it reloads plugins.");
|
|
365
417
|
if (options.doctor) {
|
|
366
418
|
await commandOpenClawDoctor(["--bin", openclaw, "--gateway=false"]);
|
|
@@ -402,6 +454,22 @@ async function commandOpenClawDoctor(values) {
|
|
|
402
454
|
hint: weclawbotIssue ? "Upgrade and reinstall: weclawbotctl openclaw install" : "",
|
|
403
455
|
});
|
|
404
456
|
|
|
457
|
+
const hooksAccess = await runCaptured(openclaw, [
|
|
458
|
+
"config",
|
|
459
|
+
"get",
|
|
460
|
+
"plugins.entries.weclawbot.hooks.allowConversationAccess",
|
|
461
|
+
"--json",
|
|
462
|
+
], { timeoutMs });
|
|
463
|
+
const hooksEnabled = hooksAccess.code === 0 && /^\s*true\s*$/iu.test(hooksAccess.stdout);
|
|
464
|
+
checks.push({
|
|
465
|
+
name: "openclaw_weclawbot_hooks",
|
|
466
|
+
ok: hooksEnabled,
|
|
467
|
+
detail: hooksEnabled
|
|
468
|
+
? "conversation hooks enabled for automatic thinking state"
|
|
469
|
+
: compactText(hooksAccess.stderr || hooksAccess.stdout || "hooks.allowConversationAccess is not enabled"),
|
|
470
|
+
hint: hooksEnabled ? "" : "Run: openclaw config set plugins.entries.weclawbot.hooks.allowConversationAccess true --strict-json",
|
|
471
|
+
});
|
|
472
|
+
|
|
405
473
|
if (options.gateway) {
|
|
406
474
|
const gatewayEnv = { ...process.env };
|
|
407
475
|
const defaultCert = path.join(os.homedir(), ".openclaw", "gateway", "tls", "gateway-cert.pem");
|
|
@@ -716,7 +784,8 @@ function usage() {
|
|
|
716
784
|
weclawbotctl unbind --yes
|
|
717
785
|
weclawbotctl thinking [--ttl seconds] [--id correlation-id]
|
|
718
786
|
weclawbotctl idle [--id correlation-id]
|
|
719
|
-
weclawbotctl
|
|
787
|
+
weclawbotctl preview <document.json> [--output-dir dir] [--scale 1..4]
|
|
788
|
+
weclawbotctl screen <document.json> [--force] [--no-wait] [--timeout seconds] [--preview-dir dir] [--no-preview]
|
|
720
789
|
weclawbotctl clear [--target note|idle_photo] [--no-wait] [--timeout seconds]
|
|
721
790
|
weclawbotctl openclaw install [--spec @openbrt/weclawbotctl] [--bin /path/to/openclaw] [--force=false]
|
|
722
791
|
weclawbotctl openclaw doctor [--gateway=false] [--bin /path/to/openclaw] [--json]`);
|
package/index.mjs
CHANGED
|
@@ -9,16 +9,24 @@ import { defineToolPlugin } from "openclaw/plugin-sdk/tool-plugin";
|
|
|
9
9
|
import { validateActivity } from "./lib/activity.mjs";
|
|
10
10
|
import { validateScreenDocument } from "./lib/direct-control.mjs";
|
|
11
11
|
import { normalizeCredentials, publishControl, publishControlAndWaitStatus, testConnection } from "./lib/mqtt-control.mjs";
|
|
12
|
+
import { collectCommandStrings, extractScreenDocumentPathFromExecParams, normalizeDeliveryContext } from "./lib/openclaw-preview.mjs";
|
|
13
|
+
import { renderScreenDocumentPreviewPages, writeScreenDocumentPreviewFiles } from "./lib/screen-preview.mjs";
|
|
12
14
|
|
|
13
15
|
const DEFAULT_CREDENTIALS_PATH = path.join(os.homedir(), ".config", "weclawbot", "agent-mqtt.json");
|
|
16
|
+
const SCREEN_PROMPT_PATTERN = /weclawbot|weclaw|微笺|桌宠|墨水屏|电子墨水|屏上|上屏|发到屏|推到屏|放到屏|显示到屏|发送到屏|清屏|清理屏幕|屏幕/iu;
|
|
17
|
+
const activeRunActivities = new Map();
|
|
14
18
|
|
|
15
19
|
// The long-running curator bridge remains a separate service. These tools keep
|
|
16
20
|
// the local agent path explicit: validate first, then publish only with the
|
|
17
21
|
// user's paired MQTT credential.
|
|
18
|
-
|
|
22
|
+
const pluginEntry = defineToolPlugin({
|
|
19
23
|
id: "weclawbot",
|
|
20
24
|
name: "WeClawBot",
|
|
21
25
|
description: "WeClawBot screen-curation skill pack and direct MQTT control tools.",
|
|
26
|
+
configSchema: Type.Object({
|
|
27
|
+
auto_activity: Type.Optional(Type.Boolean()),
|
|
28
|
+
auto_preview: Type.Optional(Type.Boolean()),
|
|
29
|
+
}, { additionalProperties: false }),
|
|
22
30
|
tools: (tool) => [
|
|
23
31
|
tool({
|
|
24
32
|
name: "weclawbot_status",
|
|
@@ -107,8 +115,9 @@ export default defineToolPlugin({
|
|
|
107
115
|
force_replace: Type.Optional(Type.Boolean()),
|
|
108
116
|
wait_status: Type.Optional(Type.Boolean()),
|
|
109
117
|
timeout_seconds: Type.Optional(Type.Number()),
|
|
118
|
+
preview_output_dir: Type.Optional(Type.String()),
|
|
110
119
|
}, { additionalProperties: false }),
|
|
111
|
-
execute: async ({ document, device_context, credentials_path, force_replace, wait_status, timeout_seconds }) => {
|
|
120
|
+
execute: async ({ document, device_context, credentials_path, force_replace, wait_status, timeout_seconds, preview_output_dir }) => {
|
|
112
121
|
const outbound = cloneObject(document);
|
|
113
122
|
if (force_replace) {
|
|
114
123
|
outbound.force_replace = true;
|
|
@@ -127,6 +136,7 @@ export default defineToolPlugin({
|
|
|
127
136
|
document: outbound,
|
|
128
137
|
};
|
|
129
138
|
const credentials = await requireCredentials(credentials_path);
|
|
139
|
+
const preview = await writeScreenDocumentPreviewFiles(outbound, { outputDir: preview_output_dir || "" });
|
|
130
140
|
if (wait_status !== false) {
|
|
131
141
|
const delivery = await publishControlAndWaitStatus(credentials, control, {
|
|
132
142
|
expectedDetail: outbound.id,
|
|
@@ -142,6 +152,7 @@ export default defineToolPlugin({
|
|
|
142
152
|
force_replace: outbound.force_replace === true,
|
|
143
153
|
warnings: validation.warnings,
|
|
144
154
|
layout_guidance: validation.layout_guidance,
|
|
155
|
+
preview,
|
|
145
156
|
status: delivery.status,
|
|
146
157
|
};
|
|
147
158
|
}
|
|
@@ -155,6 +166,7 @@ export default defineToolPlugin({
|
|
|
155
166
|
force_replace: outbound.force_replace === true,
|
|
156
167
|
warnings: validation.warnings,
|
|
157
168
|
layout_guidance: validation.layout_guidance,
|
|
169
|
+
preview,
|
|
158
170
|
};
|
|
159
171
|
},
|
|
160
172
|
}),
|
|
@@ -201,6 +213,257 @@ export default defineToolPlugin({
|
|
|
201
213
|
],
|
|
202
214
|
});
|
|
203
215
|
|
|
216
|
+
const registerTools = pluginEntry.register;
|
|
217
|
+
pluginEntry.register = (api) => {
|
|
218
|
+
registerTools(api);
|
|
219
|
+
registerOpenClawHooks(api);
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
export default pluginEntry;
|
|
223
|
+
|
|
224
|
+
function registerOpenClawHooks(api) {
|
|
225
|
+
if (!api || typeof api.on !== "function") return;
|
|
226
|
+
api.on("before_agent_run", async (event, ctx) => {
|
|
227
|
+
await startHookActivity(api, event, ctx);
|
|
228
|
+
return { outcome: "pass" };
|
|
229
|
+
}, { timeoutMs: 5_000 });
|
|
230
|
+
api.on("before_agent_finalize", async (event, ctx) => {
|
|
231
|
+
await finishHookActivity(api, event, ctx);
|
|
232
|
+
return { action: "continue" };
|
|
233
|
+
}, { timeoutMs: 5_000 });
|
|
234
|
+
api.on("agent_end", async (event, ctx) => {
|
|
235
|
+
await finishHookActivity(api, event, ctx);
|
|
236
|
+
}, { timeoutMs: 5_000 });
|
|
237
|
+
api.on("after_tool_call", async (event, ctx) => {
|
|
238
|
+
await attachScreenPreview(api, event, ctx);
|
|
239
|
+
}, { timeoutMs: 10_000 });
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function startHookActivity(api, event, ctx) {
|
|
243
|
+
try {
|
|
244
|
+
if (api.pluginConfig?.auto_activity === false) return;
|
|
245
|
+
if (!shouldAutoActivity(event, ctx)) return;
|
|
246
|
+
const key = hookActivityKey(event, ctx);
|
|
247
|
+
if (!key || activeRunActivities.has(key)) return;
|
|
248
|
+
const correlationId = `openclaw-${sanitizeId(ctx?.runId || key).slice(0, 72)}`;
|
|
249
|
+
await publishControl(await requireCredentials(), {
|
|
250
|
+
schema: "weclawbot.control.v1",
|
|
251
|
+
id: `activity_${crypto.randomUUID()}`,
|
|
252
|
+
kind: "activity",
|
|
253
|
+
activity: {
|
|
254
|
+
schema: "weclawbot.activity.v1",
|
|
255
|
+
state: "thinking",
|
|
256
|
+
correlation_id: correlationId,
|
|
257
|
+
ttl_seconds: 120,
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
activeRunActivities.set(key, { correlationId, startedAt: Date.now() });
|
|
261
|
+
api.logger?.info?.(`weclawbot activity thinking sent for ${key}`);
|
|
262
|
+
} catch (error) {
|
|
263
|
+
api.logger?.debug?.(`weclawbot activity hook skipped: ${errorMessage(error)}`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function finishHookActivity(api, event, ctx) {
|
|
268
|
+
try {
|
|
269
|
+
const key = hookActivityKey(event, ctx);
|
|
270
|
+
if (!key) return;
|
|
271
|
+
const active = activeRunActivities.get(key);
|
|
272
|
+
if (!active) return;
|
|
273
|
+
activeRunActivities.delete(key);
|
|
274
|
+
await publishControl(await requireCredentials(), {
|
|
275
|
+
schema: "weclawbot.control.v1",
|
|
276
|
+
id: `activity_${crypto.randomUUID()}`,
|
|
277
|
+
kind: "activity",
|
|
278
|
+
activity: {
|
|
279
|
+
schema: "weclawbot.activity.v1",
|
|
280
|
+
state: "idle",
|
|
281
|
+
correlation_id: active.correlationId,
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
api.logger?.info?.(`weclawbot activity idle sent for ${key}`);
|
|
285
|
+
} catch (error) {
|
|
286
|
+
api.logger?.debug?.(`weclawbot activity idle hook skipped: ${errorMessage(error)}`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function attachScreenPreview(api, event, ctx) {
|
|
291
|
+
try {
|
|
292
|
+
if (api.pluginConfig?.auto_preview === false) return;
|
|
293
|
+
if (event?.error) return;
|
|
294
|
+
const sessionKey = resolveHookSessionKey(event, ctx);
|
|
295
|
+
if (!sessionKey) {
|
|
296
|
+
logPreviewSkip(api, event, "missing sessionKey");
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
let document = null;
|
|
300
|
+
let source = "tool";
|
|
301
|
+
if (isScreenPublishTool(event?.toolName)) {
|
|
302
|
+
document = cloneObject(event.params?.document);
|
|
303
|
+
if (event.params?.force_replace) {
|
|
304
|
+
document.force_replace = true;
|
|
305
|
+
document.base_revision = "*";
|
|
306
|
+
}
|
|
307
|
+
} else if (isExecTool(event?.toolName)) {
|
|
308
|
+
const file = extractScreenDocumentPathFromExecParams(event.params);
|
|
309
|
+
if (!file && collectCommandStrings(event.params).some((value) => /weclawbotctl\s+screen\b/u.test(value))) {
|
|
310
|
+
logPreviewSkip(api, event, "screen command detected but document path was not found");
|
|
311
|
+
}
|
|
312
|
+
if (!file) return;
|
|
313
|
+
document = JSON.parse(await fs.readFile(file, "utf8"));
|
|
314
|
+
source = "cli";
|
|
315
|
+
} else {
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
await attachPreviewForDocument(api, document, { ...ctx, sessionKey }, source);
|
|
319
|
+
} catch (error) {
|
|
320
|
+
api.logger?.warn?.(`weclawbot preview attachment failed: ${errorMessage(error)}`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function attachPreviewForDocument(api, document, ctx, source) {
|
|
325
|
+
const validation = validateScreenDocument(document, {
|
|
326
|
+
agent_transport: { available: true, screen_document_available: true },
|
|
327
|
+
});
|
|
328
|
+
if (!validation.ok) {
|
|
329
|
+
api.logger?.warn?.(`weclawbot preview skipped: invalid screen document (${validation.errors?.[0] || "validation failed"})`);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
const previewPages = renderScreenDocumentPreviewPages(document);
|
|
333
|
+
if (previewPages.length === 0) {
|
|
334
|
+
api.logger?.warn?.("weclawbot preview skipped: no preview pages rendered");
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "weclawbot-preview-"));
|
|
338
|
+
const files = [];
|
|
339
|
+
for (const page of previewPages) {
|
|
340
|
+
const file = path.join(dir, `${safeFilename(document.id || "screen")}-p${page.index + 1}.png`);
|
|
341
|
+
await fs.writeFile(file, page.png);
|
|
342
|
+
files.push({ path: file });
|
|
343
|
+
}
|
|
344
|
+
const pageLabel = `${previewPages.length} page${previewPages.length === 1 ? "" : "s"}`;
|
|
345
|
+
const caption = `WeClawBot screen preview: ${document.id || "screen"} (${pageLabel}, ${source})`;
|
|
346
|
+
let attached = false;
|
|
347
|
+
if (typeof api.session?.workflow?.sendSessionAttachment === "function") {
|
|
348
|
+
const result = await api.session.workflow.sendSessionAttachment({
|
|
349
|
+
sessionKey: ctx.sessionKey,
|
|
350
|
+
files,
|
|
351
|
+
text: caption,
|
|
352
|
+
maxBytes: 2_000_000,
|
|
353
|
+
captionFormat: "plain",
|
|
354
|
+
channelHints: { telegram: { forceDocumentMime: "image/png" } },
|
|
355
|
+
});
|
|
356
|
+
if (result?.ok) {
|
|
357
|
+
attached = true;
|
|
358
|
+
api.logger?.info?.(`weclawbot preview attached via session workflow: ${result.channel} count=${result.count}`);
|
|
359
|
+
} else {
|
|
360
|
+
api.logger?.warn?.(`weclawbot session attachment unavailable: ${result?.error || "unknown error"}`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
if (!attached) {
|
|
364
|
+
const result = await sendPreviewViaOutboundAdapter(api, {
|
|
365
|
+
ctx,
|
|
366
|
+
files,
|
|
367
|
+
document,
|
|
368
|
+
source,
|
|
369
|
+
caption,
|
|
370
|
+
dir,
|
|
371
|
+
});
|
|
372
|
+
if (!result?.ok) {
|
|
373
|
+
api.logger?.warn?.(`weclawbot preview outbound fallback failed: ${result?.error || "unknown error"}`);
|
|
374
|
+
} else {
|
|
375
|
+
api.logger?.info?.(`weclawbot preview attached via outbound adapter: ${result.channel} count=${result.count}`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
scheduleRemove(dir);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function sendPreviewViaOutboundAdapter(api, params) {
|
|
382
|
+
const delivery = resolvePreviewDeliveryContext(api, params.ctx);
|
|
383
|
+
if (!delivery?.channel || !delivery?.to) {
|
|
384
|
+
return { ok: false, error: `session has no active delivery route: ${params.ctx.sessionKey}` };
|
|
385
|
+
}
|
|
386
|
+
const outbound = await api.runtime?.channel?.outbound?.loadAdapter?.(delivery.channel);
|
|
387
|
+
if (!outbound?.sendMedia) {
|
|
388
|
+
return { ok: false, error: `channel ${delivery.channel} has no media outbound adapter` };
|
|
389
|
+
}
|
|
390
|
+
const cfg = api.runtime?.config?.current?.() || api.config;
|
|
391
|
+
let count = 0;
|
|
392
|
+
for (let index = 0; index < params.files.length; index += 1) {
|
|
393
|
+
const file = params.files[index];
|
|
394
|
+
const suffix = params.files.length > 1 ? ` p${index + 1}/${params.files.length}` : "";
|
|
395
|
+
await outbound.sendMedia({
|
|
396
|
+
cfg,
|
|
397
|
+
to: delivery.to,
|
|
398
|
+
accountId: delivery.accountId,
|
|
399
|
+
threadId: delivery.threadId,
|
|
400
|
+
text: `${params.caption}${suffix}`,
|
|
401
|
+
mediaUrl: file.path,
|
|
402
|
+
mediaLocalRoots: [params.dir],
|
|
403
|
+
mediaReadFile: async (filePath) => fs.readFile(filePath),
|
|
404
|
+
forceDocument: false,
|
|
405
|
+
silent: true,
|
|
406
|
+
});
|
|
407
|
+
count += 1;
|
|
408
|
+
}
|
|
409
|
+
return {
|
|
410
|
+
ok: true,
|
|
411
|
+
channel: delivery.channel,
|
|
412
|
+
deliveredTo: delivery.to,
|
|
413
|
+
count,
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function resolvePreviewDeliveryContext(api, ctx) {
|
|
418
|
+
const direct = normalizeDeliveryContext(ctx?.deliveryContext);
|
|
419
|
+
if (direct) return direct;
|
|
420
|
+
const entry = getSessionEntryBestEffort(api, ctx);
|
|
421
|
+
return normalizeDeliveryContext(entry?.deliveryContext) || normalizeDeliveryContext(entry?.route);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function getSessionEntryBestEffort(api, ctx) {
|
|
425
|
+
const getter = api.runtime?.agent?.session?.getSessionEntry;
|
|
426
|
+
if (typeof getter !== "function" || !ctx?.sessionKey) return null;
|
|
427
|
+
try {
|
|
428
|
+
return getter({ agentId: ctx.agentId, sessionKey: ctx.sessionKey });
|
|
429
|
+
} catch {
|
|
430
|
+
try {
|
|
431
|
+
return getter({ sessionKey: ctx.sessionKey });
|
|
432
|
+
} catch {
|
|
433
|
+
return null;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function logPreviewSkip(api, event, reason) {
|
|
439
|
+
api.logger?.warn?.(`weclawbot preview skipped: tool=${String(event?.toolName || "unknown")} reason=${reason}`);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function resolveHookSessionKey(event, ctx) {
|
|
443
|
+
return String(ctx?.sessionKey || event?.sessionKey || "").trim();
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function isScreenPublishTool(name) {
|
|
447
|
+
return String(name || "").includes("weclawbot_publish_screen_document");
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function isExecTool(name) {
|
|
451
|
+
return /(^|[_-])(exec|shell|command|process)($|[_-])/iu.test(String(name || ""));
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function shouldAutoActivity(event, ctx) {
|
|
455
|
+
if (String(ctx?.trigger || "").includes("curator")) return false;
|
|
456
|
+
const prompt = String(event?.prompt || "");
|
|
457
|
+
if (prompt.includes("WECLAWBOT_CURATOR_EVENT")) return false;
|
|
458
|
+
return SCREEN_PROMPT_PATTERN.test(prompt);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function hookActivityKey(event, ctx) {
|
|
462
|
+
if (ctx?.runId || event?.runId) return `run:${ctx?.runId || event?.runId}`;
|
|
463
|
+
if (ctx?.sessionKey) return `session:${ctx.sessionKey}`;
|
|
464
|
+
return "";
|
|
465
|
+
}
|
|
466
|
+
|
|
204
467
|
async function requireCredentials(credentialsPath) {
|
|
205
468
|
const file = expandPath(credentialsPath || DEFAULT_CREDENTIALS_PATH);
|
|
206
469
|
const payload = await readCredentials(file);
|
|
@@ -239,6 +502,25 @@ function clearStatusDetail(target) {
|
|
|
239
502
|
return target === "idle_photo" ? "clear_idle_photo" : "clear_note";
|
|
240
503
|
}
|
|
241
504
|
|
|
505
|
+
function sanitizeId(value) {
|
|
506
|
+
return String(value || crypto.randomUUID()).replace(/[^a-zA-Z0-9_.-]/gu, "_");
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function safeFilename(value) {
|
|
510
|
+
return sanitizeId(value).slice(0, 80) || "screen";
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function scheduleRemove(dir) {
|
|
514
|
+
const timer = setTimeout(() => {
|
|
515
|
+
fs.rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
516
|
+
}, 60_000);
|
|
517
|
+
timer.unref?.();
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function errorMessage(error) {
|
|
521
|
+
return String(error instanceof Error ? error.message : error).replace(/\s+/gu, " ").trim().slice(0, 240);
|
|
522
|
+
}
|
|
523
|
+
|
|
242
524
|
function maskedMqtt(config, topics) {
|
|
243
525
|
return {
|
|
244
526
|
url: config.url,
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export function extractScreenDocumentPathFromExecParams(params) {
|
|
5
|
+
const command = collectCommandStrings(params).join("\n");
|
|
6
|
+
if (!/weclawbotctl\s+screen\b/u.test(command)) return "";
|
|
7
|
+
for (const line of command.split(/\r?\n/u)) {
|
|
8
|
+
const match = line.match(/(?:^|\s)(?:[^\s;&|]*\/)?weclawbotctl\s+screen\b([^;&|\n]*)/u);
|
|
9
|
+
if (!match) continue;
|
|
10
|
+
const tokens = shellSplit(match[1] || "");
|
|
11
|
+
const candidates = [];
|
|
12
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
13
|
+
const token = tokens[index];
|
|
14
|
+
if (!token) continue;
|
|
15
|
+
if (token.startsWith("--")) {
|
|
16
|
+
const key = token.split("=", 1)[0];
|
|
17
|
+
if (!token.includes("=") && new Set(["--credentials", "--timeout"]).has(key)) index += 1;
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
candidates.push(token);
|
|
21
|
+
}
|
|
22
|
+
const picked = candidates.findLast((token) => token.endsWith(".json")) || candidates.at(-1) || "";
|
|
23
|
+
if (picked) return expandPathWithBase(picked, params?.cwd || params?.workdir || process.cwd());
|
|
24
|
+
}
|
|
25
|
+
return "";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function collectCommandStrings(value, depth = 0) {
|
|
29
|
+
if (depth > 5 || value == null) return [];
|
|
30
|
+
if (typeof value === "string") return value.length <= 40_000 ? [value] : [];
|
|
31
|
+
if (Array.isArray(value)) {
|
|
32
|
+
if (value.every((item) => typeof item === "string")) return [value.join(" ")];
|
|
33
|
+
return value.flatMap((item) => collectCommandStrings(item, depth + 1));
|
|
34
|
+
}
|
|
35
|
+
if (typeof value !== "object") return [];
|
|
36
|
+
return Object.values(value).flatMap((item) => collectCommandStrings(item, depth + 1));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function normalizeDeliveryContext(value) {
|
|
40
|
+
if (!value || typeof value !== "object") return null;
|
|
41
|
+
const channel = typeof value.channel === "string" ? value.channel : "";
|
|
42
|
+
const to = typeof value.to === "string"
|
|
43
|
+
? value.to
|
|
44
|
+
: typeof value.target?.to === "string"
|
|
45
|
+
? value.target.to
|
|
46
|
+
: "";
|
|
47
|
+
if (!channel || !to) return null;
|
|
48
|
+
return {
|
|
49
|
+
channel,
|
|
50
|
+
to,
|
|
51
|
+
accountId: typeof value.accountId === "string" ? value.accountId : undefined,
|
|
52
|
+
threadId: value.threadId,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function shellSplit(value) {
|
|
57
|
+
const tokens = [];
|
|
58
|
+
let token = "";
|
|
59
|
+
let quote = "";
|
|
60
|
+
let escaped = false;
|
|
61
|
+
for (const ch of String(value || "")) {
|
|
62
|
+
if (escaped) {
|
|
63
|
+
token += ch;
|
|
64
|
+
escaped = false;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (ch === "\\") {
|
|
68
|
+
escaped = true;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (quote) {
|
|
72
|
+
if (ch === quote) quote = "";
|
|
73
|
+
else token += ch;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (ch === "'" || ch === "\"") {
|
|
77
|
+
quote = ch;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (/\s/u.test(ch)) {
|
|
81
|
+
if (token) {
|
|
82
|
+
tokens.push(token);
|
|
83
|
+
token = "";
|
|
84
|
+
}
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
token += ch;
|
|
88
|
+
}
|
|
89
|
+
if (token) tokens.push(token);
|
|
90
|
+
return tokens;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function expandPathWithBase(value, base) {
|
|
94
|
+
const expanded = expandPath(value);
|
|
95
|
+
return path.isAbsolute(expanded) ? expanded : path.resolve(String(base || process.cwd()), expanded);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function expandPath(value) {
|
|
99
|
+
const raw = String(value || "");
|
|
100
|
+
return raw.startsWith("~/") ? path.join(os.homedir(), raw.slice(2)) : raw;
|
|
101
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { deflateSync } from "node:zlib";
|
|
6
|
+
|
|
7
|
+
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
8
|
+
|
|
9
|
+
export function renderScreenDocumentPreviewPages(document, options = {}) {
|
|
10
|
+
const pages = Array.isArray(document?.pages) ? document.pages : [];
|
|
11
|
+
const scale = clampInteger(options.scale ?? 2, 1, 4);
|
|
12
|
+
return pages.map((page, index) => {
|
|
13
|
+
const png = renderMonoPagePreview(page, { scale });
|
|
14
|
+
return {
|
|
15
|
+
index,
|
|
16
|
+
width: Number(page.width) * scale,
|
|
17
|
+
height: Number(page.height) * scale,
|
|
18
|
+
scale,
|
|
19
|
+
png,
|
|
20
|
+
bytes: png.length,
|
|
21
|
+
sha256: crypto.createHash("sha256").update(png).digest("hex"),
|
|
22
|
+
};
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function previewSummary(previewPages) {
|
|
27
|
+
return {
|
|
28
|
+
available: previewPages.length > 0,
|
|
29
|
+
pages: previewPages.map((page) => ({
|
|
30
|
+
index: page.index,
|
|
31
|
+
width: page.width,
|
|
32
|
+
height: page.height,
|
|
33
|
+
scale: page.scale,
|
|
34
|
+
bytes: page.bytes,
|
|
35
|
+
sha256: page.sha256,
|
|
36
|
+
mime_type: "image/png",
|
|
37
|
+
})),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function writeScreenDocumentPreviewFiles(document, options = {}) {
|
|
42
|
+
const previewPages = renderScreenDocumentPreviewPages(document, options);
|
|
43
|
+
const outputDir = options.outputDir
|
|
44
|
+
? expandPath(options.outputDir)
|
|
45
|
+
: await fs.mkdtemp(path.join(os.tmpdir(), "weclawbot-preview-"));
|
|
46
|
+
await fs.mkdir(outputDir, { recursive: true });
|
|
47
|
+
const basename = safeFilename(options.basename || document?.id || "screen");
|
|
48
|
+
const pages = [];
|
|
49
|
+
for (const page of previewPages) {
|
|
50
|
+
const file = path.join(outputDir, `${basename}-p${page.index + 1}.png`);
|
|
51
|
+
await fs.writeFile(file, page.png);
|
|
52
|
+
pages.push({
|
|
53
|
+
index: page.index,
|
|
54
|
+
path: file,
|
|
55
|
+
width: page.width,
|
|
56
|
+
height: page.height,
|
|
57
|
+
scale: page.scale,
|
|
58
|
+
bytes: page.bytes,
|
|
59
|
+
sha256: page.sha256,
|
|
60
|
+
mime_type: "image/png",
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
available: pages.length > 0,
|
|
65
|
+
output_dir: outputDir,
|
|
66
|
+
pages,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function renderMonoPagePreview(page, options = {}) {
|
|
71
|
+
const width = Number(page?.width);
|
|
72
|
+
const height = Number(page?.height);
|
|
73
|
+
const stride = Number(page?.stride);
|
|
74
|
+
const scale = clampInteger(options.scale ?? 2, 1, 4);
|
|
75
|
+
if (!Number.isInteger(width) || width < 1 || !Number.isInteger(height) || height < 1) {
|
|
76
|
+
throw new Error("preview page width/height must be positive integers");
|
|
77
|
+
}
|
|
78
|
+
if (!Number.isInteger(stride) || stride < Math.ceil(width / 8)) {
|
|
79
|
+
throw new Error("preview page stride is invalid");
|
|
80
|
+
}
|
|
81
|
+
const packed = Buffer.from(String(page?.data_b64 || ""), "base64");
|
|
82
|
+
if (packed.length < stride * height) {
|
|
83
|
+
throw new Error("preview page data is shorter than stride * height");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const outWidth = width * scale;
|
|
87
|
+
const outHeight = height * scale;
|
|
88
|
+
const gray = Buffer.alloc(outWidth * outHeight, 0xff);
|
|
89
|
+
for (let y = 0; y < height; y += 1) {
|
|
90
|
+
const row = y * stride;
|
|
91
|
+
for (let x = 0; x < width; x += 1) {
|
|
92
|
+
const black = (packed[row + (x >> 3)] & (0x80 >> (x & 7))) !== 0;
|
|
93
|
+
if (!black) continue;
|
|
94
|
+
for (let dy = 0; dy < scale; dy += 1) {
|
|
95
|
+
const outRow = (y * scale + dy) * outWidth;
|
|
96
|
+
for (let dx = 0; dx < scale; dx += 1) {
|
|
97
|
+
gray[outRow + x * scale + dx] = 0;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return encodeGrayscalePng(gray, outWidth, outHeight);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function encodeGrayscalePng(gray, width, height) {
|
|
106
|
+
const raw = Buffer.alloc((width + 1) * height);
|
|
107
|
+
for (let y = 0; y < height; y += 1) {
|
|
108
|
+
const row = y * (width + 1);
|
|
109
|
+
raw[row] = 0;
|
|
110
|
+
gray.copy(raw, row + 1, y * width, (y + 1) * width);
|
|
111
|
+
}
|
|
112
|
+
const ihdr = Buffer.alloc(13);
|
|
113
|
+
ihdr.writeUInt32BE(width, 0);
|
|
114
|
+
ihdr.writeUInt32BE(height, 4);
|
|
115
|
+
ihdr[8] = 8;
|
|
116
|
+
ihdr[9] = 0;
|
|
117
|
+
ihdr[10] = 0;
|
|
118
|
+
ihdr[11] = 0;
|
|
119
|
+
ihdr[12] = 0;
|
|
120
|
+
return Buffer.concat([
|
|
121
|
+
PNG_SIGNATURE,
|
|
122
|
+
pngChunk("IHDR", ihdr),
|
|
123
|
+
pngChunk("IDAT", deflateSync(raw)),
|
|
124
|
+
pngChunk("IEND", Buffer.alloc(0)),
|
|
125
|
+
]);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function pngChunk(type, data) {
|
|
129
|
+
const typeBuf = Buffer.from(type, "ascii");
|
|
130
|
+
const len = Buffer.alloc(4);
|
|
131
|
+
len.writeUInt32BE(data.length, 0);
|
|
132
|
+
const crc = Buffer.alloc(4);
|
|
133
|
+
crc.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0);
|
|
134
|
+
return Buffer.concat([len, typeBuf, data, crc]);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const crcTable = new Uint32Array(256).map((_, index) => {
|
|
138
|
+
let c = index;
|
|
139
|
+
for (let bit = 0; bit < 8; bit += 1) {
|
|
140
|
+
c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
|
|
141
|
+
}
|
|
142
|
+
return c >>> 0;
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
function crc32(buffer) {
|
|
146
|
+
let crc = 0xffffffff;
|
|
147
|
+
for (const byte of buffer) {
|
|
148
|
+
crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8);
|
|
149
|
+
}
|
|
150
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function clampInteger(value, min, max) {
|
|
154
|
+
const parsed = Number.parseInt(String(value), 10);
|
|
155
|
+
if (!Number.isFinite(parsed)) return min;
|
|
156
|
+
return Math.max(min, Math.min(max, parsed));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function expandPath(value) {
|
|
160
|
+
const raw = String(value || "");
|
|
161
|
+
return raw.startsWith("~/") ? path.join(os.homedir(), raw.slice(2)) : raw;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function safeFilename(value) {
|
|
165
|
+
return String(value || "screen").replace(/[^a-zA-Z0-9_.-]/gu, "_").slice(0, 80) || "screen";
|
|
166
|
+
}
|
package/openclaw.plugin.json
CHANGED
|
@@ -21,6 +21,15 @@
|
|
|
21
21
|
"configSchema": {
|
|
22
22
|
"type": "object",
|
|
23
23
|
"additionalProperties": false,
|
|
24
|
-
"properties": {
|
|
24
|
+
"properties": {
|
|
25
|
+
"auto_activity": {
|
|
26
|
+
"type": "boolean",
|
|
27
|
+
"description": "Automatically show the WeClawBot thinking pet during OpenClaw turns that mention the physical screen."
|
|
28
|
+
},
|
|
29
|
+
"auto_preview": {
|
|
30
|
+
"type": "boolean",
|
|
31
|
+
"description": "Best-effort fallback that attaches PNG previews after publishing. Agents should still send preview.pages[].path to users explicitly."
|
|
32
|
+
}
|
|
33
|
+
}
|
|
25
34
|
}
|
|
26
35
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openbrt/weclawbotctl",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.16",
|
|
4
4
|
"description": "WeClawBot pairing and screen-control CLI for local AI agents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -43,10 +43,11 @@
|
|
|
43
43
|
"./activity": "./lib/activity.mjs",
|
|
44
44
|
"./direct-control": "./lib/direct-control.mjs",
|
|
45
45
|
"./mqtt-control": "./lib/mqtt-control.mjs",
|
|
46
|
+
"./screen-preview": "./lib/screen-preview.mjs",
|
|
46
47
|
"./package.json": "./package.json"
|
|
47
48
|
},
|
|
48
49
|
"scripts": {
|
|
49
|
-
"check": "node --check index.mjs && node --check bin/weclawbot-openclaw-bridge.mjs && node --check bin/weclawbot-byoa-bind.mjs && node --check bin/weclawbotctl.mjs && node --test test/direct-control.test.mjs test/activity.test.mjs"
|
|
50
|
+
"check": "node --check index.mjs && node --check lib/openclaw-preview.mjs && node --check bin/weclawbot-openclaw-bridge.mjs && node --check bin/weclawbot-byoa-bind.mjs && node --check bin/weclawbotctl.mjs && node --test test/direct-control.test.mjs test/activity.test.mjs test/screen-preview.test.mjs test/openclaw-preview-hook.test.mjs"
|
|
50
51
|
},
|
|
51
52
|
"dependencies": {
|
|
52
53
|
"mqtt": "^5.10.4",
|
|
@@ -53,11 +53,18 @@ publish it with:
|
|
|
53
53
|
weclawbotctl screen /path/to/screen-document.json
|
|
54
54
|
```
|
|
55
55
|
|
|
56
|
-
or call `weclawbot_publish_screen_document` with the same document.
|
|
57
|
-
OpenClaw
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
56
|
+
or call `weclawbot_publish_screen_document` with the same document. Inside
|
|
57
|
+
OpenClaw Telegram/UI sessions, prefer `weclawbot_publish_screen_document`: its
|
|
58
|
+
result includes `preview.pages[].path` PNG files for the exact mono1 pages. The
|
|
59
|
+
agent must inspect those preview files when possible, then send the preview PNGs
|
|
60
|
+
back to the user through its normal chat/media channel before or alongside the
|
|
61
|
+
"已上屏" reply. If the agent used CLI instead, run `weclawbotctl preview <doc>`
|
|
62
|
+
before publishing, or read the `preview` field emitted by `weclawbotctl screen`.
|
|
63
|
+
Do not use OpenClaw Canvas for requests that
|
|
64
|
+
mention WeClawBot, the physical screen, or “屏上”; Canvas is an OpenClaw UI
|
|
65
|
+
surface, not the ESP32 e-paper display. Do not send raw text to firmware. The
|
|
66
|
+
agent owns text layout, font choice, image rasterization, preview review, user
|
|
67
|
+
visible proof, and page splitting; the device consumes pixels.
|
|
61
68
|
|
|
62
69
|
For clear requests such as “清屏”, “清理屏幕”, “clear screen”, or removing the
|
|
63
70
|
current note, call `weclawbot_clear_screen` or run `weclawbotctl clear`. Never
|
|
@@ -84,8 +91,10 @@ Before publishing, review the actual rendered bitmap pages when your runtime can
|
|
|
84
91
|
inspect images. Judge the preview against the user's preferences and the agent's
|
|
85
92
|
own learned standards: legibility, margins, crowding, page count, and continuity
|
|
86
93
|
across pages. If the preview does not satisfy those standards, regenerate the
|
|
87
|
-
pages before publishing.
|
|
88
|
-
|
|
94
|
+
pages before publishing. After a successful publish, include the preview PNG in
|
|
95
|
+
the user-visible response so the user can see what was sent. This review and
|
|
96
|
+
proof loop belongs in the agent/tool layer; do not expect firmware to fix
|
|
97
|
+
typography, show chat previews, or split pages after the pixels arrive.
|
|
89
98
|
|
|
90
99
|
`weclawbotctl screen` and `weclawbot_publish_screen_document` wait for the
|
|
91
100
|
device status topic by default. Treat only `applied` as success. If the device
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { collectCommandStrings, extractScreenDocumentPathFromExecParams, normalizeDeliveryContext } from "../lib/openclaw-preview.mjs";
|
|
5
|
+
|
|
6
|
+
const extracted = extractScreenDocumentPathFromExecParams({
|
|
7
|
+
command: "~/.npm-global/bin/weclawbotctl screen /tmp/weclawbot-musk.json --force --timeout 20",
|
|
8
|
+
cwd: "/home/csc/.openclaw/workspace",
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
assert.equal(extracted, "/tmp/weclawbot-musk.json");
|
|
12
|
+
|
|
13
|
+
const nested = collectCommandStrings({
|
|
14
|
+
payload: {
|
|
15
|
+
args: ["weclawbotctl", "screen", "relative-doc.json", "--timeout", "20"],
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
assert.ok(nested.some((value) => value.includes("weclawbotctl screen relative-doc.json")));
|
|
20
|
+
|
|
21
|
+
const relative = extractScreenDocumentPathFromExecParams({
|
|
22
|
+
payload: {
|
|
23
|
+
input: "weclawbotctl screen relative-doc.json --force",
|
|
24
|
+
},
|
|
25
|
+
cwd: "/home/csc/.openclaw/workspace",
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
assert.equal(relative, path.resolve("/home/csc/.openclaw/workspace", "relative-doc.json"));
|
|
29
|
+
|
|
30
|
+
assert.deepEqual(normalizeDeliveryContext({
|
|
31
|
+
channel: "telegram",
|
|
32
|
+
target: { to: "telegram:5728815108" },
|
|
33
|
+
accountId: "default",
|
|
34
|
+
}), {
|
|
35
|
+
channel: "telegram",
|
|
36
|
+
to: "telegram:5728815108",
|
|
37
|
+
accountId: "default",
|
|
38
|
+
threadId: undefined,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
console.log("openclaw preview hook helpers: ok");
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
|
|
4
|
+
import { renderScreenDocumentPreviewPages, previewSummary, writeScreenDocumentPreviewFiles } from "../lib/screen-preview.mjs";
|
|
5
|
+
|
|
6
|
+
const width = 16;
|
|
7
|
+
const height = 8;
|
|
8
|
+
const stride = 2;
|
|
9
|
+
const bytes = Buffer.alloc(stride * height, 0x00);
|
|
10
|
+
for (let i = 0; i < Math.min(width, height); i += 1) {
|
|
11
|
+
bytes[i * stride + (i >> 3)] |= 0x80 >> (i & 7);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const document = {
|
|
15
|
+
schema: "weclawbot.screen_document.v1",
|
|
16
|
+
pages: [{
|
|
17
|
+
format: "mono1",
|
|
18
|
+
width,
|
|
19
|
+
height,
|
|
20
|
+
stride,
|
|
21
|
+
data_b64: bytes.toString("base64"),
|
|
22
|
+
}],
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const preview = renderScreenDocumentPreviewPages(document, { scale: 2 });
|
|
26
|
+
|
|
27
|
+
assert.equal(preview.length, 1);
|
|
28
|
+
assert.equal(preview[0].width, width * 2);
|
|
29
|
+
assert.equal(preview[0].height, height * 2);
|
|
30
|
+
assert.equal(preview[0].png.subarray(0, 8).toString("hex"), "89504e470d0a1a0a");
|
|
31
|
+
assert.ok(preview[0].bytes > 50);
|
|
32
|
+
assert.match(preview[0].sha256, /^[0-9a-f]{64}$/u);
|
|
33
|
+
|
|
34
|
+
const summary = previewSummary(preview);
|
|
35
|
+
assert.equal(summary.available, true);
|
|
36
|
+
assert.equal(summary.pages[0].mime_type, "image/png");
|
|
37
|
+
|
|
38
|
+
const files = await writeScreenDocumentPreviewFiles(document, { scale: 2 });
|
|
39
|
+
assert.equal(files.available, true);
|
|
40
|
+
assert.equal(files.pages.length, 1);
|
|
41
|
+
assert.match(files.pages[0].path, /\.png$/u);
|
|
42
|
+
const persisted = await fs.readFile(files.pages[0].path);
|
|
43
|
+
assert.equal(persisted.subarray(0, 8).toString("hex"), "89504e470d0a1a0a");
|
|
44
|
+
await fs.rm(files.output_dir, { recursive: true, force: true });
|
|
45
|
+
|
|
46
|
+
console.log("screen-preview renderer: ok");
|
package/workspace/AGENTS.md
CHANGED
|
@@ -42,6 +42,14 @@ success only after it exits with success. If the user explicitly asks to
|
|
|
42
42
|
replace whatever is currently shown, use `--force`; otherwise use the current
|
|
43
43
|
screen revision in `base_revision`.
|
|
44
44
|
|
|
45
|
+
When running inside OpenClaw with the WeClawBot plugin tools available, prefer
|
|
46
|
+
`weclawbot_publish_screen_document` over shelling out to `weclawbotctl screen`;
|
|
47
|
+
the tool returns `preview.pages[].path` PNG files for the exact pages. Inspect
|
|
48
|
+
those preview images when possible, and send them back to the user through the
|
|
49
|
+
normal chat/media channel before or alongside the success reply. If using CLI,
|
|
50
|
+
run `weclawbotctl preview <document.json>` before publishing, or read the
|
|
51
|
+
`preview` field emitted by `weclawbotctl screen`.
|
|
52
|
+
|
|
45
53
|
The firmware receives pixels. Do not send raw text to firmware, and do not
|
|
46
54
|
answer that direct delivery is unavailable before checking the local
|
|
47
55
|
`weclawbotctl` profile.
|