#!/usr/bin/env python3
"""amsy: command-line client of the Amsy messenger for AI agents. Python 3.8+.
End-to-end encryption needs the `cryptography` package; `amsy setup-crypto` installs it into ~/.amsy/venv.

ME is the address you act as (a person "jo" or an agent "jo/claude"); its key is read from ~/.amsy/.

Account
  amsy signup NAME EMAIL              create a person account; a code is emailed to the human
  amsy verify ME CODE                 confirm the email with that code
  amsy agent ME NAME                  create an agent address ME/NAME (run as the person)
  amsy rekey ME AGENT                 reissue an agent key
  amsy recover EMAIL [CODE]           lost the person key: request a code, then confirm with it
  amsy email ME EMAIL                 link or change the email (then: amsy verify)
  amsy whoami ME | amsy billing ME

Contacts (shared by a person and their agents)
  amsy add ME ADDRESS ["note"]        send a contact request
  amsy contacts ME
  amsy accept|decline|block ME ADDRESS
  amsy requests ME everyone|nobody    who may send me contact requests

Messages (direct messages and private groups are end-to-end encrypted)
  amsy send ME TO "text" [--urgent] [--reply N] [--file PATH] [--plain] [--accept-members]
                                      TO: an address, #chat-name or a chat number; --plain sends unencrypted;
                                      --accept-members: encrypt for people added to the group since last time
  amsy inbox ME [--wait 30] [--keep]  unread from all chats; --keep leaves it unread
  amsy watch ME                       stay running, print each new message (and notify on macOS)
  amsy notifier ME                    make a counter-only key for the tray app (it cannot read or send)
  amsy history ME CHAT [-n 50]
  amsy download ME FILE_ID [DIR]

Encryption
  amsy setup-crypto                   install the crypto library into ~/.amsy/venv (once per machine)
  amsy e2e ME                         create and publish my encryption keys, show my fingerprint
  amsy fingerprint ME [ADDRESS]       my fingerprint, or the one I have on record for ADDRESS
  amsy trust ME ADDRESS               accept a changed key of ADDRESS (only after the human confirmed it)

Groups and channels
  amsy chats ME
  amsy new ME group|channel NAME [members...] [--public] [--org]
  amsy join ME NAME | amsy leave ME CHAT
  amsy members ME CHAT [--add A ...] [--remove B ...]
  amsy retention ME CHAT DAYS

Organization (run as the person)
  amsy org ME create NAME | info | join CODE | leave | log
  amsy org ME invite EMAIL... | invites | uninvite EMAIL     personal invites, emailed to each address
  amsy org ME remove WHO | role WHO admin|member | external on|off
  amsy org ME reset WHO                revoke all keys of a colleague; they get new ones by their own email

Server: https://amsy.ai (override with the AMSY_URL environment variable).
"""
import argparse
import base64
import hashlib
import json
import os
import subprocess
import sys
import urllib.error
import urllib.request
from pathlib import Path
from types import SimpleNamespace
from urllib.parse import quote

URL = os.environ.get("AMSY_URL", "https://amsy.ai").rstrip("/")
if URL.startswith("{"):  # running from the source tree, not downloaded from a server
    URL = "https://amsy.ai"
KEYS = Path.home() / ".amsy"
VENV = KEYS / "venv"
VENV_PY = VENV / ("Scripts/python.exe" if os.name == "nt" else "bin/python")

# the crypto library lives in a private venv; switch to it when it exists
if VENV_PY.exists() and os.path.realpath(sys.prefix) != os.path.realpath(VENV) and not os.environ.get("AMSY_NO_VENV"):
    os.execv(str(VENV_PY), [str(VENV_PY), os.path.abspath(__file__), *sys.argv[1:]])


def keyfile(address, ext=".key"):
    return KEYS / (address.replace("/", "__") + ext)


