@seeed-studio/meshtastic 0.1.1 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/scripts/translate_readme.py +632 -0
- package/.github/translate/do-not-translate.md +6 -0
- package/.github/translate/glossary/es.md +23 -0
- package/.github/translate/glossary/fr.md +23 -0
- package/.github/translate/glossary/ja.md +23 -0
- package/.github/translate/glossary/pt.md +23 -0
- package/.github/translate/glossary/zh-CN.md +23 -0
- package/.github/translate/languages.json +37 -0
- package/.github/translate/prompts/es.md +16 -0
- package/.github/translate/prompts/fr.md +16 -0
- package/.github/translate/prompts/ja.md +17 -0
- package/.github/translate/prompts/pt.md +16 -0
- package/.github/translate/prompts/zh-CN.md +15 -0
- package/.github/workflows/publish.yml +25 -0
- package/.github/workflows/readme-translate.yml +166 -0
- package/AGENTS.md +172 -0
- package/LICENSE +21 -0
- package/README.es.md +337 -0
- package/README.fr.md +350 -0
- package/README.ja.md +344 -0
- package/README.md +262 -88
- package/README.pt.md +337 -0
- package/README.zh-CN.md +337 -0
- package/package.json +4 -3
- package/src/channel.ts +70 -17
- package/src/client.ts +108 -17
- package/src/config-schema.ts +37 -7
- package/src/inbound.ts +19 -4
- package/src/monitor.ts +131 -104
- package/src/mqtt-client.ts +30 -6
- package/src/normalize.ts +12 -4
- package/src/onboarding.ts +116 -28
- package/src/policy.ts +6 -2
- package/src/send.ts +13 -7
- package/src/types.ts +4 -2
package/package.json
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@seeed-studio/meshtastic",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "OpenClaw Meshtastic LoRa mesh
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "MeshClaw — OpenClaw channel plugin for Meshtastic LoRa mesh networks",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
9
|
-
"url": "https://github.com/Seeed-Solution/
|
|
9
|
+
"url": "https://github.com/Seeed-Solution/MeshClaw"
|
|
10
10
|
},
|
|
11
11
|
"keywords": [
|
|
12
|
+
"meshclaw",
|
|
12
13
|
"openclaw",
|
|
13
14
|
"meshtastic",
|
|
14
15
|
"lora",
|
package/src/channel.ts
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
resolveDefaultGroupPolicy,
|
|
11
11
|
setAccountEnabledInConfigSection,
|
|
12
12
|
type ChannelPlugin,
|
|
13
|
-
} from "openclaw/plugin-sdk";
|
|
13
|
+
} from "openclaw/plugin-sdk/irc";
|
|
14
14
|
import {
|
|
15
15
|
listMeshtasticAccountIds,
|
|
16
16
|
resolveDefaultMeshtasticAccountId,
|
|
@@ -264,8 +264,8 @@ export const meshtasticPlugin: ChannelPlugin<ResolvedMeshtasticAccount, Meshtast
|
|
|
264
264
|
},
|
|
265
265
|
outbound: {
|
|
266
266
|
deliveryMode: "direct",
|
|
267
|
-
chunker: (text, limit) => getMeshtasticRuntime().channel.text.
|
|
268
|
-
chunkerMode: "
|
|
267
|
+
chunker: (text, limit) => getMeshtasticRuntime().channel.text.chunkText(text, limit),
|
|
268
|
+
chunkerMode: "text",
|
|
269
269
|
textChunkLimit: 200,
|
|
270
270
|
sendText: async ({ to, text, accountId }) => {
|
|
271
271
|
const result = await sendMessageMeshtastic(to, text, {
|
|
@@ -291,25 +291,78 @@ export const meshtasticPlugin: ChannelPlugin<ResolvedMeshtasticAccount, Meshtast
|
|
|
291
291
|
lastProbeAt: snapshot.lastProbeAt ?? null,
|
|
292
292
|
}),
|
|
293
293
|
probeAccount: async ({ account }) => {
|
|
294
|
-
// Meshtastic probing is transport-dependent and may require
|
|
295
|
-
// active device connection. Return a basic status.
|
|
296
294
|
if (!account.configured) {
|
|
297
295
|
return {
|
|
298
296
|
ok: false,
|
|
299
|
-
error: "
|
|
297
|
+
error: "Not configured. Run 'openclaw onboard' to configure.",
|
|
300
298
|
transport: account.transport,
|
|
301
299
|
} as MeshtasticProbe;
|
|
302
300
|
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
account.transport === "
|
|
308
|
-
? account.
|
|
309
|
-
: account.
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
301
|
+
|
|
302
|
+
const address =
|
|
303
|
+
account.transport === "serial"
|
|
304
|
+
? account.serialPort
|
|
305
|
+
: account.transport === "http"
|
|
306
|
+
? account.httpAddress
|
|
307
|
+
: account.config.mqtt?.broker;
|
|
308
|
+
|
|
309
|
+
// Lightweight transport-specific reachability check.
|
|
310
|
+
try {
|
|
311
|
+
if (account.transport === "serial") {
|
|
312
|
+
const { access } = await import("node:fs/promises");
|
|
313
|
+
await access(account.serialPort);
|
|
314
|
+
} else if (account.transport === "http") {
|
|
315
|
+
const prefix = account.httpTls ? "https" : "http";
|
|
316
|
+
const url = `${prefix}://${account.httpAddress}/api/v1/fromradio`;
|
|
317
|
+
const controller = new AbortController();
|
|
318
|
+
const timeout = setTimeout(() => controller.abort(), 5_000);
|
|
319
|
+
try {
|
|
320
|
+
await fetch(url, { signal: controller.signal });
|
|
321
|
+
} finally {
|
|
322
|
+
clearTimeout(timeout);
|
|
323
|
+
}
|
|
324
|
+
} else if (account.transport === "mqtt") {
|
|
325
|
+
const mqtt = await import("mqtt");
|
|
326
|
+
const mqttConfig = account.config.mqtt;
|
|
327
|
+
const protocol = mqttConfig?.tls ? "mqtts" : "mqtt";
|
|
328
|
+
const broker = mqttConfig?.broker ?? "mqtt.meshtastic.org";
|
|
329
|
+
const port = mqttConfig?.port ?? 1883;
|
|
330
|
+
await new Promise<void>((resolve, reject) => {
|
|
331
|
+
const timeout = setTimeout(() => {
|
|
332
|
+
client.end(true);
|
|
333
|
+
reject(new Error("MQTT connection timed out (5s)"));
|
|
334
|
+
}, 5_000);
|
|
335
|
+
const client = mqtt.default.connect(`${protocol}://${broker}:${port}`, {
|
|
336
|
+
username: mqttConfig?.username ?? "meshdev",
|
|
337
|
+
password: mqttConfig?.password ?? "large4cats",
|
|
338
|
+
clean: true,
|
|
339
|
+
connectTimeout: 5_000,
|
|
340
|
+
});
|
|
341
|
+
client.on("connect", () => {
|
|
342
|
+
clearTimeout(timeout);
|
|
343
|
+
client.end(true);
|
|
344
|
+
resolve();
|
|
345
|
+
});
|
|
346
|
+
client.on("error", (err) => {
|
|
347
|
+
clearTimeout(timeout);
|
|
348
|
+
client.end(true);
|
|
349
|
+
reject(err);
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
return {
|
|
354
|
+
ok: true,
|
|
355
|
+
transport: account.transport,
|
|
356
|
+
address,
|
|
357
|
+
} as MeshtasticProbe;
|
|
358
|
+
} catch (err) {
|
|
359
|
+
return {
|
|
360
|
+
ok: false,
|
|
361
|
+
error: err instanceof Error ? err.message : String(err),
|
|
362
|
+
transport: account.transport,
|
|
363
|
+
address,
|
|
364
|
+
} as MeshtasticProbe;
|
|
365
|
+
}
|
|
313
366
|
},
|
|
314
367
|
buildAccountSnapshot: ({ account, runtime, probe }) => ({
|
|
315
368
|
...buildBaseAccountStatusSnapshot({ account, runtime, probe }),
|
|
@@ -324,7 +377,7 @@ export const meshtasticPlugin: ChannelPlugin<ResolvedMeshtasticAccount, Meshtast
|
|
|
324
377
|
if (!account.configured) {
|
|
325
378
|
throw new Error(
|
|
326
379
|
`Meshtastic is not configured for account "${account.accountId}". ` +
|
|
327
|
-
`
|
|
380
|
+
`Run 'openclaw onboard' or set channels.meshtastic.transport and connection details in config.`,
|
|
328
381
|
);
|
|
329
382
|
}
|
|
330
383
|
const transportDesc =
|
package/src/client.ts
CHANGED
|
@@ -2,6 +2,39 @@ import { MeshDevice } from "@meshtastic/core";
|
|
|
2
2
|
import { nodeNumToHex } from "./normalize.js";
|
|
3
3
|
import type { MeshtasticRegion } from "./types.js";
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Device status codes from @meshtastic/core DeviceStatusEnum.
|
|
7
|
+
* The SDK doesn't export these as named constants, so we define them here.
|
|
8
|
+
*/
|
|
9
|
+
export const DeviceStatus = {
|
|
10
|
+
Restarting: 1,
|
|
11
|
+
Disconnected: 2,
|
|
12
|
+
Connecting: 3,
|
|
13
|
+
Reconnecting: 4,
|
|
14
|
+
Connected: 5,
|
|
15
|
+
Configuring: 6,
|
|
16
|
+
Configured: 7,
|
|
17
|
+
} as const;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Thrown when setOwner() was called to update the device name.
|
|
21
|
+
* The device will reboot to persist the change; the caller should
|
|
22
|
+
* reconnect after a delay (~30 s).
|
|
23
|
+
*/
|
|
24
|
+
export class SetOwnerRebootError extends Error {
|
|
25
|
+
constructor(
|
|
26
|
+
public readonly newName: string,
|
|
27
|
+
public readonly previousName: string | undefined,
|
|
28
|
+
) {
|
|
29
|
+
super(
|
|
30
|
+
`Device rebooting to apply name "${newName}"` +
|
|
31
|
+
(previousName ? ` (was "${previousName}")` : "") +
|
|
32
|
+
`. Auto-reconnect expected.`,
|
|
33
|
+
);
|
|
34
|
+
this.name = "SetOwnerRebootError";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
5
38
|
export type MeshtasticTextEvent = {
|
|
6
39
|
senderNodeNum: number;
|
|
7
40
|
senderNodeId: string;
|
|
@@ -37,6 +70,7 @@ export type MeshtasticClient = {
|
|
|
37
70
|
channelIndex?: number,
|
|
38
71
|
) => Promise<number>;
|
|
39
72
|
getNodeName: (nodeNum: number) => string | undefined;
|
|
73
|
+
getMyNodeName: () => string | undefined;
|
|
40
74
|
getChannelName: (index: number) => string | undefined;
|
|
41
75
|
close: () => void;
|
|
42
76
|
};
|
|
@@ -93,6 +127,22 @@ export async function connectMeshtasticClient(
|
|
|
93
127
|
|
|
94
128
|
const device = new MeshDevice(transport);
|
|
95
129
|
|
|
130
|
+
// Expose serial port events for diagnostics — helps pinpoint why USB
|
|
131
|
+
// connections drop (power management, cable, firmware, etc.).
|
|
132
|
+
if (options.transport === "serial") {
|
|
133
|
+
const port = (transport as unknown as { port?: { on?: (...a: unknown[]) => void } }).port;
|
|
134
|
+
if (port?.on) {
|
|
135
|
+
port.on("error", (err: unknown) => {
|
|
136
|
+
options.onError?.(
|
|
137
|
+
err instanceof Error ? err : new Error(`serial port error: ${err}`),
|
|
138
|
+
);
|
|
139
|
+
});
|
|
140
|
+
port.on("close", () => {
|
|
141
|
+
options.onStatus?.("serial port closed by OS / device");
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
96
146
|
// Node info cache for name resolution.
|
|
97
147
|
const nodeNames = new Map<number, string>();
|
|
98
148
|
const channelNames = new Map<number, string>();
|
|
@@ -106,10 +156,11 @@ export async function connectMeshtasticClient(
|
|
|
106
156
|
}
|
|
107
157
|
});
|
|
108
158
|
|
|
159
|
+
// NodeInfo is dispatched directly (not wrapped in { data: … }) during config.
|
|
109
160
|
device.events.onNodeInfoPacket.subscribe(
|
|
110
|
-
(packet: {
|
|
111
|
-
if (packet.
|
|
112
|
-
nodeNames.set(packet.
|
|
161
|
+
(packet: { num?: number; user?: { longName?: string } }) => {
|
|
162
|
+
if (packet.user?.longName && packet.num) {
|
|
163
|
+
nodeNames.set(packet.num, packet.user.longName);
|
|
113
164
|
}
|
|
114
165
|
},
|
|
115
166
|
);
|
|
@@ -132,7 +183,16 @@ export async function connectMeshtasticClient(
|
|
|
132
183
|
);
|
|
133
184
|
|
|
134
185
|
device.events.onDeviceStatus.subscribe((status: number) => {
|
|
135
|
-
|
|
186
|
+
const label: Record<number, string> = {
|
|
187
|
+
[DeviceStatus.Restarting]: "Restarting",
|
|
188
|
+
[DeviceStatus.Disconnected]: "Disconnected",
|
|
189
|
+
[DeviceStatus.Connecting]: "Connecting",
|
|
190
|
+
[DeviceStatus.Reconnecting]: "Reconnecting",
|
|
191
|
+
[DeviceStatus.Connected]: "Connected",
|
|
192
|
+
[DeviceStatus.Configuring]: "Configuring",
|
|
193
|
+
[DeviceStatus.Configured]: "Configured",
|
|
194
|
+
};
|
|
195
|
+
options.onStatus?.(`status=${status} (${label[status] ?? "unknown"})`);
|
|
136
196
|
});
|
|
137
197
|
|
|
138
198
|
device.events.onMessagePacket.subscribe(
|
|
@@ -203,27 +263,31 @@ export async function connectMeshtasticClient(
|
|
|
203
263
|
};
|
|
204
264
|
const timeout = setTimeout(() => {
|
|
205
265
|
cleanup();
|
|
206
|
-
reject(new Error(
|
|
266
|
+
reject(new Error(
|
|
267
|
+
"Device configure timed out (45s). Check USB connection, try a different port, or power-cycle the device.",
|
|
268
|
+
));
|
|
207
269
|
}, 45_000);
|
|
208
270
|
device.events.onDeviceStatus.subscribe((status: number) => {
|
|
209
|
-
if (status ===
|
|
271
|
+
if (status === DeviceStatus.Configured) {
|
|
210
272
|
cleanup();
|
|
211
273
|
resolve();
|
|
212
|
-
} else if (status ===
|
|
274
|
+
} else if (status === DeviceStatus.Connected && !configureRetried) {
|
|
213
275
|
// Transport is now connected — re-send the config request after a
|
|
214
276
|
// short delay so the serial pipe is fully established.
|
|
215
277
|
configureRetried = true;
|
|
216
278
|
setTimeout(() => device.configure().catch(() => {}), 500);
|
|
217
|
-
} else if (status ===
|
|
279
|
+
} else if (status === DeviceStatus.Disconnected) {
|
|
218
280
|
cleanup();
|
|
219
|
-
reject(new Error(
|
|
281
|
+
reject(new Error(
|
|
282
|
+
"Device disconnected during configure. Check cable connection and ensure no other program is using the serial port.",
|
|
283
|
+
));
|
|
220
284
|
}
|
|
221
285
|
});
|
|
222
286
|
// Poll as fallback — ste-core dispatch can miss late subscribers.
|
|
223
287
|
poll = setInterval(() => {
|
|
224
288
|
if (
|
|
225
289
|
(device as unknown as { isConfigured: boolean }).isConfigured ||
|
|
226
|
-
(device as unknown as { deviceStatus: number }).deviceStatus ===
|
|
290
|
+
(device as unknown as { deviceStatus: number }).deviceStatus === DeviceStatus.Configured
|
|
227
291
|
) {
|
|
228
292
|
cleanup();
|
|
229
293
|
resolve();
|
|
@@ -241,17 +305,43 @@ export async function connectMeshtasticClient(
|
|
|
241
305
|
throw err;
|
|
242
306
|
}
|
|
243
307
|
|
|
308
|
+
// Serial connections require periodic heartbeat pings to stay alive.
|
|
309
|
+
// Without this the device (or macOS USB stack) drops the connection after
|
|
310
|
+
// ~30 s of inactivity once configuration is complete.
|
|
311
|
+
if (options.transport === "serial") {
|
|
312
|
+
device.setHeartbeatInterval(15_000);
|
|
313
|
+
}
|
|
314
|
+
|
|
244
315
|
// LoRa region: rely on NVS-persisted config set via `meshtastic --set lora.region`.
|
|
245
316
|
// Sending a partial setConfig (region-only) zeroes out tx_enabled, tx_power, etc.
|
|
246
317
|
// in the protobuf message, effectively disabling TX. So we skip setConfig here.
|
|
247
318
|
|
|
248
|
-
//
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
319
|
+
// Device display name — only call setOwner() when the configured name
|
|
320
|
+
// differs from what the device already reports. setOwner() sends an admin
|
|
321
|
+
// packet that writes to NVS flash and reboots the device (~20-30 s later).
|
|
322
|
+
// By gating on a name mismatch we ensure the call happens at most once;
|
|
323
|
+
// after reboot the name matches and the guard passes, preventing an
|
|
324
|
+
// infinite reboot loop.
|
|
325
|
+
if (options.nodeName) {
|
|
326
|
+
const desiredName = options.nodeName.trim();
|
|
327
|
+
const currentName = nodeNames.get(myNodeNum);
|
|
328
|
+
|
|
329
|
+
if (desiredName && currentName !== desiredName) {
|
|
330
|
+
const shortName = desiredName.slice(0, 4);
|
|
331
|
+
try {
|
|
332
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
333
|
+
await device.setOwner({ longName: desiredName, shortName } as any);
|
|
334
|
+
} catch {
|
|
335
|
+
// Admin packet may fail on a flaky serial link; fall through and
|
|
336
|
+
// let the device keep its current name rather than crashing.
|
|
337
|
+
}
|
|
338
|
+
// Allow the firmware time to receive and process the admin packet
|
|
339
|
+
// before we tear down the serial connection.
|
|
340
|
+
options.onStatus?.("waiting 2s for firmware to process name change...");
|
|
341
|
+
await new Promise((r) => setTimeout(r, 2_000));
|
|
342
|
+
safeDisconnect();
|
|
343
|
+
throw new SetOwnerRebootError(desiredName, currentName);
|
|
344
|
+
}
|
|
255
345
|
}
|
|
256
346
|
|
|
257
347
|
// Catch unhandled promise rejections originating from @meshtastic/core's
|
|
@@ -283,6 +373,7 @@ export async function connectMeshtasticClient(
|
|
|
283
373
|
sendText: (text, destination, wantAck = true, channelIndex) =>
|
|
284
374
|
device.sendText(text, destination, wantAck, channelIndex),
|
|
285
375
|
getNodeName: (nodeNum) => nodeNames.get(nodeNum),
|
|
376
|
+
getMyNodeName: () => nodeNames.get(myNodeNum),
|
|
286
377
|
getChannelName: (index) => channelNames.get(index) || (index === 0 ? "LongFast" : undefined),
|
|
287
378
|
close: () => {
|
|
288
379
|
safeDisconnect();
|
package/src/config-schema.ts
CHANGED
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
ReplyRuntimeConfigSchemaShape,
|
|
6
6
|
ToolPolicySchema,
|
|
7
7
|
requireOpenAllowFrom,
|
|
8
|
-
} from "openclaw/plugin-sdk";
|
|
8
|
+
} from "openclaw/plugin-sdk/irc";
|
|
9
9
|
import { z } from "zod";
|
|
10
10
|
|
|
11
11
|
const MeshtasticGroupSchema = z
|
|
@@ -22,13 +22,14 @@ const MeshtasticGroupSchema = z
|
|
|
22
22
|
|
|
23
23
|
const MeshtasticMqttSchema = z
|
|
24
24
|
.object({
|
|
25
|
-
broker: z.string().optional(),
|
|
26
|
-
port: z.number().int().min(1).max(65535).optional(),
|
|
27
|
-
username: z.string().optional(),
|
|
28
|
-
password: z.string().optional(),
|
|
29
|
-
topic: z.string().optional(),
|
|
25
|
+
broker: z.string().optional().default("mqtt.meshtastic.org"),
|
|
26
|
+
port: z.number().int().min(1).max(65535).optional().default(1883),
|
|
27
|
+
username: z.string().optional().default("meshdev"),
|
|
28
|
+
password: z.string().optional().default("large4cats"),
|
|
29
|
+
topic: z.string().optional().default("msh/US/2/json/#"),
|
|
30
30
|
publishTopic: z.string().optional(),
|
|
31
|
-
tls: z.boolean().optional(),
|
|
31
|
+
tls: z.boolean().optional().default(false),
|
|
32
|
+
myNodeId: z.string().optional(),
|
|
32
33
|
})
|
|
33
34
|
.strict();
|
|
34
35
|
|
|
@@ -77,11 +78,39 @@ export const MeshtasticAccountSchemaBase = z
|
|
|
77
78
|
channels: z.record(z.string(), MeshtasticGroupSchema.optional()).optional(),
|
|
78
79
|
mentionPatterns: z.array(z.string()).optional(),
|
|
79
80
|
markdown: MarkdownConfigSchema,
|
|
81
|
+
textChunkLimit: z.number().int().min(50).max(500).optional(),
|
|
80
82
|
...ReplyRuntimeConfigSchemaShape,
|
|
81
83
|
})
|
|
82
84
|
.strict();
|
|
83
85
|
|
|
86
|
+
/** Validates transport+connection coherence and open policy allowFrom. */
|
|
87
|
+
function validateTransportConnection(value: z.output<typeof MeshtasticAccountSchemaBase>, ctx: z.RefinementCtx) {
|
|
88
|
+
const transport = value.transport ?? "serial";
|
|
89
|
+
if (transport === "serial" && !value.serialPort) {
|
|
90
|
+
ctx.addIssue({
|
|
91
|
+
code: z.ZodIssueCode.custom,
|
|
92
|
+
path: ["serialPort"],
|
|
93
|
+
message: 'transport="serial" requires serialPort (e.g. "/dev/ttyUSB0" or "/dev/tty.usbmodem*")',
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
if (transport === "http" && !value.httpAddress) {
|
|
97
|
+
ctx.addIssue({
|
|
98
|
+
code: z.ZodIssueCode.custom,
|
|
99
|
+
path: ["httpAddress"],
|
|
100
|
+
message: 'transport="http" requires httpAddress (e.g. "meshtastic.local" or "192.168.1.100")',
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
if (transport === "mqtt" && !value.mqtt?.broker) {
|
|
104
|
+
ctx.addIssue({
|
|
105
|
+
code: z.ZodIssueCode.custom,
|
|
106
|
+
path: ["mqtt", "broker"],
|
|
107
|
+
message: 'transport="mqtt" requires mqtt.broker (e.g. "mqtt.meshtastic.org")',
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
84
112
|
export const MeshtasticAccountSchema = MeshtasticAccountSchemaBase.superRefine((value, ctx) => {
|
|
113
|
+
validateTransportConnection(value, ctx);
|
|
85
114
|
requireOpenAllowFrom({
|
|
86
115
|
policy: value.dmPolicy,
|
|
87
116
|
allowFrom: value.allowFrom,
|
|
@@ -95,6 +124,7 @@ export const MeshtasticAccountSchema = MeshtasticAccountSchemaBase.superRefine((
|
|
|
95
124
|
export const MeshtasticConfigSchema = MeshtasticAccountSchemaBase.extend({
|
|
96
125
|
accounts: z.record(z.string(), MeshtasticAccountSchema.optional()).optional(),
|
|
97
126
|
}).superRefine((value, ctx) => {
|
|
127
|
+
validateTransportConnection(value, ctx);
|
|
98
128
|
requireOpenAllowFrom({
|
|
99
129
|
policy: value.dmPolicy,
|
|
100
130
|
allowFrom: value.allowFrom,
|
package/src/inbound.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
GROUP_POLICY_BLOCKED_LABEL,
|
|
3
3
|
createNormalizedOutboundDeliverer,
|
|
4
|
-
createReplyPrefixOptions,
|
|
5
4
|
formatTextWithAttachmentLinks,
|
|
6
5
|
logInboundDrop,
|
|
7
6
|
resolveControlCommandGate,
|
|
@@ -12,7 +11,8 @@ import {
|
|
|
12
11
|
type OutboundReplyPayload,
|
|
13
12
|
type OpenClawConfig,
|
|
14
13
|
type RuntimeEnv,
|
|
15
|
-
} from "openclaw/plugin-sdk";
|
|
14
|
+
} from "openclaw/plugin-sdk/irc";
|
|
15
|
+
import { createReplyPrefixOptions } from "openclaw/plugin-sdk/matrix";
|
|
16
16
|
import type { ResolvedMeshtasticAccount } from "./accounts.js";
|
|
17
17
|
import {
|
|
18
18
|
normalizeMeshtasticAllowlist,
|
|
@@ -51,6 +51,14 @@ function resolveMeshtasticEffectiveAllowlists(params: {
|
|
|
51
51
|
// so the firmware doesn't silently truncate them.
|
|
52
52
|
const MESHTASTIC_CHUNK_LIMIT = 200;
|
|
53
53
|
|
|
54
|
+
/** Channel-level system prompt hint for LoRa-constrained responses. */
|
|
55
|
+
const LORA_SYSTEM_HINT =
|
|
56
|
+
"You are responding over a LoRa mesh radio (Meshtastic). " +
|
|
57
|
+
"Each message is limited to ~200 bytes. " +
|
|
58
|
+
"Keep responses extremely concise — plain text only, no markdown, no emoji, no bullet lists. " +
|
|
59
|
+
"Use short sentences. Omit filler words. Prioritize the most important information first.";
|
|
60
|
+
|
|
61
|
+
|
|
54
62
|
function chunkText(text: string, limit: number): string[] {
|
|
55
63
|
if (text.length <= limit) return [text];
|
|
56
64
|
const chunks: string[] = [];
|
|
@@ -150,7 +158,13 @@ export async function handleMeshtasticInbound(params: {
|
|
|
150
158
|
const storeAllowFrom =
|
|
151
159
|
dmPolicy === "allowlist"
|
|
152
160
|
? []
|
|
153
|
-
: await core.channel.pairing.readAllowFromStore(
|
|
161
|
+
: await core.channel.pairing.readAllowFromStore({
|
|
162
|
+
channel: CHANNEL_ID,
|
|
163
|
+
accountId: account.accountId,
|
|
164
|
+
}).catch((err: unknown) => {
|
|
165
|
+
runtime.log?.(`meshtastic: pairing allowFrom read failed: ${String(err)}`);
|
|
166
|
+
return [] as string[];
|
|
167
|
+
});
|
|
154
168
|
const storeAllowList = normalizeMeshtasticAllowlist(storeAllowFrom);
|
|
155
169
|
|
|
156
170
|
const channelLabel = message.channelName ?? `channel-${message.channelIndex}`;
|
|
@@ -227,6 +241,7 @@ export async function handleMeshtasticInbound(params: {
|
|
|
227
241
|
const normalizedId = normalizeMeshtasticNodeId(message.senderNodeId);
|
|
228
242
|
const { code, created } = await core.channel.pairing.upsertPairingRequest({
|
|
229
243
|
channel: CHANNEL_ID,
|
|
244
|
+
accountId: account.accountId,
|
|
230
245
|
id: normalizedId,
|
|
231
246
|
meta: { name: message.senderName || undefined },
|
|
232
247
|
});
|
|
@@ -336,7 +351,7 @@ export async function handleMeshtasticInbound(params: {
|
|
|
336
351
|
SenderName: message.senderName || undefined,
|
|
337
352
|
SenderId: message.senderNodeId,
|
|
338
353
|
GroupSubject: message.isGroup ? channelLabel : undefined,
|
|
339
|
-
GroupSystemPrompt:
|
|
354
|
+
GroupSystemPrompt: [LORA_SYSTEM_HINT, groupSystemPrompt].filter(Boolean).join("\n\n"),
|
|
340
355
|
Provider: CHANNEL_ID,
|
|
341
356
|
Surface: CHANNEL_ID,
|
|
342
357
|
WasMentioned: message.isGroup ? wasMentioned : undefined,
|