@shadowob/connector 1.1.3-dev.251

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.
@@ -0,0 +1,84 @@
1
+ name: shadowob
2
+ label: Shadow
3
+ kind: platform
4
+ version: 0.2.0
5
+ description: Shadow/OpenClaw Buddy messaging platform adapter for Hermes Agent.
6
+ author: Shadow/OpenClaw
7
+ requires_env:
8
+ - name: SHADOW_BASE_URL
9
+ description: "Shadow server base URL, for example https://shadowob.example.com"
10
+ prompt: "Shadow base URL"
11
+ password: false
12
+ - name: SHADOW_TOKEN
13
+ description: "Shadow bot/user access token"
14
+ prompt: "Shadow access token"
15
+ password: true
16
+ optional_env:
17
+ - name: SHADOW_CHANNEL_IDS
18
+ description: "Advanced override: comma-separated channel IDs to join. Normally omitted because Shadow policy is discovered dynamically."
19
+ prompt: "Shadow channel IDs"
20
+ password: false
21
+ - name: SHADOW_HOME_CHANNEL
22
+ description: "Default Shadow channel ID used by cron/send_message delivery."
23
+ prompt: "Shadow home channel"
24
+ password: false
25
+ - name: SHADOW_AGENT_ID
26
+ description: "Advanced override: Shadow Buddy agent ID. Normally resolved from /api/auth/me."
27
+ prompt: "Shadow Buddy agent ID"
28
+ password: false
29
+ - name: SHADOW_HEARTBEAT_INTERVAL_SECONDS
30
+ description: "Agent heartbeat interval in seconds. Defaults to 30."
31
+ prompt: "Heartbeat interval"
32
+ password: false
33
+ - name: SHADOW_SLASH_COMMANDS_JSON
34
+ description: "JSON array of slash command definitions to register on startup."
35
+ prompt: "Slash commands JSON"
36
+ password: false
37
+ - name: SHADOW_SERVER_IDS
38
+ description: "Advanced override: comma-separated server IDs/slugs to discover manually."
39
+ prompt: "Shadow server IDs"
40
+ password: false
41
+ - name: SHADOW_AUTO_DISCOVER_CHANNELS
42
+ description: "Advanced override: true/false. Discover accessible server channels without the Buddy remote policy."
43
+ prompt: "Auto-discover channels?"
44
+ password: false
45
+ - name: SHADOW_ALLOWED_USERS
46
+ description: "Comma-separated allowed Shadow user IDs/usernames for Hermes gateway authorization."
47
+ prompt: "Allowed Shadow users"
48
+ password: false
49
+ - name: SHADOW_ALLOW_ALL_USERS
50
+ description: "true/false. Allow all Shadow users."
51
+ prompt: "Allow all Shadow users?"
52
+ password: false
53
+ - name: SHADOW_BOT_USER_ID
54
+ description: "Optional bot user ID. If omitted, adapter calls /api/auth/me."
55
+ prompt: "Shadow bot user ID"
56
+ password: false
57
+ - name: SHADOW_BOT_USERNAME
58
+ description: "Optional bot username for mention-only filtering."
59
+ prompt: "Shadow bot username"
60
+ password: false
61
+ - name: SHADOW_MENTION_ONLY
62
+ description: "true/false. In group channels, only process messages that mention the bot."
63
+ prompt: "Mention-only mode?"
64
+ password: false
65
+ - name: SHADOW_REPLY_TO_BOTS
66
+ description: "true/false. Process messages authored by other bot users. Defaults false."
67
+ prompt: "Reply to bot users?"
68
+ password: false
69
+ - name: SHADOW_REST_ONLY
70
+ description: "true/false. Use REST polling instead of Socket.IO."
71
+ prompt: "REST-only mode?"
72
+ password: false
73
+ - name: SHADOW_POLL_INTERVAL_SECONDS
74
+ description: "REST polling interval in seconds. Defaults to 3."
75
+ prompt: "Poll interval"
76
+ password: false
77
+ - name: SHADOW_CATCHUP_MINUTES
78
+ description: "On startup, process recent messages newer than this window. Defaults to 0."
79
+ prompt: "Catch-up minutes"
80
+ password: false
81
+ - name: SHADOW_DOWNLOAD_MEDIA
82
+ description: "true/false. Download inbound attachments into Hermes cache. Defaults true."
83
+ prompt: "Download media?"
84
+ password: false
@@ -0,0 +1,2 @@
1
+ httpx>=0.27.0
2
+ python-socketio[asyncio_client]>=5.11.0
@@ -0,0 +1,479 @@
1
+ """Minimal async Shadow SDK used by the Hermes Shadow platform plugin.
2
+
3
+ The repository snapshot uploaded with this task contains an empty
4
+ ``packages/sdk-python`` directory, so this module implements the subset of the
5
+ TypeScript SDK that the Hermes adapter needs. It intentionally mirrors the TS
6
+ client method names where practical.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import json
13
+ import mimetypes
14
+ import os
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ from typing import Any, Awaitable, Callable, Iterable
18
+ from urllib.parse import quote, urljoin, urlparse
19
+
20
+ import httpx
21
+
22
+ JsonDict = dict[str, Any]
23
+ EventHandler = Callable[..., Any]
24
+
25
+
26
+ class ShadowApiError(RuntimeError):
27
+ """Raised when the Shadow REST API returns a non-2xx response."""
28
+
29
+ def __init__(self, method: str, path: str, status_code: int, body: str):
30
+ body = _sanitize_body(body)
31
+ super().__init__(f"Shadow API {method} {path} failed ({status_code}): {body}")
32
+ self.method = method
33
+ self.path = path
34
+ self.status_code = status_code
35
+ self.body = body
36
+
37
+
38
+ def _sanitize_body(body: str, limit: int = 600) -> str:
39
+ if not body:
40
+ return "(empty response)"
41
+ text = body.strip()
42
+ # Keep JSON readable and strip common HTML error pages to their text/title.
43
+ if text.startswith("{") or text.startswith("["):
44
+ try:
45
+ return json.dumps(json.loads(text), ensure_ascii=False)[:limit]
46
+ except Exception:
47
+ return text[:limit]
48
+ if "<" in text and ">" in text:
49
+ import re
50
+
51
+ title = re.search(r"<title>(.*?)</title>", text, flags=re.I | re.S)
52
+ if title:
53
+ return re.sub(r"\s+", " ", title.group(1)).strip()[:limit]
54
+ text = re.sub(r"<[^>]+>", " ", text)
55
+ text = re.sub(r"\s+", " ", text).strip()
56
+ return text[:limit]
57
+
58
+
59
+ def normalize_base_url(value: str) -> str:
60
+ value = (value or "").strip().rstrip("/")
61
+ if value.endswith("/api"):
62
+ value = value[:-4]
63
+ if not value:
64
+ raise ValueError("Shadow base URL is required")
65
+ return value
66
+
67
+
68
+ def content_disposition_filename(header: str | None) -> str | None:
69
+ if not header:
70
+ return None
71
+ import re
72
+ from urllib.parse import unquote
73
+
74
+ match = re.search(r"filename\*=UTF-8''([^;]+)", header, flags=re.I)
75
+ if match:
76
+ try:
77
+ return unquote(match.group(1).strip())
78
+ except Exception:
79
+ return match.group(1).strip()
80
+ match = re.search(r'filename="([^"]+)"', header, flags=re.I)
81
+ if match:
82
+ return match.group(1)
83
+ match = re.search(r"filename=([^;]+)", header, flags=re.I)
84
+ if match:
85
+ return match.group(1).strip()
86
+ return None
87
+
88
+
89
+ def infer_content_type(filename: str, fallback: str = "application/octet-stream") -> str:
90
+ guessed, _ = mimetypes.guess_type(filename)
91
+ return guessed or fallback
92
+
93
+
94
+ def parse_bool(value: Any, default: bool = False) -> bool:
95
+ if value is None:
96
+ return default
97
+ if isinstance(value, bool):
98
+ return value
99
+ if isinstance(value, (int, float)):
100
+ return bool(value)
101
+ text = str(value).strip().lower()
102
+ if text in {"1", "true", "yes", "y", "on"}:
103
+ return True
104
+ if text in {"0", "false", "no", "n", "off"}:
105
+ return False
106
+ return default
107
+
108
+
109
+ def split_csv(value: Any) -> list[str]:
110
+ if value is None:
111
+ return []
112
+ if isinstance(value, (list, tuple, set)):
113
+ return [str(v).strip() for v in value if str(v).strip()]
114
+ return [part.strip() for part in str(value).replace("\n", ",").split(",") if part.strip()]
115
+
116
+
117
+ @dataclass(slots=True)
118
+ class DownloadedFile:
119
+ data: bytes
120
+ filename: str
121
+ content_type: str
122
+
123
+
124
+ class ShadowAsyncClient:
125
+ """Small async REST client for Shadow.
126
+
127
+ It covers the gateway adapter path: auth/me, channel discovery, message
128
+ send/read/edit/delete, reactions, media upload/download, and thread sends.
129
+ """
130
+
131
+ def __init__(self, base_url: str, token: str, *, timeout: float = 60.0):
132
+ self.base_url = normalize_base_url(base_url)
133
+ self.token = token
134
+ self.timeout = timeout
135
+ self._client: httpx.AsyncClient | None = None
136
+
137
+ async def __aenter__(self) -> "ShadowAsyncClient":
138
+ await self.open()
139
+ return self
140
+
141
+ async def __aexit__(self, *exc: object) -> None:
142
+ await self.close()
143
+
144
+ async def open(self) -> None:
145
+ if self._client is None:
146
+ self._client = httpx.AsyncClient(timeout=self.timeout, follow_redirects=True)
147
+
148
+ async def close(self) -> None:
149
+ if self._client is not None:
150
+ await self._client.aclose()
151
+ self._client = None
152
+
153
+ @property
154
+ def client(self) -> httpx.AsyncClient:
155
+ if self._client is None:
156
+ self._client = httpx.AsyncClient(timeout=self.timeout, follow_redirects=True)
157
+ return self._client
158
+
159
+ def _headers(self, *, json_content: bool = True) -> dict[str, str]:
160
+ headers = {"Authorization": f"Bearer {self.token}"}
161
+ if json_content:
162
+ headers["Content-Type"] = "application/json"
163
+ return headers
164
+
165
+ def _url(self, path_or_url: str) -> str:
166
+ if path_or_url.startswith("http://") or path_or_url.startswith("https://"):
167
+ return path_or_url
168
+ if not path_or_url.startswith("/"):
169
+ path_or_url = "/" + path_or_url
170
+ return self.base_url + path_or_url
171
+
172
+ async def request(
173
+ self,
174
+ method: str,
175
+ path: str,
176
+ *,
177
+ json_body: Any | None = None,
178
+ params: dict[str, Any] | None = None,
179
+ ) -> Any:
180
+ await self.open()
181
+ response = await self.client.request(
182
+ method.upper(),
183
+ self._url(path),
184
+ headers=self._headers(json_content=True),
185
+ json=json_body,
186
+ params=params,
187
+ )
188
+ if response.status_code < 200 or response.status_code >= 300:
189
+ raise ShadowApiError(method.upper(), path, response.status_code, response.text)
190
+ if response.status_code == 204 or not response.content:
191
+ return None
192
+ payload = response.json()
193
+ if isinstance(payload, dict) and "ok" in payload and "success" not in payload:
194
+ payload = {**payload, "success": bool(payload.get("ok"))}
195
+ return payload
196
+
197
+ async def get_me(self) -> JsonDict:
198
+ return await self.request("GET", "/api/auth/me")
199
+
200
+ async def heartbeat_agent(self, agent_id: str) -> JsonDict:
201
+ return await self.request(
202
+ "POST",
203
+ f"/api/agents/{quote(str(agent_id), safe='')}/heartbeat",
204
+ )
205
+
206
+ async def update_agent_slash_commands(self, agent_id: str, commands: list[JsonDict]) -> JsonDict:
207
+ return await self.request(
208
+ "PUT",
209
+ f"/api/agents/{quote(str(agent_id), safe='')}/slash-commands",
210
+ json_body={"commands": commands},
211
+ )
212
+
213
+ async def get_agent_slash_commands(self, agent_id: str) -> JsonDict:
214
+ return await self.request(
215
+ "GET",
216
+ f"/api/agents/{quote(str(agent_id), safe='')}/slash-commands",
217
+ )
218
+
219
+ async def get_agent_config(self, agent_id: str) -> JsonDict:
220
+ return await self.request(
221
+ "GET",
222
+ f"/api/agents/{quote(str(agent_id), safe='')}/config",
223
+ )
224
+
225
+ async def list_servers(self) -> list[JsonDict]:
226
+ return await self.request("GET", "/api/servers")
227
+
228
+ async def get_server_channels(self, server_id_or_slug: str) -> list[JsonDict]:
229
+ return await self.request("GET", f"/api/servers/{quote(str(server_id_or_slug), safe='')}/channels")
230
+
231
+ async def list_direct_channels(self) -> list[JsonDict]:
232
+ return await self.request("GET", "/api/channels/dm")
233
+
234
+ async def get_channel(self, channel_id: str) -> JsonDict:
235
+ return await self.request("GET", f"/api/channels/{quote(str(channel_id), safe='')}")
236
+
237
+ async def get_messages(
238
+ self,
239
+ channel_id: str,
240
+ *,
241
+ limit: int = 50,
242
+ cursor: str | None = None,
243
+ ) -> dict[str, Any]:
244
+ params: dict[str, Any] = {"limit": int(limit)}
245
+ if cursor:
246
+ params["cursor"] = cursor
247
+ payload = await self.request(
248
+ "GET",
249
+ f"/api/channels/{quote(str(channel_id), safe='')}/messages",
250
+ params=params,
251
+ )
252
+ if isinstance(payload, list):
253
+ return {"messages": payload, "hasMore": False}
254
+ return payload
255
+
256
+ async def get_message(self, message_id: str) -> JsonDict:
257
+ return await self.request("GET", f"/api/messages/{quote(str(message_id), safe='')}")
258
+
259
+ async def resolve_attachment_media_url(self, attachment_id: str, *, disposition: str = "inline") -> JsonDict:
260
+ return await self.request(
261
+ "GET",
262
+ f"/api/attachments/{quote(str(attachment_id), safe='')}/media-url",
263
+ params={"disposition": disposition},
264
+ )
265
+
266
+ async def send_message(
267
+ self,
268
+ channel_id: str,
269
+ content: str,
270
+ *,
271
+ thread_id: str | None = None,
272
+ reply_to_id: str | None = None,
273
+ mentions: list[JsonDict] | None = None,
274
+ metadata: JsonDict | None = None,
275
+ attachments: list[JsonDict] | None = None,
276
+ ) -> JsonDict:
277
+ body: JsonDict = {"content": content}
278
+ if thread_id:
279
+ body["threadId"] = thread_id
280
+ if reply_to_id:
281
+ body["replyToId"] = reply_to_id
282
+ if mentions:
283
+ body["mentions"] = mentions
284
+ if metadata:
285
+ body["metadata"] = metadata
286
+ if attachments:
287
+ body["attachments"] = attachments
288
+ return await self.request(
289
+ "POST",
290
+ f"/api/channels/{quote(str(channel_id), safe='')}/messages",
291
+ json_body=body,
292
+ )
293
+
294
+ async def send_to_thread(
295
+ self,
296
+ thread_id: str,
297
+ content: str,
298
+ *,
299
+ reply_to_id: str | None = None,
300
+ mentions: list[JsonDict] | None = None,
301
+ metadata: JsonDict | None = None,
302
+ ) -> JsonDict:
303
+ body: JsonDict = {"content": content}
304
+ if reply_to_id:
305
+ body["replyToId"] = reply_to_id
306
+ if mentions:
307
+ body["mentions"] = mentions
308
+ if metadata:
309
+ body["metadata"] = metadata
310
+ return await self.request(
311
+ "POST",
312
+ f"/api/threads/{quote(str(thread_id), safe='')}/messages",
313
+ json_body=body,
314
+ )
315
+
316
+ async def edit_message(self, message_id: str, content: str) -> JsonDict:
317
+ return await self.request(
318
+ "PATCH",
319
+ f"/api/messages/{quote(str(message_id), safe='')}",
320
+ json_body={"content": content},
321
+ )
322
+
323
+ async def delete_message(self, message_id: str) -> None:
324
+ await self.request("DELETE", f"/api/messages/{quote(str(message_id), safe='')}")
325
+
326
+ async def add_reaction(self, message_id: str, emoji: str) -> None:
327
+ await self.request(
328
+ "POST",
329
+ f"/api/messages/{quote(str(message_id), safe='')}/reactions",
330
+ json_body={"emoji": emoji},
331
+ )
332
+
333
+ async def remove_reaction(self, message_id: str, emoji: str) -> None:
334
+ await self.request(
335
+ "DELETE",
336
+ f"/api/messages/{quote(str(message_id), safe='')}/reactions/{quote(str(emoji), safe='')}",
337
+ )
338
+
339
+ async def upload_media(
340
+ self,
341
+ data: bytes,
342
+ filename: str,
343
+ content_type: str | None = None,
344
+ *,
345
+ message_id: str | None = None,
346
+ ) -> JsonDict:
347
+ await self.open()
348
+ content_type = content_type or infer_content_type(filename)
349
+ files = {"file": (filename, data, content_type)}
350
+ form_data: dict[str, str] = {}
351
+ if message_id:
352
+ form_data["messageId"] = message_id
353
+ response = await self.client.post(
354
+ self._url("/api/media/upload"),
355
+ headers=self._headers(json_content=False),
356
+ files=files,
357
+ data=form_data,
358
+ )
359
+ if response.status_code < 200 or response.status_code >= 300:
360
+ raise ShadowApiError("POST", "/api/media/upload", response.status_code, response.text)
361
+ return response.json()
362
+
363
+ async def upload_media_from_path(self, path: str | os.PathLike[str], *, message_id: str | None = None) -> JsonDict:
364
+ p = Path(path).expanduser()
365
+ data = p.read_bytes()
366
+ return await self.upload_media(
367
+ data,
368
+ p.name,
369
+ infer_content_type(p.name),
370
+ message_id=message_id,
371
+ )
372
+
373
+ async def upload_media_from_url(self, url_or_path: str, *, message_id: str | None = None) -> JsonDict:
374
+ value = str(url_or_path).strip()
375
+ if value.upper().startswith("MEDIA:"):
376
+ value = value.split(":", 1)[1].strip()
377
+ if value.startswith("file://"):
378
+ value = value[7:]
379
+ if value.startswith("~") or value.startswith("/") or not urlparse(value).scheme:
380
+ return await self.upload_media_from_path(value, message_id=message_id)
381
+ downloaded = await self.download_file(value)
382
+ return await self.upload_media(
383
+ downloaded.data,
384
+ downloaded.filename,
385
+ downloaded.content_type,
386
+ message_id=message_id,
387
+ )
388
+
389
+ async def download_file(self, file_url: str) -> DownloadedFile:
390
+ await self.open()
391
+ headers: dict[str, str] = {}
392
+ full_url = file_url
393
+ if file_url.startswith("/"):
394
+ full_url = self.base_url + file_url
395
+ headers["Authorization"] = f"Bearer {self.token}"
396
+ elif file_url.startswith(self.base_url):
397
+ headers["Authorization"] = f"Bearer {self.token}"
398
+ response = await self.client.get(full_url, headers=headers, follow_redirects=True)
399
+ if response.status_code < 200 or response.status_code >= 300:
400
+ raise ShadowApiError("GET", file_url, response.status_code, response.text)
401
+ content_type = response.headers.get("content-type") or "application/octet-stream"
402
+ filename = content_disposition_filename(response.headers.get("content-disposition"))
403
+ if not filename:
404
+ parsed_path = urlparse(str(response.url)).path or urlparse(full_url).path
405
+ filename = os.path.basename(parsed_path) or "file"
406
+ return DownloadedFile(response.content, filename, content_type)
407
+
408
+
409
+ class ShadowSocketClient:
410
+ """Async Socket.IO wrapper for Shadow realtime events."""
411
+
412
+ def __init__(
413
+ self,
414
+ base_url: str,
415
+ token: str,
416
+ *,
417
+ transports: Iterable[str] | None = None,
418
+ reconnection: bool = True,
419
+ logger: Any | None = None,
420
+ ):
421
+ try:
422
+ import socketio # type: ignore
423
+ except Exception as exc: # pragma: no cover - depends on optional dependency
424
+ raise RuntimeError(
425
+ "python-socketio is required for Shadow realtime mode. "
426
+ "Install requirements.txt or set SHADOW_REST_ONLY=true."
427
+ ) from exc
428
+
429
+ self.base_url = normalize_base_url(base_url)
430
+ self.token = token
431
+ self.transports = list(transports or ["websocket"])
432
+ self.logger = logger
433
+ self.sio = socketio.AsyncClient(
434
+ reconnection=reconnection,
435
+ logger=False,
436
+ engineio_logger=False,
437
+ )
438
+
439
+ @property
440
+ def connected(self) -> bool:
441
+ return bool(getattr(self.sio, "connected", False))
442
+
443
+ def on(self, event: str, handler: EventHandler) -> None:
444
+ self.sio.on(event, handler=handler)
445
+
446
+ async def connect(self) -> None:
447
+ await self.sio.connect(
448
+ self.base_url,
449
+ auth={"token": self.token},
450
+ transports=self.transports,
451
+ wait_timeout=10,
452
+ )
453
+
454
+ async def disconnect(self) -> None:
455
+ if self.connected:
456
+ await self.sio.disconnect()
457
+
458
+ async def join_channel(self, channel_id: str) -> Any:
459
+ try:
460
+ return await self.sio.call("channel:join", {"channelId": channel_id}, timeout=10)
461
+ except Exception:
462
+ # Some Socket.IO servers may not ack. Fall back to fire-and-forget.
463
+ await self.sio.emit("channel:join", {"channelId": channel_id})
464
+ return {"ok": True}
465
+
466
+ async def leave_channel(self, channel_id: str) -> None:
467
+ await self.sio.emit("channel:leave", {"channelId": channel_id})
468
+
469
+ async def send_typing(self, channel_id: str, typing: bool = True) -> None:
470
+ await self.sio.emit("message:typing", {"channelId": channel_id, "typing": typing})
471
+
472
+ async def update_presence(self, status: str) -> None:
473
+ await self.sio.emit("presence:update", {"status": status})
474
+
475
+ async def update_activity(self, channel_id: str, activity: str | None) -> None:
476
+ await self.sio.emit("presence:activity", {"channelId": channel_id, "activity": activity})
477
+
478
+ async def send_message(self, payload: JsonDict) -> None:
479
+ await self.sio.emit("message:send", payload)