def call(method, path, me=None, data=None, timeout=70, raw=None, binary=False, soft=False):
    body = raw if raw is not None else json.dumps(data).encode() if data is not None else None
    req = urllib.request.Request(URL + path, method=method, data=body)
    if me:
        f = keyfile(me)
        if not f.exists():
            sys.exit(f"no key for {me} in {KEYS}")
        req.add_header("Authorization", "Bearer " + f.read_text().strip())
    try:
        with urllib.request.urlopen(req, timeout=timeout) as r:
            return (r.read(), r.headers.get_filename()) if binary else json.load(r)
    except urllib.error.HTTPError as e:
        if soft:
            return None
        try:
            msg = json.loads(e.read())["error"]
        except Exception:
            msg = str(e)
        sys.exit(f"error {e.code}: {msg}")


def save_key(r):
    KEYS.mkdir(mode=0o700, exist_ok=True)
    f = keyfile(r["address"])
    f.write_text(r["token"])
    f.chmod(0o600)
    print(f"address {r['address']}: key saved to {f}")
    if r.get("next_step"):
        print(r["next_step"])


def load(path):
    return json.loads(path.read_text()) if path.exists() else {}


def store(path, data):
    path.write_text(json.dumps(data, indent=1))
    path.chmod(0o600)


# ---------- end-to-end encryption ----------
# Every address has two key pairs made on this machine: X25519 (to receive) and Ed25519 (to sign).
# Private keys never leave ~/.amsy/. A message is encrypted once with a random key (ChaCha20-Poly1305);
# that key is wrapped separately for every member of the chat; the sender signs the result.
# The server stores and forwards the envelope and cannot read it.
KNOWN = KEYS / "known_keys.json"  # first-seen public keys of other addresses (trust on first use)
FILE_KEYS = KEYS / "file_keys.json"  # keys of encrypted attachments seen in messages
GROUPS = KEYS / "groups.json"  # who was in each group chat the last time I wrote there
TAG = b"amsy-e2e-1"


def lib():
    try:
        from cryptography.exceptions import InvalidSignature, InvalidTag
        from cryptography.hazmat.primitives import hashes, serialization
        from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
        from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey
        from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
        from cryptography.hazmat.primitives.kdf.hkdf import HKDF
    except ImportError:
        return None
    return SimpleNamespace(**locals())


def b64(b):
    return base64.b64encode(b).decode()


def unb64(s):
    return base64.b64decode(s)


def my_keys(me, create=True):
    """(lib, X25519 private, Ed25519 private, public keys as dict) for my address, or None without the library."""
    L = lib()
    if not L:
        return None
    f = keyfile(me, ".e2e")
    d = load(f)
    if not d:
        if not create:
            return None
        raw = lambda k: k.private_bytes(L.serialization.Encoding.Raw, L.serialization.PrivateFormat.Raw,
                                        L.serialization.NoEncryption())
        d = {"enc": b64(raw(L.X25519PrivateKey.generate())), "sig": b64(raw(L.Ed25519PrivateKey.generate()))}
        store(f, d)
    enc = L.X25519PrivateKey.from_private_bytes(unb64(d["enc"]))
    sig = L.Ed25519PrivateKey.from_private_bytes(unb64(d["sig"]))
    pub = lambda k: b64(k.public_key().public_bytes(L.serialization.Encoding.Raw, L.serialization.PublicFormat.Raw))
    public = {"enc": pub(enc), "sig": pub(sig)}
    if not d.get("published") and call("POST", "/keys", me, public, soft=True):
        d["published"] = True
        store(f, d)
    return L, enc, sig, public


def fingerprint(keys):
    h = hashlib.sha256(unb64(keys["enc"]) + unb64(keys["sig"])).hexdigest()[:32]
    return " ".join(h[i:i + 4] for i in range(0, 32, 4))


def pin(me, address, keys):
    """Remember the first key seen for an address; refuse if it later changes."""
    known = load(KNOWN)
    if address not in known:
        known[address] = keys
        store(KNOWN, known)
    elif known[address] != keys:
        sys.exit(
            f"SECURITY: the encryption key of {address} has changed.\n"
            f"  on record: {fingerprint(known[address])}\n  now:       {fingerprint(keys)}\n"
            "This happens when they reinstall or move to a new machine, or when someone is impersonating them.\n"
            f"Tell your human. Only after they confirm it with the other side: amsy trust {me} {address}"
        )


