@openbrt/weclawbotctl 0.1.0

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.
@@ -0,0 +1,357 @@
1
+ #!/usr/bin/env node
2
+
3
+ import crypto from "node:crypto";
4
+ import fs from "node:fs/promises";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import process from "node:process";
8
+
9
+ import { validateActivity } from "../lib/activity.mjs";
10
+ import { validateScreenDocument } from "../lib/direct-control.mjs";
11
+ import { normalizeCredentials, publishControl, testConnection } from "../lib/mqtt-control.mjs";
12
+
13
+ const DEFAULT_ENDPOINT = "https://weclawbot.link/byoa";
14
+ const DEFAULT_CREDENTIALS_PATH = path.join(os.homedir(), ".config", "weclawbot", "agent-mqtt.json");
15
+
16
+ const [command, ...args] = process.argv.slice(2);
17
+ const commands = new Set(["bind", "status", "doctor", "export", "unbind", "thinking", "idle", "screen"]);
18
+ if (!commands.has(command)) {
19
+ usage();
20
+ process.exit(64);
21
+ }
22
+
23
+ try {
24
+ if (command === "bind") await commandBind(args);
25
+ else if (command === "status") await commandStatus(args);
26
+ else if (command === "doctor") await commandDoctor(args);
27
+ else if (command === "export") await commandExport(args);
28
+ else if (command === "unbind") await commandUnbind(args);
29
+ else if (command === "screen") await commandScreen(args);
30
+ else await commandActivity(command, args);
31
+ } catch (error) {
32
+ console.error(error instanceof Error ? error.message : String(error));
33
+ process.exit(1);
34
+ }
35
+
36
+ async function commandBind(values) {
37
+ const options = parseOptions(values, {
38
+ name: "user-agent",
39
+ endpoint: process.env.WEC_BYOA_ENDPOINT || DEFAULT_ENDPOINT,
40
+ credentials: credentialsPath(),
41
+ });
42
+ const code = String(options._[0] || "").replace(/\D/gu, "");
43
+ if (!/^\d{6}$/u.test(code)) {
44
+ throw new Error("Usage: weclawbotctl bind <six-digit-code> [--name agent-name]");
45
+ }
46
+ const response = await fetch(options.endpoint, {
47
+ method: "POST",
48
+ headers: { "content-type": "application/json", accept: "application/json" },
49
+ body: JSON.stringify({
50
+ schema: "weclawbot.byoa.v1",
51
+ operation: "claim",
52
+ code,
53
+ agent_name: String(options.name || "user-agent").slice(0, 80),
54
+ }),
55
+ signal: AbortSignal.timeout(15_000),
56
+ });
57
+ const payload = await response.json().catch(() => null);
58
+ if (!response.ok || !payload?.ok || payload?.schema !== "weclawbot.byoa.agent_credentials.v1") {
59
+ throw new Error(`WeClawBot pairing failed: ${payload?.error || `HTTP ${response.status}`}`);
60
+ }
61
+
62
+ const file = expandPath(options.credentials);
63
+ await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
64
+ await fs.writeFile(file, `${JSON.stringify({
65
+ schema: "weclawbot.agent_credentials.v1",
66
+ binding: payload.binding,
67
+ mqtt: payload.mqtt,
68
+ delivery: payload.delivery,
69
+ created_at: new Date().toISOString(),
70
+ }, null, 2)}\n`, { mode: 0o600 });
71
+ await fs.chmod(file, 0o600);
72
+ console.log(`WeClawBot paired with ${payload.binding.device_id}. Credentials saved to ${file}.`);
73
+ }
74
+
75
+ async function commandStatus(values) {
76
+ const options = parseOptions(values, { credentials: credentialsPath(), json: false });
77
+ const file = expandPath(options.credentials);
78
+ const payload = await readCredentials(file);
79
+ if (!payload) {
80
+ const status = { ok: false, paired: false, credentials_path: file };
81
+ printStatus(status, options.json);
82
+ process.exitCode = 1;
83
+ return;
84
+ }
85
+ const config = normalizeCredentials(payload);
86
+ const stat = await fs.stat(file).catch(() => null);
87
+ printStatus({
88
+ ok: true,
89
+ paired: true,
90
+ credentials_path: file,
91
+ file_mode: stat ? modeString(stat.mode) : "",
92
+ binding: payload.binding || {},
93
+ mqtt: maskedMqtt(config, payload.mqtt?.topics || {}),
94
+ delivery: payload.delivery || {},
95
+ }, options.json);
96
+ }
97
+
98
+ async function commandDoctor(values) {
99
+ const options = parseOptions(values, {
100
+ credentials: credentialsPath(),
101
+ json: false,
102
+ online: false,
103
+ });
104
+ const file = expandPath(options.credentials);
105
+ const checks = [];
106
+ const payload = await readCredentials(file);
107
+ checks.push({ name: "credentials_file", ok: Boolean(payload), detail: file });
108
+ if (!payload) {
109
+ printDoctor(checks, options.json);
110
+ process.exitCode = 1;
111
+ return;
112
+ }
113
+ const stat = await fs.stat(file).catch(() => null);
114
+ checks.push({
115
+ name: "credentials_permissions",
116
+ ok: Boolean(stat) && (stat.mode & 0o077) === 0,
117
+ detail: stat ? modeString(stat.mode) : "missing",
118
+ });
119
+ let config = null;
120
+ try {
121
+ config = normalizeCredentials(payload);
122
+ checks.push({ name: "mqtt_profile", ok: true, detail: `${config.url} ${config.clientId}` });
123
+ checks.push({ name: "mqtt_url_tls", ok: config.url.startsWith("wss://"), detail: config.url });
124
+ checks.push({
125
+ name: "client_id_contains_no_secret",
126
+ ok: !/(token|secret|password|passwd|key)/iu.test(config.clientId),
127
+ detail: config.clientId,
128
+ });
129
+ } catch (error) {
130
+ checks.push({ name: "mqtt_profile", ok: false, detail: error.message });
131
+ }
132
+ if (options.online && config) {
133
+ try {
134
+ await testConnection(payload);
135
+ checks.push({ name: "mqtt_online", ok: true, detail: "connected" });
136
+ } catch (error) {
137
+ checks.push({ name: "mqtt_online", ok: false, detail: error.message });
138
+ }
139
+ }
140
+ printDoctor(checks, options.json);
141
+ if (checks.some((check) => !check.ok)) process.exitCode = 1;
142
+ }
143
+
144
+ async function commandExport(values) {
145
+ const options = parseOptions(values, {
146
+ credentials: credentialsPath(),
147
+ format: "env",
148
+ output: "",
149
+ "include-secret": false,
150
+ });
151
+ const payload = await requireCredentials(expandPath(options.credentials));
152
+ const config = normalizeCredentials(payload);
153
+ const includeSecret = Boolean(options["include-secret"]);
154
+ let body = "";
155
+ if (options.format === "json") {
156
+ body = `${JSON.stringify(maskedExport(payload, includeSecret), null, 2)}\n`;
157
+ } else if (options.format === "env") {
158
+ body = [
159
+ `WEC_MQTT_URL=${shellValue(config.url)}`,
160
+ `WEC_MQTT_CLIENT_ID=${shellValue(config.clientId)}`,
161
+ `WEC_MQTT_USERNAME=${shellValue(config.username)}`,
162
+ `WEC_MQTT_PASSWORD=${shellValue(includeSecret ? config.password : "********")}`,
163
+ `WEC_MQTT_CONTROL_TOPIC=${shellValue(config.controlTopic)}`,
164
+ "",
165
+ ].join("\n");
166
+ } else if (options.format === "mosquitto") {
167
+ body = [
168
+ `url ${config.url}`,
169
+ `id ${config.clientId}`,
170
+ `username ${config.username}`,
171
+ `password ${includeSecret ? config.password : "********"}`,
172
+ "protocol-version mqttv5",
173
+ "clean-session true",
174
+ "",
175
+ ].join("\n");
176
+ } else {
177
+ throw new Error("export format must be env, json, or mosquitto");
178
+ }
179
+ if (options.output) {
180
+ const file = expandPath(options.output);
181
+ await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
182
+ await fs.writeFile(file, body, { mode: includeSecret ? 0o600 : 0o644 });
183
+ if (includeSecret) await fs.chmod(file, 0o600);
184
+ console.log(`Wrote ${options.format} profile to ${file}.`);
185
+ } else {
186
+ process.stdout.write(body);
187
+ }
188
+ }
189
+
190
+ async function commandUnbind(values) {
191
+ const options = parseOptions(values, { credentials: credentialsPath(), yes: false });
192
+ if (!options.yes) {
193
+ throw new Error("This only removes the local credential file. Re-run with --yes.");
194
+ }
195
+ const file = expandPath(options.credentials);
196
+ await fs.rm(file, { force: true });
197
+ console.log(`Removed local WeClawBot credentials: ${file}`);
198
+ }
199
+
200
+ async function commandScreen(values) {
201
+ const options = parseOptions(values, { credentials: credentialsPath() });
202
+ const file = String(options._[0] || "").trim();
203
+ if (!file || options._.length !== 1) {
204
+ throw new Error("Usage: weclawbotctl screen <document.json>");
205
+ }
206
+ const document = JSON.parse(await fs.readFile(file, "utf8"));
207
+ const validation = validateScreenDocument(document, {
208
+ agent_transport: { available: true, screen_document_available: true },
209
+ });
210
+ if (!validation.ok) {
211
+ throw new Error(`Invalid screen document: ${validation.errors.join("; ")}`);
212
+ }
213
+ await publishControl(await requireCredentials(expandPath(options.credentials)), {
214
+ schema: "weclawbot.control.v1",
215
+ id: `screen_${crypto.randomUUID()}`,
216
+ kind: "screen_document",
217
+ document,
218
+ });
219
+ console.log(JSON.stringify({ ok: true, id: document.id, pages: document.pages.length }));
220
+ }
221
+
222
+ async function commandActivity(state, values) {
223
+ const options = parseOptions(values, {
224
+ credentials: credentialsPath(),
225
+ ttl: 45,
226
+ id: "",
227
+ });
228
+ const correlationId = options.id || crypto.randomUUID();
229
+ const activity = {
230
+ schema: "weclawbot.activity.v1",
231
+ state,
232
+ correlation_id: correlationId,
233
+ ...(state === "thinking" ? { ttl_seconds: Number(options.ttl) } : {}),
234
+ };
235
+ const validation = validateActivity(activity, { agent_transport: { available: true, activity_available: true } });
236
+ if (!validation.ok) {
237
+ throw new Error(`Invalid activity: ${validation.errors.join("; ")}`);
238
+ }
239
+
240
+ await publishControl(await requireCredentials(expandPath(options.credentials)), {
241
+ schema: "weclawbot.control.v1",
242
+ id: `activity_${crypto.randomUUID()}`,
243
+ kind: "activity",
244
+ activity,
245
+ });
246
+ console.log(JSON.stringify({ ok: true, state: activity.state, correlation_id: correlationId }));
247
+ }
248
+
249
+ function parseOptions(values, defaults = {}) {
250
+ const options = { ...defaults, _: [] };
251
+ for (let index = 0; index < values.length; index += 1) {
252
+ const value = values[index];
253
+ if (!value.startsWith("--")) {
254
+ options._.push(value);
255
+ continue;
256
+ }
257
+ const [rawKey, inlineValue] = value.slice(2).split("=", 2);
258
+ const key = rawKey.trim();
259
+ if (!key) continue;
260
+ if (typeof options[key] === "boolean") {
261
+ options[key] = inlineValue === undefined ? true : !/^(0|false|no|off)$/iu.test(inlineValue);
262
+ } else {
263
+ options[key] = inlineValue === undefined ? String(values[++index] || "") : inlineValue;
264
+ }
265
+ }
266
+ return options;
267
+ }
268
+
269
+ function credentialsPath() {
270
+ return process.env.WEC_AGENT_CREDENTIALS_PATH || DEFAULT_CREDENTIALS_PATH;
271
+ }
272
+
273
+ async function readCredentials(file) {
274
+ try {
275
+ const payload = JSON.parse(await fs.readFile(file, "utf8"));
276
+ return payload && typeof payload === "object" ? payload : null;
277
+ } catch {
278
+ return null;
279
+ }
280
+ }
281
+
282
+ async function requireCredentials(file) {
283
+ const payload = await readCredentials(file);
284
+ if (!payload) throw new Error(`WeClawBot is not paired. Run: weclawbotctl bind <six-digit-code>`);
285
+ return payload;
286
+ }
287
+
288
+ function expandPath(value) {
289
+ const raw = String(value || DEFAULT_CREDENTIALS_PATH);
290
+ return raw.startsWith("~/") ? path.join(os.homedir(), raw.slice(2)) : raw;
291
+ }
292
+
293
+ function maskedMqtt(config, topics) {
294
+ return {
295
+ url: config.url,
296
+ client_id: config.clientId,
297
+ username: config.username,
298
+ password: "********",
299
+ topics: {
300
+ control: config.controlTopic,
301
+ events: topics.events || "",
302
+ status: topics.status || "",
303
+ },
304
+ };
305
+ }
306
+
307
+ function maskedExport(payload, includeSecret) {
308
+ const copy = JSON.parse(JSON.stringify(payload));
309
+ if (!includeSecret && copy?.mqtt?.password) copy.mqtt.password = "********";
310
+ return copy;
311
+ }
312
+
313
+ function printStatus(status, json) {
314
+ if (json) {
315
+ console.log(JSON.stringify(status, null, 2));
316
+ return;
317
+ }
318
+ if (!status.paired) {
319
+ console.log(`WeClawBot not paired. Credentials: ${status.credentials_path}`);
320
+ return;
321
+ }
322
+ console.log(`WeClawBot paired: ${status.binding?.device_id || "device"}`);
323
+ console.log(`Credentials: ${status.credentials_path} (${status.file_mode || "unknown mode"})`);
324
+ console.log(`MQTT: ${status.mqtt.url}`);
325
+ console.log(`Client ID: ${status.mqtt.client_id}`);
326
+ console.log(`Control topic: ${status.mqtt.topics.control}`);
327
+ }
328
+
329
+ function printDoctor(checks, json) {
330
+ if (json) {
331
+ console.log(JSON.stringify({ ok: checks.every((check) => check.ok), checks }, null, 2));
332
+ return;
333
+ }
334
+ for (const check of checks) {
335
+ console.log(`${check.ok ? "ok" : "fail"} ${check.name}: ${check.detail}`);
336
+ }
337
+ }
338
+
339
+ function shellValue(value) {
340
+ return `'${String(value).replaceAll("'", "'\\''")}'`;
341
+ }
342
+
343
+ function modeString(mode) {
344
+ return `0${(mode & 0o777).toString(8)}`;
345
+ }
346
+
347
+ function usage() {
348
+ console.error(`Usage:
349
+ weclawbotctl bind <six-digit-code> [--name agent-name]
350
+ weclawbotctl status [--json]
351
+ weclawbotctl doctor [--online] [--json]
352
+ weclawbotctl export [--format env|json|mosquitto] [--include-secret] [--output file]
353
+ weclawbotctl unbind --yes
354
+ weclawbotctl thinking [--ttl seconds] [--id correlation-id]
355
+ weclawbotctl idle [--id correlation-id]
356
+ weclawbotctl screen <document.json>`);
357
+ }
package/index.mjs ADDED
@@ -0,0 +1,35 @@
1
+ import { Type } from "typebox";
2
+ import { defineToolPlugin } from "openclaw/plugin-sdk/tool-plugin";
3
+
4
+ import { validateActivity } from "./lib/activity.mjs";
5
+ import { validateScreenDocument } from "./lib/direct-control.mjs";
6
+
7
+ // The long-running curator bridge remains a separate service. This plugin's
8
+ // tool is local, deterministic, and contains no credential or network access.
9
+ export default defineToolPlugin({
10
+ id: "weclawbot",
11
+ name: "WeClawBot",
12
+ description: "WeClawBot screen-curation skill pack and direct-control validator.",
13
+ tools: (tool) => [
14
+ tool({
15
+ name: "weclawbot_validate_screen_document",
16
+ label: "Validate WeClawBot screen document",
17
+ description: "Validate a candidate direct screen document against the firmware-supplied device_context. This does not send or queue anything.",
18
+ parameters: Type.Object({
19
+ document: Type.Any(),
20
+ device_context: Type.Optional(Type.Any()),
21
+ }, { additionalProperties: false }),
22
+ execute: ({ document, device_context }) => validateScreenDocument(document, device_context),
23
+ }),
24
+ tool({
25
+ name: "weclawbot_validate_activity",
26
+ label: "Validate WeClawBot activity",
27
+ description: "Validate a temporary thinking or idle activity message. It does not send or queue anything.",
28
+ parameters: Type.Object({
29
+ activity: Type.Any(),
30
+ device_context: Type.Optional(Type.Any()),
31
+ }, { additionalProperties: false }),
32
+ execute: ({ activity, device_context }) => validateActivity(activity, device_context),
33
+ }),
34
+ ],
35
+ });
@@ -0,0 +1,34 @@
1
+ const STATES = new Set(["thinking", "idle"]);
2
+
3
+ export function validateActivity(value, suppliedContext) {
4
+ const context = suppliedContext && typeof suppliedContext === "object" ? suppliedContext : {};
5
+ const transport = context.agent_transport || null;
6
+ const activity = value && typeof value === "object" ? value : null;
7
+ const errors = [];
8
+ if (!activity) {
9
+ errors.push("activity must be an object");
10
+ } else {
11
+ if (activity.schema !== "weclawbot.activity.v1") {
12
+ errors.push("schema must be weclawbot.activity.v1");
13
+ }
14
+ if (!STATES.has(activity.state)) {
15
+ errors.push("state must be thinking or idle");
16
+ }
17
+ if (typeof activity.correlation_id !== "string" || !activity.correlation_id.trim()) {
18
+ errors.push("correlation_id is required");
19
+ }
20
+ if (activity.state === "thinking") {
21
+ const ttl = Number(activity.ttl_seconds);
22
+ if (!Number.isInteger(ttl) || ttl < 5 || ttl > 120) {
23
+ errors.push("thinking ttl_seconds must be an integer from 5 to 120");
24
+ }
25
+ }
26
+ }
27
+ return {
28
+ ok: errors.length === 0,
29
+ errors,
30
+ agent_transport: transport,
31
+ direct_delivery_ready: transport?.activity_available === true || transport?.available === true,
32
+ delivery: { qos: 1, retained: false, clean_start: true, session_expiry_seconds: 0 },
33
+ };
34
+ }
@@ -0,0 +1,110 @@
1
+ const DEFAULT_CONTEXT = {
2
+ schema: "weclawbot.device_context.v1",
3
+ canvas: { width: 400, height: 300, color: "mono1", refresh: "reflective_slow" },
4
+ content_viewport: {
5
+ id: "content", x: 16, y: 42, width: 368, height: 206,
6
+ format: "mono1", max_pages: 3, auto_page_seconds: 12,
7
+ },
8
+ chrome: { owner: "firmware", reserved: "status_bar,footer" },
9
+ agent_transport: {
10
+ mode: "mqtt_tls_pubsub",
11
+ state: "provisioning_required",
12
+ available: false,
13
+ screen_document_available: false,
14
+ activity_available: false,
15
+ queue_or_mailbox: false,
16
+ delivery: "live_qos1_no_offline_queue",
17
+ session_expiry_seconds: 0,
18
+ recommended_min_update_interval_ms: 60000,
19
+ },
20
+ };
21
+
22
+ export function resolveDeviceContext(value) {
23
+ if (!value || typeof value !== "object" || value.schema !== "weclawbot.device_context.v1") {
24
+ return structuredClone(DEFAULT_CONTEXT);
25
+ }
26
+ return value;
27
+ }
28
+
29
+ export function validateScreenDocument(value, suppliedContext) {
30
+ const context = resolveDeviceContext(suppliedContext);
31
+ const errors = [];
32
+ const document = value && typeof value === "object" ? value : null;
33
+ const viewport = context.content_viewport;
34
+ if (!document) return result(context, errors.concat("document must be an object"));
35
+ if (document.schema !== "weclawbot.screen_document.v1") {
36
+ errors.push("schema must be weclawbot.screen_document.v1");
37
+ }
38
+ if (document.target !== viewport.id) {
39
+ errors.push(`target must be ${viewport.id}`);
40
+ }
41
+ if (document.kind !== "replace") errors.push("kind must be replace");
42
+ if (!string(document.id)) errors.push("id is required");
43
+ if (typeof document.base_revision !== "string") errors.push("base_revision must be a string (empty for the first document)");
44
+ if (!isFutureIso(document.expires_at)) errors.push("expires_at must be a future UTC RFC3339 timestamp");
45
+ if (!Array.isArray(document.pages) || document.pages.length < 1 || document.pages.length > viewport.max_pages) {
46
+ errors.push(`pages must contain 1..${viewport.max_pages} items`);
47
+ } else {
48
+ document.pages.forEach((page, index) => validatePage(page, index, viewport, errors));
49
+ }
50
+ return result(context, errors);
51
+ }
52
+
53
+ function validatePage(page, index, viewport, errors) {
54
+ if (!page || typeof page !== "object") {
55
+ errors.push(`pages[${index}] must be an object`);
56
+ return;
57
+ }
58
+ const width = Number(page.width);
59
+ const height = Number(page.height);
60
+ const stride = Number(page.stride);
61
+ if (page.format !== "mono1") errors.push(`pages[${index}].format must be mono1`);
62
+ if (!Number.isInteger(width) || width < 1 || width > viewport.width) {
63
+ errors.push(`pages[${index}].width exceeds viewport`);
64
+ }
65
+ if (!Number.isInteger(height) || height < 1 || height > viewport.height) {
66
+ errors.push(`pages[${index}].height exceeds viewport`);
67
+ }
68
+ const minStride = Number.isInteger(width) ? Math.ceil(width / 8) : 0;
69
+ if (!Number.isInteger(stride) || stride < minStride) {
70
+ errors.push(`pages[${index}].stride must be at least ceil(width / 8)`);
71
+ }
72
+ const bytes = decodeStrictBase64(page.data_b64);
73
+ if (!bytes) {
74
+ errors.push(`pages[${index}].data_b64 is not valid base64`);
75
+ } else if (Number.isInteger(stride) && Number.isInteger(height) && bytes.length !== stride * height) {
76
+ errors.push(`pages[${index}].data_b64 byte length does not match stride * height`);
77
+ }
78
+ }
79
+
80
+ function result(context, errors) {
81
+ return {
82
+ ok: errors.length === 0,
83
+ errors,
84
+ viewport: context.content_viewport,
85
+ agent_transport: context.agent_transport || null,
86
+ direct_delivery_ready: context.agent_transport?.screen_document_available === true
87
+ || context.agent_transport?.available === true,
88
+ };
89
+ }
90
+
91
+ function decodeStrictBase64(value) {
92
+ if (!string(value) || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(value)) {
93
+ return null;
94
+ }
95
+ try {
96
+ return Buffer.from(value, "base64");
97
+ } catch {
98
+ return null;
99
+ }
100
+ }
101
+
102
+ function isFutureIso(value) {
103
+ if (!/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]{3})?Z$/u.test(String(value || ""))) return false;
104
+ const stamp = Date.parse(value);
105
+ return Number.isFinite(stamp) && stamp > Date.now();
106
+ }
107
+
108
+ function string(value) {
109
+ return typeof value === "string" && value.length > 0;
110
+ }
@@ -0,0 +1,91 @@
1
+ import mqtt from "mqtt";
2
+
3
+ export async function publishControl(credentials, control) {
4
+ const config = normalizeCredentials(credentials);
5
+ if (!control || typeof control !== "object" || control.schema !== "weclawbot.control.v1") {
6
+ throw new Error("invalid_control_message");
7
+ }
8
+ const client = mqtt.connect(config.url, {
9
+ clientId: config.clientId,
10
+ username: config.username,
11
+ password: config.password,
12
+ clean: true,
13
+ reconnectPeriod: 0,
14
+ connectTimeout: 12_000,
15
+ protocolVersion: 5,
16
+ properties: { sessionExpiryInterval: 0 },
17
+ });
18
+ try {
19
+ await onceConnected(client);
20
+ await new Promise((resolve, reject) => {
21
+ client.publish(config.controlTopic, JSON.stringify(control), { qos: 1, retain: false }, (error) => {
22
+ if (error) reject(error);
23
+ else resolve();
24
+ });
25
+ });
26
+ } finally {
27
+ client.end(true);
28
+ }
29
+ }
30
+
31
+ export async function testConnection(credentials) {
32
+ const config = normalizeCredentials(credentials);
33
+ const client = mqtt.connect(config.url, {
34
+ clientId: config.clientId,
35
+ username: config.username,
36
+ password: config.password,
37
+ clean: true,
38
+ reconnectPeriod: 0,
39
+ connectTimeout: 12_000,
40
+ protocolVersion: 5,
41
+ properties: { sessionExpiryInterval: 0 },
42
+ });
43
+ try {
44
+ await onceConnected(client);
45
+ } finally {
46
+ client.end(true);
47
+ }
48
+ return {
49
+ ok: true,
50
+ url: config.url,
51
+ client_id: config.clientId,
52
+ username: config.username,
53
+ control_topic: config.controlTopic,
54
+ };
55
+ }
56
+
57
+ export function normalizeCredentials(value) {
58
+ const mqttConfig = value?.mqtt && typeof value.mqtt === "object" ? value.mqtt : value;
59
+ const topics = mqttConfig?.topics;
60
+ const url = string(mqttConfig?.url);
61
+ const username = string(mqttConfig?.username);
62
+ const password = string(mqttConfig?.password);
63
+ const clientId = string(mqttConfig?.client_id);
64
+ const controlTopic = string(topics?.control);
65
+ if (!url || !username || !password || !clientId || !controlTopic) {
66
+ throw new Error("agent_credentials_incomplete");
67
+ }
68
+ if (!/^wss:\/\//u.test(url)) throw new Error("agent_mqtt_requires_wss");
69
+ return { url, username, password, clientId, controlTopic };
70
+ }
71
+
72
+ function onceConnected(client) {
73
+ return new Promise((resolve, reject) => {
74
+ const timeout = setTimeout(() => finish(new Error("mqtt_connect_timeout")), 12_500);
75
+ const finish = (error) => {
76
+ clearTimeout(timeout);
77
+ client.removeListener("connect", onConnect);
78
+ client.removeListener("error", onError);
79
+ if (error) reject(error);
80
+ else resolve();
81
+ };
82
+ const onConnect = () => finish();
83
+ const onError = (error) => finish(error);
84
+ client.once("connect", onConnect);
85
+ client.once("error", onError);
86
+ });
87
+ }
88
+
89
+ function string(value) {
90
+ return typeof value === "string" ? value.trim() : "";
91
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "id": "weclawbot",
3
+ "name": "WeClawBot",
4
+ "description": "Lets OpenClaw curate WeChat messages and validate live screen documents for a paired WeClawBot screen.",
5
+ "skills": [
6
+ "./skills"
7
+ ],
8
+ "activation": {
9
+ "onStartup": true
10
+ },
11
+ "configSchema": {
12
+ "type": "object",
13
+ "additionalProperties": false,
14
+ "properties": {}
15
+ }
16
+ }