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.
207 lines
7.4 KiB
207 lines
7.4 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 |
|
# attach <ch_id> <path> [--video] send a file (or video) as attachment |
|
# invite <ch_id> [node_id] create invite link (auto best node by default) |
|
# invite_nodes <ch_id> list reachable nodes for invite |
|
# 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_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_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 == "attach": |
|
if len(args) < 3: |
|
_die("usage: attach <ch_id> <path> [--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 <ch_id> [node_id]"),)) |
|
elif cmd == "invite_nodes": cmd_invite_nodes(*args[1:2] if len(args) > 1 else (_die("usage: invite_nodes <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()
|
|
|