pi-lxmf 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/CHANGELOG.md +111 -0
- package/README.md +202 -0
- package/SPEC.md +469 -0
- package/package.json +52 -0
- package/src/bin.js +220 -0
- package/src/bridge.js +622 -0
- package/src/bz2.js +46 -0
- package/src/commands.js +288 -0
- package/src/config.js +393 -0
- package/src/identity.js +93 -0
- package/src/lxmf.js +380 -0
- package/src/quota.js +512 -0
- package/src/rpc.js +627 -0
- package/src/text.js +97 -0
package/src/identity.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file identity.js
|
|
3
|
+
*
|
|
4
|
+
* Pure identity-hash helpers (node:crypto only, no @reticulum import, so the
|
|
5
|
+
* bridge and its tests stay light).
|
|
6
|
+
*
|
|
7
|
+
* The daemon identifies people by their **Reticulum identity hash** — the
|
|
8
|
+
* protocol-agnostic 32-hex identifier of an Ed25519 identity — rather than by
|
|
9
|
+
* any single destination hash. One identity can host many destinations
|
|
10
|
+
* (`lxmf.delivery`, `lxmf.propagation`, `nomadnetwork.node`, …), and
|
|
11
|
+
* identity-keyed access maps directly onto future DACAR-style permission
|
|
12
|
+
* management (../dacar grants are made to identities).
|
|
13
|
+
*
|
|
14
|
+
* On the wire, LXMF source/destination hashes are `lxmf.delivery` *destination*
|
|
15
|
+
* hashes, so a configured identity hash is expanded via
|
|
16
|
+
* {@link deriveLxmfDestinationHash} for comparison — the same derivation
|
|
17
|
+
* @reticulum/core's `Destination` performs (`SHA256(nameHash ‖ identityHash)[:16]`,
|
|
18
|
+
* `nameHash = SHA256(full_name)[:10]`), proven in `test/identity.test.js`
|
|
19
|
+
* against the real `Destination.IN`.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { createHash } from "node:crypto";
|
|
23
|
+
|
|
24
|
+
/** Truncated hash length in bytes (SHA-256[:16]). */
|
|
25
|
+
const HASH_BYTES = 16;
|
|
26
|
+
|
|
27
|
+
/** The LXMF single-endpoint full name (app name + aspect). */
|
|
28
|
+
const LXMF_DELIVERY_APP_NAME = "lxmf.delivery";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Decodes a 32-hex-char hash string to bytes.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} hex
|
|
34
|
+
* @returns {Uint8Array}
|
|
35
|
+
* @throws {Error} on wrong length or non-hex input.
|
|
36
|
+
*/
|
|
37
|
+
function hashFromHex(hex) {
|
|
38
|
+
const cleaned = hex.trim().toLowerCase();
|
|
39
|
+
if (!/^[0-9a-f]{32}$/.test(cleaned)) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
`Expected a 32-hex-character hash, got: ${JSON.stringify(hex)}`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
return new Uint8Array(
|
|
45
|
+
Array.from({ length: 16 }, (_, i) =>
|
|
46
|
+
Number.parseInt(cleaned.slice(i * 2, i * 2 + 2), 16),
|
|
47
|
+
),
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Encodes bytes as lowercase hex.
|
|
53
|
+
*
|
|
54
|
+
* @param {Uint8Array} bytes
|
|
55
|
+
* @returns {string}
|
|
56
|
+
*/
|
|
57
|
+
function hashToHex(bytes) {
|
|
58
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Derives a destination hash for an identity and application name, matching
|
|
63
|
+
* @reticulum/core's `Destination`: `SHA256(nameHash ‖ identityHash)[:16]`
|
|
64
|
+
* where `nameHash = SHA256(full_name)[:10]`.
|
|
65
|
+
*
|
|
66
|
+
* @param {string} identityHashHex - The identity's 32-hex hash.
|
|
67
|
+
* @param {string} appName - Full destination name (e.g. `"lxmf.delivery"`).
|
|
68
|
+
* @returns {string} 32-hex destination hash.
|
|
69
|
+
*/
|
|
70
|
+
export function deriveDestinationHash(identityHashHex, appName) {
|
|
71
|
+
const identityHash = hashFromHex(identityHashHex);
|
|
72
|
+
const nameHash = createHash("sha256")
|
|
73
|
+
.update(appName, "utf8")
|
|
74
|
+
.digest()
|
|
75
|
+
.subarray(0, 10);
|
|
76
|
+
const combined = new Uint8Array(nameHash.length + identityHash.length);
|
|
77
|
+
combined.set(nameHash, 0);
|
|
78
|
+
combined.set(identityHash, nameHash.length);
|
|
79
|
+
const digest = createHash("sha256").update(combined).digest();
|
|
80
|
+
return hashToHex(digest.subarray(0, HASH_BYTES));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Derives the `lxmf.delivery` destination hash (the "LXMF address" clients
|
|
85
|
+
* display) for an identity hash — the wire form an identity-configured owner
|
|
86
|
+
* is compared against.
|
|
87
|
+
*
|
|
88
|
+
* @param {string} identityHashHex
|
|
89
|
+
* @returns {string} 32-hex `lxmf.delivery` destination hash.
|
|
90
|
+
*/
|
|
91
|
+
export function deriveLxmfDestinationHash(identityHashHex) {
|
|
92
|
+
return deriveDestinationHash(identityHashHex, LXMF_DELIVERY_APP_NAME);
|
|
93
|
+
}
|
package/src/lxmf.js
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file lxmf.js
|
|
3
|
+
*
|
|
4
|
+
* The LXMF/mesh side of the bridge (SPEC §5): persistent identity,
|
|
5
|
+
* interface bootstrap (shared instance → AutoInterface → TCP), the
|
|
6
|
+
* `LXMRouter` with periodic announcing, optional propagation-node sync,
|
|
7
|
+
* and chunked outbound message delivery.
|
|
8
|
+
*
|
|
9
|
+
* The wiring mirrors reticulum-js's `examples/lxmf_echobot.js`; see
|
|
10
|
+
* `../reticulum-js/packages/lxmf/src/router.js` for the router API.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { fromHex, Identity, Reticulum, toHex } from "@reticulum/core";
|
|
15
|
+
import { LXMessage, LXMFConstants, LXMRouter } from "@reticulum/lxmf";
|
|
16
|
+
import {
|
|
17
|
+
AutoInterface,
|
|
18
|
+
FileStorageAdapter,
|
|
19
|
+
LocalClientInterface,
|
|
20
|
+
TCPClientInterface,
|
|
21
|
+
} from "@reticulum/node";
|
|
22
|
+
import { createBz2 } from "./bz2.js";
|
|
23
|
+
import { chunkText } from "./text.js";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Attaches diagnostic logging to the inbound LXMF choke points that the
|
|
27
|
+
* bridge itself can't see: packets that decrypt but never dispatch.
|
|
28
|
+
*
|
|
29
|
+
* The `lxmf.delivery` destination emits a `"data"` event the instant a
|
|
30
|
+
* single-packet message decrypts — before the router has resolved the
|
|
31
|
+
* sender's identity. When the identity is UNKNOWN the router parks the
|
|
32
|
+
* message and solicits a path/announce, and it is only dispatched once
|
|
33
|
+
* that announce arrives. This is the most common reason a sender sees
|
|
34
|
+
* its packet acknowledged but the bridge never logs receiving anything,
|
|
35
|
+
* so we surface it here (along with unparseable packets) rather than at
|
|
36
|
+
* the bridge, which only ever sees successfully dispatched messages.
|
|
37
|
+
*
|
|
38
|
+
* Normal, successfully-dispatched packets are intentionally silent here —
|
|
39
|
+
* the bridge logs their disposition (ignored / command / prompt) where it
|
|
40
|
+
* actually decides what to do with them.
|
|
41
|
+
*
|
|
42
|
+
* The router's `"peer"` event fires when an announce (or inbound-link
|
|
43
|
+
* LINKIDENTIFY) makes an identity available, so a parked message can be
|
|
44
|
+
* correlated with the announce that released it.
|
|
45
|
+
*
|
|
46
|
+
* Ported from signalk-reticulum's `attachInboundDiagnostics`, where this
|
|
47
|
+
* instrumentation proved out the identity-parking failure mode.
|
|
48
|
+
*
|
|
49
|
+
* @param {LXMRouter} lxmf - An initialised router.
|
|
50
|
+
* @param {(msg: string) => void} [log] - Diagnostic sink.
|
|
51
|
+
* @param {string} [ownerIdentityHash] - 32-hex identity hash of the
|
|
52
|
+
* configured owner; when set, only peer-learn events whose announce
|
|
53
|
+
* carries this identity are logged (other peers are routine mesh
|
|
54
|
+
* bookkeeping).
|
|
55
|
+
* @returns {() => void} unsubscribe
|
|
56
|
+
*/
|
|
57
|
+
export function attachInboundDiagnostics(
|
|
58
|
+
lxmf,
|
|
59
|
+
log = () => {},
|
|
60
|
+
ownerIdentityHash,
|
|
61
|
+
) {
|
|
62
|
+
const onData = async (/** @type {any} */ event) => {
|
|
63
|
+
const plaintext = event?.detail?.plaintext;
|
|
64
|
+
if (!plaintext) return;
|
|
65
|
+
try {
|
|
66
|
+
const parsed = await LXMessage.deserialize(
|
|
67
|
+
plaintext,
|
|
68
|
+
lxmf.deliveryDest?.destinationHash ?? undefined,
|
|
69
|
+
);
|
|
70
|
+
const known = await lxmf.rns.transport.recallIdentity(parsed.sourceHash);
|
|
71
|
+
if (!known) {
|
|
72
|
+
log(
|
|
73
|
+
`pi-lxmf: inbound packet from ${toHex(parsed.sourceHash || [])} ` +
|
|
74
|
+
`(${plaintext.length} bytes) parked — sender identity unknown, ` +
|
|
75
|
+
`waiting for announce/path`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
} catch (e) {
|
|
79
|
+
log(
|
|
80
|
+
`pi-lxmf: inbound packet (${plaintext.length} bytes) could not be ` +
|
|
81
|
+
`parsed: ${e instanceof Error ? e.message : e}`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
/** @type {any} */ (lxmf.deliveryDest).addEventListener("data", onData);
|
|
86
|
+
|
|
87
|
+
const onPeer = (/** @type {any} */ event) => {
|
|
88
|
+
const destinationHash = event?.detail?.destinationHash;
|
|
89
|
+
const identity = event?.detail?.identity;
|
|
90
|
+
const identityHash = identity?.identityHash;
|
|
91
|
+
if (
|
|
92
|
+
destinationHash &&
|
|
93
|
+
(!ownerIdentityHash ||
|
|
94
|
+
(identityHash &&
|
|
95
|
+
toHex(identityHash) === ownerIdentityHash.toLowerCase()))
|
|
96
|
+
) {
|
|
97
|
+
log(`pi-lxmf: learned LXMF peer ${toHex(destinationHash)}`);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
lxmf.addEventListener("peer", onPeer);
|
|
101
|
+
|
|
102
|
+
return () => {
|
|
103
|
+
try {
|
|
104
|
+
/** @type {any} */ (lxmf.deliveryDest)?.removeEventListener(
|
|
105
|
+
"data",
|
|
106
|
+
onData,
|
|
107
|
+
);
|
|
108
|
+
} catch {
|
|
109
|
+
/* best effort */
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
lxmf.removeEventListener("peer", onPeer);
|
|
113
|
+
} catch {
|
|
114
|
+
/* best effort */
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Starts the mesh side. Resolves once the LXMF delivery destination is
|
|
121
|
+
* registered and announcing has begun.
|
|
122
|
+
*
|
|
123
|
+
* @param {import("./config.js").PiLxmfConfig} config - Resolved configuration (`loadConfig` output).
|
|
124
|
+
* @param {object} [options]
|
|
125
|
+
* @param {(msg: string) => void} [options.log] - Diagnostic sink.
|
|
126
|
+
* @returns {Promise<{
|
|
127
|
+
* rns: Reticulum,
|
|
128
|
+
* lxmf: LXMRouter,
|
|
129
|
+
* identity: Identity,
|
|
130
|
+
* identityHash: string,
|
|
131
|
+
* deliveryHash: string,
|
|
132
|
+
* interfaceNames: string[],
|
|
133
|
+
* sendText: (destinationHex: string, text: string, options?: {link?: any, title?: string}) => Promise<void>,
|
|
134
|
+
* sendReaction: (destinationHex: string, targetMessageId: Uint8Array, emoji: string, options?: {link?: any}) => Promise<void>,
|
|
135
|
+
* verifySender: (message: LXMessage) => Promise<"verified"|"unknown"|"invalid">,
|
|
136
|
+
* stop: () => void
|
|
137
|
+
* }>}
|
|
138
|
+
*/
|
|
139
|
+
export async function startLxmf(config, options = {}) {
|
|
140
|
+
const log = options.log || ((msg) => console.log(msg));
|
|
141
|
+
|
|
142
|
+
// rngit-style Resources (and oversized LXMF bodies) compress with bz2;
|
|
143
|
+
// without a provider they transfer uncompressed over slow links.
|
|
144
|
+
const bz2 = await createBz2();
|
|
145
|
+
|
|
146
|
+
const storageDir = join(config.dataDir, "storage");
|
|
147
|
+
const rns = new Reticulum({
|
|
148
|
+
storageAdapter: new FileStorageAdapter(storageDir),
|
|
149
|
+
compressionProvider: bz2,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
/** @type {string[]} */
|
|
153
|
+
const interfaceNames = [];
|
|
154
|
+
// Attaching to a local rnsd shared instance only works for 0-hop local
|
|
155
|
+
// traffic in some setups: a shared rnsd that does not forward routed
|
|
156
|
+
// (multi-hop) traffic to its local clients (observed Termux↔Columba,
|
|
157
|
+
// where the daemon's announces reach the mesh but inbound link requests
|
|
158
|
+
// die at the rnsd) silently blackholes every remote message.
|
|
159
|
+
// `skipSharedInstance` opts out in favour of own interfaces.
|
|
160
|
+
const shared = config.skipSharedInstance
|
|
161
|
+
? null
|
|
162
|
+
: await LocalClientInterface.connectToSharedInstance();
|
|
163
|
+
if (shared) {
|
|
164
|
+
// The generated @reticulum types hold two path identities for Interface
|
|
165
|
+
// (src/ vs types/), which strict tsc rejects; the runtime types match.
|
|
166
|
+
rns.addInterface(/** @type {any} */ (shared), true);
|
|
167
|
+
interfaceNames.push("shared-instance");
|
|
168
|
+
log("pi-lxmf: attached to local rnsd shared instance");
|
|
169
|
+
} else {
|
|
170
|
+
const auto = new AutoInterface({ name: "auto" });
|
|
171
|
+
await /** @type {any} */ (auto).connect();
|
|
172
|
+
rns.addInterface(/** @type {any} */ (auto), true);
|
|
173
|
+
interfaceNames.push("auto");
|
|
174
|
+
if (config.rnsHost && config.rnsPort) {
|
|
175
|
+
const tcp = new TCPClientInterface({
|
|
176
|
+
host: config.rnsHost,
|
|
177
|
+
port: config.rnsPort,
|
|
178
|
+
});
|
|
179
|
+
await /** @type {any} */ (tcp).connect();
|
|
180
|
+
rns.addInterface(/** @type {any} */ (tcp), true);
|
|
181
|
+
interfaceNames.push(`tcp:${config.rnsHost}:${config.rnsPort}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// The node's stable LXMF identity: loaded from (or created in) the
|
|
186
|
+
// persistent storage directory. This hash IS the node's address.
|
|
187
|
+
const identity = await Identity.loadOrGenerate(rns.storage);
|
|
188
|
+
const identityHash = toHex(identity.identityHash);
|
|
189
|
+
log(`pi-lxmf: identity ${identityHash}`);
|
|
190
|
+
|
|
191
|
+
const lxmf = new LXMRouter(identity, rns);
|
|
192
|
+
await lxmf.init();
|
|
193
|
+
// init() registers the delivery destination; narrow the nullable types
|
|
194
|
+
// and capture a non-null alias usable from the sendText closure.
|
|
195
|
+
const registered = lxmf.deliveryDest;
|
|
196
|
+
if (!registered) {
|
|
197
|
+
throw new Error("LXMRouter.init() did not register lxmf.delivery");
|
|
198
|
+
}
|
|
199
|
+
const deliveryDest = registered;
|
|
200
|
+
const deliveryHash = toHex(
|
|
201
|
+
/** @type {Uint8Array} */ (deliveryDest.destinationHash),
|
|
202
|
+
);
|
|
203
|
+
log(`pi-lxmf: lxmf.delivery destination ${deliveryHash}`);
|
|
204
|
+
|
|
205
|
+
// Instrument the inbound path so a silently-parked or failing message is
|
|
206
|
+
// visible in the daemon log (see attachInboundDiagnostics).
|
|
207
|
+
const detachDiagnostics = attachInboundDiagnostics(lxmf, log, config.owner);
|
|
208
|
+
|
|
209
|
+
// Immediate announce + periodic re-announce so cached mesh paths stay
|
|
210
|
+
// fresh and peers (Sideband/Nomadnet) show our display name.
|
|
211
|
+
await lxmf.startAnnouncing(config.name, {
|
|
212
|
+
intervalMs: config.announceIntervalSec
|
|
213
|
+
? config.announceIntervalSec * 1000
|
|
214
|
+
: undefined,
|
|
215
|
+
});
|
|
216
|
+
log(`pi-lxmf: announcing as "${config.name}"`);
|
|
217
|
+
|
|
218
|
+
// Optional propagation-node integration: outbound submits go through the
|
|
219
|
+
// node when a direct link cannot be established, and a periodic sync
|
|
220
|
+
// pulls messages that arrived while this daemon was down.
|
|
221
|
+
if (config.propagationNode) {
|
|
222
|
+
lxmf.setOutboundPropagationNode(fromHex(config.propagationNode));
|
|
223
|
+
log(`pi-lxmf: outbound propagation node ${config.propagationNode}`);
|
|
224
|
+
}
|
|
225
|
+
/** @type {NodeJS.Timeout|null} */
|
|
226
|
+
let syncTimer = null;
|
|
227
|
+
if (config.propagationNode && config.syncIntervalSec > 0) {
|
|
228
|
+
syncTimer = setInterval(() => {
|
|
229
|
+
lxmf
|
|
230
|
+
.syncFromPropagationNode(identity)
|
|
231
|
+
.then((/** @type {{received?: number}} */ res) => {
|
|
232
|
+
if ((res?.received ?? 0) > 0) {
|
|
233
|
+
log(
|
|
234
|
+
`pi-lxmf: propagation sync delivered ${res.received} message(s)`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
})
|
|
238
|
+
.catch((/** @type {Error} */ e) => {
|
|
239
|
+
log(`pi-lxmf: propagation sync failed: ${e.message}`);
|
|
240
|
+
});
|
|
241
|
+
}, config.syncIntervalSec * 1000);
|
|
242
|
+
syncTimer.unref();
|
|
243
|
+
log(`pi-lxmf: propagation sync every ${config.syncIntervalSec}s`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Sends `text` to `destinationHex` (a 32-hex lxmf.delivery source hash),
|
|
248
|
+
* chunked to `chunkChars`, titled on the first chunk. A failed send is
|
|
249
|
+
* retried once over the same path, then once more opportunistically
|
|
250
|
+
* (without the link) — battery-conscious mobile clients tear their link
|
|
251
|
+
* down right after their message is acknowledged, so the arrival link can
|
|
252
|
+
* be gone by reply time; the same `LXMessage` object is re-sent so both
|
|
253
|
+
* wire copies share one message id and a deduplicating client shows the
|
|
254
|
+
* reply once (learned in signalk-reticulum's deliverer).
|
|
255
|
+
*
|
|
256
|
+
* @param {string} destinationHex
|
|
257
|
+
* @param {string} text
|
|
258
|
+
* @param {{link?: any, title?: string}} [sendOptions]
|
|
259
|
+
*/
|
|
260
|
+
async function sendText(destinationHex, text, sendOptions = {}) {
|
|
261
|
+
const chunks = chunkText(text, config.chunkChars);
|
|
262
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
263
|
+
const isLast = i === chunks.length - 1;
|
|
264
|
+
const content =
|
|
265
|
+
chunks.length > 1 && !isLast
|
|
266
|
+
? `${chunks[i]}\n\n[… ${i + 1}/${chunks.length}]`
|
|
267
|
+
: chunks[i];
|
|
268
|
+
const message = new LXMessage({
|
|
269
|
+
// destinationHash is always set once init() registered the destination.
|
|
270
|
+
sourceHash: /** @type {Uint8Array} */ (deliveryDest.destinationHash),
|
|
271
|
+
destinationHash: fromHex(destinationHex),
|
|
272
|
+
content,
|
|
273
|
+
...(i === 0 && sendOptions.title ? { title: sendOptions.title } : {}),
|
|
274
|
+
});
|
|
275
|
+
await sendWithRetry(message, sendOptions.link);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Sends an LXMF reaction (FIELD_REACTION, §5.9.8) to `destinationHex`,
|
|
281
|
+
* targeting the message whose `messageId` is `targetMessageId`. The
|
|
282
|
+
* reaction field is rendered natively by Sideband/NomadNet (confirmed in
|
|
283
|
+
* live testing); no `content` is set so no separate chat bubble is
|
|
284
|
+
* produced alongside the reaction. Reuses `sendWithRetry` for the same
|
|
285
|
+
* retry-once path as `sendText` (same message object across retries → one
|
|
286
|
+
* message id).
|
|
287
|
+
*
|
|
288
|
+
* @param {string} destinationHex
|
|
289
|
+
* @param {Uint8Array} targetMessageId - The `message_id` of the message being reacted to.
|
|
290
|
+
* @param {string} emoji
|
|
291
|
+
* @param {{link?: any}} [sendOptions]
|
|
292
|
+
*/
|
|
293
|
+
async function sendReaction(
|
|
294
|
+
destinationHex,
|
|
295
|
+
targetMessageId,
|
|
296
|
+
emoji,
|
|
297
|
+
sendOptions = {},
|
|
298
|
+
) {
|
|
299
|
+
const reaction = new Map();
|
|
300
|
+
reaction.set(LXMFConstants.REACTION_TO, targetMessageId);
|
|
301
|
+
reaction.set(
|
|
302
|
+
LXMFConstants.REACTION_CONTENT,
|
|
303
|
+
new TextEncoder().encode(emoji),
|
|
304
|
+
);
|
|
305
|
+
const fields = new Map();
|
|
306
|
+
fields.set(LXMFConstants.FIELD_REACTION, reaction);
|
|
307
|
+
const message = new LXMessage({
|
|
308
|
+
sourceHash: /** @type {Uint8Array} */ (deliveryDest.destinationHash),
|
|
309
|
+
destinationHash: fromHex(destinationHex),
|
|
310
|
+
fields,
|
|
311
|
+
});
|
|
312
|
+
await sendWithRetry(message, sendOptions.link);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* @param {LXMessage} message
|
|
317
|
+
* @param {any} [link]
|
|
318
|
+
*/
|
|
319
|
+
async function sendWithRetry(message, link) {
|
|
320
|
+
try {
|
|
321
|
+
await lxmf.send(message, identity, link);
|
|
322
|
+
} catch (e) {
|
|
323
|
+
log(
|
|
324
|
+
`pi-lxmf: LXMF send failed (${e instanceof Error ? e.message : e}), retrying once`,
|
|
325
|
+
);
|
|
326
|
+
try {
|
|
327
|
+
await lxmf.send(message, identity, link);
|
|
328
|
+
} catch (e2) {
|
|
329
|
+
// The arrival link is likely gone (the peer closed it after its
|
|
330
|
+
// message was acknowledged). Retry without it: `LXMRouter.send`
|
|
331
|
+
// then establishes a fresh DIRECT link, falling back to an
|
|
332
|
+
// opportunistic packet. Same message object → same message id, so
|
|
333
|
+
// a deduplicating client renders the reply once.
|
|
334
|
+
log(
|
|
335
|
+
`pi-lxmf: link retry failed (${e2 instanceof Error ? e2.message : e2}), retrying without link`,
|
|
336
|
+
);
|
|
337
|
+
await lxmf.send(message, identity, null);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Verifies the signature of an inbound `message` against the sender's
|
|
344
|
+
* recalled identity. The router verifies signatures on the direct-delivery
|
|
345
|
+
* path, but a message pulled in via `syncFromPropagationNode` whose sender
|
|
346
|
+
* identity is not yet recalled is dispatched WITHOUT verification
|
|
347
|
+
* (mirroring Python's `SOURCE_UNKNOWN` handling). The bridge must not rely
|
|
348
|
+
* on the router for this on the sync path, so it calls here to close the
|
|
349
|
+
* gap: a message is admitted only when this returns `"verified"`.
|
|
350
|
+
*
|
|
351
|
+
* @param {LXMessage} message
|
|
352
|
+
* @returns {Promise<"verified"|"unknown"|"invalid">}
|
|
353
|
+
* `"verified"` — signature checks against the recalled sender identity.
|
|
354
|
+
* `"unknown"` — sender identity not recalled (parked); admission would
|
|
355
|
+
* be unverified, so the caller should drop and log.
|
|
356
|
+
* `"invalid"` — signature failed cryptographic proof.
|
|
357
|
+
*/
|
|
358
|
+
async function verifySender(message) {
|
|
359
|
+
const sender = await lxmf.rns.transport.recallIdentity(message.sourceHash);
|
|
360
|
+
if (!sender) return "unknown";
|
|
361
|
+
return (await message.verifySignature(sender)) ? "verified" : "invalid";
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return {
|
|
365
|
+
rns,
|
|
366
|
+
lxmf,
|
|
367
|
+
identity,
|
|
368
|
+
identityHash,
|
|
369
|
+
deliveryHash,
|
|
370
|
+
interfaceNames,
|
|
371
|
+
sendText,
|
|
372
|
+
sendReaction,
|
|
373
|
+
verifySender,
|
|
374
|
+
stop() {
|
|
375
|
+
detachDiagnostics();
|
|
376
|
+
lxmf.stopAnnouncing();
|
|
377
|
+
if (syncTimer) clearInterval(syncTimer);
|
|
378
|
+
},
|
|
379
|
+
};
|
|
380
|
+
}
|