279 lines
11 KiB
Plaintext
Executable File
279 lines
11 KiB
Plaintext
Executable File
# 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
|
|
# poll <ch_id> <question> <option1> <option2> ... [--multiple] [--custom]
|
|
# vote <ch_id> <message_id> [option_id ...] [--custom <text>]
|
|
# react <ch_id> <message_id> [emoji_or_id] [--off] [--clear]
|
|
# feedback <ch_id> <message_id> poll results and reactions
|
|
# reactions available emoji IDs
|
|
# 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
|
|
# dm_start <node_id> [ch_id] start direct chat (DM) with a group member
|
|
# dm_list list DM conversations
|
|
# dm_messages <conv_id> [count] read DM messages (decrypted)
|
|
# dm_send <conv_id> <text> send DM text message
|
|
# listen interactive event listener
|
|
|
|
import sys, os, json, socket, struct, argparse
|
|
|
|
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_interaction(cmd, args):
|
|
parser = argparse.ArgumentParser(prog="chatcli " + cmd)
|
|
if cmd == "reactions":
|
|
parser.parse_args(args)
|
|
_req("reaction_catalog")
|
|
return
|
|
parser.add_argument("ch")
|
|
if cmd == "poll":
|
|
parser.add_argument("question")
|
|
parser.add_argument("options", nargs="+")
|
|
parser.add_argument("--multiple", action="store_true")
|
|
parser.add_argument("--custom", action="store_true")
|
|
else:
|
|
parser.add_argument("message_id")
|
|
if cmd == "vote":
|
|
parser.add_argument("choices", nargs="*", type=int, help="option IDs from feedback (start at 0)")
|
|
parser.add_argument("--custom", default="")
|
|
elif cmd == "react":
|
|
parser.add_argument("emoji", nargs="?")
|
|
parser.add_argument("--off", action="store_true")
|
|
parser.add_argument("--clear", action="store_true")
|
|
values = parser.parse_args(args)
|
|
if cmd == "poll":
|
|
_req("poll_create", ch=values.ch, question=values.question, options=values.options,
|
|
mode="multiple" if values.multiple else "single", allow_custom_answer=values.custom)
|
|
elif cmd == "vote":
|
|
_req("vote", ch=values.ch, message_id=values.message_id, choices=values.choices, custom=values.custom)
|
|
elif cmd == "react":
|
|
if not values.clear and values.emoji is None:
|
|
parser.error("specify an emoji or --clear")
|
|
emoji = int(values.emoji) if values.emoji and values.emoji.isdigit() else values.emoji
|
|
_req("react", ch=values.ch, message_id=values.message_id, emoji=emoji, on=not values.off, clear=values.clear)
|
|
else:
|
|
_req("feedback", ch=values.ch, message_id=values.message_id)
|
|
|
|
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 in ("poll", "vote", "react", "feedback", "reactions"): cmd_interaction(cmd, args[1:])
|
|
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 == "dm_start": cmd_dm_start(*args[1:3] if len(args) > 1 else (_die("usage: dm_start <node_id> [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 <conv_id> [count]"),))
|
|
elif cmd == "dm_send": cmd_dm_send(*args[1:3] if len(args) > 2 else (_die("usage: dm_send <conv_id> <text>"),))
|
|
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()
|