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.
115 lines
3.4 KiB
115 lines
3.4 KiB
""" |
|
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)) |
|
|
|
async def invite_to(self, ch_id, node_id, pubkey, addr, proto=1): |
|
return await self._request( |
|
"invite_to", ch=str(ch_id), node_id=str(node_id), |
|
pubkey=str(pubkey), addr=str(addr), proto=int(proto), |
|
)
|
|
|