@seeed-studio/meshtastic 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.
- package/README.md +170 -0
- package/index.ts +17 -0
- package/openclaw.plugin.json +9 -0
- package/package.json +30 -0
- package/src/accounts.ts +196 -0
- package/src/channel.ts +347 -0
- package/src/client.ts +296 -0
- package/src/config-schema.ts +106 -0
- package/src/inbound.ts +397 -0
- package/src/monitor.ts +300 -0
- package/src/mqtt-client.ts +162 -0
- package/src/normalize.ts +112 -0
- package/src/onboarding.ts +421 -0
- package/src/policy.ts +153 -0
- package/src/runtime.ts +14 -0
- package/src/send.ts +100 -0
- package/src/types.ts +108 -0
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
import {
|
|
2
|
+
addWildcardAllowFrom,
|
|
3
|
+
DEFAULT_ACCOUNT_ID,
|
|
4
|
+
formatDocsLink,
|
|
5
|
+
promptAccountId,
|
|
6
|
+
promptChannelAccessConfig,
|
|
7
|
+
type ChannelOnboardingAdapter,
|
|
8
|
+
type ChannelOnboardingDmPolicy,
|
|
9
|
+
type DmPolicy,
|
|
10
|
+
type WizardPrompter,
|
|
11
|
+
} from "openclaw/plugin-sdk";
|
|
12
|
+
import {
|
|
13
|
+
listMeshtasticAccountIds,
|
|
14
|
+
resolveDefaultMeshtasticAccountId,
|
|
15
|
+
resolveMeshtasticAccount,
|
|
16
|
+
} from "./accounts.js";
|
|
17
|
+
import { normalizeMeshtasticAllowEntry } from "./normalize.js";
|
|
18
|
+
import type {
|
|
19
|
+
CoreConfig,
|
|
20
|
+
MeshtasticAccountConfig,
|
|
21
|
+
MeshtasticRegion,
|
|
22
|
+
MeshtasticTransport,
|
|
23
|
+
} from "./types.js";
|
|
24
|
+
|
|
25
|
+
const channel = "meshtastic" as const;
|
|
26
|
+
|
|
27
|
+
function parseListInput(raw: string): string[] {
|
|
28
|
+
return raw
|
|
29
|
+
.split(/[\n,;]+/g)
|
|
30
|
+
.map((entry) => entry.trim())
|
|
31
|
+
.filter(Boolean);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function updateMeshtasticAccountConfig(
|
|
35
|
+
cfg: CoreConfig,
|
|
36
|
+
accountId: string,
|
|
37
|
+
patch: Partial<MeshtasticAccountConfig>,
|
|
38
|
+
): CoreConfig {
|
|
39
|
+
const current = cfg.channels?.meshtastic ?? {};
|
|
40
|
+
if (accountId === DEFAULT_ACCOUNT_ID) {
|
|
41
|
+
return {
|
|
42
|
+
...cfg,
|
|
43
|
+
channels: {
|
|
44
|
+
...cfg.channels,
|
|
45
|
+
meshtastic: {
|
|
46
|
+
...current,
|
|
47
|
+
...patch,
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
...cfg,
|
|
54
|
+
channels: {
|
|
55
|
+
...cfg.channels,
|
|
56
|
+
meshtastic: {
|
|
57
|
+
...current,
|
|
58
|
+
accounts: {
|
|
59
|
+
...current.accounts,
|
|
60
|
+
[accountId]: {
|
|
61
|
+
...current.accounts?.[accountId],
|
|
62
|
+
...patch,
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function setMeshtasticDmPolicy(cfg: CoreConfig, dmPolicy: DmPolicy): CoreConfig {
|
|
71
|
+
const allowFrom =
|
|
72
|
+
dmPolicy === "open" ? addWildcardAllowFrom(cfg.channels?.meshtastic?.allowFrom) : undefined;
|
|
73
|
+
return {
|
|
74
|
+
...cfg,
|
|
75
|
+
channels: {
|
|
76
|
+
...cfg.channels,
|
|
77
|
+
meshtastic: {
|
|
78
|
+
...cfg.channels?.meshtastic,
|
|
79
|
+
dmPolicy,
|
|
80
|
+
...(allowFrom ? { allowFrom } : {}),
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function setMeshtasticAllowFrom(cfg: CoreConfig, allowFrom: string[]): CoreConfig {
|
|
87
|
+
return {
|
|
88
|
+
...cfg,
|
|
89
|
+
channels: {
|
|
90
|
+
...cfg.channels,
|
|
91
|
+
meshtastic: {
|
|
92
|
+
...cfg.channels?.meshtastic,
|
|
93
|
+
allowFrom,
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function setMeshtasticGroupAccess(
|
|
100
|
+
cfg: CoreConfig,
|
|
101
|
+
accountId: string,
|
|
102
|
+
policy: "open" | "allowlist" | "disabled",
|
|
103
|
+
entries: string[],
|
|
104
|
+
): CoreConfig {
|
|
105
|
+
if (policy !== "allowlist") {
|
|
106
|
+
return updateMeshtasticAccountConfig(cfg, accountId, {
|
|
107
|
+
enabled: true,
|
|
108
|
+
groupPolicy: policy,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
const normalizedEntries = [...new Set(entries.map((e) => e.trim()).filter(Boolean))];
|
|
112
|
+
const channels = Object.fromEntries(normalizedEntries.map((entry) => [entry, {}]));
|
|
113
|
+
return updateMeshtasticAccountConfig(cfg, accountId, {
|
|
114
|
+
enabled: true,
|
|
115
|
+
groupPolicy: "allowlist",
|
|
116
|
+
channels,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function noteMeshtasticSetupHelp(prompter: WizardPrompter): Promise<void> {
|
|
121
|
+
await prompter.note(
|
|
122
|
+
[
|
|
123
|
+
"Meshtastic connects to LoRa mesh devices.",
|
|
124
|
+
"Transport options: serial (USB), http (WiFi), mqtt (broker).",
|
|
125
|
+
"Serial needs a device port (e.g. /dev/ttyUSB0).",
|
|
126
|
+
"HTTP needs a device IP/hostname (e.g. meshtastic.local).",
|
|
127
|
+
"MQTT needs a broker address (default: mqtt.meshtastic.org).",
|
|
128
|
+
"Env vars: MESHTASTIC_TRANSPORT, MESHTASTIC_SERIAL_PORT, MESHTASTIC_HTTP_ADDRESS, MESHTASTIC_MQTT_BROKER.",
|
|
129
|
+
].join("\n"),
|
|
130
|
+
"Meshtastic setup",
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function promptMeshtasticAllowFrom(params: {
|
|
135
|
+
cfg: CoreConfig;
|
|
136
|
+
prompter: WizardPrompter;
|
|
137
|
+
accountId?: string;
|
|
138
|
+
}): Promise<CoreConfig> {
|
|
139
|
+
const existing = params.cfg.channels?.meshtastic?.allowFrom ?? [];
|
|
140
|
+
|
|
141
|
+
await params.prompter.note(
|
|
142
|
+
[
|
|
143
|
+
"Allowlist Meshtastic DMs by node ID.",
|
|
144
|
+
"Format: !aabbccdd (hex node ID)",
|
|
145
|
+
"Multiple entries: comma-separated.",
|
|
146
|
+
].join("\n"),
|
|
147
|
+
"Meshtastic allowlist",
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
const raw = await params.prompter.text({
|
|
151
|
+
message: "Meshtastic allowFrom (node IDs)",
|
|
152
|
+
placeholder: "!aabbccdd, !11223344",
|
|
153
|
+
initialValue: existing[0] ? String(existing[0]) : undefined,
|
|
154
|
+
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const parsed = parseListInput(String(raw));
|
|
158
|
+
const normalized = [
|
|
159
|
+
...new Set(parsed.map((entry) => normalizeMeshtasticAllowEntry(entry)).filter(Boolean)),
|
|
160
|
+
];
|
|
161
|
+
return setMeshtasticAllowFrom(params.cfg, normalized);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const dmPolicy: ChannelOnboardingDmPolicy = {
|
|
165
|
+
label: "Meshtastic",
|
|
166
|
+
channel,
|
|
167
|
+
policyKey: "channels.meshtastic.dmPolicy",
|
|
168
|
+
allowFromKey: "channels.meshtastic.allowFrom",
|
|
169
|
+
getCurrent: (cfg) => (cfg as CoreConfig).channels?.meshtastic?.dmPolicy ?? "pairing",
|
|
170
|
+
setPolicy: (cfg, policy) => setMeshtasticDmPolicy(cfg as CoreConfig, policy),
|
|
171
|
+
promptAllowFrom: promptMeshtasticAllowFrom,
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
export const meshtasticOnboardingAdapter: ChannelOnboardingAdapter = {
|
|
175
|
+
channel,
|
|
176
|
+
getStatus: async ({ cfg }) => {
|
|
177
|
+
const coreCfg = cfg as CoreConfig;
|
|
178
|
+
const configured = listMeshtasticAccountIds(coreCfg).some(
|
|
179
|
+
(accountId) => resolveMeshtasticAccount({ cfg: coreCfg, accountId }).configured,
|
|
180
|
+
);
|
|
181
|
+
return {
|
|
182
|
+
channel,
|
|
183
|
+
configured,
|
|
184
|
+
statusLines: [`Meshtastic: ${configured ? "configured" : "needs transport config"}`],
|
|
185
|
+
selectionHint: configured ? "configured" : "needs transport config",
|
|
186
|
+
quickstartScore: configured ? 1 : 0,
|
|
187
|
+
};
|
|
188
|
+
},
|
|
189
|
+
configure: async ({
|
|
190
|
+
cfg,
|
|
191
|
+
prompter,
|
|
192
|
+
accountOverrides,
|
|
193
|
+
shouldPromptAccountIds,
|
|
194
|
+
forceAllowFrom,
|
|
195
|
+
}) => {
|
|
196
|
+
let next = cfg as CoreConfig;
|
|
197
|
+
const meshOverride = accountOverrides.meshtastic?.trim();
|
|
198
|
+
const defaultAccountId = resolveDefaultMeshtasticAccountId(next);
|
|
199
|
+
let accountId = meshOverride || defaultAccountId;
|
|
200
|
+
if (shouldPromptAccountIds && !meshOverride) {
|
|
201
|
+
accountId = await promptAccountId({
|
|
202
|
+
cfg: next,
|
|
203
|
+
prompter,
|
|
204
|
+
label: "Meshtastic",
|
|
205
|
+
currentId: accountId,
|
|
206
|
+
listAccountIds: listMeshtasticAccountIds,
|
|
207
|
+
defaultAccountId,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const resolved = resolveMeshtasticAccount({ cfg: next, accountId });
|
|
212
|
+
|
|
213
|
+
if (!resolved.configured) {
|
|
214
|
+
await noteMeshtasticSetupHelp(prompter);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Transport selection.
|
|
218
|
+
const transportChoice = await prompter.select({
|
|
219
|
+
message: "Meshtastic transport",
|
|
220
|
+
options: [
|
|
221
|
+
{ value: "serial", label: "Serial (USB device)" },
|
|
222
|
+
{ value: "http", label: "HTTP (WiFi device)" },
|
|
223
|
+
{ value: "mqtt", label: "MQTT (broker, no local hardware)" },
|
|
224
|
+
],
|
|
225
|
+
initialValue: resolved.transport || "serial",
|
|
226
|
+
});
|
|
227
|
+
const transport = String(transportChoice) as MeshtasticTransport;
|
|
228
|
+
|
|
229
|
+
if (transport === "serial") {
|
|
230
|
+
const serialPort = String(
|
|
231
|
+
await prompter.text({
|
|
232
|
+
message: "Serial port path",
|
|
233
|
+
placeholder: "/dev/ttyUSB0 or /dev/tty.usbmodem*",
|
|
234
|
+
initialValue: resolved.serialPort || undefined,
|
|
235
|
+
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
|
236
|
+
}),
|
|
237
|
+
).trim();
|
|
238
|
+
|
|
239
|
+
next = updateMeshtasticAccountConfig(next, accountId, {
|
|
240
|
+
enabled: true,
|
|
241
|
+
transport: "serial",
|
|
242
|
+
serialPort,
|
|
243
|
+
});
|
|
244
|
+
} else if (transport === "http") {
|
|
245
|
+
const httpAddress = String(
|
|
246
|
+
await prompter.text({
|
|
247
|
+
message: "Device IP or hostname",
|
|
248
|
+
placeholder: "meshtastic.local or 192.168.1.100",
|
|
249
|
+
initialValue: resolved.httpAddress || undefined,
|
|
250
|
+
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
|
251
|
+
}),
|
|
252
|
+
).trim();
|
|
253
|
+
|
|
254
|
+
const httpTls = await prompter.confirm({
|
|
255
|
+
message: "Use HTTPS?",
|
|
256
|
+
initialValue: resolved.httpTls,
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
next = updateMeshtasticAccountConfig(next, accountId, {
|
|
260
|
+
enabled: true,
|
|
261
|
+
transport: "http",
|
|
262
|
+
httpAddress,
|
|
263
|
+
httpTls,
|
|
264
|
+
});
|
|
265
|
+
} else {
|
|
266
|
+
const broker = String(
|
|
267
|
+
await prompter.text({
|
|
268
|
+
message: "MQTT broker hostname",
|
|
269
|
+
initialValue: resolved.config.mqtt?.broker || "mqtt.meshtastic.org",
|
|
270
|
+
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
|
271
|
+
}),
|
|
272
|
+
).trim();
|
|
273
|
+
|
|
274
|
+
const port = Number.parseInt(
|
|
275
|
+
String(
|
|
276
|
+
await prompter.text({
|
|
277
|
+
message: "MQTT broker port",
|
|
278
|
+
initialValue: String(resolved.config.mqtt?.port ?? 1883),
|
|
279
|
+
}),
|
|
280
|
+
),
|
|
281
|
+
10,
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
const username = String(
|
|
285
|
+
await prompter.text({
|
|
286
|
+
message: "MQTT username",
|
|
287
|
+
initialValue: resolved.config.mqtt?.username || "meshdev",
|
|
288
|
+
}),
|
|
289
|
+
).trim();
|
|
290
|
+
|
|
291
|
+
const password = String(
|
|
292
|
+
await prompter.text({
|
|
293
|
+
message: "MQTT password",
|
|
294
|
+
initialValue: resolved.config.mqtt?.password || "large4cats",
|
|
295
|
+
}),
|
|
296
|
+
).trim();
|
|
297
|
+
|
|
298
|
+
const topic = String(
|
|
299
|
+
await prompter.text({
|
|
300
|
+
message: "MQTT subscribe topic",
|
|
301
|
+
initialValue: resolved.config.mqtt?.topic || "msh/US/2/json/#",
|
|
302
|
+
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
|
303
|
+
}),
|
|
304
|
+
).trim();
|
|
305
|
+
|
|
306
|
+
const mqttTls = await prompter.confirm({
|
|
307
|
+
message: "Use TLS for MQTT?",
|
|
308
|
+
initialValue: resolved.config.mqtt?.tls ?? false,
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
next = updateMeshtasticAccountConfig(next, accountId, {
|
|
312
|
+
enabled: true,
|
|
313
|
+
transport: "mqtt",
|
|
314
|
+
mqtt: {
|
|
315
|
+
broker,
|
|
316
|
+
port: Number.isFinite(port) ? port : 1883,
|
|
317
|
+
username: username || undefined,
|
|
318
|
+
password: password || undefined,
|
|
319
|
+
topic,
|
|
320
|
+
tls: mqttTls,
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// LoRa region (serial/HTTP only — applied to device on connect).
|
|
326
|
+
if (transport !== "mqtt") {
|
|
327
|
+
const regionChoice = await prompter.select({
|
|
328
|
+
message: "LoRa region",
|
|
329
|
+
options: [
|
|
330
|
+
{ value: "UNSET", label: "UNSET (keep device default)" },
|
|
331
|
+
{ value: "US", label: "US (902-928 MHz)" },
|
|
332
|
+
{ value: "EU_433", label: "EU_433 (433 MHz)" },
|
|
333
|
+
{ value: "EU_868", label: "EU_868 (869 MHz)" },
|
|
334
|
+
{ value: "CN", label: "CN (470-510 MHz)" },
|
|
335
|
+
{ value: "JP", label: "JP (920 MHz)" },
|
|
336
|
+
{ value: "ANZ", label: "ANZ (915-928 MHz)" },
|
|
337
|
+
{ value: "KR", label: "KR (920-923 MHz)" },
|
|
338
|
+
{ value: "TW", label: "TW (920-925 MHz)" },
|
|
339
|
+
{ value: "RU", label: "RU (868 MHz)" },
|
|
340
|
+
{ value: "IN", label: "IN (865-867 MHz)" },
|
|
341
|
+
{ value: "TH", label: "TH (920-925 MHz)" },
|
|
342
|
+
{ value: "LORA_24", label: "LORA_24 (2.4 GHz)" },
|
|
343
|
+
],
|
|
344
|
+
initialValue: resolved.config.region ?? "UNSET",
|
|
345
|
+
});
|
|
346
|
+
const region = String(regionChoice) as MeshtasticRegion;
|
|
347
|
+
if (region !== "UNSET") {
|
|
348
|
+
next = updateMeshtasticAccountConfig(next, accountId, { region });
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Device display name — also used as a mention pattern so users can
|
|
353
|
+
// @NodeName the bot in group channels.
|
|
354
|
+
const currentNodeName = resolveMeshtasticAccount({ cfg: next, accountId }).config.nodeName;
|
|
355
|
+
const nodeNameInput = String(
|
|
356
|
+
await prompter.text({
|
|
357
|
+
message: "Device display name (also used as @mention trigger)",
|
|
358
|
+
placeholder: "e.g. OpenClaw",
|
|
359
|
+
initialValue: currentNodeName || undefined,
|
|
360
|
+
}),
|
|
361
|
+
).trim();
|
|
362
|
+
if (nodeNameInput) {
|
|
363
|
+
next = updateMeshtasticAccountConfig(next, accountId, { nodeName: nodeNameInput });
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Channel access config.
|
|
367
|
+
const afterConfig = resolveMeshtasticAccount({ cfg: next, accountId });
|
|
368
|
+
const accessConfig = await promptChannelAccessConfig({
|
|
369
|
+
prompter,
|
|
370
|
+
label: "Meshtastic channels",
|
|
371
|
+
currentPolicy: afterConfig.config.groupPolicy ?? "disabled",
|
|
372
|
+
currentEntries: Object.keys(afterConfig.config.channels ?? {}),
|
|
373
|
+
placeholder: "LongFast, Emergency, *",
|
|
374
|
+
updatePrompt: Boolean(afterConfig.config.channels),
|
|
375
|
+
});
|
|
376
|
+
if (accessConfig) {
|
|
377
|
+
next = setMeshtasticGroupAccess(next, accountId, accessConfig.policy, accessConfig.entries);
|
|
378
|
+
|
|
379
|
+
const wantsMentions = await prompter.confirm({
|
|
380
|
+
message: "Require mention to reply in Meshtastic channels?",
|
|
381
|
+
initialValue: true,
|
|
382
|
+
});
|
|
383
|
+
if (!wantsMentions) {
|
|
384
|
+
const resolvedAfter = resolveMeshtasticAccount({ cfg: next, accountId });
|
|
385
|
+
const channels = resolvedAfter.config.channels ?? {};
|
|
386
|
+
const patched = Object.fromEntries(
|
|
387
|
+
Object.entries(channels).map(([key, value]) => [
|
|
388
|
+
key,
|
|
389
|
+
{ ...value, requireMention: false },
|
|
390
|
+
]),
|
|
391
|
+
);
|
|
392
|
+
next = updateMeshtasticAccountConfig(next, accountId, { channels: patched });
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (forceAllowFrom) {
|
|
397
|
+
next = await promptMeshtasticAllowFrom({ cfg: next, prompter, accountId });
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
await prompter.note(
|
|
401
|
+
[
|
|
402
|
+
"Next: restart gateway and verify status.",
|
|
403
|
+
"Command: openclaw channels status --probe",
|
|
404
|
+
].join("\n"),
|
|
405
|
+
"Meshtastic next steps",
|
|
406
|
+
);
|
|
407
|
+
|
|
408
|
+
return { cfg: next, accountId };
|
|
409
|
+
},
|
|
410
|
+
dmPolicy,
|
|
411
|
+
disable: (cfg) => ({
|
|
412
|
+
...(cfg as CoreConfig),
|
|
413
|
+
channels: {
|
|
414
|
+
...(cfg as CoreConfig).channels,
|
|
415
|
+
meshtastic: {
|
|
416
|
+
...(cfg as CoreConfig).channels?.meshtastic,
|
|
417
|
+
enabled: false,
|
|
418
|
+
},
|
|
419
|
+
},
|
|
420
|
+
}),
|
|
421
|
+
};
|
package/src/policy.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { normalizeMeshtasticAllowlist, resolveMeshtasticAllowlistMatch } from "./normalize.js";
|
|
2
|
+
import type { MeshtasticAccountConfig, MeshtasticChannelConfig } from "./types.js";
|
|
3
|
+
import type { MeshtasticInboundMessage } from "./types.js";
|
|
4
|
+
|
|
5
|
+
export type MeshtasticGroupMatch = {
|
|
6
|
+
allowed: boolean;
|
|
7
|
+
groupConfig?: MeshtasticChannelConfig;
|
|
8
|
+
wildcardConfig?: MeshtasticChannelConfig;
|
|
9
|
+
hasConfiguredGroups: boolean;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type MeshtasticGroupAccessGate = {
|
|
13
|
+
allowed: boolean;
|
|
14
|
+
reason: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function resolveMeshtasticGroupMatch(params: {
|
|
18
|
+
groups?: Record<string, MeshtasticChannelConfig>;
|
|
19
|
+
target: string;
|
|
20
|
+
}): MeshtasticGroupMatch {
|
|
21
|
+
const groups = params.groups ?? {};
|
|
22
|
+
const hasConfiguredGroups = Object.keys(groups).length > 0;
|
|
23
|
+
|
|
24
|
+
const direct = groups[params.target];
|
|
25
|
+
if (direct) {
|
|
26
|
+
return {
|
|
27
|
+
allowed: true,
|
|
28
|
+
groupConfig: direct,
|
|
29
|
+
wildcardConfig: groups["*"],
|
|
30
|
+
hasConfiguredGroups,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Case-insensitive match for channel names.
|
|
35
|
+
const targetLower = params.target.toLowerCase();
|
|
36
|
+
const directKey = Object.keys(groups).find((key) => key.toLowerCase() === targetLower);
|
|
37
|
+
if (directKey) {
|
|
38
|
+
const matched = groups[directKey];
|
|
39
|
+
if (matched) {
|
|
40
|
+
return {
|
|
41
|
+
allowed: true,
|
|
42
|
+
groupConfig: matched,
|
|
43
|
+
wildcardConfig: groups["*"],
|
|
44
|
+
hasConfiguredGroups,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const wildcard = groups["*"];
|
|
50
|
+
if (wildcard) {
|
|
51
|
+
return {
|
|
52
|
+
allowed: true,
|
|
53
|
+
wildcardConfig: wildcard,
|
|
54
|
+
hasConfiguredGroups,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
allowed: false,
|
|
59
|
+
hasConfiguredGroups,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function resolveMeshtasticGroupAccessGate(params: {
|
|
64
|
+
groupPolicy: MeshtasticAccountConfig["groupPolicy"];
|
|
65
|
+
groupMatch: MeshtasticGroupMatch;
|
|
66
|
+
}): MeshtasticGroupAccessGate {
|
|
67
|
+
const policy = params.groupPolicy ?? "disabled";
|
|
68
|
+
if (policy === "disabled") {
|
|
69
|
+
return { allowed: false, reason: "groupPolicy=disabled" };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (policy === "allowlist") {
|
|
73
|
+
if (!params.groupMatch.hasConfiguredGroups) {
|
|
74
|
+
return {
|
|
75
|
+
allowed: false,
|
|
76
|
+
reason: "groupPolicy=allowlist and no channels configured",
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
if (!params.groupMatch.allowed) {
|
|
80
|
+
return { allowed: false, reason: "not allowlisted" };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (
|
|
85
|
+
params.groupMatch.groupConfig?.enabled === false ||
|
|
86
|
+
params.groupMatch.wildcardConfig?.enabled === false
|
|
87
|
+
) {
|
|
88
|
+
return { allowed: false, reason: "disabled" };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { allowed: true, reason: policy === "open" ? "open" : "allowlisted" };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function resolveMeshtasticRequireMention(params: {
|
|
95
|
+
groupConfig?: MeshtasticChannelConfig;
|
|
96
|
+
wildcardConfig?: MeshtasticChannelConfig;
|
|
97
|
+
}): boolean {
|
|
98
|
+
if (params.groupConfig?.requireMention !== undefined) {
|
|
99
|
+
return params.groupConfig.requireMention;
|
|
100
|
+
}
|
|
101
|
+
if (params.wildcardConfig?.requireMention !== undefined) {
|
|
102
|
+
return params.wildcardConfig.requireMention;
|
|
103
|
+
}
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function resolveMeshtasticMentionGate(params: {
|
|
108
|
+
isGroup: boolean;
|
|
109
|
+
requireMention: boolean;
|
|
110
|
+
wasMentioned: boolean;
|
|
111
|
+
hasControlCommand: boolean;
|
|
112
|
+
allowTextCommands: boolean;
|
|
113
|
+
commandAuthorized: boolean;
|
|
114
|
+
}): { shouldSkip: boolean; reason: string } {
|
|
115
|
+
if (!params.isGroup) {
|
|
116
|
+
return { shouldSkip: false, reason: "direct" };
|
|
117
|
+
}
|
|
118
|
+
if (!params.requireMention) {
|
|
119
|
+
return { shouldSkip: false, reason: "mention-not-required" };
|
|
120
|
+
}
|
|
121
|
+
if (params.wasMentioned) {
|
|
122
|
+
return { shouldSkip: false, reason: "mentioned" };
|
|
123
|
+
}
|
|
124
|
+
if (params.hasControlCommand && params.allowTextCommands && params.commandAuthorized) {
|
|
125
|
+
return { shouldSkip: false, reason: "authorized-command" };
|
|
126
|
+
}
|
|
127
|
+
return { shouldSkip: true, reason: "missing-mention" };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function resolveMeshtasticGroupSenderAllowed(params: {
|
|
131
|
+
groupPolicy: MeshtasticAccountConfig["groupPolicy"];
|
|
132
|
+
message: MeshtasticInboundMessage;
|
|
133
|
+
outerAllowFrom: string[];
|
|
134
|
+
innerAllowFrom: string[];
|
|
135
|
+
}): boolean {
|
|
136
|
+
const policy = params.groupPolicy ?? "disabled";
|
|
137
|
+
const inner = normalizeMeshtasticAllowlist(params.innerAllowFrom);
|
|
138
|
+
const outer = normalizeMeshtasticAllowlist(params.outerAllowFrom);
|
|
139
|
+
|
|
140
|
+
if (inner.length > 0) {
|
|
141
|
+
return resolveMeshtasticAllowlistMatch({
|
|
142
|
+
allowFrom: inner,
|
|
143
|
+
message: params.message,
|
|
144
|
+
}).allowed;
|
|
145
|
+
}
|
|
146
|
+
if (outer.length > 0) {
|
|
147
|
+
return resolveMeshtasticAllowlistMatch({
|
|
148
|
+
allowFrom: outer,
|
|
149
|
+
message: params.message,
|
|
150
|
+
}).allowed;
|
|
151
|
+
}
|
|
152
|
+
return policy === "open";
|
|
153
|
+
}
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
|
2
|
+
|
|
3
|
+
let runtime: PluginRuntime | null = null;
|
|
4
|
+
|
|
5
|
+
export function setMeshtasticRuntime(next: PluginRuntime) {
|
|
6
|
+
runtime = next;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function getMeshtasticRuntime(): PluginRuntime {
|
|
10
|
+
if (!runtime) {
|
|
11
|
+
throw new Error("Meshtastic runtime not initialized");
|
|
12
|
+
}
|
|
13
|
+
return runtime;
|
|
14
|
+
}
|
package/src/send.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { resolveMeshtasticAccount } from "./accounts.js";
|
|
3
|
+
import { hexToNodeNum, normalizeMeshtasticMessagingTarget } from "./normalize.js";
|
|
4
|
+
import { getMeshtasticRuntime } from "./runtime.js";
|
|
5
|
+
import type { CoreConfig } from "./types.js";
|
|
6
|
+
|
|
7
|
+
type SendMeshtasticOptions = {
|
|
8
|
+
accountId?: string;
|
|
9
|
+
channelIndex?: number;
|
|
10
|
+
channelName?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type SendMeshtasticResult = {
|
|
14
|
+
messageId: string;
|
|
15
|
+
target: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// Active transport handles set by monitor.ts for reuse.
|
|
19
|
+
let activeSerialSend:
|
|
20
|
+
| ((text: string, destination?: number, channelIndex?: number) => Promise<number>)
|
|
21
|
+
| null = null;
|
|
22
|
+
let activeMqttSend:
|
|
23
|
+
| ((text: string, destination?: string, channelName?: string) => Promise<void>)
|
|
24
|
+
| null = null;
|
|
25
|
+
|
|
26
|
+
export function setActiveSerialSend(
|
|
27
|
+
fn: ((text: string, destination?: number, channelIndex?: number) => Promise<number>) | null,
|
|
28
|
+
) {
|
|
29
|
+
activeSerialSend = fn;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function setActiveMqttSend(
|
|
33
|
+
fn: ((text: string, destination?: string, channelName?: string) => Promise<void>) | null,
|
|
34
|
+
) {
|
|
35
|
+
activeMqttSend = fn;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function sendMessageMeshtastic(
|
|
39
|
+
to: string,
|
|
40
|
+
text: string,
|
|
41
|
+
opts: SendMeshtasticOptions = {},
|
|
42
|
+
): Promise<SendMeshtasticResult> {
|
|
43
|
+
const runtime = getMeshtasticRuntime();
|
|
44
|
+
const cfg = runtime.config.loadConfig() as CoreConfig;
|
|
45
|
+
const account = resolveMeshtasticAccount({
|
|
46
|
+
cfg,
|
|
47
|
+
accountId: opts.accountId,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
if (!account.configured) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`Meshtastic is not configured for account "${account.accountId}". ` +
|
|
53
|
+
`Set channels.meshtastic.transport and connection details.`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const target = normalizeMeshtasticMessagingTarget(to);
|
|
58
|
+
if (!target) {
|
|
59
|
+
throw new Error(`Invalid Meshtastic target: ${to}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const tableMode = runtime.channel.text.resolveMarkdownTableMode({
|
|
63
|
+
cfg,
|
|
64
|
+
channel: "meshtastic",
|
|
65
|
+
accountId: account.accountId,
|
|
66
|
+
});
|
|
67
|
+
const prepared = runtime.channel.text.convertMarkdownTables(text.trim(), tableMode);
|
|
68
|
+
if (!prepared.trim()) {
|
|
69
|
+
throw new Error("Message must be non-empty for Meshtastic sends");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const transport = account.transport;
|
|
73
|
+
|
|
74
|
+
if (transport === "mqtt") {
|
|
75
|
+
if (activeMqttSend) {
|
|
76
|
+
await activeMqttSend(prepared, target, opts.channelName);
|
|
77
|
+
} else {
|
|
78
|
+
throw new Error("No active MQTT connection. Start the gateway first.");
|
|
79
|
+
}
|
|
80
|
+
} else {
|
|
81
|
+
// Serial or HTTP: use active transport if available.
|
|
82
|
+
if (activeSerialSend) {
|
|
83
|
+
const destination = target.startsWith("!") ? hexToNodeNum(target) : undefined;
|
|
84
|
+
await activeSerialSend(prepared, destination, opts.channelIndex);
|
|
85
|
+
} else {
|
|
86
|
+
throw new Error(`No active ${transport} connection. Start the gateway first.`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
runtime.channel.activity.record({
|
|
91
|
+
channel: "meshtastic",
|
|
92
|
+
accountId: account.accountId,
|
|
93
|
+
direction: "outbound",
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
messageId: randomUUID(),
|
|
98
|
+
target,
|
|
99
|
+
};
|
|
100
|
+
}
|