miti-agent-sdk 0.1.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.
- miti_agent_sdk/__init__.py +67 -0
- miti_agent_sdk/agent.py +272 -0
- miti_agent_sdk/auth.py +156 -0
- miti_agent_sdk/gateway.py +159 -0
- miti_agent_sdk/message.py +197 -0
- miti_agent_sdk/models.py +156 -0
- miti_agent_sdk/stream.py +53 -0
- miti_agent_sdk-0.1.0.dist-info/METADATA +164 -0
- miti_agent_sdk-0.1.0.dist-info/RECORD +11 -0
- miti_agent_sdk-0.1.0.dist-info/WHEEL +5 -0
- miti_agent_sdk-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Miti Agent SDK – connect third-party agents to Miti IM."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from .agent import MitiAgent
|
|
6
|
+
from .auth import AuthClient, AuthError
|
|
7
|
+
from .gateway import GatewayClient
|
|
8
|
+
from .message import ApiError, HistoryClient, MessageClient
|
|
9
|
+
from .models import (
|
|
10
|
+
AgentEvent,
|
|
11
|
+
EventHeader,
|
|
12
|
+
EventMessage,
|
|
13
|
+
EventSender,
|
|
14
|
+
GroupAtEvent,
|
|
15
|
+
HistoryMessage,
|
|
16
|
+
MessageEvent,
|
|
17
|
+
)
|
|
18
|
+
from .stream import build_stream_full_markdown
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"MitiAgent",
|
|
22
|
+
"AuthClient",
|
|
23
|
+
"AuthError",
|
|
24
|
+
"GatewayClient",
|
|
25
|
+
"MessageClient",
|
|
26
|
+
"HistoryClient",
|
|
27
|
+
"ApiError",
|
|
28
|
+
"AgentEvent",
|
|
29
|
+
"EventHeader",
|
|
30
|
+
"EventMessage",
|
|
31
|
+
"EventSender",
|
|
32
|
+
"GroupAtEvent",
|
|
33
|
+
"HistoryMessage",
|
|
34
|
+
"MessageEvent",
|
|
35
|
+
"build_stream_full_markdown",
|
|
36
|
+
"enable_logging",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
__version__ = "0.1.0"
|
|
40
|
+
|
|
41
|
+
# Library best practice: NullHandler prevents "No handlers found" warnings.
|
|
42
|
+
# Users must configure logging themselves or call enable_logging() below.
|
|
43
|
+
logging.getLogger("miti_agent_sdk").addHandler(logging.NullHandler())
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def enable_logging(
|
|
47
|
+
level: int = logging.INFO,
|
|
48
|
+
fmt: str = "%(asctime)s [%(name)s] %(levelname)s %(message)s",
|
|
49
|
+
) -> None:
|
|
50
|
+
"""Enable SDK logging to stderr with a human-readable format.
|
|
51
|
+
|
|
52
|
+
Call this before ``agent.run()`` to see SDK internal activity::
|
|
53
|
+
|
|
54
|
+
import miti_agent_sdk
|
|
55
|
+
miti_agent_sdk.enable_logging() # INFO level
|
|
56
|
+
miti_agent_sdk.enable_logging(logging.DEBUG) # verbose
|
|
57
|
+
|
|
58
|
+
If you already have ``logging.basicConfig()`` or a framework logger
|
|
59
|
+
configured, you don't need this — SDK logs will flow through the
|
|
60
|
+
``miti_agent_sdk.*`` logger hierarchy automatically.
|
|
61
|
+
"""
|
|
62
|
+
root = logging.getLogger("miti_agent_sdk")
|
|
63
|
+
root.setLevel(level)
|
|
64
|
+
if not any(isinstance(h, logging.StreamHandler) for h in root.handlers):
|
|
65
|
+
handler = logging.StreamHandler()
|
|
66
|
+
handler.setFormatter(logging.Formatter(fmt))
|
|
67
|
+
root.addHandler(handler)
|
miti_agent_sdk/agent.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""MitiAgent – top-level facade that wires AuthClient, GatewayClient,
|
|
2
|
+
MessageClient, and HistoryClient together."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import signal
|
|
11
|
+
from typing import Any, Callable, Optional
|
|
12
|
+
|
|
13
|
+
import aiohttp
|
|
14
|
+
|
|
15
|
+
from .auth import AuthClient
|
|
16
|
+
from .gateway import EventHandler, GatewayClient
|
|
17
|
+
from .message import HistoryClient, MessageClient
|
|
18
|
+
from .models import AgentEvent, HistoryMessage
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger("miti_agent_sdk")
|
|
21
|
+
|
|
22
|
+
_BASE_URL_ENV = "MITI_API_BASE_URL"
|
|
23
|
+
_DEFAULT_BASE_URL = "https://www.miti.chat/chat"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class MitiAgent:
|
|
27
|
+
"""One-stop entry point for third-party agents connecting to Miti.
|
|
28
|
+
|
|
29
|
+
Usage::
|
|
30
|
+
|
|
31
|
+
agent = MitiAgent(app_id="cli_xxx", app_secret="secret_xxx")
|
|
32
|
+
|
|
33
|
+
@agent.on_message
|
|
34
|
+
async def handle(event: AgentEvent):
|
|
35
|
+
await event.reply("got it!")
|
|
36
|
+
|
|
37
|
+
agent.run()
|
|
38
|
+
|
|
39
|
+
Parameters
|
|
40
|
+
----------
|
|
41
|
+
app_id : str
|
|
42
|
+
Agent application ID.
|
|
43
|
+
app_secret : str
|
|
44
|
+
Agent application secret.
|
|
45
|
+
base_url : str
|
|
46
|
+
Miti API base URL. Defaults to the ``MITI_API_BASE_URL`` environment
|
|
47
|
+
variable when set, otherwise ``https://www.miti.chat/chat``.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
app_id: str,
|
|
53
|
+
app_secret: str,
|
|
54
|
+
base_url: Optional[str] = None,
|
|
55
|
+
):
|
|
56
|
+
self._base_url = (
|
|
57
|
+
base_url or os.getenv(_BASE_URL_ENV) or _DEFAULT_BASE_URL
|
|
58
|
+
).rstrip("/")
|
|
59
|
+
|
|
60
|
+
if not _is_tls_or_localhost(self._base_url):
|
|
61
|
+
raise ValueError(
|
|
62
|
+
f"base_url must use https:// (got {self._base_url!r}). "
|
|
63
|
+
f"Only localhost / 127.0.0.1 is allowed over http:// for local development."
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
self._session: Optional[aiohttp.ClientSession] = None
|
|
67
|
+
|
|
68
|
+
self._auth = AuthClient(
|
|
69
|
+
app_id=app_id,
|
|
70
|
+
app_secret=app_secret,
|
|
71
|
+
base_url=self._base_url,
|
|
72
|
+
)
|
|
73
|
+
self._gateway = GatewayClient(auth=self._auth, base_url=self._base_url)
|
|
74
|
+
self._msg_client: Optional[MessageClient] = None
|
|
75
|
+
self._history_client: Optional[HistoryClient] = None
|
|
76
|
+
|
|
77
|
+
# ------------------------------------------------------------------
|
|
78
|
+
# Decorator API
|
|
79
|
+
# ------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
def on_message(self, fn: EventHandler) -> EventHandler:
|
|
82
|
+
"""Decorator: register *fn* as the handler for single-chat messages
|
|
83
|
+
(``im.message.receive``).
|
|
84
|
+
|
|
85
|
+
::
|
|
86
|
+
|
|
87
|
+
@agent.on_message
|
|
88
|
+
async def handle(event):
|
|
89
|
+
await event.reply("hello")
|
|
90
|
+
"""
|
|
91
|
+
self._gateway.on("im.message.receive", fn)
|
|
92
|
+
return fn
|
|
93
|
+
|
|
94
|
+
def on_group_at(self, fn: EventHandler) -> EventHandler:
|
|
95
|
+
"""Decorator: register *fn* as the handler for group-chat @ messages
|
|
96
|
+
(``im.message.group_at``).
|
|
97
|
+
|
|
98
|
+
::
|
|
99
|
+
|
|
100
|
+
@agent.on_group_at
|
|
101
|
+
async def handle(event):
|
|
102
|
+
await event.reply("group reply")
|
|
103
|
+
"""
|
|
104
|
+
self._gateway.on("im.message.group_at", fn)
|
|
105
|
+
return fn
|
|
106
|
+
|
|
107
|
+
def on(self, event_type: str) -> Callable[[EventHandler], EventHandler]:
|
|
108
|
+
"""Generic decorator for any event type::
|
|
109
|
+
|
|
110
|
+
@agent.on("im.message.receive")
|
|
111
|
+
async def handle(event):
|
|
112
|
+
...
|
|
113
|
+
"""
|
|
114
|
+
def decorator(fn: EventHandler) -> EventHandler:
|
|
115
|
+
self._gateway.on(event_type, fn)
|
|
116
|
+
return fn
|
|
117
|
+
return decorator
|
|
118
|
+
|
|
119
|
+
# ------------------------------------------------------------------
|
|
120
|
+
# REST helpers
|
|
121
|
+
# ------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
async def send_message(
|
|
124
|
+
self,
|
|
125
|
+
*,
|
|
126
|
+
to_user_id: Optional[str] = None,
|
|
127
|
+
to_group_id: Optional[str] = None,
|
|
128
|
+
msg_type: str = "text",
|
|
129
|
+
content: dict[str, Any] | None = None,
|
|
130
|
+
) -> dict[str, Any]:
|
|
131
|
+
"""Send a message via ``POST /agent/v1/messages/send``.
|
|
132
|
+
|
|
133
|
+
Supported ``msg_type`` values:
|
|
134
|
+
|
|
135
|
+
- ``text`` — plain text (contentType 101)
|
|
136
|
+
- ``stream_full`` — Markdown reply (contentType 125); use
|
|
137
|
+
:func:`miti_agent_sdk.build_stream_full_markdown` or pass
|
|
138
|
+
``{"markdown": "...", "ask_msg_id": "..."}``
|
|
139
|
+
|
|
140
|
+
Returns the ``data`` dict from the response.
|
|
141
|
+
"""
|
|
142
|
+
client = self._ensure_msg_client()
|
|
143
|
+
return await client.send(
|
|
144
|
+
to_user_id=to_user_id,
|
|
145
|
+
to_group_id=to_group_id,
|
|
146
|
+
msg_type=msg_type,
|
|
147
|
+
content=content,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
async def send_markdown_reply(
|
|
151
|
+
self,
|
|
152
|
+
markdown: str,
|
|
153
|
+
*,
|
|
154
|
+
to_user_id: Optional[str] = None,
|
|
155
|
+
to_group_id: Optional[str] = None,
|
|
156
|
+
ask_msg_id: str = "",
|
|
157
|
+
) -> dict[str, Any]:
|
|
158
|
+
"""Send a Markdown reply as ``stream_full`` (contentType 125)."""
|
|
159
|
+
from .stream import build_stream_full_markdown
|
|
160
|
+
|
|
161
|
+
return await self.send_message(
|
|
162
|
+
to_user_id=to_user_id,
|
|
163
|
+
to_group_id=to_group_id,
|
|
164
|
+
msg_type="stream_full",
|
|
165
|
+
content=build_stream_full_markdown(markdown, ask_msg_id),
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
async def get_history(
|
|
169
|
+
self,
|
|
170
|
+
conversation_id: str,
|
|
171
|
+
limit: int = 10,
|
|
172
|
+
) -> list[HistoryMessage]:
|
|
173
|
+
"""Fetch conversation history via ``GET /agent/v1/messages/history``."""
|
|
174
|
+
client = self._ensure_history_client()
|
|
175
|
+
return await client.get(conversation_id=conversation_id, limit=limit)
|
|
176
|
+
|
|
177
|
+
# ------------------------------------------------------------------
|
|
178
|
+
# Lifecycle
|
|
179
|
+
# ------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
def run(self) -> None:
|
|
182
|
+
"""Blocking entry point. Starts the event loop, connects the
|
|
183
|
+
WebSocket gateway, and runs until interrupted (Ctrl-C / SIGTERM).
|
|
184
|
+
|
|
185
|
+
Internally calls :meth:`run_async`.
|
|
186
|
+
"""
|
|
187
|
+
try:
|
|
188
|
+
asyncio.run(self.run_async())
|
|
189
|
+
except KeyboardInterrupt:
|
|
190
|
+
logger.info("interrupted, shutting down")
|
|
191
|
+
|
|
192
|
+
async def run_async(self, *, register_signals: bool = True) -> None:
|
|
193
|
+
"""Async entry point – call this if you already own the event loop.
|
|
194
|
+
|
|
195
|
+
Parameters
|
|
196
|
+
----------
|
|
197
|
+
register_signals : bool
|
|
198
|
+
When *True* (default), register SIGINT/SIGTERM handlers to
|
|
199
|
+
gracefully stop the gateway. Set to *False* when embedding
|
|
200
|
+
the agent inside a host process (e.g. Hermes) that manages
|
|
201
|
+
its own signal handling.
|
|
202
|
+
"""
|
|
203
|
+
if register_signals:
|
|
204
|
+
loop = asyncio.get_running_loop()
|
|
205
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
206
|
+
try:
|
|
207
|
+
loop.add_signal_handler(sig, self._gateway.stop)
|
|
208
|
+
except NotImplementedError:
|
|
209
|
+
pass # Windows
|
|
210
|
+
|
|
211
|
+
logger.info("MitiAgent starting")
|
|
212
|
+
self._auth.set_shared_session(self._ensure_session())
|
|
213
|
+
await self._auth.start_auto_refresh()
|
|
214
|
+
try:
|
|
215
|
+
await self._gateway.run_forever(inject_agent=self)
|
|
216
|
+
finally:
|
|
217
|
+
await self.close()
|
|
218
|
+
logger.info("MitiAgent stopped")
|
|
219
|
+
|
|
220
|
+
async def close(self) -> None:
|
|
221
|
+
"""Release all resources (HTTP sessions, refresh tasks, WebSocket).
|
|
222
|
+
|
|
223
|
+
Safe to call multiple times — subsequent calls are no-ops.
|
|
224
|
+
"""
|
|
225
|
+
await self._auth.close()
|
|
226
|
+
if self._msg_client:
|
|
227
|
+
await self._msg_client.close()
|
|
228
|
+
if self._history_client:
|
|
229
|
+
await self._history_client.close()
|
|
230
|
+
if self._session and not self._session.closed:
|
|
231
|
+
await self._session.close()
|
|
232
|
+
|
|
233
|
+
# ------------------------------------------------------------------
|
|
234
|
+
# Internal helpers
|
|
235
|
+
# ------------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
def _ensure_session(self) -> aiohttp.ClientSession:
|
|
238
|
+
if self._session is None or self._session.closed:
|
|
239
|
+
self._session = aiohttp.ClientSession()
|
|
240
|
+
return self._session
|
|
241
|
+
|
|
242
|
+
def _ensure_msg_client(self) -> MessageClient:
|
|
243
|
+
if self._msg_client is None:
|
|
244
|
+
self._msg_client = MessageClient(
|
|
245
|
+
auth=self._auth,
|
|
246
|
+
base_url=self._base_url,
|
|
247
|
+
session=self._ensure_session(),
|
|
248
|
+
)
|
|
249
|
+
return self._msg_client
|
|
250
|
+
|
|
251
|
+
def _ensure_history_client(self) -> HistoryClient:
|
|
252
|
+
if self._history_client is None:
|
|
253
|
+
self._history_client = HistoryClient(
|
|
254
|
+
auth=self._auth,
|
|
255
|
+
base_url=self._base_url,
|
|
256
|
+
session=self._ensure_session(),
|
|
257
|
+
)
|
|
258
|
+
return self._history_client
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
_LOCAL_HOST_RE = re.compile(
|
|
262
|
+
r"^https?://(localhost|127\.0\.0\.1)(:\d+)?(/|$)", re.IGNORECASE,
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _is_tls_or_localhost(url: str) -> bool:
|
|
267
|
+
"""Return True if *url* uses https:// or targets localhost over http://."""
|
|
268
|
+
if url.startswith("https://"):
|
|
269
|
+
return True
|
|
270
|
+
if url.startswith("http://") and _LOCAL_HOST_RE.match(url):
|
|
271
|
+
return True
|
|
272
|
+
return False
|
miti_agent_sdk/auth.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""AuthClient – obtain and auto-refresh Miti Agent access tokens."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
import aiohttp
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger("miti_agent_sdk.auth")
|
|
13
|
+
|
|
14
|
+
# Refresh 10 minutes before expiry to avoid edge-case rejections.
|
|
15
|
+
_REFRESH_MARGIN_S = 10 * 60
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AuthError(Exception):
|
|
19
|
+
"""Raised when token acquisition fails."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, code: int, message: str):
|
|
22
|
+
self.code = code
|
|
23
|
+
super().__init__(f"[{code}] {message}")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AuthClient:
|
|
27
|
+
"""Handles ``POST /agent/v1/auth/token`` and transparent token caching.
|
|
28
|
+
|
|
29
|
+
Parameters
|
|
30
|
+
----------
|
|
31
|
+
app_id : str
|
|
32
|
+
Agent application ID (``cli_xxx``).
|
|
33
|
+
app_secret : str
|
|
34
|
+
Agent application secret.
|
|
35
|
+
base_url : str
|
|
36
|
+
Miti API base URL (no trailing slash).
|
|
37
|
+
session : aiohttp.ClientSession | None
|
|
38
|
+
Shared HTTP session. If *None*, one is created internally.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
app_id: str,
|
|
44
|
+
app_secret: str,
|
|
45
|
+
base_url: str,
|
|
46
|
+
session: Optional[aiohttp.ClientSession] = None,
|
|
47
|
+
):
|
|
48
|
+
self._app_id = app_id
|
|
49
|
+
self._app_secret = app_secret
|
|
50
|
+
self._base_url = base_url.rstrip("/")
|
|
51
|
+
self._session = session
|
|
52
|
+
self._owns_session = session is None
|
|
53
|
+
|
|
54
|
+
self._token: Optional[str] = None
|
|
55
|
+
self._expires_at: float = 0.0 # unix timestamp
|
|
56
|
+
self._lock = asyncio.Lock()
|
|
57
|
+
self._refresh_task: Optional[asyncio.Task] = None
|
|
58
|
+
|
|
59
|
+
# ------------------------------------------------------------------
|
|
60
|
+
# Public API
|
|
61
|
+
# ------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
async def get_token(self) -> str:
|
|
64
|
+
"""Return a valid access token, fetching or refreshing as needed."""
|
|
65
|
+
if self._token and time.time() < self._expires_at:
|
|
66
|
+
return self._token
|
|
67
|
+
async with self._lock:
|
|
68
|
+
# Double-check after acquiring lock.
|
|
69
|
+
if self._token and time.time() < self._expires_at:
|
|
70
|
+
return self._token
|
|
71
|
+
await self._fetch_token()
|
|
72
|
+
return self._token # type: ignore[return-value]
|
|
73
|
+
|
|
74
|
+
async def start_auto_refresh(self) -> None:
|
|
75
|
+
"""Start a background task that refreshes the token before expiry."""
|
|
76
|
+
if self._refresh_task is not None:
|
|
77
|
+
return
|
|
78
|
+
await self.get_token() # ensure first token
|
|
79
|
+
self._refresh_task = asyncio.create_task(self._auto_refresh_loop())
|
|
80
|
+
|
|
81
|
+
def set_shared_session(self, session: aiohttp.ClientSession) -> None:
|
|
82
|
+
"""Inject an externally-owned session (won't be closed by this client)."""
|
|
83
|
+
if self._session is None or self._session.closed:
|
|
84
|
+
self._session = session
|
|
85
|
+
self._owns_session = False
|
|
86
|
+
|
|
87
|
+
async def close(self) -> None:
|
|
88
|
+
if self._refresh_task is not None:
|
|
89
|
+
self._refresh_task.cancel()
|
|
90
|
+
try:
|
|
91
|
+
await self._refresh_task
|
|
92
|
+
except asyncio.CancelledError:
|
|
93
|
+
pass
|
|
94
|
+
self._refresh_task = None
|
|
95
|
+
if self._owns_session and self._session and not self._session.closed:
|
|
96
|
+
await self._session.close()
|
|
97
|
+
|
|
98
|
+
# ------------------------------------------------------------------
|
|
99
|
+
# Internal
|
|
100
|
+
# ------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
def _ensure_session(self) -> aiohttp.ClientSession:
|
|
103
|
+
if self._session is None or self._session.closed:
|
|
104
|
+
self._session = aiohttp.ClientSession()
|
|
105
|
+
self._owns_session = True
|
|
106
|
+
return self._session
|
|
107
|
+
|
|
108
|
+
async def _fetch_token(self) -> None:
|
|
109
|
+
url = f"{self._base_url}/agent/v1/auth/token"
|
|
110
|
+
payload = {"app_id": self._app_id, "app_secret": self._app_secret}
|
|
111
|
+
session = self._ensure_session()
|
|
112
|
+
|
|
113
|
+
logger.debug("requesting token from %s", url)
|
|
114
|
+
async with session.post(url, json=payload) as resp:
|
|
115
|
+
if resp.status != 200:
|
|
116
|
+
text = await resp.text()
|
|
117
|
+
logger.warning("auth token HTTP error: status=%d body=%s", resp.status, text[:200])
|
|
118
|
+
raise AuthError(resp.status, f"HTTP {resp.status}: {text[:200]}")
|
|
119
|
+
body = await resp.json()
|
|
120
|
+
|
|
121
|
+
code = body.get("errCode", body.get("code", -1))
|
|
122
|
+
if code != 0:
|
|
123
|
+
msg = body.get("errMsg", body.get("message", "unknown error"))
|
|
124
|
+
logger.warning("auth token API error: code=%d msg=%s", code, msg)
|
|
125
|
+
raise AuthError(code, msg)
|
|
126
|
+
|
|
127
|
+
data = body.get("data", {})
|
|
128
|
+
token = data.get("token") or data.get("access_token")
|
|
129
|
+
if not token:
|
|
130
|
+
raise AuthError(-1, "server returned code=0 but missing token")
|
|
131
|
+
self._token = token
|
|
132
|
+
expires_in = data.get("expires_in", 7200)
|
|
133
|
+
self._expires_at = time.time() + expires_in - _REFRESH_MARGIN_S
|
|
134
|
+
logger.info("token acquired, expires_in=%ds", expires_in)
|
|
135
|
+
|
|
136
|
+
async def _auto_refresh_loop(self) -> None:
|
|
137
|
+
"""Sleep until close to expiry, then refresh."""
|
|
138
|
+
retry_backoff = 0.0
|
|
139
|
+
while True:
|
|
140
|
+
if retry_backoff > 0:
|
|
141
|
+
logger.debug("auto-refresh retry backoff %.0fs", retry_backoff)
|
|
142
|
+
await asyncio.sleep(retry_backoff)
|
|
143
|
+
else:
|
|
144
|
+
sleep_for = max(self._expires_at - time.time(), 1.0)
|
|
145
|
+
logger.debug("auto-refresh sleeping %.0fs", sleep_for)
|
|
146
|
+
await asyncio.sleep(sleep_for)
|
|
147
|
+
try:
|
|
148
|
+
async with self._lock:
|
|
149
|
+
await self._fetch_token()
|
|
150
|
+
logger.info("token auto-refreshed")
|
|
151
|
+
retry_backoff = 0.0
|
|
152
|
+
except Exception:
|
|
153
|
+
retry_backoff = min((retry_backoff or 15) * 2, 120)
|
|
154
|
+
logger.exception(
|
|
155
|
+
"auto-refresh failed, will retry in %.0fs", retry_backoff,
|
|
156
|
+
)
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""GatewayClient – WebSocket connection to Miti Event Gateway with
|
|
2
|
+
automatic keep-alive and exponential-backoff reconnection."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
from typing import Any, Awaitable, Callable, Optional
|
|
10
|
+
|
|
11
|
+
import websockets
|
|
12
|
+
import websockets.exceptions
|
|
13
|
+
|
|
14
|
+
from .auth import AuthClient
|
|
15
|
+
from .models import AgentEvent, parse_event
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger("miti_agent_sdk.gateway")
|
|
18
|
+
|
|
19
|
+
_INITIAL_BACKOFF_S = 1.0
|
|
20
|
+
_MAX_BACKOFF_S = 60.0
|
|
21
|
+
_BACKOFF_FACTOR = 2.0
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
EventHandler = Callable[[AgentEvent], Awaitable[None]]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class GatewayClient:
|
|
28
|
+
"""Manages the WebSocket lifecycle: connect, receive, ping/pong,
|
|
29
|
+
and exponential-backoff reconnect.
|
|
30
|
+
|
|
31
|
+
Parameters
|
|
32
|
+
----------
|
|
33
|
+
auth : AuthClient
|
|
34
|
+
Provides Bearer tokens for the ``Authorization`` header.
|
|
35
|
+
base_url : str
|
|
36
|
+
HTTP(S) base URL; scheme is converted to ``ws`` / ``wss``.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, auth: AuthClient, base_url: str):
|
|
40
|
+
self._auth = auth
|
|
41
|
+
self._ws_url = self._to_ws_url(base_url.rstrip("/") + "/agent/gateway")
|
|
42
|
+
self._handlers: dict[str, list[EventHandler]] = {}
|
|
43
|
+
self._ws: Optional[websockets.WebSocketClientProtocol] = None
|
|
44
|
+
self._running = False
|
|
45
|
+
|
|
46
|
+
# ------------------------------------------------------------------
|
|
47
|
+
# Handler registration
|
|
48
|
+
# ------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
def on(self, event_type: str, handler: EventHandler) -> None:
|
|
51
|
+
"""Register *handler* for events of *event_type*."""
|
|
52
|
+
self._handlers.setdefault(event_type, []).append(handler)
|
|
53
|
+
|
|
54
|
+
# ------------------------------------------------------------------
|
|
55
|
+
# Lifecycle
|
|
56
|
+
# ------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
async def run_forever(self, inject_agent: Any = None) -> None:
|
|
59
|
+
"""Connect and reconnect in a loop until :meth:`stop` is called.
|
|
60
|
+
|
|
61
|
+
Parameters
|
|
62
|
+
----------
|
|
63
|
+
inject_agent :
|
|
64
|
+
If provided, set as ``event._agent`` before dispatching so that
|
|
65
|
+
``event.reply()`` works.
|
|
66
|
+
"""
|
|
67
|
+
self._running = True
|
|
68
|
+
backoff = _INITIAL_BACKOFF_S
|
|
69
|
+
|
|
70
|
+
while self._running:
|
|
71
|
+
try:
|
|
72
|
+
await self._connect_and_listen(inject_agent)
|
|
73
|
+
backoff = _INITIAL_BACKOFF_S
|
|
74
|
+
except asyncio.CancelledError:
|
|
75
|
+
break
|
|
76
|
+
except Exception:
|
|
77
|
+
if not self._running:
|
|
78
|
+
break
|
|
79
|
+
logger.exception(
|
|
80
|
+
"gateway connection lost, reconnecting in %.0fs", backoff,
|
|
81
|
+
)
|
|
82
|
+
await asyncio.sleep(backoff)
|
|
83
|
+
backoff = min(backoff * _BACKOFF_FACTOR, _MAX_BACKOFF_S)
|
|
84
|
+
|
|
85
|
+
logger.info("gateway run_forever exited")
|
|
86
|
+
|
|
87
|
+
def stop(self) -> None:
|
|
88
|
+
"""Signal the run loop to exit after the current iteration."""
|
|
89
|
+
self._running = False
|
|
90
|
+
if self._ws:
|
|
91
|
+
asyncio.ensure_future(self._ws.close())
|
|
92
|
+
|
|
93
|
+
# ------------------------------------------------------------------
|
|
94
|
+
# Internal
|
|
95
|
+
# ------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
async def _connect_and_listen(self, inject_agent: Any) -> None:
|
|
98
|
+
token = await self._auth.get_token()
|
|
99
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
100
|
+
|
|
101
|
+
logger.info("connecting to %s", self._ws_url)
|
|
102
|
+
async with websockets.connect(
|
|
103
|
+
self._ws_url,
|
|
104
|
+
additional_headers=headers,
|
|
105
|
+
ping_interval=25,
|
|
106
|
+
ping_timeout=30,
|
|
107
|
+
) as ws:
|
|
108
|
+
self._ws = ws
|
|
109
|
+
logger.info("gateway connected")
|
|
110
|
+
try:
|
|
111
|
+
async for raw in ws:
|
|
112
|
+
await self._dispatch(raw, inject_agent)
|
|
113
|
+
except websockets.exceptions.ConnectionClosedError as exc:
|
|
114
|
+
logger.warning("gateway connection closed: %s", exc)
|
|
115
|
+
raise
|
|
116
|
+
finally:
|
|
117
|
+
self._ws = None
|
|
118
|
+
|
|
119
|
+
async def _dispatch(self, raw: str | bytes, inject_agent: Any) -> None:
|
|
120
|
+
try:
|
|
121
|
+
data: dict[str, Any] = json.loads(raw)
|
|
122
|
+
except json.JSONDecodeError:
|
|
123
|
+
logger.warning("received non-JSON frame, ignoring: %s", raw[:200])
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
event = parse_event(data)
|
|
128
|
+
except Exception:
|
|
129
|
+
logger.exception("failed to parse event: %s", raw[:500])
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
if inject_agent is not None:
|
|
133
|
+
event._agent = inject_agent
|
|
134
|
+
|
|
135
|
+
event_type = event.header.event_type
|
|
136
|
+
logger.info("event received: type=%s id=%s", event_type, event.header.event_id)
|
|
137
|
+
handlers = self._handlers.get(event_type, [])
|
|
138
|
+
if not handlers:
|
|
139
|
+
logger.warning("no handler for event_type=%s, ignoring event_id=%s",
|
|
140
|
+
event_type, event.header.event_id)
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
for handler in handlers:
|
|
144
|
+
try:
|
|
145
|
+
await handler(event)
|
|
146
|
+
except Exception:
|
|
147
|
+
logger.exception(
|
|
148
|
+
"handler error for event_type=%s event_id=%s",
|
|
149
|
+
event_type,
|
|
150
|
+
event.header.event_id,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
@staticmethod
|
|
154
|
+
def _to_ws_url(http_url: str) -> str:
|
|
155
|
+
if http_url.startswith("https://"):
|
|
156
|
+
return "wss://" + http_url[len("https://"):]
|
|
157
|
+
if http_url.startswith("http://"):
|
|
158
|
+
return "ws://" + http_url[len("http://"):]
|
|
159
|
+
return http_url
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""MessageClient and HistoryClient – REST API wrappers for sending messages
|
|
2
|
+
and fetching conversation history."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
|
|
9
|
+
import aiohttp
|
|
10
|
+
|
|
11
|
+
from .auth import AuthClient
|
|
12
|
+
from .models import HistoryMessage
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("miti_agent_sdk.message")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ApiError(Exception):
|
|
18
|
+
"""Raised when a Miti REST API returns a non-zero code."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, code: int, message: str):
|
|
21
|
+
self.code = code
|
|
22
|
+
super().__init__(f"[{code}] {message}")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class MessageClient:
|
|
26
|
+
"""``POST /agent/v1/messages/send`` – send a message to a user or group.
|
|
27
|
+
|
|
28
|
+
Parameters
|
|
29
|
+
----------
|
|
30
|
+
auth : AuthClient
|
|
31
|
+
Provides Bearer tokens.
|
|
32
|
+
base_url : str
|
|
33
|
+
Miti API base URL (no trailing slash).
|
|
34
|
+
session : aiohttp.ClientSession | None
|
|
35
|
+
Shared HTTP session.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
auth: AuthClient,
|
|
41
|
+
base_url: str,
|
|
42
|
+
session: Optional[aiohttp.ClientSession] = None,
|
|
43
|
+
):
|
|
44
|
+
self._auth = auth
|
|
45
|
+
self._base_url = base_url.rstrip("/")
|
|
46
|
+
self._session = session
|
|
47
|
+
self._owns_session = session is None
|
|
48
|
+
|
|
49
|
+
def _ensure_session(self) -> aiohttp.ClientSession:
|
|
50
|
+
if self._session is None or self._session.closed:
|
|
51
|
+
self._session = aiohttp.ClientSession()
|
|
52
|
+
self._owns_session = True
|
|
53
|
+
return self._session
|
|
54
|
+
|
|
55
|
+
async def send(
|
|
56
|
+
self,
|
|
57
|
+
*,
|
|
58
|
+
to_user_id: Optional[str] = None,
|
|
59
|
+
to_group_id: Optional[str] = None,
|
|
60
|
+
msg_type: str = "text",
|
|
61
|
+
content: dict[str, Any] | None = None,
|
|
62
|
+
) -> dict[str, Any]:
|
|
63
|
+
"""Send a message and return the response ``data`` dict.
|
|
64
|
+
|
|
65
|
+
Exactly one of *to_user_id* or *to_group_id* must be provided.
|
|
66
|
+
|
|
67
|
+
Returns
|
|
68
|
+
-------
|
|
69
|
+
dict
|
|
70
|
+
``{"message_id": "...", "server_time": "..."}``
|
|
71
|
+
"""
|
|
72
|
+
if not to_user_id and not to_group_id:
|
|
73
|
+
raise ValueError("to_user_id or to_group_id required")
|
|
74
|
+
if to_user_id and to_group_id:
|
|
75
|
+
raise ValueError("only one of to_user_id or to_group_id is allowed")
|
|
76
|
+
|
|
77
|
+
token = await self._auth.get_token()
|
|
78
|
+
url = f"{self._base_url}/agent/v1/messages/send"
|
|
79
|
+
payload: dict[str, Any] = {
|
|
80
|
+
"msg_type": msg_type,
|
|
81
|
+
"content": content or {},
|
|
82
|
+
}
|
|
83
|
+
if to_user_id:
|
|
84
|
+
payload["to_user_id"] = to_user_id
|
|
85
|
+
else:
|
|
86
|
+
payload["to_group_id"] = to_group_id
|
|
87
|
+
|
|
88
|
+
session = self._ensure_session()
|
|
89
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
90
|
+
|
|
91
|
+
target = to_user_id or to_group_id
|
|
92
|
+
logger.debug("POST %s target=%s", url, target)
|
|
93
|
+
async with session.post(url, json=payload, headers=headers) as resp:
|
|
94
|
+
if resp.status != 200:
|
|
95
|
+
text = await resp.text()
|
|
96
|
+
logger.warning("send_message HTTP error: status=%d target=%s body=%s",
|
|
97
|
+
resp.status, target, text[:200])
|
|
98
|
+
raise ApiError(resp.status, f"HTTP {resp.status}: {text[:200]}")
|
|
99
|
+
body = await resp.json()
|
|
100
|
+
|
|
101
|
+
code = body.get("errCode", body.get("code", -1))
|
|
102
|
+
if code != 0:
|
|
103
|
+
msg = body.get("errMsg", body.get("message", "unknown error"))
|
|
104
|
+
logger.warning("send_message API error: code=%d target=%s msg=%s",
|
|
105
|
+
code, target, msg)
|
|
106
|
+
raise ApiError(code, msg)
|
|
107
|
+
|
|
108
|
+
data = body.get("data", {})
|
|
109
|
+
logger.info("send_message success: target=%s message_id=%s",
|
|
110
|
+
target, data.get("message_id", ""))
|
|
111
|
+
return data
|
|
112
|
+
|
|
113
|
+
async def close(self) -> None:
|
|
114
|
+
if self._owns_session and self._session and not self._session.closed:
|
|
115
|
+
await self._session.close()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class HistoryClient:
|
|
119
|
+
"""``GET /agent/v1/messages/history`` – fetch conversation history.
|
|
120
|
+
|
|
121
|
+
Parameters
|
|
122
|
+
----------
|
|
123
|
+
auth : AuthClient
|
|
124
|
+
Provides Bearer tokens.
|
|
125
|
+
base_url : str
|
|
126
|
+
Miti API base URL (no trailing slash).
|
|
127
|
+
session : aiohttp.ClientSession | None
|
|
128
|
+
Shared HTTP session.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
def __init__(
|
|
132
|
+
self,
|
|
133
|
+
auth: AuthClient,
|
|
134
|
+
base_url: str,
|
|
135
|
+
session: Optional[aiohttp.ClientSession] = None,
|
|
136
|
+
):
|
|
137
|
+
self._auth = auth
|
|
138
|
+
self._base_url = base_url.rstrip("/")
|
|
139
|
+
self._session = session
|
|
140
|
+
self._owns_session = session is None
|
|
141
|
+
|
|
142
|
+
def _ensure_session(self) -> aiohttp.ClientSession:
|
|
143
|
+
if self._session is None or self._session.closed:
|
|
144
|
+
self._session = aiohttp.ClientSession()
|
|
145
|
+
self._owns_session = True
|
|
146
|
+
return self._session
|
|
147
|
+
|
|
148
|
+
async def get(
|
|
149
|
+
self,
|
|
150
|
+
conversation_id: str,
|
|
151
|
+
limit: int = 10,
|
|
152
|
+
) -> list[HistoryMessage]:
|
|
153
|
+
"""Fetch recent messages for a conversation.
|
|
154
|
+
|
|
155
|
+
Parameters
|
|
156
|
+
----------
|
|
157
|
+
conversation_id : str
|
|
158
|
+
The ``si_...`` or ``sg_...`` conversation identifier.
|
|
159
|
+
limit : int
|
|
160
|
+
Maximum number of messages to return (server caps at 50).
|
|
161
|
+
|
|
162
|
+
Returns
|
|
163
|
+
-------
|
|
164
|
+
list[HistoryMessage]
|
|
165
|
+
"""
|
|
166
|
+
token = await self._auth.get_token()
|
|
167
|
+
url = f"{self._base_url}/agent/v1/messages/history"
|
|
168
|
+
params = {"conversation_id": conversation_id, "limit": str(limit)}
|
|
169
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
170
|
+
|
|
171
|
+
session = self._ensure_session()
|
|
172
|
+
logger.debug("GET %s conversation_id=%s limit=%d", url, conversation_id, limit)
|
|
173
|
+
async with session.get(url, params=params, headers=headers) as resp:
|
|
174
|
+
if resp.status != 200:
|
|
175
|
+
text = await resp.text()
|
|
176
|
+
logger.warning("get_history HTTP error: status=%d conversation_id=%s body=%s",
|
|
177
|
+
resp.status, conversation_id, text[:200])
|
|
178
|
+
raise ApiError(resp.status, f"HTTP {resp.status}: {text[:200]}")
|
|
179
|
+
body = await resp.json()
|
|
180
|
+
|
|
181
|
+
code = body.get("errCode", body.get("code", -1))
|
|
182
|
+
if code != 0:
|
|
183
|
+
msg = body.get("errMsg", body.get("message", "unknown error"))
|
|
184
|
+
logger.warning("get_history API error: code=%d conversation_id=%s msg=%s",
|
|
185
|
+
code, conversation_id, msg)
|
|
186
|
+
raise ApiError(code, msg)
|
|
187
|
+
|
|
188
|
+
data = body.get("data", {})
|
|
189
|
+
raw_messages = data.get("messages", [])
|
|
190
|
+
return [
|
|
191
|
+
HistoryMessage(role=m.get("role", ""), content=m.get("content", {}))
|
|
192
|
+
for m in raw_messages
|
|
193
|
+
]
|
|
194
|
+
|
|
195
|
+
async def close(self) -> None:
|
|
196
|
+
if self._owns_session and self._session and not self._session.closed:
|
|
197
|
+
await self._session.close()
|
miti_agent_sdk/models.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Data models for Miti Agent SDK events and messages."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
from .stream import build_stream_full_markdown
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class EventSender:
|
|
13
|
+
user_id: str
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class EventMessage:
|
|
18
|
+
message_id: str
|
|
19
|
+
conversation_id: str
|
|
20
|
+
session_type: str # "single" | "group"
|
|
21
|
+
msg_type: str # "text" | "image" | "multimodal" | "at_text" | ...
|
|
22
|
+
content: dict[str, Any] = field(default_factory=dict)
|
|
23
|
+
send_time: str = ""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class EventHeader:
|
|
28
|
+
event_id: str
|
|
29
|
+
event_type: str # "im.message.receive" | "im.message.group_at"
|
|
30
|
+
app_id: str
|
|
31
|
+
create_time: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class MessageEvent:
|
|
36
|
+
"""im.message.receive – single-chat message."""
|
|
37
|
+
sender: EventSender
|
|
38
|
+
message: EventMessage
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class GroupAtEvent:
|
|
43
|
+
"""im.message.group_at – group-chat @ message."""
|
|
44
|
+
group_id: str
|
|
45
|
+
at_user_id: str
|
|
46
|
+
sender: EventSender
|
|
47
|
+
message: EventMessage
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class AgentEvent:
|
|
52
|
+
"""Top-level event envelope pushed via WebSocket."""
|
|
53
|
+
schema: str
|
|
54
|
+
header: EventHeader
|
|
55
|
+
event: MessageEvent | GroupAtEvent
|
|
56
|
+
|
|
57
|
+
# --- back-references injected by MitiAgent before dispatching ---
|
|
58
|
+
_agent: Any = field(default=None, repr=False, compare=False)
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def is_single(self) -> bool:
|
|
62
|
+
return self.header.event_type == "im.message.receive"
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def is_group_at(self) -> bool:
|
|
66
|
+
return self.header.event_type == "im.message.group_at"
|
|
67
|
+
|
|
68
|
+
async def reply(self, text: str, *, use_markdown: bool = True) -> dict[str, Any]:
|
|
69
|
+
"""Reply to the sender (single) or group (group_at).
|
|
70
|
+
|
|
71
|
+
By default sends ``stream_full`` (contentType 125) so the Miti App
|
|
72
|
+
renders Markdown. Set ``use_markdown=False`` for plain text (101).
|
|
73
|
+
"""
|
|
74
|
+
if self._agent is None:
|
|
75
|
+
raise RuntimeError("reply() requires event._agent to be set by MitiAgent")
|
|
76
|
+
|
|
77
|
+
ask_msg_id = getattr(getattr(self.event, "message", None), "message_id", "")
|
|
78
|
+
|
|
79
|
+
if use_markdown:
|
|
80
|
+
content = build_stream_full_markdown(text, ask_msg_id)
|
|
81
|
+
msg_type = "stream_full"
|
|
82
|
+
else:
|
|
83
|
+
content = {"text": text}
|
|
84
|
+
msg_type = "text"
|
|
85
|
+
|
|
86
|
+
if self.is_group_at:
|
|
87
|
+
evt: GroupAtEvent = self.event # type: ignore[assignment]
|
|
88
|
+
return await self._agent.send_message(
|
|
89
|
+
to_group_id=evt.group_id,
|
|
90
|
+
msg_type=msg_type,
|
|
91
|
+
content=content,
|
|
92
|
+
)
|
|
93
|
+
return await self._agent.send_message(
|
|
94
|
+
to_user_id=self.event.sender.user_id,
|
|
95
|
+
msg_type=msg_type,
|
|
96
|
+
content=content,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass
|
|
101
|
+
class HistoryMessage:
|
|
102
|
+
"""A single message in conversation history (OpenAI messages style)."""
|
|
103
|
+
role: str # "user" | "assistant"
|
|
104
|
+
content: Any # {"text": "..."} etc.
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
# Parsing helpers
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
def _parse_sender(raw: dict) -> EventSender:
|
|
112
|
+
return EventSender(user_id=raw.get("user_id", ""))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _parse_message(raw: dict) -> EventMessage:
|
|
116
|
+
return EventMessage(
|
|
117
|
+
message_id=raw.get("message_id", ""),
|
|
118
|
+
conversation_id=raw.get("conversation_id", ""),
|
|
119
|
+
session_type=raw.get("session_type", ""),
|
|
120
|
+
msg_type=raw.get("msg_type", ""),
|
|
121
|
+
content=raw.get("content", {}),
|
|
122
|
+
send_time=raw.get("send_time", ""),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def parse_event(raw: dict) -> AgentEvent:
|
|
127
|
+
"""Parse a raw JSON dict into an :class:`AgentEvent`."""
|
|
128
|
+
header_raw = raw.get("header", {})
|
|
129
|
+
header = EventHeader(
|
|
130
|
+
event_id=header_raw.get("event_id", ""),
|
|
131
|
+
event_type=header_raw.get("event_type", ""),
|
|
132
|
+
app_id=header_raw.get("app_id", ""),
|
|
133
|
+
create_time=header_raw.get("create_time", ""),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
event_raw = raw.get("event", {})
|
|
137
|
+
event_type = header.event_type
|
|
138
|
+
|
|
139
|
+
if event_type == "im.message.group_at":
|
|
140
|
+
event: MessageEvent | GroupAtEvent = GroupAtEvent(
|
|
141
|
+
group_id=event_raw.get("group_id", ""),
|
|
142
|
+
at_user_id=event_raw.get("at_user_id", ""),
|
|
143
|
+
sender=_parse_sender(event_raw.get("sender", {})),
|
|
144
|
+
message=_parse_message(event_raw.get("message", {})),
|
|
145
|
+
)
|
|
146
|
+
else:
|
|
147
|
+
event = MessageEvent(
|
|
148
|
+
sender=_parse_sender(event_raw.get("sender", {})),
|
|
149
|
+
message=_parse_message(event_raw.get("message", {})),
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
return AgentEvent(
|
|
153
|
+
schema=raw.get("schema", ""),
|
|
154
|
+
header=header,
|
|
155
|
+
event=event,
|
|
156
|
+
)
|
miti_agent_sdk/stream.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Helpers for MessageStreamFull (contentType 125) outbound messages."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def build_stream_full_markdown(
|
|
10
|
+
markdown: str,
|
|
11
|
+
ask_msg_id: str = "",
|
|
12
|
+
*,
|
|
13
|
+
seq: int = 1,
|
|
14
|
+
) -> dict[str, Any]:
|
|
15
|
+
"""Build Agent API ``stream_full`` content for a single Markdown reply.
|
|
16
|
+
|
|
17
|
+
The server converts this to IM ``MessageStreamFull`` (125), which the
|
|
18
|
+
Miti App renders via ``ChatStream`` + Markdown.
|
|
19
|
+
|
|
20
|
+
Parameters
|
|
21
|
+
----------
|
|
22
|
+
markdown:
|
|
23
|
+
Full Markdown body (not incremental).
|
|
24
|
+
ask_msg_id:
|
|
25
|
+
Server message ID of the user's question; links reply to the ask
|
|
26
|
+
bubble in the App UI. Pass ``event.event.message.message_id`` from
|
|
27
|
+
an inbound event when available.
|
|
28
|
+
seq:
|
|
29
|
+
Event sequence number inside ``content_list`` (default 1).
|
|
30
|
+
"""
|
|
31
|
+
delta_payload = json.dumps(
|
|
32
|
+
{"markdown": {"delta": markdown}},
|
|
33
|
+
ensure_ascii=False,
|
|
34
|
+
)
|
|
35
|
+
content: dict[str, Any] = {
|
|
36
|
+
"content_list": [
|
|
37
|
+
{
|
|
38
|
+
"event": "delta",
|
|
39
|
+
"seq": seq,
|
|
40
|
+
"content": delta_payload,
|
|
41
|
+
"desc": {
|
|
42
|
+
"isIncrease": False,
|
|
43
|
+
"isCompleted": True,
|
|
44
|
+
"isShow": True,
|
|
45
|
+
"history": True,
|
|
46
|
+
"showType": "markdown",
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
],
|
|
50
|
+
}
|
|
51
|
+
if ask_msg_id:
|
|
52
|
+
content["ask_msg_id"] = ask_msg_id
|
|
53
|
+
return content
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: miti-agent-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Miti Agent SDK – connect third-party agents to Miti IM
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: aiohttp>=3.9
|
|
9
|
+
Requires-Dist: websockets>=12.0
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
12
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
13
|
+
|
|
14
|
+
# miti-agent-sdk
|
|
15
|
+
|
|
16
|
+
Connect third-party agents (Hermes, OpenClaw, Claude Code, etc.) to the Miti IM platform.
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install miti-agent-sdk
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Quick Start
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from miti_agent_sdk import MitiAgent
|
|
28
|
+
|
|
29
|
+
agent = MitiAgent(
|
|
30
|
+
app_id="cli_xxx",
|
|
31
|
+
app_secret="secret_xxx",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# 单聊消息
|
|
35
|
+
@agent.on_message
|
|
36
|
+
async def handle(event):
|
|
37
|
+
user_text = event.event.message.content.get("text", "")
|
|
38
|
+
await event.reply(f"收到:{user_text}")
|
|
39
|
+
|
|
40
|
+
# 群 @ 消息
|
|
41
|
+
@agent.on_group_at
|
|
42
|
+
async def handle_group(event):
|
|
43
|
+
text = event.event.message.content.get("text", "")
|
|
44
|
+
await event.reply(f"群收到:{text}")
|
|
45
|
+
|
|
46
|
+
agent.run()
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
SDK 内部自动处理:Token 获取/刷新、WebSocket 保活、指数退避重连。
|
|
50
|
+
|
|
51
|
+
## 环境配置
|
|
52
|
+
|
|
53
|
+
`base_url` 按以下优先级决定(先找到先用):
|
|
54
|
+
|
|
55
|
+
| 优先级 | 来源 | 示例 |
|
|
56
|
+
|--------|------|------|
|
|
57
|
+
| 1(最高) | 构造函数 `base_url=` 参数 | `MitiAgent(base_url="https://...")` |
|
|
58
|
+
| 2 | 环境变量 `MITI_API_BASE_URL` | `export MITI_API_BASE_URL="https://..."` |
|
|
59
|
+
| 3(默认) | 正式环境 | `https://www.miti.chat/chat` |
|
|
60
|
+
|
|
61
|
+
大多数场景下**不需要配置任何东西**,SDK 自动连接正式环境。
|
|
62
|
+
|
|
63
|
+
本地开发调试时,通过环境变量指向本地 appserver 即可,无需改业务代码:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
export MITI_API_BASE_URL="http://localhost:10006/chat"
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
也可以在初始化时显式传入:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
agent = MitiAgent(
|
|
73
|
+
app_id="cli_xxx",
|
|
74
|
+
app_secret="secret_xxx",
|
|
75
|
+
base_url="http://localhost:10006/chat",
|
|
76
|
+
)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## 拉取历史记录
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
@agent.on_message
|
|
83
|
+
async def handle(event):
|
|
84
|
+
history = await agent.get_history(
|
|
85
|
+
event.event.message.conversation_id, limit=10
|
|
86
|
+
)
|
|
87
|
+
# history: list[HistoryMessage] each has .role and .content
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## 日志 & 问题排查
|
|
91
|
+
|
|
92
|
+
SDK 使用 Python 标准 `logging` 模块,logger 层级为 `miti_agent_sdk.*`。
|
|
93
|
+
|
|
94
|
+
### 快速开启日志
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
import miti_agent_sdk
|
|
98
|
+
|
|
99
|
+
# INFO 级别:token 获取/刷新、WebSocket 连接/断开/重连、事件收发、消息发送
|
|
100
|
+
miti_agent_sdk.enable_logging()
|
|
101
|
+
|
|
102
|
+
# DEBUG 级别:额外输出每次 HTTP 请求 URL/参数、自动刷新 sleep 倒计时
|
|
103
|
+
miti_agent_sdk.enable_logging(logging.DEBUG)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
日志输出到 **stderr**(默认),格式示例:
|
|
107
|
+
|
|
108
|
+
```
|
|
109
|
+
2026-06-18 18:00:01 [miti_agent_sdk.auth] INFO token acquired, expires_in=7200s
|
|
110
|
+
2026-06-18 18:00:01 [miti_agent_sdk.gateway] INFO connecting to wss://www.miti.chat/chat/agent/gateway
|
|
111
|
+
2026-06-18 18:00:02 [miti_agent_sdk.gateway] INFO gateway connected
|
|
112
|
+
2026-06-18 18:00:05 [miti_agent_sdk.gateway] INFO event received: type=im.message.receive id=evt-xxx
|
|
113
|
+
2026-06-18 18:00:05 [miti_agent_sdk.message] INFO send_message success: target=user-abc message_id=msg-xxx
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### 日志存储
|
|
117
|
+
|
|
118
|
+
SDK 本身不写日志文件。日志的最终存储取决于宿主进程:
|
|
119
|
+
|
|
120
|
+
| 方式 | 配置 | 适用场景 |
|
|
121
|
+
|------|------|----------|
|
|
122
|
+
| 控制台 | `miti_agent_sdk.enable_logging()` | 本地开发调试 |
|
|
123
|
+
| 文件 | `logging.basicConfig(filename="agent.log", level=logging.INFO)` | 单机部署 |
|
|
124
|
+
| 结构化日志 | 宿主框架的 JSON handler(如 `python-json-logger`) | 生产环境 / 接入 ELK 等 |
|
|
125
|
+
| 容器 stdout | 不做额外配置,`enable_logging()` 输出到 stderr 即可 | Docker / K8s |
|
|
126
|
+
|
|
127
|
+
### 排查常见问题
|
|
128
|
+
|
|
129
|
+
| 现象 | 排查方向 | 关键日志 |
|
|
130
|
+
|------|----------|----------|
|
|
131
|
+
| 启动后无反应 | Token 获取失败 | `auth token API error: code=... msg=...` |
|
|
132
|
+
| 连接后收不到消息 | WebSocket 断开或无事件推送 | `gateway connection lost` / `no handler for event_type` |
|
|
133
|
+
| 回复消息失败 | 权限不足或目标不存在 | `send_message API error: code=10012 msg=no permission` |
|
|
134
|
+
| Token 过期后断连 | 自动刷新失败 | `auto-refresh failed, will retry in ...s` |
|
|
135
|
+
| WebSocket 频繁重连 | 网络不稳或服务端问题 | `reconnecting in Xs`(观察退避时间是否递增) |
|
|
136
|
+
|
|
137
|
+
## 安全注意事项
|
|
138
|
+
|
|
139
|
+
### 日志安全
|
|
140
|
+
|
|
141
|
+
SDK 自身**不会**在日志中输出 `app_secret` 或 `access_token`。但如果你开启了
|
|
142
|
+
`aiohttp` 的 trace/debug 日志(如 `AIOHTTP_TRACE=1`),HTTP 请求体会被底层框架
|
|
143
|
+
打印出来,其中包含 `app_secret`。**生产环境请勿开启 aiohttp trace 日志。**
|
|
144
|
+
|
|
145
|
+
### 凭证内存存储
|
|
146
|
+
|
|
147
|
+
`app_secret` 和 `access_token` 在 SDK 运行期间以明文存储在进程内存中。
|
|
148
|
+
这是 Python SDK 的通用模式(与飞书 SDK、Slack SDK 一致),无法避免。
|
|
149
|
+
请确保:
|
|
150
|
+
|
|
151
|
+
- 不要在多租户共享环境中运行 agent 进程
|
|
152
|
+
- 不要将进程 memory dump 输出到不安全的位置
|
|
153
|
+
- 进程退出后凭证自动释放,无需手动清理
|
|
154
|
+
|
|
155
|
+
### TLS 要求
|
|
156
|
+
|
|
157
|
+
SDK 强制要求 `base_url` 使用 `https://`(对应 WebSocket `wss://`)。
|
|
158
|
+
本地开发调试时 `localhost` / `127.0.0.1` 可使用 `http://`,其他地址会被拒绝。
|
|
159
|
+
|
|
160
|
+
## Requirements
|
|
161
|
+
|
|
162
|
+
- Python 3.9+
|
|
163
|
+
- `aiohttp >= 3.9`
|
|
164
|
+
- `websockets >= 12.0`
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
miti_agent_sdk/__init__.py,sha256=-dObqlDNi50TLh6YaWMsuHKqiy41K-IsUcrcEeesKS8,1934
|
|
2
|
+
miti_agent_sdk/agent.py,sha256=l9kbsuhTmtdyoMwS0gkPm_ckmgBnqKM4QeyRrRfvYVc,8878
|
|
3
|
+
miti_agent_sdk/auth.py,sha256=5L-eUFORU88wpHESUSmAAXt2o16OhIFn7XQGL4IpORA,5814
|
|
4
|
+
miti_agent_sdk/gateway.py,sha256=p-0T9eNVxZf6j6Uh-mxU7Sbts3i6gx-FxS5clB0UnJI,5431
|
|
5
|
+
miti_agent_sdk/message.py,sha256=B-eI0WOvp_uLLnautnSK1FnJhzBep7Mqa88M2IpUETA,6615
|
|
6
|
+
miti_agent_sdk/models.py,sha256=UnAFZxUVJ7X7v3whhjXVfdidP4KW2UTpsx0O236g3Qk,4637
|
|
7
|
+
miti_agent_sdk/stream.py,sha256=JWbuyQTnd3teU6gt06BZwAjeWrcUDG2dQAWRlgakm6A,1495
|
|
8
|
+
miti_agent_sdk-0.1.0.dist-info/METADATA,sha256=GPwlTMwZ7bDhkmAkDp6uBByaBTkCjGT5-okXMogkAvw,5275
|
|
9
|
+
miti_agent_sdk-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
10
|
+
miti_agent_sdk-0.1.0.dist-info/top_level.txt,sha256=qJCgD-F4MvjqxZovxzd_iN6fTCQjWQbVbLn_YuANi4k,15
|
|
11
|
+
miti_agent_sdk-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
miti_agent_sdk
|