jcomprns 0.1.0__py3-none-any.whl
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.
- jcomprns/__init__.py +4 -0
- jcomprns/file_transfer.py +389 -0
- jcomprns/lxmf_messenger.py +303 -0
- jcomprns/rnode_pair.py +532 -0
- jcomprns/rns_git.py +386 -0
- jcomprns/shared.py +143 -0
- jcomprns-0.1.0.dist-info/METADATA +233 -0
- jcomprns-0.1.0.dist-info/RECORD +12 -0
- jcomprns-0.1.0.dist-info/WHEEL +5 -0
- jcomprns-0.1.0.dist-info/entry_points.txt +6 -0
- jcomprns-0.1.0.dist-info/licenses/LICENSE +21 -0
- jcomprns-0.1.0.dist-info/top_level.txt +1 -0
jcomprns/__init__.py
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Interactive file transfer client over the Reticulum network set up by
|
|
4
|
+
rnode_pair.py.
|
|
5
|
+
|
|
6
|
+
Runs Reticulum in-process (attaching to the shared instance if rnsd is
|
|
7
|
+
already running, or bringing up the configured interfaces itself if not),
|
|
8
|
+
registers a "jcomprns.filetransfer" destination for your identity, and
|
|
9
|
+
gives you a tiny keyboard-driven UI:
|
|
10
|
+
|
|
11
|
+
[S] Send a file to a pasted address
|
|
12
|
+
[R] List received files and where they were saved
|
|
13
|
+
[P] Open the presence directory of peers seen announcing on the network
|
|
14
|
+
[Q] Quit
|
|
15
|
+
|
|
16
|
+
Files are sent over an RNS Link using RNS.Resource, which handles chunking,
|
|
17
|
+
compression and integrity checking for you. This is a different destination
|
|
18
|
+
namespace from lxmf_messenger.py ("jcomprns.filetransfer" vs
|
|
19
|
+
"lxmf.delivery"), so it has its own address, contacts, and announces --
|
|
20
|
+
even though it can share the same identity file.
|
|
21
|
+
|
|
22
|
+
Note (from RNS's own docs): Resources aren't recommended for very large
|
|
23
|
+
files, since compression/encryption/hashmap sequencing can take longer than
|
|
24
|
+
the receiver's timeout on slow links or slow CPUs. Fine for the kind of
|
|
25
|
+
files you'd send over a LoRa-connected RNode; if you need to move large
|
|
26
|
+
files routinely, look at RNS's Bundle class instead.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
import argparse
|
|
30
|
+
import queue
|
|
31
|
+
import select
|
|
32
|
+
import sys
|
|
33
|
+
import termios
|
|
34
|
+
import threading
|
|
35
|
+
import time
|
|
36
|
+
import tty
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
|
|
39
|
+
import RNS
|
|
40
|
+
import RNS.vendor.umsgpack as msgpack
|
|
41
|
+
|
|
42
|
+
from .rnode_pair import create_or_load_identity, resolve_config_dir
|
|
43
|
+
from .shared import (
|
|
44
|
+
notify, load_json, save_json, human_size, debug, set_verbose,
|
|
45
|
+
DEFAULT_IDENTITY, DEFAULT_FILETRANSFER_CONTACTS as DEFAULT_CONTACTS,
|
|
46
|
+
DEFAULT_RECEIVED_DIR, DEFAULT_MANIFEST,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
APP_NAME = "jcomprns"
|
|
50
|
+
ASPECT = "filetransfer"
|
|
51
|
+
ASPECT_FILTER = f"{APP_NAME}.{ASPECT}"
|
|
52
|
+
|
|
53
|
+
LINK_TIMEOUT = 15.0
|
|
54
|
+
PATH_TIMEOUT = 15.0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def decode_peer_name(app_data):
|
|
58
|
+
"""Our own announce app_data is msgpack([display_name_bytes_or_None]).
|
|
59
|
+
app_data comes from the network, so any shape of garbage is expected."""
|
|
60
|
+
if not app_data:
|
|
61
|
+
return None
|
|
62
|
+
try:
|
|
63
|
+
unpacked = msgpack.unpackb(app_data)
|
|
64
|
+
name_bytes = unpacked[0] if isinstance(unpacked, list) and unpacked else None
|
|
65
|
+
return name_bytes.decode("utf-8") if name_bytes else None
|
|
66
|
+
except Exception as e:
|
|
67
|
+
debug(f"decode_peer_name() couldn't parse announce app_data: {e}")
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class FileTransferNode:
|
|
72
|
+
def __init__(self, config_dir, identity_path, display_name,
|
|
73
|
+
received_dir=DEFAULT_RECEIVED_DIR, manifest_path=DEFAULT_MANIFEST,
|
|
74
|
+
contacts_path=DEFAULT_CONTACTS, announce_interval=0, verbose=False):
|
|
75
|
+
self.reticulum = RNS.Reticulum(str(Path(config_dir).expanduser()), loglevel=RNS.LOG_DEBUG if verbose else None)
|
|
76
|
+
self.identity = create_or_load_identity(identity_path)
|
|
77
|
+
self.display_name = display_name
|
|
78
|
+
|
|
79
|
+
self.destination = RNS.Destination(
|
|
80
|
+
self.identity, RNS.Destination.IN, RNS.Destination.SINGLE, APP_NAME, ASPECT
|
|
81
|
+
)
|
|
82
|
+
self.destination.set_link_established_callback(self._on_incoming_link)
|
|
83
|
+
|
|
84
|
+
self.received_dir = Path(received_dir).expanduser()
|
|
85
|
+
self.received_dir.mkdir(parents=True, exist_ok=True)
|
|
86
|
+
self.manifest_path = Path(manifest_path).expanduser()
|
|
87
|
+
self.manifest_lock = threading.Lock()
|
|
88
|
+
self.manifest = load_json(self.manifest_path, [])
|
|
89
|
+
|
|
90
|
+
self.notify_queue = queue.Queue()
|
|
91
|
+
|
|
92
|
+
# Presence directory, same mechanism as lxmf_messenger.py but scoped
|
|
93
|
+
# to this app's own destination namespace.
|
|
94
|
+
self.aspect_filter = ASPECT_FILTER
|
|
95
|
+
self.contacts_path = Path(contacts_path).expanduser()
|
|
96
|
+
self.contacts = load_json(self.contacts_path, {})
|
|
97
|
+
self.contacts_lock = threading.Lock()
|
|
98
|
+
self.presence_queue = queue.Queue()
|
|
99
|
+
RNS.Transport.register_announce_handler(self)
|
|
100
|
+
|
|
101
|
+
self.announce_interval = announce_interval
|
|
102
|
+
if announce_interval > 0:
|
|
103
|
+
threading.Thread(target=self._announce_loop, daemon=True).start()
|
|
104
|
+
|
|
105
|
+
self.announce_self()
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def address(self):
|
|
109
|
+
return self.destination.hash.hex()
|
|
110
|
+
|
|
111
|
+
def announce_self(self):
|
|
112
|
+
name_bytes = self.display_name.encode("utf-8") if self.display_name else None
|
|
113
|
+
self.destination.announce(app_data=msgpack.packb([name_bytes]))
|
|
114
|
+
|
|
115
|
+
def _announce_loop(self):
|
|
116
|
+
while True:
|
|
117
|
+
time.sleep(self.announce_interval * 60)
|
|
118
|
+
self.announce_self()
|
|
119
|
+
|
|
120
|
+
def received_announce(self, destination_hash, announced_identity, app_data):
|
|
121
|
+
if destination_hash == self.destination.hash:
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
address = destination_hash.hex()
|
|
125
|
+
name = decode_peer_name(app_data)
|
|
126
|
+
now = time.strftime("%Y-%m-%d %H:%M:%S")
|
|
127
|
+
|
|
128
|
+
with self.contacts_lock:
|
|
129
|
+
entry = self.contacts.get(address, {})
|
|
130
|
+
is_new = address not in self.contacts
|
|
131
|
+
entry["name"] = name or entry.get("name")
|
|
132
|
+
entry["last_seen"] = now
|
|
133
|
+
entry.setdefault("first_seen", now)
|
|
134
|
+
self.contacts[address] = entry
|
|
135
|
+
save_json(self.contacts_path, self.contacts)
|
|
136
|
+
|
|
137
|
+
self.presence_queue.put((address, entry["name"], is_new))
|
|
138
|
+
|
|
139
|
+
# --- receiving -----------------------------------------------------
|
|
140
|
+
|
|
141
|
+
def _on_incoming_link(self, link):
|
|
142
|
+
link.set_resource_strategy(RNS.Link.ACCEPT_ALL)
|
|
143
|
+
link.set_resource_started_callback(self._on_resource_started)
|
|
144
|
+
link.set_resource_concluded_callback(self._on_resource_concluded)
|
|
145
|
+
self.notify_queue.put(("status", "Incoming connection, standing by for a file..."))
|
|
146
|
+
|
|
147
|
+
def _on_resource_started(self, resource):
|
|
148
|
+
self.notify_queue.put(("status", f"Receiving a file ({human_size(resource.total_size)})..."))
|
|
149
|
+
|
|
150
|
+
def _on_resource_concluded(self, resource):
|
|
151
|
+
if resource.status != RNS.Resource.COMPLETE:
|
|
152
|
+
self.notify_queue.put(("failed", "An incoming file transfer failed."))
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
meta = resource.metadata or {}
|
|
156
|
+
filename = meta.get("filename") or f"file_{int(time.time())}.bin"
|
|
157
|
+
data = resource.data.read()
|
|
158
|
+
|
|
159
|
+
dest_path = self._unique_path(filename)
|
|
160
|
+
dest_path.write_bytes(data)
|
|
161
|
+
|
|
162
|
+
entry = {
|
|
163
|
+
"filename": dest_path.name,
|
|
164
|
+
"size": len(data),
|
|
165
|
+
"received_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
166
|
+
"path": str(dest_path),
|
|
167
|
+
}
|
|
168
|
+
with self.manifest_lock:
|
|
169
|
+
self.manifest.append(entry)
|
|
170
|
+
save_json(self.manifest_path, self.manifest)
|
|
171
|
+
|
|
172
|
+
self.notify_queue.put(("received", entry))
|
|
173
|
+
|
|
174
|
+
def _unique_path(self, filename):
|
|
175
|
+
stem, suffix = Path(filename).stem, Path(filename).suffix
|
|
176
|
+
candidate = self.received_dir / filename
|
|
177
|
+
counter = 1
|
|
178
|
+
while candidate.exists():
|
|
179
|
+
candidate = self.received_dir / f"{stem}.{counter}{suffix}"
|
|
180
|
+
counter += 1
|
|
181
|
+
return candidate
|
|
182
|
+
|
|
183
|
+
def drain_notifications(self):
|
|
184
|
+
while True:
|
|
185
|
+
try:
|
|
186
|
+
kind, payload = self.notify_queue.get_nowait()
|
|
187
|
+
except queue.Empty:
|
|
188
|
+
break
|
|
189
|
+
if kind == "received":
|
|
190
|
+
print(f"\n\a\U0001F4E6 File received: {payload['filename']} ({human_size(payload['size'])})")
|
|
191
|
+
print("Press [R] to view received files.")
|
|
192
|
+
notify("File Received", payload["filename"], human_size(payload["size"]))
|
|
193
|
+
else:
|
|
194
|
+
print(f"\n{payload}")
|
|
195
|
+
|
|
196
|
+
while True:
|
|
197
|
+
try:
|
|
198
|
+
address, name, is_new = self.presence_queue.get_nowait()
|
|
199
|
+
except queue.Empty:
|
|
200
|
+
return
|
|
201
|
+
if is_new:
|
|
202
|
+
label = f"{name} ({address})" if name else address
|
|
203
|
+
print(f"\n\U0001F7E2 New file-transfer peer seen: {label}")
|
|
204
|
+
print("Press [P] for the presence directory.")
|
|
205
|
+
|
|
206
|
+
# --- sending ---------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
def send_file(self, address_hex, path):
|
|
209
|
+
path = Path(path).expanduser()
|
|
210
|
+
if not path.is_file():
|
|
211
|
+
print(f"No such file: {path}")
|
|
212
|
+
return
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
dest_hash = bytes.fromhex(address_hex.strip())
|
|
216
|
+
except ValueError:
|
|
217
|
+
print("That doesn't look like a valid hex address.")
|
|
218
|
+
return
|
|
219
|
+
|
|
220
|
+
if not RNS.Transport.has_path(dest_hash):
|
|
221
|
+
print("Path to recipient unknown, requesting...")
|
|
222
|
+
RNS.Transport.request_path(dest_hash)
|
|
223
|
+
deadline = time.time() + PATH_TIMEOUT
|
|
224
|
+
while not RNS.Transport.has_path(dest_hash) and time.time() < deadline:
|
|
225
|
+
time.sleep(0.2)
|
|
226
|
+
if not RNS.Transport.has_path(dest_hash):
|
|
227
|
+
print("Could not find a path to that address. They may be offline or out of range.")
|
|
228
|
+
return
|
|
229
|
+
|
|
230
|
+
recipient_identity = RNS.Identity.recall(dest_hash)
|
|
231
|
+
if not recipient_identity:
|
|
232
|
+
print("Could not resolve an identity for that address.")
|
|
233
|
+
return
|
|
234
|
+
|
|
235
|
+
dest = RNS.Destination(recipient_identity, RNS.Destination.OUT, RNS.Destination.SINGLE, APP_NAME, ASPECT)
|
|
236
|
+
|
|
237
|
+
established = threading.Event()
|
|
238
|
+
link = RNS.Link(dest, established_callback=lambda l: established.set())
|
|
239
|
+
|
|
240
|
+
print("Establishing link with recipient...")
|
|
241
|
+
if not established.wait(timeout=LINK_TIMEOUT):
|
|
242
|
+
print("Could not establish a link (they may be offline). Transfer cancelled.")
|
|
243
|
+
link.teardown()
|
|
244
|
+
return
|
|
245
|
+
|
|
246
|
+
filesize = path.stat().st_size
|
|
247
|
+
print(f"Sending {path.name} ({human_size(filesize)})...")
|
|
248
|
+
|
|
249
|
+
done = threading.Event()
|
|
250
|
+
result = {}
|
|
251
|
+
|
|
252
|
+
def on_concluded(resource):
|
|
253
|
+
result["status"] = resource.status
|
|
254
|
+
done.set()
|
|
255
|
+
|
|
256
|
+
file_handle = path.open("rb")
|
|
257
|
+
resource = RNS.Resource(
|
|
258
|
+
file_handle, link,
|
|
259
|
+
metadata={"filename": path.name},
|
|
260
|
+
callback=on_concluded,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
while not done.is_set():
|
|
264
|
+
percent = round(resource.get_progress() * 100, 1)
|
|
265
|
+
print(f"\rProgress: {percent}% ", end="", flush=True)
|
|
266
|
+
done.wait(timeout=0.25)
|
|
267
|
+
|
|
268
|
+
file_handle.close()
|
|
269
|
+
print()
|
|
270
|
+
if result.get("status") == RNS.Resource.COMPLETE:
|
|
271
|
+
print("Transfer complete.")
|
|
272
|
+
else:
|
|
273
|
+
print("Transfer failed.")
|
|
274
|
+
link.teardown()
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def send_prompt(node, to_address=None, to_name=None):
|
|
278
|
+
print()
|
|
279
|
+
if to_address:
|
|
280
|
+
print(f"To: {to_name + ' ' if to_name else ''}({to_address})")
|
|
281
|
+
address_hex = to_address
|
|
282
|
+
else:
|
|
283
|
+
address_hex = input("To (address hex, blank to cancel): ").strip()
|
|
284
|
+
if not address_hex:
|
|
285
|
+
print("Cancelled.")
|
|
286
|
+
return
|
|
287
|
+
path = input("File path (blank to cancel): ").strip()
|
|
288
|
+
if not path:
|
|
289
|
+
print("Cancelled.")
|
|
290
|
+
return
|
|
291
|
+
node.send_file(address_hex, path)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def show_received(node):
|
|
295
|
+
with node.manifest_lock:
|
|
296
|
+
entries = list(node.manifest)
|
|
297
|
+
|
|
298
|
+
print("\n--- Received Files ---")
|
|
299
|
+
if not entries:
|
|
300
|
+
print("(none yet)")
|
|
301
|
+
return
|
|
302
|
+
for i, e in enumerate(entries):
|
|
303
|
+
print(f"[{i}] {e['received_at']} {e['filename']} ({human_size(e['size'])})")
|
|
304
|
+
print(f" saved to {e['path']}")
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def show_presence(node):
|
|
308
|
+
with node.contacts_lock:
|
|
309
|
+
contacts = list(node.contacts.items())
|
|
310
|
+
contacts.sort(key=lambda kv: kv[1].get("last_seen", ""), reverse=True)
|
|
311
|
+
|
|
312
|
+
print("\n--- Presence Directory (file transfer) ---")
|
|
313
|
+
print(f"Your address: {node.address}")
|
|
314
|
+
if not contacts:
|
|
315
|
+
print("(no peers seen yet -- they'll show up here once they announce on the network)")
|
|
316
|
+
else:
|
|
317
|
+
for i, (address, info) in enumerate(contacts):
|
|
318
|
+
name = info.get("name") or "(no name)"
|
|
319
|
+
print(f"[{i}] {name} {address}")
|
|
320
|
+
print(f" first seen {info.get('first_seen')} last seen {info.get('last_seen')}")
|
|
321
|
+
|
|
322
|
+
choice = input("Enter number to send a file to someone, [A] to announce yourself, or blank to go back: ").strip()
|
|
323
|
+
if choice.lower() == "a":
|
|
324
|
+
node.announce_self()
|
|
325
|
+
print("Announced.")
|
|
326
|
+
elif choice.isdigit() and int(choice) in range(len(contacts)):
|
|
327
|
+
address, info = contacts[int(choice)]
|
|
328
|
+
send_prompt(node, to_address=address, to_name=info.get("name"))
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def run_keyboard_loop(node):
|
|
332
|
+
print(f"Your file-transfer address: {node.address}")
|
|
333
|
+
print("[S] Send [R] Received files [P] Presence [Q] Quit\n")
|
|
334
|
+
|
|
335
|
+
fd = sys.stdin.fileno()
|
|
336
|
+
old_settings = termios.tcgetattr(fd)
|
|
337
|
+
try:
|
|
338
|
+
tty.setcbreak(fd)
|
|
339
|
+
while True:
|
|
340
|
+
ready, _, _ = select.select([sys.stdin], [], [], 0.5)
|
|
341
|
+
if ready:
|
|
342
|
+
ch = sys.stdin.read(1).lower()
|
|
343
|
+
if ch == "s":
|
|
344
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
|
345
|
+
send_prompt(node)
|
|
346
|
+
tty.setcbreak(fd)
|
|
347
|
+
elif ch == "r":
|
|
348
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
|
349
|
+
show_received(node)
|
|
350
|
+
tty.setcbreak(fd)
|
|
351
|
+
elif ch == "p":
|
|
352
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
|
353
|
+
show_presence(node)
|
|
354
|
+
tty.setcbreak(fd)
|
|
355
|
+
elif ch == "q":
|
|
356
|
+
break
|
|
357
|
+
node.drain_notifications()
|
|
358
|
+
finally:
|
|
359
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def main():
|
|
363
|
+
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
364
|
+
parser.add_argument("-v", "--verbose", action="store_true",
|
|
365
|
+
help="Show diagnostic detail for errors that are normally handled silently, and run RNS at debug log level")
|
|
366
|
+
parser.add_argument("--config", default=None, help="Path to the RNS config directory (skips the startup config prompt if given)")
|
|
367
|
+
parser.add_argument("--identity", default=DEFAULT_IDENTITY, help="Path to the RNS identity file to create/reuse")
|
|
368
|
+
parser.add_argument("--display-name", default="ble-connector", help="Display name announced with your file-transfer address")
|
|
369
|
+
parser.add_argument("--received-dir", default=DEFAULT_RECEIVED_DIR, help="Where incoming files are saved")
|
|
370
|
+
parser.add_argument("--manifest", default=DEFAULT_MANIFEST, help="Where the list of received files is recorded")
|
|
371
|
+
parser.add_argument("--contacts", default=DEFAULT_CONTACTS, help="Where the presence directory is persisted")
|
|
372
|
+
parser.add_argument("--announce-interval", type=float, default=0,
|
|
373
|
+
help="Re-announce yourself every N minutes so others can discover you (0 = only announce once at startup)")
|
|
374
|
+
args = parser.parse_args()
|
|
375
|
+
set_verbose(args.verbose)
|
|
376
|
+
args.config = resolve_config_dir(args.config)
|
|
377
|
+
|
|
378
|
+
node = FileTransferNode(args.config, args.identity, args.display_name,
|
|
379
|
+
received_dir=args.received_dir, manifest_path=args.manifest,
|
|
380
|
+
contacts_path=args.contacts, announce_interval=args.announce_interval, verbose=args.verbose)
|
|
381
|
+
|
|
382
|
+
try:
|
|
383
|
+
run_keyboard_loop(node)
|
|
384
|
+
except KeyboardInterrupt:
|
|
385
|
+
pass
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
if __name__ == "__main__":
|
|
389
|
+
main()
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Interactive LXMF messaging client over the Reticulum network set up by
|
|
4
|
+
rnode_pair.py.
|
|
5
|
+
|
|
6
|
+
Runs Reticulum in-process (attaching to the shared instance if rnsd is
|
|
7
|
+
already running, or bringing up the configured interfaces itself if not),
|
|
8
|
+
registers an LXMF delivery destination for your identity, and gives you a
|
|
9
|
+
tiny keyboard-driven UI:
|
|
10
|
+
|
|
11
|
+
[M] Compose a message to a pasted LXMF address
|
|
12
|
+
[I] Open the inbox and optionally reply to a message
|
|
13
|
+
[P] Open the presence directory of peers seen announcing on the network
|
|
14
|
+
[Q] Quit
|
|
15
|
+
|
|
16
|
+
Incoming messages trigger a terminal alert and a native OS notification.
|
|
17
|
+
Every LXMF peer that announces on the network (not just people who've
|
|
18
|
+
messaged you) is recorded in the presence directory, so you can build up a
|
|
19
|
+
contact list of who's reachable without needing to be sent their address.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import queue
|
|
24
|
+
import select
|
|
25
|
+
import sys
|
|
26
|
+
import termios
|
|
27
|
+
import threading
|
|
28
|
+
import time
|
|
29
|
+
import tty
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
import RNS
|
|
33
|
+
import RNS.vendor.umsgpack as msgpack
|
|
34
|
+
import LXMF
|
|
35
|
+
|
|
36
|
+
from .rnode_pair import create_or_load_identity, resolve_config_dir
|
|
37
|
+
from .shared import notify, load_json, save_json, debug, set_verbose, DEFAULT_IDENTITY, DEFAULT_CONTACTS
|
|
38
|
+
|
|
39
|
+
LXMF_DELIVERY_ASPECT = "lxmf.delivery"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def decode_display_name(app_data):
|
|
43
|
+
"""LXMF encodes an lxmf.delivery announce's app_data as
|
|
44
|
+
msgpack([display_name_bytes_or_None, stamp_cost, supported_functionality]).
|
|
45
|
+
app_data comes from the network, so any shape of garbage is expected."""
|
|
46
|
+
if not app_data:
|
|
47
|
+
return None
|
|
48
|
+
try:
|
|
49
|
+
unpacked = msgpack.unpackb(app_data)
|
|
50
|
+
name_bytes = unpacked[0] if isinstance(unpacked, list) and unpacked else None
|
|
51
|
+
return name_bytes.decode("utf-8") if name_bytes else None
|
|
52
|
+
except Exception as e:
|
|
53
|
+
debug(f"decode_display_name() couldn't parse announce app_data: {e}")
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Messenger:
|
|
58
|
+
def __init__(self, config_dir, identity_path, display_name, stamp_cost,
|
|
59
|
+
contacts_path=DEFAULT_CONTACTS, announce_interval=0, verbose=False):
|
|
60
|
+
self.reticulum = RNS.Reticulum(str(Path(config_dir).expanduser()), loglevel=RNS.LOG_DEBUG if verbose else None)
|
|
61
|
+
self.identity = create_or_load_identity(identity_path)
|
|
62
|
+
|
|
63
|
+
storage_dir = Path(config_dir).expanduser() / "lxmf"
|
|
64
|
+
self.router = LXMF.LXMRouter(storagepath=str(storage_dir), enforce_stamps=False)
|
|
65
|
+
self.router.register_delivery_callback(self._on_message)
|
|
66
|
+
self.destination = self.router.register_delivery_identity(
|
|
67
|
+
self.identity, display_name=display_name, stamp_cost=stamp_cost
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
self.inbox = []
|
|
71
|
+
self.inbox_lock = threading.Lock()
|
|
72
|
+
self.notify_queue = queue.Queue()
|
|
73
|
+
|
|
74
|
+
# Presence directory: every lxmf.delivery destination we see announced
|
|
75
|
+
# anywhere on the network, not just people who've messaged us.
|
|
76
|
+
self.aspect_filter = LXMF_DELIVERY_ASPECT
|
|
77
|
+
self.contacts_path = Path(contacts_path).expanduser()
|
|
78
|
+
self.contacts = load_json(self.contacts_path, {})
|
|
79
|
+
self.contacts_lock = threading.Lock()
|
|
80
|
+
self.presence_queue = queue.Queue()
|
|
81
|
+
RNS.Transport.register_announce_handler(self)
|
|
82
|
+
|
|
83
|
+
self.announce_interval = announce_interval
|
|
84
|
+
if announce_interval > 0:
|
|
85
|
+
threading.Thread(target=self._announce_loop, daemon=True).start()
|
|
86
|
+
|
|
87
|
+
self.announce_self()
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def address(self):
|
|
91
|
+
return self.destination.hash.hex()
|
|
92
|
+
|
|
93
|
+
def announce_self(self):
|
|
94
|
+
self.router.announce(self.destination.hash)
|
|
95
|
+
|
|
96
|
+
def _announce_loop(self):
|
|
97
|
+
while True:
|
|
98
|
+
time.sleep(self.announce_interval * 60)
|
|
99
|
+
self.announce_self()
|
|
100
|
+
|
|
101
|
+
def _on_message(self, message):
|
|
102
|
+
with self.inbox_lock:
|
|
103
|
+
self.inbox.append(message)
|
|
104
|
+
self.notify_queue.put(message)
|
|
105
|
+
|
|
106
|
+
def received_announce(self, destination_hash, announced_identity, app_data):
|
|
107
|
+
if destination_hash == self.destination.hash:
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
address = destination_hash.hex()
|
|
111
|
+
name = decode_display_name(app_data)
|
|
112
|
+
now = time.strftime("%Y-%m-%d %H:%M:%S")
|
|
113
|
+
|
|
114
|
+
with self.contacts_lock:
|
|
115
|
+
entry = self.contacts.get(address, {})
|
|
116
|
+
is_new = address not in self.contacts
|
|
117
|
+
entry["name"] = name or entry.get("name")
|
|
118
|
+
entry["last_seen"] = now
|
|
119
|
+
entry.setdefault("first_seen", now)
|
|
120
|
+
self.contacts[address] = entry
|
|
121
|
+
save_json(self.contacts_path, self.contacts)
|
|
122
|
+
|
|
123
|
+
self.presence_queue.put((address, entry["name"], is_new))
|
|
124
|
+
|
|
125
|
+
def drain_notifications(self):
|
|
126
|
+
while True:
|
|
127
|
+
try:
|
|
128
|
+
message = self.notify_queue.get_nowait()
|
|
129
|
+
except queue.Empty:
|
|
130
|
+
break
|
|
131
|
+
preview = message.content_as_string() or ""
|
|
132
|
+
print(f"\n\a\U0001F4E9 New message from {message.source_hash.hex()}: {preview[:80]}")
|
|
133
|
+
print("Press [I] for inbox, [M] to compose, [Q] to quit.")
|
|
134
|
+
notify("LXMF Message", f"From {message.source_hash.hex()[:16]}...", preview)
|
|
135
|
+
|
|
136
|
+
while True:
|
|
137
|
+
try:
|
|
138
|
+
address, name, is_new = self.presence_queue.get_nowait()
|
|
139
|
+
except queue.Empty:
|
|
140
|
+
return
|
|
141
|
+
if is_new:
|
|
142
|
+
label = f"{name} ({address})" if name else address
|
|
143
|
+
print(f"\n\U0001F7E2 New contact seen: {label}")
|
|
144
|
+
print("Press [P] for the presence directory.")
|
|
145
|
+
|
|
146
|
+
def send(self, address_hex, title, body):
|
|
147
|
+
try:
|
|
148
|
+
dest_hash = bytes.fromhex(address_hex.strip())
|
|
149
|
+
except ValueError:
|
|
150
|
+
print("That doesn't look like a valid hex address.")
|
|
151
|
+
return
|
|
152
|
+
|
|
153
|
+
if not RNS.Transport.has_path(dest_hash):
|
|
154
|
+
print("Path to recipient unknown, requesting...")
|
|
155
|
+
RNS.Transport.request_path(dest_hash)
|
|
156
|
+
deadline = time.time() + 15
|
|
157
|
+
while not RNS.Transport.has_path(dest_hash) and time.time() < deadline:
|
|
158
|
+
time.sleep(0.2)
|
|
159
|
+
if not RNS.Transport.has_path(dest_hash):
|
|
160
|
+
print("Could not find a path to that address. They may be offline or out of range.")
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
recipient_identity = RNS.Identity.recall(dest_hash)
|
|
164
|
+
if not recipient_identity:
|
|
165
|
+
print("Could not resolve an identity for that address.")
|
|
166
|
+
return
|
|
167
|
+
|
|
168
|
+
dest = RNS.Destination(recipient_identity, RNS.Destination.OUT, RNS.Destination.SINGLE, "lxmf", "delivery")
|
|
169
|
+
self._deliver(dest, title, body)
|
|
170
|
+
print("Message sent.")
|
|
171
|
+
|
|
172
|
+
def reply(self, message, body):
|
|
173
|
+
self._deliver(message.source, "", body)
|
|
174
|
+
print("Reply sent.")
|
|
175
|
+
|
|
176
|
+
def _deliver(self, dest, title, body):
|
|
177
|
+
lxm = LXMF.LXMessage(dest, self.destination, body, title, desired_method=LXMF.LXMessage.DIRECT, include_ticket=True)
|
|
178
|
+
self.router.handle_outbound(lxm)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def compose(messenger, to_address=None, to_name=None):
|
|
182
|
+
print()
|
|
183
|
+
if to_address:
|
|
184
|
+
print(f"To: {to_name + ' ' if to_name else ''}({to_address})")
|
|
185
|
+
address_hex = to_address
|
|
186
|
+
else:
|
|
187
|
+
address_hex = input("To (LXMF address hex, blank to cancel): ").strip()
|
|
188
|
+
if not address_hex:
|
|
189
|
+
print("Cancelled.")
|
|
190
|
+
return
|
|
191
|
+
title = input("Title (optional): ").strip()
|
|
192
|
+
body = input("Message: ")
|
|
193
|
+
if not body:
|
|
194
|
+
print("Cancelled (empty message).")
|
|
195
|
+
return
|
|
196
|
+
messenger.send(address_hex, title, body)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def show_inbox(messenger):
|
|
200
|
+
with messenger.inbox_lock:
|
|
201
|
+
messages = list(messenger.inbox)
|
|
202
|
+
|
|
203
|
+
print("\n--- Inbox ---")
|
|
204
|
+
if not messages:
|
|
205
|
+
print("(empty)")
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
for i, m in enumerate(messages):
|
|
209
|
+
ts = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(m.timestamp))
|
|
210
|
+
title = m.title_as_string() or "(no title)"
|
|
211
|
+
print(f"[{i}] {ts} from {m.source_hash.hex()}")
|
|
212
|
+
print(f" {title}: {m.content_as_string()}")
|
|
213
|
+
|
|
214
|
+
choice = input("Enter number to reply, or blank to go back: ").strip()
|
|
215
|
+
if choice.isdigit() and int(choice) in range(len(messages)):
|
|
216
|
+
body = input("Reply: ")
|
|
217
|
+
if body:
|
|
218
|
+
messenger.reply(messages[int(choice)], body)
|
|
219
|
+
else:
|
|
220
|
+
print("Cancelled.")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def show_presence(messenger):
|
|
224
|
+
with messenger.contacts_lock:
|
|
225
|
+
contacts = list(messenger.contacts.items())
|
|
226
|
+
contacts.sort(key=lambda kv: kv[1].get("last_seen", ""), reverse=True)
|
|
227
|
+
|
|
228
|
+
print("\n--- Presence Directory ---")
|
|
229
|
+
print(f"Your address: {messenger.address}")
|
|
230
|
+
if not contacts:
|
|
231
|
+
print("(no peers seen yet -- they'll show up here once they announce on the network)")
|
|
232
|
+
else:
|
|
233
|
+
for i, (address, info) in enumerate(contacts):
|
|
234
|
+
name = info.get("name") or "(no name)"
|
|
235
|
+
print(f"[{i}] {name} {address}")
|
|
236
|
+
print(f" first seen {info.get('first_seen')} last seen {info.get('last_seen')}")
|
|
237
|
+
|
|
238
|
+
choice = input("Enter number to message someone, [A] to announce yourself, or blank to go back: ").strip()
|
|
239
|
+
if choice.lower() == "a":
|
|
240
|
+
messenger.announce_self()
|
|
241
|
+
print("Announced.")
|
|
242
|
+
elif choice.isdigit() and int(choice) in range(len(contacts)):
|
|
243
|
+
address, info = contacts[int(choice)]
|
|
244
|
+
compose(messenger, to_address=address, to_name=info.get("name"))
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def run_keyboard_loop(messenger):
|
|
248
|
+
print(f"Your LXMF address: {messenger.address}")
|
|
249
|
+
print("[M] Compose [I] Inbox [P] Presence [Q] Quit\n")
|
|
250
|
+
|
|
251
|
+
fd = sys.stdin.fileno()
|
|
252
|
+
old_settings = termios.tcgetattr(fd)
|
|
253
|
+
try:
|
|
254
|
+
tty.setcbreak(fd)
|
|
255
|
+
while True:
|
|
256
|
+
ready, _, _ = select.select([sys.stdin], [], [], 0.5)
|
|
257
|
+
if ready:
|
|
258
|
+
ch = sys.stdin.read(1).lower()
|
|
259
|
+
if ch == "m":
|
|
260
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
|
261
|
+
compose(messenger)
|
|
262
|
+
tty.setcbreak(fd)
|
|
263
|
+
elif ch == "i":
|
|
264
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
|
265
|
+
show_inbox(messenger)
|
|
266
|
+
tty.setcbreak(fd)
|
|
267
|
+
elif ch == "p":
|
|
268
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
|
269
|
+
show_presence(messenger)
|
|
270
|
+
tty.setcbreak(fd)
|
|
271
|
+
elif ch == "q":
|
|
272
|
+
break
|
|
273
|
+
messenger.drain_notifications()
|
|
274
|
+
finally:
|
|
275
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def main():
|
|
279
|
+
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
280
|
+
parser.add_argument("-v", "--verbose", action="store_true",
|
|
281
|
+
help="Show diagnostic detail for errors that are normally handled silently, and run RNS at debug log level")
|
|
282
|
+
parser.add_argument("--config", default=None, help="Path to the RNS config directory (skips the startup config prompt if given)")
|
|
283
|
+
parser.add_argument("--identity", default=DEFAULT_IDENTITY, help="Path to the RNS identity file to create/reuse")
|
|
284
|
+
parser.add_argument("--display-name", default="ble-connector", help="Display name announced with your LXMF address")
|
|
285
|
+
parser.add_argument("--stamp-cost", type=int, default=0, help="Proof-of-work stamp cost required from senders")
|
|
286
|
+
parser.add_argument("--contacts", default=DEFAULT_CONTACTS, help="Where the presence directory is persisted")
|
|
287
|
+
parser.add_argument("--announce-interval", type=float, default=0,
|
|
288
|
+
help="Re-announce yourself every N minutes so others can discover you (0 = only announce once at startup)")
|
|
289
|
+
args = parser.parse_args()
|
|
290
|
+
set_verbose(args.verbose)
|
|
291
|
+
args.config = resolve_config_dir(args.config)
|
|
292
|
+
|
|
293
|
+
messenger = Messenger(args.config, args.identity, args.display_name, args.stamp_cost,
|
|
294
|
+
contacts_path=args.contacts, announce_interval=args.announce_interval, verbose=args.verbose)
|
|
295
|
+
|
|
296
|
+
try:
|
|
297
|
+
run_keyboard_loop(messenger)
|
|
298
|
+
except KeyboardInterrupt:
|
|
299
|
+
pass
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
if __name__ == "__main__":
|
|
303
|
+
main()
|