# chatcli — uTun headless chat CLI client # # Usage: # chatcli [--host HOST] [--port PORT] [args...] # # Environment: # CHATCLI_HOST — default host (default: 127.0.0.1) # CHATCLI_PORT — default port (default: 9999) # # Commands (see chatcli_commands.txt for full description): # ping alive check # status node info, connections, NTP # channels list all channels # members list members with full state # messages [count] read last messages # send send text message # attach [--video] send a file (or video) as attachment # invite [node_id] create invite link (auto best node by default) # invite_nodes list reachable nodes for invite # connect join channel via invite link # create create new channel # invite_to [proto] # invite a node to add our channel # dm_start [ch_id] start direct chat (DM) with a group member # dm_list list DM conversations # dm_messages [count] read DM messages (decrypted) # dm_send send DM text message # listen interactive event listener import sys, os, json, socket, struct DEFAULT_HOST = os.environ.get("CHATCLI_HOST", "127.0.0.1") DEFAULT_PORT = int(os.environ.get("CHATCLI_PORT", "9999")) TIMEOUT = 3 def _connect(): s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.settimeout(TIMEOUT) try: s.connect((DEFAULT_HOST, DEFAULT_PORT)) except (socket.timeout, ConnectionRefusedError, OSError) as e: print(f"ERROR: cannot connect to {DEFAULT_HOST}:{DEFAULT_PORT} — {e}", file=sys.stderr) sys.exit(1) return s def _readline(s): buf = b"" while True: try: ch = s.recv(1) except socket.timeout: break if not ch: break if ch == b"\n": break buf += ch return buf.decode("utf-8", errors="replace") def _send(s, js): s.sendall(js.encode()) def _req(cmd, **params): s = _connect() js = {"id": 1, "cmd": cmd} js.update(params) _send(s, json.dumps(js, ensure_ascii=True) + "\n") resp = _readline(s) s.close() try: obj = json.loads(resp) except json.JSONDecodeError: print(f"RAW: {resp}") return if obj.get("ok"): data = obj.get("data", {}) if isinstance(data, str): print(data) else: print(json.dumps(data, indent=2, ensure_ascii=False)) else: print(f"ERROR: {obj.get('error', 'unknown')}", file=sys.stderr) sys.exit(1) def cmd_ping(): _req("ping") def cmd_status(): _req("status") def cmd_channels(): _req("channels") def cmd_members(ch_id): _req("members", ch=ch_id) def cmd_messages(ch_id, count=None): params = {"ch": ch_id} if count: params["count"] = int(count) _req("messages", **params) def cmd_send(ch_id, text): _req("send", ch=ch_id, content_type="text", data=text) def cmd_attach(ch_id, path, as_video=False, content_type=None): params = {"ch": ch_id, "path": path, "as_video": 1 if as_video else 0} if content_type: params["content_type"] = content_type _req("attach", **params) def cmd_invite(ch_id, node_id=None): params = {"ch": ch_id} if node_id: params["node_id"] = node_id _req("invite", **params) def cmd_invite_nodes(ch_id): _req("invite_nodes", ch=ch_id) def cmd_connect(link): _req("connect", link=link) def cmd_create(name): _req("create_channel", name=name) def cmd_invite_to(ch_id, node_id, pubkey, addr, proto=None): params = {"ch": ch_id, "node_id": node_id, "pubkey": pubkey, "addr": addr} if proto: params["proto"] = int(proto) _req("invite_to", **params) def cmd_dm_start(node_id, ch=None): params = {"node_id": node_id} if ch: params["ch"] = ch _req("dm_start", **params) def cmd_dm_list(): _req("dm_list") def cmd_dm_messages(conv_id, count=None): params = {"conv_id": conv_id} if count: params["count"] = int(count) _req("dm_messages", **params) def cmd_dm_send(conv_id, text): _req("dm_send", conv_id=conv_id, data=text) def cmd_listen(): print(f"Listening on {DEFAULT_HOST}:{DEFAULT_PORT} (Ctrl+C to quit)") s = _connect() _send(s, json.dumps({"id": 1, "cmd": "subscribe", "enable": 1}) + "\n") try: while True: line = _readline(s) if not line: break try: obj = json.loads(line) except json.JSONDecodeError: print(line) continue evt = obj.get("event", "") if evt == "msg": print(f"[{obj.get('ch','?')}] {obj.get('author_id','?')}: new message") elif evt == "members_changed": print(f"[{obj.get('ch','?')}] members changed") elif evt == "channel_updated": print(f"[{obj.get('ch','?')}] channel updated") elif evt == "invite_received": print(f"INVITE: ch={obj.get('ch','?')} name={obj.get('ch_name','?')} from={obj.get('from_id','?')}") elif evt == "dm_msg": print(f"DM[{obj.get('conv','?')}] from={obj.get('author_id','?')}: new message") elif evt == "dm_conv": print(f"DM[{obj.get('conv','?')}] conversation updated") else: print(json.dumps(obj, indent=2, ensure_ascii=False)) except KeyboardInterrupt: print("\nDisconnected.") finally: s.close() def main(): args = sys.argv[1:] i = 0 while i < len(args): if args[i] == "--host" and i + 1 < len(args): global DEFAULT_HOST; DEFAULT_HOST = args[i + 1]; i += 2 elif args[i] == "--port" and i + 1 < len(args): global DEFAULT_PORT; DEFAULT_PORT = int(args[i + 1]); i += 2 else: break args = args[i:] if not args or args[0] in ("help", "--help", "-h"): print(__doc__) sys.exit(0) cmd = args[0] try: if cmd == "ping": cmd_ping() elif cmd == "status": cmd_status() elif cmd == "channels": cmd_channels() elif cmd == "members": cmd_members(*args[1:2] if len(args) > 1 else (_die("usage: members "),)) elif cmd in ("messages","msgs"): cmd_messages(*args[1:3] if len(args) > 1 else (_die("usage: messages [count]"),)) elif cmd == "send": cmd_send(*args[1:3] if len(args) > 2 else (_die("usage: send "),)) elif cmd == "attach": if len(args) < 3: _die("usage: attach [--video]") as_video = "--video" in args path = args[2] cmd_attach(args[1], path, as_video) elif cmd == "invite": cmd_invite(*args[1:3] if len(args) > 1 else (_die("usage: invite [node_id]"),)) elif cmd == "invite_nodes": cmd_invite_nodes(*args[1:2] if len(args) > 1 else (_die("usage: invite_nodes "),)) elif cmd in ("connect","join"): cmd_connect(*args[1:2] if len(args) > 1 else (_die("usage: connect "),)) elif cmd == "create": cmd_create(*args[1:2] if len(args) > 1 else (_die("usage: create "),)) elif cmd == "invite_to": cmd_invite_to(*args[1:6] if len(args) > 4 else (_die("usage: invite_to [proto]"),)) elif cmd == "dm_start": cmd_dm_start(*args[1:3] if len(args) > 1 else (_die("usage: dm_start [ch_id]"),)) elif cmd == "dm_list": cmd_dm_list() elif cmd in ("dm_messages","dm_msgs"): cmd_dm_messages(*args[1:3] if len(args) > 1 else (_die("usage: dm_messages [count]"),)) elif cmd == "dm_send": cmd_dm_send(*args[1:3] if len(args) > 2 else (_die("usage: dm_send "),)) elif cmd == "listen": cmd_listen() else: print(f"Unknown command: {cmd}\nUse --help for usage", file=sys.stderr); sys.exit(1) except socket.timeout: print(f"ERROR: timeout ({TIMEOUT}s) connecting to {DEFAULT_HOST}:{DEFAULT_PORT}", file=sys.stderr) sys.exit(1) except ConnectionRefusedError: print(f"ERROR: connection refused at {DEFAULT_HOST}:{DEFAULT_PORT} — is uTun running with headless_control_bind?", file=sys.stderr) sys.exit(1) def _die(msg): print(msg, file=sys.stderr); sys.exit(1) if __name__ == "__main__": main()