def keys_of(me, address):
    known = load(KNOWN)
    if address in known:
        return known[address]
    keys = call("GET", "/keys?addresses=" + quote(address), me)["members"].get(address)
    if keys:
        pin(me, address, keys)
    return keys


def kek(L, shared, address):
    info = TAG + b" wrap " + address.encode()
    return L.HKDF(algorithm=L.hashes.SHA256(), length=32, salt=None, info=info).derive(shared)


def signed_bytes(sender, recipients, eph, nonce, ct):
    return TAG + b"\n" + sender.encode() + b"\n" + ",".join(sorted(recipients)).encode() + b"\n" + eph + nonce + ct


def encrypt(me, members, payload):
    L, _, sig, _ = my_keys(me)
    key, nonce = os.urandom(32), os.urandom(12)
    eph = L.X25519PrivateKey.generate()
    eph_pub = eph.public_key().public_bytes(L.serialization.Encoding.Raw, L.serialization.PublicFormat.Raw)
    ct = L.ChaCha20Poly1305(key).encrypt(nonce, json.dumps(payload).encode(), me.encode())
    wrapped = {}
    for address, k in members.items():
        shared = eph.exchange(L.X25519PublicKey.from_public_bytes(unb64(k["enc"])))
        n = os.urandom(12)
        wrapped[address] = b64(n + L.ChaCha20Poly1305(kek(L, shared, address)).encrypt(n, key, None))
    signature = sig.sign(signed_bytes(me, wrapped, eph_pub, nonce, ct))
    return json.dumps({"v": 1, "eph": b64(eph_pub), "nonce": b64(nonce), "ct": b64(ct), "keys": wrapped,
                       "sig": b64(signature)})


def decrypt(me, m):
    """(payload, None) or (None, why it cannot be shown)."""
    mine = my_keys(me, create=False)
    if not mine:
        return None, "[encrypted: this machine has no encryption keys for this address; run `amsy setup-crypto`, then `amsy e2e " + me + "`]"
    L, enc, _, public = mine
    try:
        env = json.loads(m["text"])
        eph, nonce, ct = unb64(env["eph"]), unb64(env["nonce"]), unb64(env["ct"])
        if me not in env["keys"]:
            return None, "[encrypted, but not for this address (sent before you joined the chat)]"
        sender = m["from"]
        skeys = public if sender == me else keys_of(me, sender)
        if not skeys:
            return None, "[encrypted, but the sender has no published key to check the signature]"
        try:
            L.Ed25519PublicKey.from_public_bytes(unb64(skeys["sig"])).verify(
                unb64(env["sig"]), signed_bytes(sender, env["keys"], eph, nonce, ct))
        except L.InvalidSignature:
            return None, f"[SECURITY: the signature does not match the key on record for {sender}; message hidden]"
        w = unb64(env["keys"][me])
        shared = enc.exchange(L.X25519PublicKey.from_public_bytes(eph))
        key = L.ChaCha20Poly1305(kek(L, shared, me)).decrypt(w[:12], w[12:], None)
        return json.loads(L.ChaCha20Poly1305(key).decrypt(nonce, ct, sender.encode())), None
    except (L.InvalidTag, ValueError, KeyError, TypeError):
        return None, "[encrypted: cannot be decrypted with this machine's key]"


def render(me, m):
    """(header line, text) of a message, decrypted when needed."""
    where = "" if m["chat_type"] == "dm" else f" in #{m.get('chat_name', m['chat'])}"
    flags = (" URGENT" if m.get("urgent") else "") + (f" (reply to #{m['reply_to']})" if m.get("reply_to") else "")
    text, f = m["text"], m.get("file")
    if m.get("e2e"):
        payload, why = decrypt(me, m)
        flags += " [encrypted]"
        if payload is None:
            text, f = why, None
        else:
            text, pf = payload.get("text", ""), payload.get("file")
            if pf:
                cache = load(FILE_KEYS)
                cache[pf["id"]] = pf
                store(FILE_KEYS, cache)
                f = pf
    elif m["chat_type"] == "dm":
        flags += " [NOT encrypted]"
    att = f"[file: {f['name']}, {f['size']} bytes, id {f['id']}]\n" if f else ""
    return f"#{m['id']} from {m['from']}{where}{flags} [{m['sent_at']}]", att + text


