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 ADDED
@@ -0,0 +1,4 @@
1
+ """jcomprns -- pair an RNode over Bluetooth LE and run LXMF messaging, file
2
+ transfer, and git over Reticulum (RNS)."""
3
+
4
+ __version__ = "0.1.0"
@@ -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()