@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.
- package/LICENSE +661 -0
- package/README.md +157 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +477 -0
- package/dist/index.cjs +293 -0
- package/dist/index.d.cts +35 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +267 -0
- package/hermes-shadowob-plugin/README.md +176 -0
- package/hermes-shadowob-plugin/__init__.py +8 -0
- package/hermes-shadowob-plugin/adapter.py +1572 -0
- package/hermes-shadowob-plugin/plugin.yaml +84 -0
- package/hermes-shadowob-plugin/requirements.txt +2 -0
- package/hermes-shadowob-plugin/shadow_sdk.py +479 -0
- package/hermes-shadowob-plugin/tests/test_adapter_env.py +159 -0
- package/hermes-shadowob-plugin/tests/test_shadow_sdk_helpers.py +25 -0
- package/package.json +57 -0
|
@@ -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,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)
|