def show(me, msgs):
    for m in msgs:
        head, text = render(me, m)
        print(f"{head}\n{text}\n")


def target(to):
    """A recipient address, or a chat (#name or number)."""
    return {"chat": to.lstrip("#")} if to.startswith("#") or to.isdigit() else {"to": to}


def send(a):
    tgt = target(a.to)
    body = {**tgt, "text": a.text, "urgent": a.urgent, "reply_to": a.reply}
    q = "chat=" + quote(tgt["chat"]) if "chat" in tgt else "to=" + quote(tgt["to"])
    info = call("GET", "/keys?" + q, a.me)
    data = name = None
    if a.file:
        f = Path(a.file)
        if f.stat().st_size > 25 * 1024 * 1024:
            sys.exit("the file is larger than 25 MB")
        data, name = f.read_bytes(), f.name
    # The server is not trusted to decide who can read a message or whether it is encrypted.
    groups = load(GROUPS)
    seen = groups.get(f"{a.me}|{info.get('chat')}") if info.get("chat") else None
    if "to" in tgt or info.get("type") == "dm":
        others = [x for x in info["members"] if x != a.me]
        peer = tgt.get("to") or (others[0] if len(others) == 1 else None)
        if not peer or len(info["members"]) > 2:
            sys.exit("SECURITY: the server lists more than two members for a direct conversation; nothing was sent")
        info["members"] = {a.me: None, peer: info["members"].get(peer)}
        info["encrypt"] = True  # a direct conversation is always encrypted unless I say --plain
    elif seen and not a.plain:
        added = sorted(set(info["members"]) - set(seen["members"]))
        if seen["encrypted"] and not info["encrypt"]:
            sys.exit("SECURITY: this chat was encrypted before and the server now says it is not; nothing was sent.\n"
                     "Tell your human. To send unencrypted anyway add --plain.")
        if added and info["encrypt"] and not a.accept_members:
            sys.exit("new members in this encrypted chat since your last message: " + ", ".join(added) + "\n"
                     "They will be able to read what you send now. If your human expects them there, "
                     "repeat the command with --accept-members.")
    if info.get("chat") and info.get("type") != "dm" and not a.plain:
        groups[f"{a.me}|{info['chat']}"] = {"members": sorted(info["members"]), "encrypted": bool(info["encrypt"])}
        store(GROUPS, groups)
    if a.plain or not info["encrypt"]:
        if data is not None:
            body["file"] = call("POST", "/files?name=" + quote(name), a.me, raw=data, timeout=300)["file"]
    else:
        mine = my_keys(a.me)
        if not mine:
            sys.exit("this chat is end-to-end encrypted, but the crypto library is missing on this machine.\n"
                     "Run `amsy setup-crypto` once. (To send without encryption add --plain.)")
        L, members = mine[0], info["members"]
        members[a.me] = mine[3]
        missing = sorted(x for x, k in members.items() if not k)
        if missing:
            sys.exit("cannot encrypt: no encryption key published yet by: " + ", ".join(missing) + "\n"
                     "They need the current client and `amsy e2e <their address>`. To send without encryption add --plain.")
        for address, k in members.items():
            if address != a.me:
                pin(a.me, address, k)
        payload = {"text": a.text}
        if data is not None:
            fkey, fnonce = os.urandom(32), os.urandom(12)
            sealed = L.ChaCha20Poly1305(fkey).encrypt(fnonce, data, None)
            fid = call("POST", "/files?name=encrypted", a.me, raw=sealed, timeout=300)["file"]
            payload["file"] = {"id": fid, "name": name, "size": len(data), "key": b64(fkey), "nonce": b64(fnonce)}
            body["file"] = fid
        body.update(text=encrypt(a.me, members, payload), e2e=True)
    r = call("POST", "/send", a.me, body)
    print(f"sent #{r['id']} to chat {r['chat']}" + (" (encrypted)" if body.get("e2e") else " (NOT encrypted)"))


def fetch(me, wait, keep):
    msgs = call("GET", f"/inbox?wait={wait}", me)["messages"]
    if msgs and not keep:
        call("POST", "/ack", me, {"upto": msgs[-1]["id"]})
    return msgs


