retalk 0.0.1__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.
- retalk/__init__.py +6 -0
- retalk/cli.py +439 -0
- retalk/server.py +307 -0
- retalk/user.py +374 -0
- retalk-0.0.1.dist-info/METADATA +381 -0
- retalk-0.0.1.dist-info/RECORD +8 -0
- retalk-0.0.1.dist-info/WHEEL +4 -0
- retalk-0.0.1.dist-info/entry_points.txt +4 -0
retalk/__init__.py
ADDED
retalk/cli.py
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
"""retalk command-line interface.
|
|
2
|
+
|
|
3
|
+
An identity lives in a folder (created by `retalk init`) containing
|
|
4
|
+
store.db: keys encrypted at rest, sessions, saved peers, outbox.
|
|
5
|
+
|
|
6
|
+
Store resolution for every command, in order:
|
|
7
|
+
-s DIR > -u [NAME] > STORE env > user-level "default" identity
|
|
8
|
+
(if it exists) > error.
|
|
9
|
+
Only `init` ever creates an identity; every other command refuses loudly
|
|
10
|
+
when none exists.
|
|
11
|
+
|
|
12
|
+
The pickle secret is read from PICKLE_SECRET or prompted (never echoed).
|
|
13
|
+
The server URL comes from --server, SERVER_URL, or the value saved at
|
|
14
|
+
init. Identity banners go to stderr so stdout stays clean for --json.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import getpass
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import sqlite3
|
|
23
|
+
import sys
|
|
24
|
+
import time
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from .user import User
|
|
28
|
+
|
|
29
|
+
STORE_FILE = "store.db"
|
|
30
|
+
ID_RE = re.compile(r"^[0-9a-f]{32}$")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _data_home() -> Path:
|
|
34
|
+
return Path(os.environ.get("XDG_DATA_HOME",
|
|
35
|
+
Path.home() / ".local" / "share")) / "retalk"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _die(msg: str, code: int = 2):
|
|
39
|
+
print(f"retalk: {msg}", file=sys.stderr)
|
|
40
|
+
sys.exit(code)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _resolve_store(args, creating: bool = False) -> Path:
|
|
44
|
+
if getattr(args, "dir", None):
|
|
45
|
+
d = Path(args.dir)
|
|
46
|
+
elif args.user_level is not None:
|
|
47
|
+
d = _data_home() / args.user_level
|
|
48
|
+
elif not creating and os.environ.get("STORE"):
|
|
49
|
+
d = Path(os.environ["STORE"])
|
|
50
|
+
elif not creating and (_data_home() / "default" / STORE_FILE).exists():
|
|
51
|
+
d = _data_home() / "default"
|
|
52
|
+
elif creating:
|
|
53
|
+
_die("init needs a location: a directory argument or -u [NAME]")
|
|
54
|
+
else:
|
|
55
|
+
_die("no identity specified: use -s DIR, -u [NAME], the STORE env "
|
|
56
|
+
"var, or create one with `retalk init`")
|
|
57
|
+
if not creating and not (d / STORE_FILE).exists():
|
|
58
|
+
_die(f"no identity at {d} — create one with `retalk init {d}`")
|
|
59
|
+
return d
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _secret(confirm: bool = False) -> str:
|
|
63
|
+
s = os.environ.get("PICKLE_SECRET")
|
|
64
|
+
if s:
|
|
65
|
+
return s
|
|
66
|
+
if not sys.stdin.isatty():
|
|
67
|
+
_die("PICKLE_SECRET is not set and there is no terminal to prompt on")
|
|
68
|
+
s = getpass.getpass("pickle secret (unlocks this identity): ")
|
|
69
|
+
if confirm and getpass.getpass("repeat to confirm: ") != s:
|
|
70
|
+
_die("secrets do not match")
|
|
71
|
+
return s
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _store_sql(store_db: Path, query: str, *params) -> list:
|
|
75
|
+
conn = sqlite3.connect(store_db)
|
|
76
|
+
try:
|
|
77
|
+
with conn:
|
|
78
|
+
return conn.execute(query, params).fetchall()
|
|
79
|
+
finally:
|
|
80
|
+
conn.close()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _meta(store_db: Path, key: str) -> str | None:
|
|
84
|
+
rows = _store_sql(store_db, "SELECT v FROM meta WHERE k=?", key)
|
|
85
|
+
return rows[0][0] if rows else None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _saved_peers(store_db: Path) -> dict:
|
|
89
|
+
_store_sql(store_db, "CREATE TABLE IF NOT EXISTS peers("
|
|
90
|
+
"name TEXT PRIMARY KEY, id TEXT, pin TEXT)")
|
|
91
|
+
return {name: (pid, pin) for name, pid, pin in
|
|
92
|
+
_store_sql(store_db, "SELECT name, id, pin FROM peers")}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _open_user(args, need_server: bool = True, banner: bool = True) -> User:
|
|
96
|
+
d = _resolve_store(args)
|
|
97
|
+
store_db = d / STORE_FILE
|
|
98
|
+
server = (getattr(args, "server", None) or os.environ.get("SERVER_URL")
|
|
99
|
+
or _meta(store_db, "server_url") or "")
|
|
100
|
+
if need_server and not server:
|
|
101
|
+
_die("no server URL: pass --server, set SERVER_URL, or save one "
|
|
102
|
+
"at init time")
|
|
103
|
+
peers = _saved_peers(store_db)
|
|
104
|
+
pins = {pid: pin for _, (pid, pin) in peers.items() if pin}
|
|
105
|
+
names = {pid: name for name, (pid, _) in peers.items()}
|
|
106
|
+
try:
|
|
107
|
+
u = User(server, _secret(), name=_meta(store_db, "name") or "",
|
|
108
|
+
store=str(store_db), pins=pins, names=names)
|
|
109
|
+
except Exception:
|
|
110
|
+
_die(f"could not unlock the identity at {d} (wrong secret?)")
|
|
111
|
+
if banner:
|
|
112
|
+
print(f"using {u.name or 'user'} ({u.user_id()}) from {d}",
|
|
113
|
+
file=sys.stderr)
|
|
114
|
+
return u
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _ensure_published(u: User):
|
|
118
|
+
"""Make sure our keys exist on this server before acting.
|
|
119
|
+
|
|
120
|
+
Asks the server rather than trusting a local flag, so a wiped or
|
|
121
|
+
replaced server database heals automatically on the next command."""
|
|
122
|
+
if not u._call("count_keys")["has_fallback"]:
|
|
123
|
+
u.publish()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _peer_to_id(peer: str, store_db: Path) -> str:
|
|
127
|
+
peers = _saved_peers(store_db)
|
|
128
|
+
if peer in peers:
|
|
129
|
+
return peers[peer][0]
|
|
130
|
+
if ID_RE.match(peer):
|
|
131
|
+
return peer
|
|
132
|
+
_die(f"unknown peer '{peer}': `retalk add {peer} <user-id>` first, "
|
|
133
|
+
"or pass a 32-hex user id")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# ---------- commands ----------
|
|
137
|
+
|
|
138
|
+
def cmd_init(args):
|
|
139
|
+
if args.directory:
|
|
140
|
+
args.dir = args.directory
|
|
141
|
+
d = _resolve_store(args, creating=True)
|
|
142
|
+
if (d / STORE_FILE).exists():
|
|
143
|
+
_die(f"an identity already exists at {d}")
|
|
144
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
145
|
+
secret = _secret(confirm=True)
|
|
146
|
+
server = args.server or os.environ.get("SERVER_URL") or ""
|
|
147
|
+
u = User(server, secret, name=args.name or "",
|
|
148
|
+
store=str(d / STORE_FILE))
|
|
149
|
+
if args.name:
|
|
150
|
+
u._meta_set("name", args.name)
|
|
151
|
+
if server:
|
|
152
|
+
u._meta_set("server_url", server)
|
|
153
|
+
print(f"created identity at {d}", file=sys.stderr)
|
|
154
|
+
print("user id (share out-of-band; it is address + pin in one):",
|
|
155
|
+
file=sys.stderr)
|
|
156
|
+
print(u.user_id())
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def cmd_id(args):
|
|
160
|
+
u = _open_user(args, need_server=False, banner=False)
|
|
161
|
+
if args.json:
|
|
162
|
+
print(json.dumps({"user_id": u.user_id(),
|
|
163
|
+
"identity_key": u.identity_key(),
|
|
164
|
+
"name": u.name}))
|
|
165
|
+
else:
|
|
166
|
+
print(u.user_id())
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def cmd_add(args):
|
|
170
|
+
d = _resolve_store(args)
|
|
171
|
+
if not ID_RE.match(args.user_id):
|
|
172
|
+
_die("user id must be 32 hex characters")
|
|
173
|
+
if ID_RE.match(args.name):
|
|
174
|
+
_die("peer name looks like a user id — give it a human name")
|
|
175
|
+
store_db = d / STORE_FILE
|
|
176
|
+
_saved_peers(store_db) # ensure the table exists
|
|
177
|
+
_store_sql(store_db,
|
|
178
|
+
"INSERT INTO peers(name, id, pin) VALUES(?,?,?) "
|
|
179
|
+
"ON CONFLICT(name) DO UPDATE SET id=excluded.id, "
|
|
180
|
+
"pin=excluded.pin",
|
|
181
|
+
args.name, args.user_id, args.pin)
|
|
182
|
+
print(f"added {args.name} -> {args.user_id}", file=sys.stderr)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def cmd_send(args):
|
|
186
|
+
u = _open_user(args)
|
|
187
|
+
to = _peer_to_id(args.peer, _resolve_store(args) / STORE_FILE)
|
|
188
|
+
|
|
189
|
+
_ensure_published(u)
|
|
190
|
+
u.send(to, args.text)
|
|
191
|
+
print(f"sent to {args.peer}", file=sys.stderr)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def cmd_receive(args):
|
|
195
|
+
u = _open_user(args)
|
|
196
|
+
|
|
197
|
+
def emit(batch):
|
|
198
|
+
for sender, name, text in batch:
|
|
199
|
+
if args.json:
|
|
200
|
+
print(json.dumps({"from": sender, "name": name,
|
|
201
|
+
"text": text}), flush=True)
|
|
202
|
+
else:
|
|
203
|
+
print(f"{name or sender}: {text}", flush=True)
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
_ensure_published(u)
|
|
207
|
+
emit(u.receive())
|
|
208
|
+
if not args.follow:
|
|
209
|
+
return
|
|
210
|
+
last_maintain = time.monotonic()
|
|
211
|
+
while True:
|
|
212
|
+
time.sleep(2)
|
|
213
|
+
emit(u.receive())
|
|
214
|
+
if time.monotonic() - last_maintain > 60:
|
|
215
|
+
u.maintain()
|
|
216
|
+
last_maintain = time.monotonic()
|
|
217
|
+
except KeyboardInterrupt:
|
|
218
|
+
pass
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def main():
|
|
222
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
223
|
+
raw = argparse.RawDescriptionHelpFormatter
|
|
224
|
+
|
|
225
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
226
|
+
g = common.add_argument_group("identity selection (shared by every command)")
|
|
227
|
+
g.add_argument("-s", "--store", dest="dir", metavar="DIR",
|
|
228
|
+
help="use the identity in directory DIR "
|
|
229
|
+
"(created earlier by `retalk init DIR`)")
|
|
230
|
+
g.add_argument("-u", "--user-level", nargs="?", const="default",
|
|
231
|
+
default=None, metavar="NAME",
|
|
232
|
+
help="use the user-level identity NAME, stored under "
|
|
233
|
+
"~/.local/share/retalk/NAME/ (defaults to 'default' "
|
|
234
|
+
"when NAME is omitted)")
|
|
235
|
+
g.add_argument("--server", metavar="URL",
|
|
236
|
+
help="server URL for this invocation; overrides the "
|
|
237
|
+
"SERVER_URL env var and the URL saved at init")
|
|
238
|
+
|
|
239
|
+
p = argparse.ArgumentParser(
|
|
240
|
+
prog="retalk",
|
|
241
|
+
formatter_class=raw,
|
|
242
|
+
description="""\
|
|
243
|
+
End-to-end-encrypted messages between users, relayed by a server that is
|
|
244
|
+
never trusted: it stores only public keys and ciphertext, and every
|
|
245
|
+
request to it is signed with your key (no accounts, no tokens, no
|
|
246
|
+
registration). A "user" is anything with a keypair and a mailbox — an AI
|
|
247
|
+
agent, a service, or you.
|
|
248
|
+
|
|
249
|
+
Your USER ID is a 32-hex fingerprint of your public keys. It is both your
|
|
250
|
+
address and your peers' proof of your keys: share it over any channel the
|
|
251
|
+
server does not control (chat, email, in person).""",
|
|
252
|
+
epilog="""\
|
|
253
|
+
how every command finds your identity (first match wins):
|
|
254
|
+
1. -s DIR an explicit identity directory
|
|
255
|
+
2. -u [NAME] a named user-level identity (~/.local/share/retalk/)
|
|
256
|
+
3. STORE env var same as -s
|
|
257
|
+
4. the user-level identity called 'default', if you created one
|
|
258
|
+
5. otherwise: error. Only `retalk init` ever creates an identity, so a
|
|
259
|
+
mistyped path fails loudly instead of silently
|
|
260
|
+
creating a fresh one.
|
|
261
|
+
|
|
262
|
+
environment variables:
|
|
263
|
+
PICKLE_SECRET the secret that unlocks your keys; prompted interactively
|
|
264
|
+
when unset (use the env var for scripts and daemons)
|
|
265
|
+
SERVER_URL server to talk to (init can save one per identity instead)
|
|
266
|
+
STORE identity directory, like -s
|
|
267
|
+
|
|
268
|
+
output conventions:
|
|
269
|
+
stdout carries results (ids, messages, --json lines); everything else —
|
|
270
|
+
banners, progress, errors — goes to stderr, so pipes stay clean. Every
|
|
271
|
+
command that acts prints `using <name> (<id>) from <dir>` to stderr
|
|
272
|
+
so you always know which identity acted. Exit codes: 0 ok, 2 usage or
|
|
273
|
+
refusal (no identity, wrong secret, unknown peer).
|
|
274
|
+
|
|
275
|
+
quickstart:
|
|
276
|
+
retalk init -u --name alice-1 --server https://server.example.com
|
|
277
|
+
retalk add bob <bob's user id>
|
|
278
|
+
retalk send bob "hello"
|
|
279
|
+
retalk receive --follow
|
|
280
|
+
|
|
281
|
+
run `retalk <command> --help` for the full story of each command.""")
|
|
282
|
+
sub = p.add_subparsers(dest="command", required=True,
|
|
283
|
+
metavar="{init,id,add,send,receive}")
|
|
284
|
+
|
|
285
|
+
sp = sub.add_parser(
|
|
286
|
+
"init", parents=[common], formatter_class=raw,
|
|
287
|
+
help="create a new identity (the only command that ever does)",
|
|
288
|
+
description="""\
|
|
289
|
+
Create a new identity: generate an encryption keypair, encrypt it with a
|
|
290
|
+
secret you choose, and store it in a folder of your choosing. Prints the
|
|
291
|
+
new USER ID on stdout — share it with peers out-of-band; it is both your
|
|
292
|
+
address and the fingerprint they verify you by.
|
|
293
|
+
|
|
294
|
+
The location is mandatory: either a directory argument (project-local) or
|
|
295
|
+
-u [NAME] (user-level, under ~/.local/share/retalk/). The folder will
|
|
296
|
+
contain store.db — keys (encrypted), sessions, saved peers, and the
|
|
297
|
+
outbox of not-yet-acknowledged messages. Back it up to keep the identity;
|
|
298
|
+
delete it to destroy the identity.
|
|
299
|
+
|
|
300
|
+
The secret is prompted twice (or taken from PICKLE_SECRET). It encrypts
|
|
301
|
+
your private keys at rest and is required by every later command. It
|
|
302
|
+
cannot be recovered: losing it means losing this identity and all its
|
|
303
|
+
conversations.
|
|
304
|
+
|
|
305
|
+
init is offline — it does not contact the server. Keys are published
|
|
306
|
+
automatically the first time you send or receive.""",
|
|
307
|
+
epilog="""\
|
|
308
|
+
examples:
|
|
309
|
+
retalk init ./alice identity in ./alice/
|
|
310
|
+
retalk init -u user-level 'default' identity
|
|
311
|
+
retalk init -u work --name work-bot \\
|
|
312
|
+
--server https://srv.example.com named identity, server saved""")
|
|
313
|
+
sp.add_argument("directory", nargs="?",
|
|
314
|
+
help="folder to hold the identity (alternative to -u)")
|
|
315
|
+
sp.add_argument("--name", metavar="NAME",
|
|
316
|
+
help="display name attached to your messages; peers see "
|
|
317
|
+
"it marked '~NAME' because it is not verified — "
|
|
318
|
+
"only their locally saved peer name for you is")
|
|
319
|
+
sp.set_defaults(fn=cmd_init)
|
|
320
|
+
|
|
321
|
+
sp = sub.add_parser(
|
|
322
|
+
"id", parents=[common], formatter_class=raw,
|
|
323
|
+
help="print my user id",
|
|
324
|
+
description="""\
|
|
325
|
+
Print this identity's USER ID (32 hex chars) on stdout.
|
|
326
|
+
|
|
327
|
+
The ID is the sha256 fingerprint of your public keys, which makes it
|
|
328
|
+
self-verifying: a peer who knows your ID can detect any attempt by the
|
|
329
|
+
server to hand out substitute keys for you. Sharing it is therefore
|
|
330
|
+
sharing your address and your key fingerprint in one string. It contains
|
|
331
|
+
no secret — it is safe to post publicly.
|
|
332
|
+
|
|
333
|
+
Needs your secret (to open the store) but never contacts the server.""",
|
|
334
|
+
epilog="""\
|
|
335
|
+
examples:
|
|
336
|
+
retalk id id of the default identity
|
|
337
|
+
retalk id -s ./alice id of a project-local identity
|
|
338
|
+
retalk id --json {"user_id", "identity_key", "name"}""")
|
|
339
|
+
sp.add_argument("--json", action="store_true",
|
|
340
|
+
help="emit JSON with user_id, identity_key (base64 "
|
|
341
|
+
"Curve25519 public key), and name")
|
|
342
|
+
sp.set_defaults(fn=cmd_id)
|
|
343
|
+
|
|
344
|
+
sp = sub.add_parser(
|
|
345
|
+
"add", parents=[common], formatter_class=raw,
|
|
346
|
+
help="save a peer's user id under a local name",
|
|
347
|
+
description="""\
|
|
348
|
+
Save a peer's USER ID under a short local name, so `send bob ...` works
|
|
349
|
+
and incoming messages from that ID display as 'bob' instead of an
|
|
350
|
+
unverified '~name'. The name is yours alone — it never travels over
|
|
351
|
+
the network and the peer never learns it.
|
|
352
|
+
|
|
353
|
+
Get the peer's ID out-of-band (they run `retalk id`). Adding an existing
|
|
354
|
+
name overwrites it. No secret needed and no server contact — this only
|
|
355
|
+
writes your local peers table.""",
|
|
356
|
+
epilog="""\
|
|
357
|
+
examples:
|
|
358
|
+
retalk add bob f1041c25c87351d8550b31cc6b13ab04
|
|
359
|
+
retalk add bob <id> --pin "vGY3...=" also pin bob's full identity key
|
|
360
|
+
|
|
361
|
+
The fingerprint ID already pins the peer's keys; --pin adds an explicit
|
|
362
|
+
second check of the full identity key for belt-and-braces.""")
|
|
363
|
+
sp.add_argument("name", help="local name for this peer (e.g. 'bob')")
|
|
364
|
+
sp.add_argument("user_id", help="the peer's 32-hex user id")
|
|
365
|
+
sp.add_argument("--pin", metavar="KEY",
|
|
366
|
+
help="peer's full base64 identity key, verified against "
|
|
367
|
+
"everything the server serves for this peer")
|
|
368
|
+
sp.set_defaults(fn=cmd_add)
|
|
369
|
+
|
|
370
|
+
sp = sub.add_parser(
|
|
371
|
+
"send", parents=[common], formatter_class=raw,
|
|
372
|
+
help="encrypt and send one message",
|
|
373
|
+
description="""\
|
|
374
|
+
Encrypt TEXT for one peer and hand the ciphertext to the server, then
|
|
375
|
+
exit. The server (and anyone watching it) sees only the ciphertext and
|
|
376
|
+
the metadata sender/recipient/time/size — never the content.
|
|
377
|
+
|
|
378
|
+
PEER is a name saved with `retalk add`, or a raw 32-hex user id. The
|
|
379
|
+
first message to a new peer performs the key handshake automatically
|
|
380
|
+
(claiming one of the peer's one-time keys from the server); if the
|
|
381
|
+
server's served keys do not match the peer's ID fingerprint or your
|
|
382
|
+
saved --pin, the send refuses with PIN MISMATCH instead of encrypting to
|
|
383
|
+
an impostor key.
|
|
384
|
+
|
|
385
|
+
Delivery is tracked: the message stays in your local outbox until the
|
|
386
|
+
peer's client acknowledges decrypting it (acks arrive during your next
|
|
387
|
+
`receive`). Unacknowledged messages are re-sent automatically by
|
|
388
|
+
`receive --follow`, so nothing is lost if the server dies or is swapped.
|
|
389
|
+
On first contact with a server your public keys are published
|
|
390
|
+
automatically.""",
|
|
391
|
+
epilog="""\
|
|
392
|
+
examples:
|
|
393
|
+
retalk send bob "hello"
|
|
394
|
+
retalk send f1041c25c87351d8550b31cc6b13ab04 "hi, stranger"
|
|
395
|
+
retalk send bob "psst" -s ./alice --server http://127.0.0.1:8766""")
|
|
396
|
+
sp.add_argument("peer", help="saved peer name, or a raw 32-hex user id")
|
|
397
|
+
sp.add_argument("text", help="the message plaintext (quote it)")
|
|
398
|
+
sp.set_defaults(fn=cmd_send)
|
|
399
|
+
|
|
400
|
+
sp = sub.add_parser(
|
|
401
|
+
"receive", parents=[common], formatter_class=raw,
|
|
402
|
+
help="decrypt pending messages",
|
|
403
|
+
description="""\
|
|
404
|
+
Fetch this identity's mailbox from the server, decrypt each message, and
|
|
405
|
+
print it — `name: text` per line, where name is your saved peer name for
|
|
406
|
+
the sender, or their unverified self-chosen name marked '~', or the
|
|
407
|
+
bare sender id. Each successfully decrypted message is acknowledged back
|
|
408
|
+
to its sender (encrypted, like everything else).
|
|
409
|
+
|
|
410
|
+
Without --follow: drain the mailbox once and exit (good for cron and
|
|
411
|
+
scripts). With --follow: poll every 2 seconds until interrupted, and once
|
|
412
|
+
a minute run key maintenance — replenish one-time keys on the server,
|
|
413
|
+
rotate the fallback key daily, and re-send any of your own messages that
|
|
414
|
+
have gone unacknowledged for 2 minutes.
|
|
415
|
+
|
|
416
|
+
Messages the server already handed over are never served again, so pipe
|
|
417
|
+
--json output somewhere durable if you need a log.""",
|
|
418
|
+
epilog="""\
|
|
419
|
+
examples:
|
|
420
|
+
retalk receive drain once, human-readable
|
|
421
|
+
retalk receive --follow live tail + key maintenance
|
|
422
|
+
retalk receive --json | jq .text script-friendly, one object per line
|
|
423
|
+
|
|
424
|
+
json fields per message: "from" (sender id), "name" (your peer name,
|
|
425
|
+
'~name', or ''), "text" (the plaintext).""")
|
|
426
|
+
sp.add_argument("--follow", action="store_true",
|
|
427
|
+
help="keep polling every 2s and maintain keys every "
|
|
428
|
+
"60s until ctrl-c")
|
|
429
|
+
sp.add_argument("--json", action="store_true",
|
|
430
|
+
help="one JSON object per message on stdout "
|
|
431
|
+
"(banners stay on stderr)")
|
|
432
|
+
sp.set_defaults(fn=cmd_receive)
|
|
433
|
+
|
|
434
|
+
args = p.parse_args()
|
|
435
|
+
args.fn(args)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
if __name__ == "__main__":
|
|
439
|
+
main()
|