You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

186 lines
6.5 KiB

# chatcli — uTun headless chat CLI client
#
# Usage:
# chatcli [--host HOST] [--port PORT] <command> [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 <ch_id> list members with full state
# messages <ch_id> [count] read last messages
# send <ch_id> <text> send text message
# invite <ch_id> create invite link
# connect <utun://...> join channel via invite link
# create <name> create new channel
# invite_to <ch_id> <node_id> <pubkey_hex> <ip:port> [proto]
# invite a node to add our channel
# 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_invite(ch_id):
_req("invite", 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_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','?')}")
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 <ch_id>"),))
elif cmd in ("messages","msgs"): cmd_messages(*args[1:3] if len(args) > 1 else (_die("usage: messages <ch_id> [count]"),))
elif cmd == "send": cmd_send(*args[1:3] if len(args) > 2 else (_die("usage: send <ch_id> <text>"),))
elif cmd == "invite": cmd_invite(*args[1:2] if len(args) > 1 else (_die("usage: invite <ch_id>"),))
elif cmd in ("connect","join"): cmd_connect(*args[1:2] if len(args) > 1 else (_die("usage: connect <utun://...>"),))
elif cmd == "create": cmd_create(*args[1:2] if len(args) > 1 else (_die("usage: create <name>"),))
elif cmd == "invite_to": cmd_invite_to(*args[1:6] if len(args) > 4 else (_die("usage: invite_to <ch_id> <node_id> <pubkey_hex> <ip:port> [proto]"),))
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()