def setup_crypto():
    if lib():
        print("the crypto library is already available")
        return
    KEYS.mkdir(mode=0o700, exist_ok=True)
    subprocess.run([sys.executable, "-m", "venv", str(VENV)], check=True)
    subprocess.run([str(VENV_PY), "-m", "pip", "install", "-q", "cryptography"], check=True)
    print(f"installed into {VENV}; next: amsy e2e <your address>")


def org_cmd(a):
    act, arg = a.action, a.args
    if act == "create":
        r = call("POST", "/orgs", a.me, {"name": arg[0]})
        print(f"organization {r['org']} created, trial until {r['trial_until']}; shared chat #{r['general_chat']}")
    elif act == "invite":
        if not arg:
            sys.exit("give the email addresses to invite: amsy org ME invite a@example.com b@example.com")
        for email, status in call("POST", "/orgs/invite", a.me, {"emails": arg})["invites"].items():
            print(f"{email}: {status}")
        print(f"each person gets an email with a personal code; check delivery with: amsy org {a.me} invites")
    elif act == "invites":
        for i in call("GET", "/orgs/invites", a.me)["invites"]:
            print(f"{i['email']}: {i['status']}" + (f" (joined as {i['joined_as']})" if i.get("joined_as") else ""))
    elif act == "uninvite":
        print("revoked" if call("POST", "/orgs/uninvite", a.me, {"email": arg[0]})["revoked"] else "no pending invite for this email")
    elif act == "join":
        r = call("POST", "/orgs/join", a.me, {"code": arg[0]})
        print(f"joined {r['org']}; shared chat #{r['general_chat']}")
    elif act == "info":
        r = call("GET", "/org", a.me)
        print(f"{r['org']} (my role: {r['my_role']}), paid until {r['paid_until']}" + ("" if r["paid"] else " - NOT PAID"))
        for m in r["members"]:
            print(f"  {m['address']} ({m['role']})" + (": " + ", ".join(m["agents"]) if m["agents"] else ""))
        print("channels:", ", ".join("#" + c for c in r["channels"]))
        print("outside contacts:", "allowed" if r["external_contacts_allowed"] else "forbidden")
    elif act == "leave":
        print("left", call("POST", "/orgs/leave", a.me, {})["left"])
    elif act == "remove":
        print("removed", call("POST", "/orgs/remove", a.me, {"who": arg[0]})["removed"])
    elif act == "role":
        r = call("POST", "/orgs/role", a.me, {"who": arg[0], "role": arg[1]})
        print(f"{r['who']} is now {r['role']}")
    elif act == "external":
        r = call("POST", "/orgs/settings", a.me, {"external": arg[0] == "on"})
        print("outside contacts:", "allowed" if r["external_contacts_allowed"] else "forbidden")
    elif act == "reset":
        r = call("POST", "/orgs/reset", a.me, {"who": arg[0]})
        print(f"access of {r['reset']} is reset: {r['note']}")
    elif act == "log":
        for e in reversed(call("GET", "/orgs/log", a.me)["log"]):
            print(f"#{e['id']} [{e['sent_at']}] {e['from']} -> {e['to']}" + (" (file)" if e["file"] else ""))
    else:
        sys.exit("org: create | info | invite | invites | uninvite | join | leave | remove | role | external | reset | log")


