- u_async.c: process immediate_queue BEFORE epoll_wait in uasync_poll - stcp_link.c: defensive NULL etcp_conn/etcp_link in stcp_link_close - utun_instance.c: move stcp_server_list_destroy_all to Phase J (before ETCP), add deferred drain in both Phase J and L - stcp.h/c: STCP_HS_ENC_CLIENT 6→38, STCP_HS_ENC_SERVER 7→39, exchange ed25519_pubkey in both handshake directions - etcp_connections.c: copy peer_ed25519 from STCP to ETCP_CONN in etcp_link_enter_ready_tcp; guard link->etcp in tcp_link_close_cb - chat_sync.c: fix lk->conn NULL dereference for TCP links - test_stcp.c: update stcp_server_create/stcp_client_connect callsites - tools/chat_tcp_test: change transport udp→tcp
110 lines
3.1 KiB
Python
110 lines
3.1 KiB
Python
"""
|
|
chat_client.py — async TCP JSON-RPC client for uTun headless chat control.
|
|
|
|
Usage:
|
|
async with ChatClient(port=9999) as cli:
|
|
await cli.create_channel("Test")
|
|
channels = await cli.channels()
|
|
await cli.send(channels[0]["id"], "hello")
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
|
|
|
|
class ChatClientError(Exception):
|
|
"""Error from chat client (timeout, refused, protocol, server error)."""
|
|
|
|
|
|
class ChatClient:
|
|
def __init__(self, host="127.0.0.1", port=9999, timeout=5.0):
|
|
self.host = host
|
|
self.port = port
|
|
self.timeout = timeout
|
|
self._connected = False
|
|
|
|
async def connect(self):
|
|
self._connected = True
|
|
|
|
async def disconnect(self):
|
|
self._connected = False
|
|
|
|
async def __aenter__(self):
|
|
await self.connect()
|
|
return self
|
|
|
|
async def __aexit__(self, *args):
|
|
await self.disconnect()
|
|
|
|
# ── Internal: one-shot request ──
|
|
|
|
async def _request(self, cmd, **params):
|
|
if not self._connected:
|
|
raise ChatClientError("not connected")
|
|
|
|
reader, writer = await asyncio.wait_for(
|
|
asyncio.open_connection(self.host, self.port),
|
|
timeout=self.timeout,
|
|
)
|
|
|
|
msg = {"id": 1, "cmd": cmd}
|
|
msg.update(params)
|
|
writer.write((json.dumps(msg, ensure_ascii=True) + "\n").encode())
|
|
await writer.drain()
|
|
|
|
try:
|
|
line = await asyncio.wait_for(
|
|
reader.readline(), timeout=self.timeout
|
|
)
|
|
except asyncio.TimeoutError:
|
|
writer.close()
|
|
raise ChatClientError(f"timeout waiting for '{cmd}' response")
|
|
|
|
writer.close()
|
|
if not line:
|
|
raise ChatClientError(f"empty response for '{cmd}'")
|
|
|
|
try:
|
|
obj = json.loads(line.decode("utf-8", errors="replace"))
|
|
except json.JSONDecodeError:
|
|
raw = line.decode("utf-8", errors="replace")
|
|
raise ChatClientError(f"invalid JSON for '{cmd}' len={len(raw)} tail=...{raw[-50:] if len(raw)>50 else raw}")
|
|
|
|
if not obj.get("ok"):
|
|
raise ChatClientError(obj.get("error", "unknown error"))
|
|
|
|
return obj.get("data", {})
|
|
|
|
# ── Commands ──
|
|
|
|
async def ping(self):
|
|
return await self._request("ping")
|
|
|
|
async def status(self):
|
|
return await self._request("status")
|
|
|
|
async def channels(self):
|
|
return await self._request("channels")
|
|
|
|
async def members(self, ch_id):
|
|
return await self._request("members", ch=str(ch_id))
|
|
|
|
async def messages(self, ch_id, count=20, offset=0):
|
|
return await self._request(
|
|
"messages", ch=str(ch_id), count=int(count), offset=int(offset)
|
|
)
|
|
|
|
async def send(self, ch_id, text):
|
|
return await self._request(
|
|
"send", ch=str(ch_id), content_type="text", data=str(text)
|
|
)
|
|
|
|
async def invite(self, ch_id):
|
|
return await self._request("invite", ch=str(ch_id))
|
|
|
|
async def connect_channel(self, link):
|
|
return await self._request("connect", link=str(link))
|
|
|
|
async def create_channel(self, name):
|
|
return await self._request("create_channel", name=str(name))
|