auntui 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aun_daemon.py +709 -0
- auntui-0.2.0.dist-info/METADATA +144 -0
- auntui-0.2.0.dist-info/RECORD +7 -0
- auntui-0.2.0.dist-info/WHEEL +5 -0
- auntui-0.2.0.dist-info/entry_points.txt +3 -0
- auntui-0.2.0.dist-info/top_level.txt +2 -0
- auntui.py +6482 -0
aun_daemon.py
ADDED
|
@@ -0,0 +1,709 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""AUN Daemon (MVP v0.1) — headless JSON-RPC bridge for AUN Mac App.
|
|
3
|
+
|
|
4
|
+
Reads NDJSON requests from stdin, writes NDJSON responses + events to stdout.
|
|
5
|
+
Runtime logs go to stderr. Protocol specified in ./protocol.md.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
python3 aun_daemon.py [--data-dir ~/.aun]
|
|
9
|
+
|
|
10
|
+
Environment:
|
|
11
|
+
AUN_CLI_DATA data root dir (default ~/.aun), same semantics as auntui.py
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import asyncio
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import signal
|
|
23
|
+
import sqlite3
|
|
24
|
+
import sys
|
|
25
|
+
import time
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any, Optional
|
|
28
|
+
|
|
29
|
+
# force utf-8 stdio
|
|
30
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
31
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
32
|
+
if hasattr(sys.stderr, "reconfigure"):
|
|
33
|
+
sys.stderr.reconfigure(encoding="utf-8")
|
|
34
|
+
|
|
35
|
+
logging.basicConfig(
|
|
36
|
+
level=os.environ.get("AUN_DAEMON_LOG", "INFO").upper(),
|
|
37
|
+
format="[daemon] %(asctime)s %(levelname)s %(message)s",
|
|
38
|
+
stream=sys.stderr,
|
|
39
|
+
)
|
|
40
|
+
log = logging.getLogger("aun_daemon")
|
|
41
|
+
|
|
42
|
+
# ── Paths & config ──────────────────────────────────────────────────────
|
|
43
|
+
AUN_PATH: Path
|
|
44
|
+
DATA_DIR: Path
|
|
45
|
+
CONFIG_FILE: Path
|
|
46
|
+
MAX_RECENT_TARGETS = 10
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _init_paths(override: Optional[str]) -> None:
|
|
50
|
+
global AUN_PATH, DATA_DIR, CONFIG_FILE
|
|
51
|
+
base = Path(override).expanduser() if override else (
|
|
52
|
+
Path(os.environ.get("AUN_CLI_DATA", "")).expanduser()
|
|
53
|
+
if os.environ.get("AUN_CLI_DATA") else Path.home() / ".aun"
|
|
54
|
+
)
|
|
55
|
+
AUN_PATH = base
|
|
56
|
+
DATA_DIR = base / "aun-cli"
|
|
57
|
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
CONFIG_FILE = DATA_DIR / "config.json"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _load_config() -> dict:
|
|
62
|
+
if CONFIG_FILE.exists():
|
|
63
|
+
try:
|
|
64
|
+
return json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
|
|
65
|
+
except (json.JSONDecodeError, OSError):
|
|
66
|
+
pass
|
|
67
|
+
return {}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _save_config(cfg: dict) -> None:
|
|
71
|
+
CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
CONFIG_FILE.write_text(
|
|
73
|
+
json.dumps(cfg, ensure_ascii=False, indent=2), encoding="utf-8"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ── AID / target helpers ────────────────────────────────────────────────
|
|
78
|
+
_AID_RE = re.compile(
|
|
79
|
+
r"^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?"
|
|
80
|
+
r"(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?){2,}$"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _is_valid_aid(name: str) -> bool:
|
|
85
|
+
return bool(name) and bool(_AID_RE.match(name))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _short_name(aid: str) -> str:
|
|
89
|
+
return aid.split(".")[0] if aid else "?"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _make_peer_target(aid: str) -> dict:
|
|
93
|
+
return {"type": "peer", "id": aid, "name": _short_name(aid)}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _normalize_target(value: Any) -> Optional[dict]:
|
|
97
|
+
if isinstance(value, dict) and value.get("type") == "peer":
|
|
98
|
+
tid = str(value.get("id") or "")
|
|
99
|
+
if _is_valid_aid(tid):
|
|
100
|
+
return {"type": "peer", "id": tid, "name": value.get("name") or _short_name(tid)}
|
|
101
|
+
if isinstance(value, str) and _is_valid_aid(value):
|
|
102
|
+
return _make_peer_target(value)
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _normalize_recent(cfg: dict) -> list[dict]:
|
|
107
|
+
raw = cfg.get("recent_targets", []) or []
|
|
108
|
+
out: list[dict] = []
|
|
109
|
+
for t in raw if isinstance(raw, list) else []:
|
|
110
|
+
n = _normalize_target(t)
|
|
111
|
+
if n:
|
|
112
|
+
out.append(n)
|
|
113
|
+
return out
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _record_target(cfg: dict, target: dict) -> dict:
|
|
117
|
+
"""Update cfg in-place + persist, return updated cfg."""
|
|
118
|
+
if not target or target.get("type") != "peer":
|
|
119
|
+
return cfg
|
|
120
|
+
tid = target["id"]
|
|
121
|
+
targets = _normalize_recent(cfg)
|
|
122
|
+
targets = [t for t in targets if t.get("id") != tid]
|
|
123
|
+
targets.insert(0, target)
|
|
124
|
+
cfg["recent_targets"] = targets[:MAX_RECENT_TARGETS]
|
|
125
|
+
_save_config(cfg)
|
|
126
|
+
return cfg
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ── Message store (sqlite, mirror of auntui.MessageStore) ──────────────
|
|
130
|
+
class MessageStore:
|
|
131
|
+
def __init__(self, db_path: str):
|
|
132
|
+
self._db_path = db_path
|
|
133
|
+
self._conn: Optional[sqlite3.Connection] = None
|
|
134
|
+
|
|
135
|
+
def _ensure(self) -> None:
|
|
136
|
+
if self._conn is not None:
|
|
137
|
+
return
|
|
138
|
+
Path(self._db_path).parent.mkdir(parents=True, exist_ok=True)
|
|
139
|
+
self._conn = sqlite3.connect(self._db_path)
|
|
140
|
+
self._conn.execute("PRAGMA journal_mode=WAL")
|
|
141
|
+
self._conn.execute(
|
|
142
|
+
"""CREATE TABLE IF NOT EXISTS messages (
|
|
143
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
144
|
+
message_id TEXT UNIQUE,
|
|
145
|
+
conversation_id TEXT NOT NULL,
|
|
146
|
+
conversation_type TEXT NOT NULL,
|
|
147
|
+
direction TEXT NOT NULL,
|
|
148
|
+
sender TEXT NOT NULL,
|
|
149
|
+
payload TEXT NOT NULL,
|
|
150
|
+
seq INTEGER,
|
|
151
|
+
timestamp INTEGER NOT NULL,
|
|
152
|
+
is_read INTEGER NOT NULL DEFAULT 0)"""
|
|
153
|
+
)
|
|
154
|
+
self._conn.execute(
|
|
155
|
+
"CREATE INDEX IF NOT EXISTS idx_conv_ts ON messages (conversation_id, timestamp)"
|
|
156
|
+
)
|
|
157
|
+
self._conn.execute(
|
|
158
|
+
"CREATE INDEX IF NOT EXISTS idx_conv_unread ON messages (conversation_id, is_read)"
|
|
159
|
+
)
|
|
160
|
+
self._conn.commit()
|
|
161
|
+
|
|
162
|
+
def save(self, **row) -> None:
|
|
163
|
+
self._ensure()
|
|
164
|
+
assert self._conn is not None
|
|
165
|
+
try:
|
|
166
|
+
self._conn.execute(
|
|
167
|
+
"""INSERT OR IGNORE INTO messages
|
|
168
|
+
(message_id, conversation_id, conversation_type, direction,
|
|
169
|
+
sender, payload, seq, timestamp, is_read)
|
|
170
|
+
VALUES (?,?,?,?,?,?,?,?,?)""",
|
|
171
|
+
(
|
|
172
|
+
row.get("message_id"),
|
|
173
|
+
row["conversation_id"],
|
|
174
|
+
row["conversation_type"],
|
|
175
|
+
row["direction"],
|
|
176
|
+
row["sender"],
|
|
177
|
+
row["payload"],
|
|
178
|
+
row.get("seq"),
|
|
179
|
+
row["timestamp"],
|
|
180
|
+
row.get("is_read", 0),
|
|
181
|
+
),
|
|
182
|
+
)
|
|
183
|
+
self._conn.commit()
|
|
184
|
+
except Exception as e:
|
|
185
|
+
log.warning("store.save failed: %s", e)
|
|
186
|
+
|
|
187
|
+
def history(self, conversation_id: str, limit: int, before_id: Optional[int]) -> list[dict]:
|
|
188
|
+
self._ensure()
|
|
189
|
+
assert self._conn is not None
|
|
190
|
+
limit = max(1, min(int(limit or 50), 200))
|
|
191
|
+
if before_id:
|
|
192
|
+
cur = self._conn.execute(
|
|
193
|
+
"""SELECT id, message_id, conversation_id, conversation_type,
|
|
194
|
+
direction, sender, payload, seq, timestamp, is_read
|
|
195
|
+
FROM messages WHERE conversation_id = ? AND id < ?
|
|
196
|
+
ORDER BY timestamp DESC LIMIT ?""",
|
|
197
|
+
(conversation_id, before_id, limit),
|
|
198
|
+
)
|
|
199
|
+
else:
|
|
200
|
+
cur = self._conn.execute(
|
|
201
|
+
"""SELECT id, message_id, conversation_id, conversation_type,
|
|
202
|
+
direction, sender, payload, seq, timestamp, is_read
|
|
203
|
+
FROM messages WHERE conversation_id = ?
|
|
204
|
+
ORDER BY timestamp DESC LIMIT ?""",
|
|
205
|
+
(conversation_id, limit),
|
|
206
|
+
)
|
|
207
|
+
cols = [d[0] for d in cur.description]
|
|
208
|
+
rows = [dict(zip(cols, r)) for r in cur.fetchall()]
|
|
209
|
+
rows.reverse()
|
|
210
|
+
return rows
|
|
211
|
+
|
|
212
|
+
def mark_read(self, conversation_id: str) -> None:
|
|
213
|
+
self._ensure()
|
|
214
|
+
assert self._conn is not None
|
|
215
|
+
self._conn.execute(
|
|
216
|
+
"UPDATE messages SET is_read = 1 WHERE conversation_id = ? AND is_read = 0",
|
|
217
|
+
(conversation_id,),
|
|
218
|
+
)
|
|
219
|
+
self._conn.commit()
|
|
220
|
+
|
|
221
|
+
def close(self) -> None:
|
|
222
|
+
if self._conn is not None:
|
|
223
|
+
self._conn.close()
|
|
224
|
+
self._conn = None
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# ── Errors ──────────────────────────────────────────────────────────────
|
|
228
|
+
class DaemonError(Exception):
|
|
229
|
+
def __init__(self, code: str, message: str, recoverable: bool = False, data: Any = None):
|
|
230
|
+
super().__init__(message)
|
|
231
|
+
self.code = code
|
|
232
|
+
self.message = message
|
|
233
|
+
self.recoverable = recoverable
|
|
234
|
+
self.data = data
|
|
235
|
+
|
|
236
|
+
def to_dict(self) -> dict:
|
|
237
|
+
out: dict[str, Any] = {
|
|
238
|
+
"code": self.code,
|
|
239
|
+
"message": self.message,
|
|
240
|
+
"recoverable": self.recoverable,
|
|
241
|
+
}
|
|
242
|
+
if self.data is not None:
|
|
243
|
+
out["data"] = self.data
|
|
244
|
+
return out
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# ── AUN service ─────────────────────────────────────────────────────────
|
|
248
|
+
class AUNService:
|
|
249
|
+
"""Thin wrapper around AUNClient that translates SDK callbacks into daemon events."""
|
|
250
|
+
|
|
251
|
+
def __init__(self, emit_event):
|
|
252
|
+
self._emit = emit_event
|
|
253
|
+
self.client = None
|
|
254
|
+
self.aid: Optional[str] = None
|
|
255
|
+
self.target: Optional[dict] = None
|
|
256
|
+
self.connected = False
|
|
257
|
+
self.gateway: Optional[str] = None
|
|
258
|
+
self._store: Optional[MessageStore] = None
|
|
259
|
+
self._seen_msg_ids: set[str] = set()
|
|
260
|
+
self._chat_id = ""
|
|
261
|
+
|
|
262
|
+
def _get_store(self) -> MessageStore:
|
|
263
|
+
if self._store is None:
|
|
264
|
+
db_dir = AUN_PATH / "AIDs" / (self.aid or "_") if self.aid else DATA_DIR
|
|
265
|
+
self._store = MessageStore(str(db_dir / "messages.db"))
|
|
266
|
+
return self._store
|
|
267
|
+
|
|
268
|
+
async def initialize(self, aid: Optional[str]) -> dict:
|
|
269
|
+
from aun_core import AUNClient
|
|
270
|
+
from aun_core.keystore.file import FileKeyStore
|
|
271
|
+
try:
|
|
272
|
+
from importlib.metadata import version as _pkg_version
|
|
273
|
+
sdk_ver = _pkg_version("fastaun")
|
|
274
|
+
except Exception:
|
|
275
|
+
try:
|
|
276
|
+
from aun_core import __version__ as sdk_ver # type: ignore
|
|
277
|
+
except Exception:
|
|
278
|
+
sdk_ver = "unknown"
|
|
279
|
+
|
|
280
|
+
cfg = _load_config()
|
|
281
|
+
target_aid = aid or cfg.get("aid")
|
|
282
|
+
if not target_aid:
|
|
283
|
+
raise DaemonError("NO_AID", "No AID configured. Pass params.aid or configure via aun CLI.")
|
|
284
|
+
if not _is_valid_aid(target_aid):
|
|
285
|
+
raise DaemonError("INVALID_AID", f"Invalid AID: {target_aid}")
|
|
286
|
+
|
|
287
|
+
ks = FileKeyStore(AUN_PATH)
|
|
288
|
+
has_dir = (ks._aids_root / target_aid).is_dir()
|
|
289
|
+
local = ks.load_identity(target_aid)
|
|
290
|
+
if local is None and not has_dir:
|
|
291
|
+
raise DaemonError(
|
|
292
|
+
"AID_NOT_FOUND",
|
|
293
|
+
f"AID {target_aid} not found in keystore. Create it via: aun aid new {target_aid}",
|
|
294
|
+
)
|
|
295
|
+
if local is None or "private_key_pem" not in (local or {}):
|
|
296
|
+
raise DaemonError(
|
|
297
|
+
"AID_NOT_FOUND", f"AID {target_aid} private key missing"
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
self.client = AUNClient({"aun_path": str(AUN_PATH)}, debug=False)
|
|
301
|
+
self.aid = target_aid
|
|
302
|
+
|
|
303
|
+
self.client.on("message.received", self._on_message)
|
|
304
|
+
self.client.on("connection.state", self._on_state)
|
|
305
|
+
|
|
306
|
+
try:
|
|
307
|
+
auth = await self.client.auth.authenticate({"aid": target_aid})
|
|
308
|
+
except Exception as e:
|
|
309
|
+
raise DaemonError("AUTH_FAILED", f"Authentication failed: {e}", recoverable=True)
|
|
310
|
+
|
|
311
|
+
try:
|
|
312
|
+
await self.client.connect({
|
|
313
|
+
"access_token": auth["access_token"],
|
|
314
|
+
"gateway": auth["gateway"],
|
|
315
|
+
"auto_reconnect": True,
|
|
316
|
+
"slot_id": (self.target or {}).get("id") or "default",
|
|
317
|
+
})
|
|
318
|
+
except Exception as e:
|
|
319
|
+
raise DaemonError("GATEWAY_DISCONNECTED", f"Connect failed: {e}", recoverable=True)
|
|
320
|
+
|
|
321
|
+
self.connected = True
|
|
322
|
+
self.gateway = auth.get("gateway")
|
|
323
|
+
# update device-bound chat_id (used to filter echo-back messages)
|
|
324
|
+
device_id = getattr(self.client, "_device_id", "") or ""
|
|
325
|
+
slot = (self.target or {}).get("id") or "default"
|
|
326
|
+
self._chat_id = f"{target_aid}:{device_id}:{slot}"
|
|
327
|
+
|
|
328
|
+
# restore previous target (if any)
|
|
329
|
+
raw_target = cfg.get("target")
|
|
330
|
+
self.target = _normalize_target(raw_target)
|
|
331
|
+
|
|
332
|
+
# persist current aid
|
|
333
|
+
cfg["aid"] = target_aid
|
|
334
|
+
_save_config(cfg)
|
|
335
|
+
|
|
336
|
+
recent = _normalize_recent(cfg)
|
|
337
|
+
|
|
338
|
+
snapshot = {
|
|
339
|
+
"aid": target_aid,
|
|
340
|
+
"target": self.target,
|
|
341
|
+
"recent_targets": recent,
|
|
342
|
+
"gateway": self.gateway,
|
|
343
|
+
"sdk_version": sdk_ver,
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
# emit ready + connection_state events asynchronously
|
|
347
|
+
self._emit("ready", {
|
|
348
|
+
"aid": target_aid,
|
|
349
|
+
"target": self.target,
|
|
350
|
+
"gateway": self.gateway,
|
|
351
|
+
})
|
|
352
|
+
self._emit("connection_state", {"state": "connected", "reason": None})
|
|
353
|
+
return snapshot
|
|
354
|
+
|
|
355
|
+
async def set_target(self, aid: str) -> dict:
|
|
356
|
+
if not _is_valid_aid(aid):
|
|
357
|
+
raise DaemonError("INVALID_AID", f"Invalid AID: {aid}")
|
|
358
|
+
target = _make_peer_target(aid)
|
|
359
|
+
self.target = target
|
|
360
|
+
cfg = _load_config()
|
|
361
|
+
cfg["target"] = target
|
|
362
|
+
cfg = _record_target(cfg, target)
|
|
363
|
+
# keep chat_id slot aligned with target so echo filtering works
|
|
364
|
+
if self.client is not None:
|
|
365
|
+
device_id = getattr(self.client, "_device_id", "") or ""
|
|
366
|
+
self._chat_id = f"{self.aid}:{device_id}:{target['id']}"
|
|
367
|
+
return {"target": target}
|
|
368
|
+
|
|
369
|
+
async def send_text(self, text: str, encrypt: bool = True) -> dict:
|
|
370
|
+
if not self.target:
|
|
371
|
+
raise DaemonError("NO_TARGET", "No target set. Call set_target first.")
|
|
372
|
+
if not self.client or not self.connected:
|
|
373
|
+
raise DaemonError("NOT_CONNECTED", "Not connected to gateway.", recoverable=True)
|
|
374
|
+
target_id = self.target["id"]
|
|
375
|
+
payload = {"type": "text", "text": text, "chat_id": self._chat_id}
|
|
376
|
+
try:
|
|
377
|
+
result = await self.client.call(
|
|
378
|
+
"message.send",
|
|
379
|
+
{"to": target_id, "payload": payload, "encrypt": bool(encrypt)},
|
|
380
|
+
)
|
|
381
|
+
except Exception as e:
|
|
382
|
+
raise DaemonError("SEND_FAILED", f"Send failed: {e}", data={"raw": str(e)})
|
|
383
|
+
|
|
384
|
+
ts = int(time.time() * 1000)
|
|
385
|
+
msg_id = ""
|
|
386
|
+
if isinstance(result, dict):
|
|
387
|
+
msg_id = result.get("message_id") or ""
|
|
388
|
+
if not msg_id:
|
|
389
|
+
inner = result.get("message")
|
|
390
|
+
if isinstance(inner, dict):
|
|
391
|
+
msg_id = inner.get("message_id") or ""
|
|
392
|
+
if not msg_id:
|
|
393
|
+
msg_id = f"sent_{target_id}_{ts}"
|
|
394
|
+
|
|
395
|
+
self._get_store().save(
|
|
396
|
+
message_id=msg_id,
|
|
397
|
+
conversation_id=target_id,
|
|
398
|
+
conversation_type="peer",
|
|
399
|
+
direction="sent",
|
|
400
|
+
sender=self.aid,
|
|
401
|
+
payload=text,
|
|
402
|
+
seq=None,
|
|
403
|
+
timestamp=ts,
|
|
404
|
+
is_read=1,
|
|
405
|
+
)
|
|
406
|
+
cfg = _load_config()
|
|
407
|
+
_record_target(cfg, self.target)
|
|
408
|
+
|
|
409
|
+
self._emit("message_sent", {
|
|
410
|
+
"message_id": msg_id,
|
|
411
|
+
"target": self.target,
|
|
412
|
+
"text": text,
|
|
413
|
+
"ts": ts,
|
|
414
|
+
})
|
|
415
|
+
return {"message_id": msg_id, "target": self.target, "ts": ts}
|
|
416
|
+
|
|
417
|
+
async def list_messages(self, target_id: str, limit: int = 50, before_id: Optional[int] = None) -> dict:
|
|
418
|
+
if not target_id:
|
|
419
|
+
raise DaemonError("INVALID_PARAMS", "target_id is required")
|
|
420
|
+
rows = self._get_store().history(target_id, limit, before_id)
|
|
421
|
+
messages = [
|
|
422
|
+
{
|
|
423
|
+
"id": r["id"],
|
|
424
|
+
"message_id": r.get("message_id"),
|
|
425
|
+
"conversation_id": r["conversation_id"],
|
|
426
|
+
"conversation_type": r["conversation_type"],
|
|
427
|
+
"direction": r["direction"],
|
|
428
|
+
"sender": r["sender"],
|
|
429
|
+
"text": r["payload"],
|
|
430
|
+
"seq": r.get("seq"),
|
|
431
|
+
"ts": r["timestamp"],
|
|
432
|
+
"is_read": r.get("is_read", 0),
|
|
433
|
+
}
|
|
434
|
+
for r in rows
|
|
435
|
+
]
|
|
436
|
+
self._get_store().mark_read(target_id)
|
|
437
|
+
return {"messages": messages}
|
|
438
|
+
|
|
439
|
+
async def list_recent_targets(self) -> dict:
|
|
440
|
+
cfg = _load_config()
|
|
441
|
+
return {"targets": _normalize_recent(cfg)}
|
|
442
|
+
|
|
443
|
+
async def get_status(self) -> dict:
|
|
444
|
+
return {
|
|
445
|
+
"connected": self.connected,
|
|
446
|
+
"aid": self.aid,
|
|
447
|
+
"target": self.target,
|
|
448
|
+
"gateway": self.gateway,
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async def shutdown(self) -> dict:
|
|
452
|
+
try:
|
|
453
|
+
if self.client:
|
|
454
|
+
try:
|
|
455
|
+
await self.client.disconnect()
|
|
456
|
+
except Exception as e:
|
|
457
|
+
log.warning("client.disconnect error: %s", e)
|
|
458
|
+
finally:
|
|
459
|
+
if self._store:
|
|
460
|
+
self._store.close()
|
|
461
|
+
return {"ok": True}
|
|
462
|
+
|
|
463
|
+
# ── SDK callbacks → events ──────────────────────────────────────────
|
|
464
|
+
async def _on_message(self, data):
|
|
465
|
+
if not isinstance(data, dict):
|
|
466
|
+
return
|
|
467
|
+
msg_id = data.get("message_id") or ""
|
|
468
|
+
if msg_id:
|
|
469
|
+
if msg_id in self._seen_msg_ids:
|
|
470
|
+
return
|
|
471
|
+
self._seen_msg_ids.add(msg_id)
|
|
472
|
+
if len(self._seen_msg_ids) > 500:
|
|
473
|
+
self._seen_msg_ids = set(list(self._seen_msg_ids)[-250:])
|
|
474
|
+
|
|
475
|
+
from_aid = data.get("from") or "?"
|
|
476
|
+
if from_aid == self.aid:
|
|
477
|
+
# echo from our own other instances — ignore in MVP
|
|
478
|
+
return
|
|
479
|
+
|
|
480
|
+
payload = data.get("payload", "")
|
|
481
|
+
text = (
|
|
482
|
+
payload.get("text") if isinstance(payload, dict)
|
|
483
|
+
else str(payload)
|
|
484
|
+
)
|
|
485
|
+
if text is None:
|
|
486
|
+
text = json.dumps(payload, ensure_ascii=False) if isinstance(payload, dict) else ""
|
|
487
|
+
|
|
488
|
+
# skip processing/menu status notifications (MVP)
|
|
489
|
+
if isinstance(payload, dict):
|
|
490
|
+
ptype = payload.get("type")
|
|
491
|
+
if ptype in ("processing", "menu.response", "menu.query") or ptype == "event":
|
|
492
|
+
return
|
|
493
|
+
|
|
494
|
+
seq = data.get("seq")
|
|
495
|
+
ts = int(time.time() * 1000)
|
|
496
|
+
e2ee = bool(data.get("e2ee"))
|
|
497
|
+
|
|
498
|
+
if not msg_id:
|
|
499
|
+
msg_id = f"recv_{from_aid}_{ts}"
|
|
500
|
+
|
|
501
|
+
self._get_store().save(
|
|
502
|
+
message_id=msg_id,
|
|
503
|
+
conversation_id=from_aid,
|
|
504
|
+
conversation_type="peer",
|
|
505
|
+
direction="recv",
|
|
506
|
+
sender=from_aid,
|
|
507
|
+
payload=text,
|
|
508
|
+
seq=seq,
|
|
509
|
+
timestamp=ts,
|
|
510
|
+
is_read=0,
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
cfg = _load_config()
|
|
514
|
+
_record_target(cfg, _make_peer_target(from_aid))
|
|
515
|
+
|
|
516
|
+
self._emit("message_received", {
|
|
517
|
+
"message_id": msg_id,
|
|
518
|
+
"from": from_aid,
|
|
519
|
+
"conversation_id": from_aid,
|
|
520
|
+
"conversation_type": "peer",
|
|
521
|
+
"text": text,
|
|
522
|
+
"seq": seq,
|
|
523
|
+
"ts": ts,
|
|
524
|
+
"e2ee": e2ee,
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
async def _on_state(self, data):
|
|
528
|
+
if not isinstance(data, dict):
|
|
529
|
+
return
|
|
530
|
+
state = data.get("state") or "unknown"
|
|
531
|
+
reason = data.get("reason")
|
|
532
|
+
self.connected = (state == "connected")
|
|
533
|
+
self._emit("connection_state", {"state": state, "reason": reason})
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
# ── JSON-RPC dispatcher ─────────────────────────────────────────────────
|
|
537
|
+
class Dispatcher:
|
|
538
|
+
def __init__(self, service: AUNService):
|
|
539
|
+
self.service = service
|
|
540
|
+
self._shutdown_requested = False
|
|
541
|
+
|
|
542
|
+
async def dispatch(self, method: str, params: dict) -> Any:
|
|
543
|
+
p = params or {}
|
|
544
|
+
if method == "initialize":
|
|
545
|
+
return await self.service.initialize(aid=p.get("aid"))
|
|
546
|
+
if method == "set_target":
|
|
547
|
+
aid = p.get("aid")
|
|
548
|
+
if not aid:
|
|
549
|
+
raise DaemonError("INVALID_PARAMS", "aid is required")
|
|
550
|
+
return await self.service.set_target(aid)
|
|
551
|
+
if method == "send_text":
|
|
552
|
+
text = p.get("text")
|
|
553
|
+
if text is None or not isinstance(text, str):
|
|
554
|
+
raise DaemonError("INVALID_PARAMS", "text is required and must be a string")
|
|
555
|
+
return await self.service.send_text(text, encrypt=p.get("encrypt", True))
|
|
556
|
+
if method == "list_messages":
|
|
557
|
+
return await self.service.list_messages(
|
|
558
|
+
target_id=p.get("target_id", ""),
|
|
559
|
+
limit=p.get("limit", 50),
|
|
560
|
+
before_id=p.get("before_id"),
|
|
561
|
+
)
|
|
562
|
+
if method == "list_recent_targets":
|
|
563
|
+
return await self.service.list_recent_targets()
|
|
564
|
+
if method == "get_status":
|
|
565
|
+
return await self.service.get_status()
|
|
566
|
+
if method == "shutdown":
|
|
567
|
+
self._shutdown_requested = True
|
|
568
|
+
return await self.service.shutdown()
|
|
569
|
+
raise DaemonError("UNKNOWN_METHOD", f"Unknown method: {method}")
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
# ── Daemon main loop ────────────────────────────────────────────────────
|
|
573
|
+
class Daemon:
|
|
574
|
+
def __init__(self):
|
|
575
|
+
self._out_lock = asyncio.Lock()
|
|
576
|
+
self._event_queue: asyncio.Queue = asyncio.Queue(maxsize=1000)
|
|
577
|
+
self.service = AUNService(self._emit)
|
|
578
|
+
self.dispatcher = Dispatcher(self.service)
|
|
579
|
+
self._stop_event = asyncio.Event()
|
|
580
|
+
|
|
581
|
+
def _emit(self, event: str, data: dict) -> None:
|
|
582
|
+
"""Thread-safe event emitter (can be called from sync SDK callbacks)."""
|
|
583
|
+
try:
|
|
584
|
+
self._event_queue.put_nowait({"event": event, "data": data})
|
|
585
|
+
except asyncio.QueueFull:
|
|
586
|
+
log.warning("event queue full, dropping event: %s", event)
|
|
587
|
+
|
|
588
|
+
async def _write(self, obj: dict) -> None:
|
|
589
|
+
line = json.dumps(obj, ensure_ascii=False, default=str) + "\n"
|
|
590
|
+
async with self._out_lock:
|
|
591
|
+
try:
|
|
592
|
+
sys.stdout.write(line)
|
|
593
|
+
sys.stdout.flush()
|
|
594
|
+
except (BrokenPipeError, OSError):
|
|
595
|
+
self._stop_event.set()
|
|
596
|
+
|
|
597
|
+
async def _handle_request(self, raw: str) -> None:
|
|
598
|
+
try:
|
|
599
|
+
req = json.loads(raw)
|
|
600
|
+
except json.JSONDecodeError as e:
|
|
601
|
+
await self._write({
|
|
602
|
+
"id": None,
|
|
603
|
+
"error": {"code": "INVALID_PARAMS", "message": f"Bad JSON: {e}", "recoverable": False},
|
|
604
|
+
})
|
|
605
|
+
return
|
|
606
|
+
|
|
607
|
+
req_id = req.get("id")
|
|
608
|
+
method = req.get("method")
|
|
609
|
+
params = req.get("params") or {}
|
|
610
|
+
|
|
611
|
+
if not isinstance(method, str):
|
|
612
|
+
await self._write({
|
|
613
|
+
"id": req_id,
|
|
614
|
+
"error": {"code": "INVALID_PARAMS", "message": "method is required", "recoverable": False},
|
|
615
|
+
})
|
|
616
|
+
return
|
|
617
|
+
|
|
618
|
+
try:
|
|
619
|
+
result = await self.dispatcher.dispatch(method, params)
|
|
620
|
+
await self._write({"id": req_id, "result": result})
|
|
621
|
+
except DaemonError as e:
|
|
622
|
+
log.info("method %s → error %s: %s", method, e.code, e.message)
|
|
623
|
+
await self._write({"id": req_id, "error": e.to_dict()})
|
|
624
|
+
except Exception as e:
|
|
625
|
+
log.exception("method %s unexpected exception", method)
|
|
626
|
+
await self._write({
|
|
627
|
+
"id": req_id,
|
|
628
|
+
"error": {
|
|
629
|
+
"code": "INTERNAL_ERROR",
|
|
630
|
+
"message": f"{type(e).__name__}: {e}",
|
|
631
|
+
"recoverable": False,
|
|
632
|
+
},
|
|
633
|
+
})
|
|
634
|
+
|
|
635
|
+
if self.dispatcher._shutdown_requested and method == "shutdown":
|
|
636
|
+
self._stop_event.set()
|
|
637
|
+
|
|
638
|
+
async def _read_loop(self) -> None:
|
|
639
|
+
loop = asyncio.get_running_loop()
|
|
640
|
+
while not self._stop_event.is_set():
|
|
641
|
+
line = await loop.run_in_executor(None, sys.stdin.readline)
|
|
642
|
+
if not line:
|
|
643
|
+
log.info("stdin closed, stopping")
|
|
644
|
+
self._stop_event.set()
|
|
645
|
+
break
|
|
646
|
+
line = line.strip()
|
|
647
|
+
if not line:
|
|
648
|
+
continue
|
|
649
|
+
# handle concurrently so long-running methods don't block short ones
|
|
650
|
+
asyncio.create_task(self._handle_request(line))
|
|
651
|
+
|
|
652
|
+
async def _event_loop(self) -> None:
|
|
653
|
+
while not self._stop_event.is_set():
|
|
654
|
+
try:
|
|
655
|
+
evt = await asyncio.wait_for(self._event_queue.get(), timeout=0.5)
|
|
656
|
+
except asyncio.TimeoutError:
|
|
657
|
+
continue
|
|
658
|
+
await self._write(evt)
|
|
659
|
+
|
|
660
|
+
async def run(self) -> int:
|
|
661
|
+
def _sig_handler(signum, _frame):
|
|
662
|
+
log.info("signal %s received, stopping", signum)
|
|
663
|
+
self._stop_event.set()
|
|
664
|
+
|
|
665
|
+
try:
|
|
666
|
+
signal.signal(signal.SIGTERM, _sig_handler)
|
|
667
|
+
signal.signal(signal.SIGINT, _sig_handler)
|
|
668
|
+
except ValueError:
|
|
669
|
+
pass # non-main thread: skip
|
|
670
|
+
|
|
671
|
+
reader = asyncio.create_task(self._read_loop())
|
|
672
|
+
events = asyncio.create_task(self._event_loop())
|
|
673
|
+
|
|
674
|
+
await self._stop_event.wait()
|
|
675
|
+
|
|
676
|
+
for t in (reader, events):
|
|
677
|
+
if not t.done():
|
|
678
|
+
t.cancel()
|
|
679
|
+
for t in (reader, events):
|
|
680
|
+
try:
|
|
681
|
+
await t
|
|
682
|
+
except asyncio.CancelledError:
|
|
683
|
+
pass
|
|
684
|
+
|
|
685
|
+
# drain any remaining events briefly
|
|
686
|
+
while not self._event_queue.empty():
|
|
687
|
+
try:
|
|
688
|
+
await self._write(self._event_queue.get_nowait())
|
|
689
|
+
except Exception:
|
|
690
|
+
break
|
|
691
|
+
return 0
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def main() -> int:
|
|
695
|
+
ap = argparse.ArgumentParser(description="AUN headless daemon (MVP v0.1)")
|
|
696
|
+
ap.add_argument("--data-dir", help="override AUN data dir (default ~/.aun or $AUN_CLI_DATA)")
|
|
697
|
+
args = ap.parse_args()
|
|
698
|
+
|
|
699
|
+
_init_paths(args.data_dir)
|
|
700
|
+
log.info("aun_daemon starting data_dir=%s", AUN_PATH)
|
|
701
|
+
|
|
702
|
+
try:
|
|
703
|
+
return asyncio.run(Daemon().run())
|
|
704
|
+
except KeyboardInterrupt:
|
|
705
|
+
return 130
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
if __name__ == "__main__":
|
|
709
|
+
sys.exit(main())
|