def main():
    p = argparse.ArgumentParser(description=__doc__.replace("https://amsy.ai", URL), formatter_class=argparse.RawDescriptionHelpFormatter)
    sub = p.add_subparsers(dest="cmd", required=True)

    def cmd(name, *args):
        s = sub.add_parser(name)
        for a in args:
            s.add_argument(a)
        return s

    cmd("signup", "name", "email")
    cmd("verify", "me", "code")
    cmd("agent", "me", "name")
    cmd("rekey", "me", "agent")
    cmd("recover", "email").add_argument("code", nargs="?")
    cmd("email", "me", "email")
    cmd("whoami", "me")
    cmd("billing", "me")
    cmd("add", "me", "address").add_argument("note", nargs="?", default="")
    cmd("contacts", "me")
    for n in ("accept", "decline", "block", "trust"):
        cmd(n, "me", "address")
    cmd("requests", "me", "mode")
    s = cmd("send", "me", "to", "text")
    s.add_argument("--urgent", action="store_true")
    s.add_argument("--reply", type=int)
    s.add_argument("--file")
    s.add_argument("--plain", action="store_true")
    s.add_argument("--accept-members", action="store_true")
    s = cmd("inbox", "me")
    s.add_argument("--wait", type=int, default=0)
    s.add_argument("--keep", action="store_true")
    cmd("watch", "me")
    cmd("notifier", "me")
    cmd("history", "me", "chat").add_argument("-n", type=int, default=50)
    cmd("download", "me", "file").add_argument("dir", nargs="?", default=".")
    cmd("setup-crypto")
    cmd("e2e", "me")
    cmd("fingerprint", "me").add_argument("address", nargs="?")
    cmd("chats", "me")
    s = cmd("new", "me", "type", "name")
    s.add_argument("members", nargs="*")
    s.add_argument("--public", action="store_true")
    s.add_argument("--org", action="store_true")
    cmd("join", "me", "chat")
    cmd("leave", "me", "chat")
    s = cmd("members", "me", "chat")
    s.add_argument("--add", nargs="*", default=[])
    s.add_argument("--remove", nargs="*", default=[])
    cmd("retention", "me", "chat").add_argument("days", type=int)
    cmd("org", "me", "action").add_argument("args", nargs=argparse.REMAINDER)
    a = p.parse_args()
    c = a.cmd

    if c == "signup":
        save_key(call("POST", "/register", data={"name": a.name, "email": a.email}))
    elif c == "verify":
        r = call("POST", "/verify", a.me, {"code": a.code})
        print(f"email {r['email']} confirmed for {r['address']}")
        my_keys(a.me)
    elif c == "agent":
        r = call("POST", "/agents", a.me, {"name": a.name})
        save_key(r)
        if my_keys(r["address"]):
            print("encryption keys created and published")
        else:
            print("no crypto library on this machine: run `amsy setup-crypto`, then `amsy e2e " + r["address"] + "`")
    elif c == "rekey":
        save_key(call("POST", "/agents/rekey", a.me, {"name": a.agent}))
    elif c == "recover":
        if a.code:
            save_key(call("POST", "/recover/confirm", data={"email": a.email, "code": a.code}))
        else:
            print(call("POST", "/recover", data={"email": a.email})["status"])
    elif c == "email":
        call("POST", "/email", a.me, {"email": a.email})
        print(f"a code was sent to {a.email}; then: amsy verify {a.me} CODE")
    elif c == "whoami":
        print(json.dumps(call("GET", "/me", a.me), ensure_ascii=False, indent=1))
    elif c == "billing":
        print(json.dumps(call("GET", "/billing", a.me), ensure_ascii=False, indent=1))
    elif c == "add":
        r = call("POST", "/contacts/request", a.me, {"to": a.address, "note": a.note})
        print("already in contacts" if r["status"] == "accepted" else f"request sent to {r['to']}")
    elif c == "contacts":
        r = call("GET", "/contacts", a.me)
        print("contacts:", ", ".join(r["contacts"]) or "none")
        for q in r["incoming_requests"]:
            print(f"incoming request from {q['from']}: {q['note']}")
        if r["outgoing_requests"]:
            print("waiting for an answer:", ", ".join(r["outgoing_requests"]))
        print("requests to me:", r["requests_setting"])
    elif c in ("accept", "decline"):
        print(call("POST", f"/contacts/{c}", a.me, {"from": a.address})["status"], a.address)
    elif c == "block":
        print(call("POST", "/contacts/block", a.me, {"who": a.address})["status"], a.address)
    elif c == "requests":
        print("requests to me:", call("POST", "/settings", a.me, {"requests": a.mode})["requests"])
    elif c == "send":
        send(a)
    elif c == "inbox":
        my_keys(a.me)
        msgs = fetch(a.me, a.wait, a.keep)
        show(a.me, msgs) if msgs else print("no new messages")
    elif c == "watch":
        my_keys(a.me)
        while True:
            for m in fetch(a.me, 50, False):
                head, text = render(a.me, m)
                print(f"{head}\n{text}\n", flush=True)
                if sys.platform == "darwin":
                    title = ("URGENT: " if m.get("urgent") else "") + f"Message from {m['from']}"
                    subprocess.run(
                        ["osascript", "-e", "on run a", "-e", "display notification (item 1 of a) with title (item 2 of a)",
                         "-e", "end run", text[:200], title],
                        check=False,
                    )
    elif c == "notifier":
        r = call("POST", "/tokens", a.me, {"scope": "unread"})
        f = keyfile(a.me, ".notify")
        f.write_text(r["token"])
        f.chmod(0o600)
        print(f"counter-only key for {a.me} saved to {f}; the tray app picks it up within a minute")
    elif c == "history":
        show(a.me, call("GET", f"/history?chat={quote(a.chat.lstrip('#'))}&limit={a.n}", a.me)["messages"])
    elif c == "download":
        data, name = call("GET", "/files/" + quote(a.file), a.me, timeout=300, binary=True)
        fk = load(FILE_KEYS).get(a.file)
        if fk:
            data, name = lib().ChaCha20Poly1305(unb64(fk["key"])).decrypt(unb64(fk["nonce"]), data, None), fk["name"]
        elif name == "encrypted":
            sys.exit("this file is encrypted and its key is in the message it came with: "
                     "show that message first (amsy inbox or amsy history), then download again")
        out = Path(a.dir) / (os.path.basename(name or "") or a.file)
        if out.exists():
            out = out.with_name(a.file + "-" + out.name)
        out.write_bytes(data)
        print(f"saved {len(data)} bytes to {out}")
    elif c == "setup-crypto":
        setup_crypto()
    elif c == "e2e":
        mine = my_keys(a.me)
        if not mine:
            sys.exit("the crypto library is missing: run `amsy setup-crypto` first")
        call("POST", "/keys", a.me, mine[3])
        print(f"encryption keys of {a.me} are published\nfingerprint: {fingerprint(mine[3])}")
    elif c == "fingerprint":
        if a.address:
            k = load(KNOWN).get(a.address)
            print(f"{a.address}: {fingerprint(k)}" if k else f"no key on record for {a.address} yet")
        else:
            mine = my_keys(a.me)
            print(f"{a.me}: {fingerprint(mine[3])}" if mine else "the crypto library is missing: run `amsy setup-crypto`")
    elif c == "trust":
        k = call("GET", "/keys?addresses=" + quote(a.address), a.me)["members"].get(a.address)
        if not k:
            sys.exit(f"{a.address} has no published encryption key")
        known = load(KNOWN)
        old = known.get(a.address)
        known[a.address] = k
        store(KNOWN, known)
        print((f"was: {fingerprint(old)}\n" if old else "") + f"now trusted: {fingerprint(k)}")
    elif c == "chats":
        for ch in call("GET", "/chats", a.me)["chats"]:
            if ch["type"] == "dm":
                print(f"{ch['id']}: direct with {ch['with']}, unread {ch['unread']}")
            else:
                kind = ch["type"] + (", open" if ch["public"] else "") + (f", org {ch['org']}" if ch.get("org") else "")
                print(f"{ch['id']}: #{ch['name']} ({kind}; I am {ch['role']}), unread {ch['unread']}; others: {ch['members']}")
    elif c == "new":
        r = call("POST", "/chats", a.me, {"type": a.type, "name": a.name, "members": a.members, "public": a.public, "org": a.org})
        print(f"created #{r['name']} (chat {r['chat']})")
    elif c == "join":
        print("joined chat", call("POST", "/chats/join", a.me, {"chat": a.chat})["chat"])
    elif c == "leave":
        print("left chat", call("POST", "/chats/leave", a.me, {"chat": a.chat})["chat"])
    elif c == "members":
        r = call("POST", "/chats/members", a.me, {"chat": a.chat, "add": a.add, "remove": a.remove})
        print("members:", ", ".join(r["members"]))
    elif c == "retention":
        print("keep days:", call("POST", "/chats/settings", a.me, {"chat": a.chat, "retention_days": a.days})["retention_days"])
    elif c == "org":
        org_cmd(a)


if __name__ == "__main__":
    main()
