@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.
@@ -0,0 +1,106 @@
1
+ import {
2
+ DmPolicySchema,
3
+ GroupPolicySchema,
4
+ MarkdownConfigSchema,
5
+ ReplyRuntimeConfigSchemaShape,
6
+ ToolPolicySchema,
7
+ requireOpenAllowFrom,
8
+ } from "openclaw/plugin-sdk";
9
+ import { z } from "zod";
10
+
11
+ const MeshtasticGroupSchema = z
12
+ .object({
13
+ requireMention: z.boolean().optional(),
14
+ tools: ToolPolicySchema,
15
+ toolsBySender: z.record(z.string(), ToolPolicySchema).optional(),
16
+ skills: z.array(z.string()).optional(),
17
+ enabled: z.boolean().optional(),
18
+ allowFrom: z.array(z.string()).optional(),
19
+ systemPrompt: z.string().optional(),
20
+ })
21
+ .strict();
22
+
23
+ const MeshtasticMqttSchema = z
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(),
30
+ publishTopic: z.string().optional(),
31
+ tls: z.boolean().optional(),
32
+ })
33
+ .strict();
34
+
35
+ const MeshtasticTransportSchema = z.enum(["serial", "http", "mqtt"]).optional().default("serial");
36
+
37
+ const MeshtasticRegionSchema = z
38
+ .enum([
39
+ "UNSET",
40
+ "US",
41
+ "EU_433",
42
+ "EU_868",
43
+ "CN",
44
+ "JP",
45
+ "ANZ",
46
+ "KR",
47
+ "TW",
48
+ "RU",
49
+ "IN",
50
+ "NZ_865",
51
+ "TH",
52
+ "UA_433",
53
+ "UA_868",
54
+ "MY_433",
55
+ "MY_919",
56
+ "SG_923",
57
+ "LORA_24",
58
+ ])
59
+ .optional();
60
+
61
+ export const MeshtasticAccountSchemaBase = z
62
+ .object({
63
+ name: z.string().optional(),
64
+ enabled: z.boolean().optional(),
65
+ transport: MeshtasticTransportSchema,
66
+ region: MeshtasticRegionSchema,
67
+ nodeName: z.string().optional(),
68
+ serialPort: z.string().optional(),
69
+ httpAddress: z.string().optional(),
70
+ httpTls: z.boolean().optional(),
71
+ mqtt: MeshtasticMqttSchema.optional(),
72
+ dmPolicy: DmPolicySchema.optional().default("pairing"),
73
+ allowFrom: z.array(z.string()).optional(),
74
+ defaultTo: z.string().optional(),
75
+ groupPolicy: GroupPolicySchema.optional().default("disabled"),
76
+ groupAllowFrom: z.array(z.string()).optional(),
77
+ channels: z.record(z.string(), MeshtasticGroupSchema.optional()).optional(),
78
+ mentionPatterns: z.array(z.string()).optional(),
79
+ markdown: MarkdownConfigSchema,
80
+ ...ReplyRuntimeConfigSchemaShape,
81
+ })
82
+ .strict();
83
+
84
+ export const MeshtasticAccountSchema = MeshtasticAccountSchemaBase.superRefine((value, ctx) => {
85
+ requireOpenAllowFrom({
86
+ policy: value.dmPolicy,
87
+ allowFrom: value.allowFrom,
88
+ ctx,
89
+ path: ["allowFrom"],
90
+ message:
91
+ 'channels.meshtastic.dmPolicy="open" requires channels.meshtastic.allowFrom to include "*"',
92
+ });
93
+ });
94
+
95
+ export const MeshtasticConfigSchema = MeshtasticAccountSchemaBase.extend({
96
+ accounts: z.record(z.string(), MeshtasticAccountSchema.optional()).optional(),
97
+ }).superRefine((value, ctx) => {
98
+ requireOpenAllowFrom({
99
+ policy: value.dmPolicy,
100
+ allowFrom: value.allowFrom,
101
+ ctx,
102
+ path: ["allowFrom"],
103
+ message:
104
+ 'channels.meshtastic.dmPolicy="open" requires channels.meshtastic.allowFrom to include "*"',
105
+ });
106
+ });
package/src/inbound.ts ADDED
@@ -0,0 +1,397 @@
1
+ import {
2
+ GROUP_POLICY_BLOCKED_LABEL,
3
+ createNormalizedOutboundDeliverer,
4
+ createReplyPrefixOptions,
5
+ formatTextWithAttachmentLinks,
6
+ logInboundDrop,
7
+ resolveControlCommandGate,
8
+ resolveOutboundMediaUrls,
9
+ resolveAllowlistProviderRuntimeGroupPolicy,
10
+ resolveDefaultGroupPolicy,
11
+ warnMissingProviderGroupPolicyFallbackOnce,
12
+ type OutboundReplyPayload,
13
+ type OpenClawConfig,
14
+ type RuntimeEnv,
15
+ } from "openclaw/plugin-sdk";
16
+ import type { ResolvedMeshtasticAccount } from "./accounts.js";
17
+ import {
18
+ normalizeMeshtasticAllowlist,
19
+ normalizeMeshtasticNodeId,
20
+ resolveMeshtasticAllowlistMatch,
21
+ } from "./normalize.js";
22
+ import {
23
+ resolveMeshtasticMentionGate,
24
+ resolveMeshtasticGroupAccessGate,
25
+ resolveMeshtasticGroupMatch,
26
+ resolveMeshtasticGroupSenderAllowed,
27
+ resolveMeshtasticRequireMention,
28
+ } from "./policy.js";
29
+ import { getMeshtasticRuntime } from "./runtime.js";
30
+ import { sendMessageMeshtastic } from "./send.js";
31
+ import type { CoreConfig, MeshtasticInboundMessage } from "./types.js";
32
+
33
+ const CHANNEL_ID = "meshtastic" as const;
34
+
35
+ const escapeRegexLiteral = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
36
+
37
+ function resolveMeshtasticEffectiveAllowlists(params: {
38
+ configAllowFrom: string[];
39
+ configGroupAllowFrom: string[];
40
+ storeAllowList: string[];
41
+ }): {
42
+ effectiveAllowFrom: string[];
43
+ effectiveGroupAllowFrom: string[];
44
+ } {
45
+ const effectiveAllowFrom = [...params.configAllowFrom, ...params.storeAllowList].filter(Boolean);
46
+ const effectiveGroupAllowFrom = [...params.configGroupAllowFrom].filter(Boolean);
47
+ return { effectiveAllowFrom, effectiveGroupAllowFrom };
48
+ }
49
+
50
+ // LoRa payload limit is ~230 bytes. Split longer replies into chunks
51
+ // so the firmware doesn't silently truncate them.
52
+ const MESHTASTIC_CHUNK_LIMIT = 200;
53
+
54
+ function chunkText(text: string, limit: number): string[] {
55
+ if (text.length <= limit) return [text];
56
+ const chunks: string[] = [];
57
+ let remaining = text;
58
+ while (remaining.length > 0) {
59
+ if (remaining.length <= limit) {
60
+ chunks.push(remaining);
61
+ break;
62
+ }
63
+ // Try to break at a space near the limit.
64
+ let breakAt = remaining.lastIndexOf(" ", limit);
65
+ if (breakAt <= limit * 0.4) breakAt = limit; // no good break point
66
+ chunks.push(remaining.slice(0, breakAt).trimEnd());
67
+ remaining = remaining.slice(breakAt).trimStart();
68
+ }
69
+ return chunks;
70
+ }
71
+
72
+ async function deliverMeshtasticReply(params: {
73
+ payload: OutboundReplyPayload;
74
+ target: string;
75
+ accountId: string;
76
+ channelIndex?: number;
77
+ channelName?: string;
78
+ chunkLimit?: number;
79
+ sendReply?: (target: string, text: string) => Promise<void>;
80
+ statusSink?: (patch: { lastOutboundAt?: number }) => void;
81
+ }) {
82
+ const combined = formatTextWithAttachmentLinks(
83
+ params.payload.text,
84
+ resolveOutboundMediaUrls(params.payload),
85
+ );
86
+ if (!combined) {
87
+ return;
88
+ }
89
+
90
+ const chunks = chunkText(combined, params.chunkLimit ?? MESHTASTIC_CHUNK_LIMIT);
91
+
92
+ for (const chunk of chunks) {
93
+ if (params.sendReply) {
94
+ await params.sendReply(params.target, chunk);
95
+ } else {
96
+ await sendMessageMeshtastic(params.target, chunk, {
97
+ accountId: params.accountId,
98
+ channelIndex: params.channelIndex,
99
+ channelName: params.channelName,
100
+ });
101
+ }
102
+ // Small delay between chunks to avoid overwhelming the radio queue.
103
+ if (chunks.length > 1) {
104
+ await new Promise<void>((r) => setTimeout(r, 1_500));
105
+ }
106
+ }
107
+ params.statusSink?.({ lastOutboundAt: Date.now() });
108
+ }
109
+
110
+ export async function handleMeshtasticInbound(params: {
111
+ message: MeshtasticInboundMessage;
112
+ account: ResolvedMeshtasticAccount;
113
+ config: CoreConfig;
114
+ runtime: RuntimeEnv;
115
+ sendReply?: (target: string, text: string) => Promise<void>;
116
+ statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
117
+ }): Promise<void> {
118
+ const { message, account, config, runtime, statusSink } = params;
119
+ const core = getMeshtasticRuntime();
120
+
121
+ const rawBody = message.text?.trim() ?? "";
122
+ if (!rawBody) {
123
+ return;
124
+ }
125
+
126
+ statusSink?.({ lastInboundAt: message.timestamp });
127
+
128
+ const senderDisplay = message.senderName
129
+ ? `${message.senderName} (${message.senderNodeId})`
130
+ : message.senderNodeId;
131
+
132
+ const dmPolicy = account.config.dmPolicy ?? "pairing";
133
+ const defaultGroupPolicy = resolveDefaultGroupPolicy(config);
134
+ const { groupPolicy, providerMissingFallbackApplied } =
135
+ resolveAllowlistProviderRuntimeGroupPolicy({
136
+ providerConfigPresent: config.channels?.meshtastic !== undefined,
137
+ groupPolicy: account.config.groupPolicy,
138
+ defaultGroupPolicy,
139
+ });
140
+ warnMissingProviderGroupPolicyFallbackOnce({
141
+ providerMissingFallbackApplied,
142
+ providerKey: "meshtastic",
143
+ accountId: account.accountId,
144
+ blockedLabel: GROUP_POLICY_BLOCKED_LABEL.channel,
145
+ log: (message) => runtime.log?.(message),
146
+ });
147
+
148
+ const configAllowFrom = normalizeMeshtasticAllowlist(account.config.allowFrom);
149
+ const configGroupAllowFrom = normalizeMeshtasticAllowlist(account.config.groupAllowFrom);
150
+ const storeAllowFrom =
151
+ dmPolicy === "allowlist"
152
+ ? []
153
+ : await core.channel.pairing.readAllowFromStore(CHANNEL_ID).catch(() => []);
154
+ const storeAllowList = normalizeMeshtasticAllowlist(storeAllowFrom);
155
+
156
+ const channelLabel = message.channelName ?? `channel-${message.channelIndex}`;
157
+ const groupMatch = resolveMeshtasticGroupMatch({
158
+ groups: account.config.channels,
159
+ target: channelLabel,
160
+ });
161
+
162
+ if (message.isGroup) {
163
+ const groupAccess = resolveMeshtasticGroupAccessGate({ groupPolicy, groupMatch });
164
+ if (!groupAccess.allowed) {
165
+ runtime.log?.(`meshtastic: drop channel ${channelLabel} (${groupAccess.reason})`);
166
+ return;
167
+ }
168
+ }
169
+
170
+ const directGroupAllowFrom = normalizeMeshtasticAllowlist(groupMatch.groupConfig?.allowFrom);
171
+ const wildcardGroupAllowFrom = normalizeMeshtasticAllowlist(groupMatch.wildcardConfig?.allowFrom);
172
+ const groupAllowFrom =
173
+ directGroupAllowFrom.length > 0 ? directGroupAllowFrom : wildcardGroupAllowFrom;
174
+
175
+ const { effectiveAllowFrom, effectiveGroupAllowFrom } = resolveMeshtasticEffectiveAllowlists({
176
+ configAllowFrom,
177
+ configGroupAllowFrom,
178
+ storeAllowList,
179
+ });
180
+
181
+ const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
182
+ cfg: config as OpenClawConfig,
183
+ surface: CHANNEL_ID,
184
+ });
185
+ const useAccessGroups = config.commands?.useAccessGroups !== false;
186
+ const senderAllowedForCommands = resolveMeshtasticAllowlistMatch({
187
+ allowFrom: message.isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom,
188
+ message,
189
+ }).allowed;
190
+ const hasControlCommand = core.channel.text.hasControlCommand(rawBody, config as OpenClawConfig);
191
+ const commandGate = resolveControlCommandGate({
192
+ useAccessGroups,
193
+ authorizers: [
194
+ {
195
+ configured: (message.isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom).length > 0,
196
+ allowed: senderAllowedForCommands,
197
+ },
198
+ ],
199
+ allowTextCommands,
200
+ hasControlCommand,
201
+ });
202
+ const commandAuthorized = commandGate.commandAuthorized;
203
+
204
+ if (message.isGroup) {
205
+ const senderAllowed = resolveMeshtasticGroupSenderAllowed({
206
+ groupPolicy,
207
+ message,
208
+ outerAllowFrom: effectiveGroupAllowFrom,
209
+ innerAllowFrom: groupAllowFrom,
210
+ });
211
+ if (!senderAllowed) {
212
+ runtime.log?.(`meshtastic: drop group sender ${senderDisplay} (policy=${groupPolicy})`);
213
+ return;
214
+ }
215
+ } else {
216
+ if (dmPolicy === "disabled") {
217
+ runtime.log?.(`meshtastic: drop DM sender=${senderDisplay} (dmPolicy=disabled)`);
218
+ return;
219
+ }
220
+ if (dmPolicy !== "open") {
221
+ const dmAllowed = resolveMeshtasticAllowlistMatch({
222
+ allowFrom: effectiveAllowFrom,
223
+ message,
224
+ }).allowed;
225
+ if (!dmAllowed) {
226
+ if (dmPolicy === "pairing") {
227
+ const normalizedId = normalizeMeshtasticNodeId(message.senderNodeId);
228
+ const { code, created } = await core.channel.pairing.upsertPairingRequest({
229
+ channel: CHANNEL_ID,
230
+ id: normalizedId,
231
+ meta: { name: message.senderName || undefined },
232
+ });
233
+ if (created) {
234
+ try {
235
+ const reply = core.channel.pairing.buildPairingReply({
236
+ channel: CHANNEL_ID,
237
+ idLine: `Your node ID: ${normalizedId}`,
238
+ code,
239
+ });
240
+ await deliverMeshtasticReply({
241
+ payload: { text: reply },
242
+ target: message.senderNodeId,
243
+ accountId: account.accountId,
244
+ sendReply: params.sendReply,
245
+ statusSink,
246
+ });
247
+ } catch (err) {
248
+ runtime.error?.(
249
+ `meshtastic: pairing reply failed for ${senderDisplay}: ${String(err)}`,
250
+ );
251
+ }
252
+ }
253
+ }
254
+ runtime.log?.(`meshtastic: drop DM sender ${senderDisplay} (dmPolicy=${dmPolicy})`);
255
+ return;
256
+ }
257
+ }
258
+ }
259
+
260
+ if (message.isGroup && commandGate.shouldBlock) {
261
+ logInboundDrop({
262
+ log: (line) => runtime.log?.(line),
263
+ channel: CHANNEL_ID,
264
+ reason: "control command (unauthorized)",
265
+ target: senderDisplay,
266
+ });
267
+ return;
268
+ }
269
+
270
+ const mentionRegexes = core.channel.mentions.buildMentionRegexes(config as OpenClawConfig);
271
+ const wasMentioned = core.channel.mentions.matchesMentionPatterns(rawBody, mentionRegexes);
272
+
273
+ const requireMention = message.isGroup
274
+ ? resolveMeshtasticRequireMention({
275
+ groupConfig: groupMatch.groupConfig,
276
+ wildcardConfig: groupMatch.wildcardConfig,
277
+ })
278
+ : false;
279
+
280
+ const mentionGate = resolveMeshtasticMentionGate({
281
+ isGroup: message.isGroup,
282
+ requireMention,
283
+ wasMentioned,
284
+ hasControlCommand,
285
+ allowTextCommands,
286
+ commandAuthorized,
287
+ });
288
+ if (mentionGate.shouldSkip) {
289
+ runtime.log?.(`meshtastic: drop channel ${channelLabel} (${mentionGate.reason})`);
290
+ return;
291
+ }
292
+
293
+ const peerId = message.isGroup ? channelLabel : message.senderNodeId;
294
+ const route = core.channel.routing.resolveAgentRoute({
295
+ cfg: config as OpenClawConfig,
296
+ channel: CHANNEL_ID,
297
+ accountId: account.accountId,
298
+ peer: {
299
+ kind: message.isGroup ? "group" : "direct",
300
+ id: peerId,
301
+ },
302
+ });
303
+
304
+ const fromLabel = message.isGroup ? channelLabel : senderDisplay;
305
+ const storePath = core.channel.session.resolveStorePath(config.session?.store, {
306
+ agentId: route.agentId,
307
+ });
308
+ const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config as OpenClawConfig);
309
+ const previousTimestamp = core.channel.session.readSessionUpdatedAt({
310
+ storePath,
311
+ sessionKey: route.sessionKey,
312
+ });
313
+ const body = core.channel.reply.formatAgentEnvelope({
314
+ channel: "Meshtastic",
315
+ from: fromLabel,
316
+ timestamp: message.timestamp,
317
+ previousTimestamp,
318
+ envelope: envelopeOptions,
319
+ body: rawBody,
320
+ });
321
+
322
+ const groupSystemPrompt = groupMatch.groupConfig?.systemPrompt?.trim() || undefined;
323
+
324
+ const ctxPayload = core.channel.reply.finalizeInboundContext({
325
+ Body: body,
326
+ RawBody: rawBody,
327
+ CommandBody: rawBody,
328
+ From: message.isGroup
329
+ ? `meshtastic:channel:${channelLabel}`
330
+ : `meshtastic:${message.senderNodeId}`,
331
+ To: `meshtastic:${peerId}`,
332
+ SessionKey: route.sessionKey,
333
+ AccountId: route.accountId,
334
+ ChatType: message.isGroup ? "group" : "direct",
335
+ ConversationLabel: fromLabel,
336
+ SenderName: message.senderName || undefined,
337
+ SenderId: message.senderNodeId,
338
+ GroupSubject: message.isGroup ? channelLabel : undefined,
339
+ GroupSystemPrompt: message.isGroup ? groupSystemPrompt : undefined,
340
+ Provider: CHANNEL_ID,
341
+ Surface: CHANNEL_ID,
342
+ WasMentioned: message.isGroup ? wasMentioned : undefined,
343
+ MessageSid: message.messageId,
344
+ Timestamp: message.timestamp,
345
+ OriginatingChannel: CHANNEL_ID,
346
+ OriginatingTo: `meshtastic:${peerId}`,
347
+ CommandAuthorized: commandAuthorized,
348
+ });
349
+
350
+ await core.channel.session.recordInboundSession({
351
+ storePath,
352
+ sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
353
+ ctx: ctxPayload,
354
+ onRecordError: (err) => {
355
+ runtime.error?.(`meshtastic: failed updating session meta: ${String(err)}`);
356
+ },
357
+ });
358
+
359
+ const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
360
+ cfg: config as OpenClawConfig,
361
+ agentId: route.agentId,
362
+ channel: CHANNEL_ID,
363
+ accountId: account.accountId,
364
+ });
365
+ const deliverReply = createNormalizedOutboundDeliverer(async (payload) => {
366
+ await deliverMeshtasticReply({
367
+ payload,
368
+ target: peerId,
369
+ accountId: account.accountId,
370
+ channelIndex: message.isGroup ? message.channelIndex : undefined,
371
+ channelName: message.isGroup ? message.channelName : undefined,
372
+ chunkLimit: account.config.textChunkLimit,
373
+ sendReply: params.sendReply,
374
+ statusSink,
375
+ });
376
+ });
377
+
378
+ await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
379
+ ctx: ctxPayload,
380
+ cfg: config as OpenClawConfig,
381
+ dispatcherOptions: {
382
+ ...prefixOptions,
383
+ deliver: deliverReply,
384
+ onError: (err, info) => {
385
+ runtime.error?.(`meshtastic ${info.kind} reply failed: ${String(err)}`);
386
+ },
387
+ },
388
+ replyOptions: {
389
+ skillFilter: groupMatch.groupConfig?.skills,
390
+ onModelSelected,
391
+ disableBlockStreaming:
392
+ typeof account.config.blockStreaming === "boolean"
393
+ ? !account.config.blockStreaming
394
+ : undefined,
395
+ },
396
+ });
397
+ }