@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,1572 @@
|
|
|
1
|
+
"""Hermes platform adapter for Shadow/OpenClaw Buddy.
|
|
2
|
+
|
|
3
|
+
Install this directory as a Hermes plugin, usually at:
|
|
4
|
+
|
|
5
|
+
~/.hermes/plugins/shadowob/
|
|
6
|
+
|
|
7
|
+
This first version targets the messaging gateway path: channel/direct/thread
|
|
8
|
+
messages, media attachments, typing/activity, edit/delete, reactions and cron
|
|
9
|
+
standalone sends. Shadow business surfaces such as marketplace, wallet, cloud
|
|
10
|
+
sandbox and workspace APIs should be exposed as separate Hermes tools/MCPs.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
from collections import deque
|
|
21
|
+
from datetime import datetime, timedelta, timezone
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any, Optional
|
|
24
|
+
|
|
25
|
+
try: # Hermes imports
|
|
26
|
+
from gateway.config import Platform, PlatformConfig
|
|
27
|
+
from gateway.platforms.base import (
|
|
28
|
+
BasePlatformAdapter,
|
|
29
|
+
MessageEvent,
|
|
30
|
+
MessageType,
|
|
31
|
+
SendResult,
|
|
32
|
+
cache_audio_from_bytes,
|
|
33
|
+
cache_document_from_bytes,
|
|
34
|
+
cache_image_from_bytes,
|
|
35
|
+
cache_video_from_bytes,
|
|
36
|
+
resolve_channel_prompt,
|
|
37
|
+
resolve_channel_skills,
|
|
38
|
+
)
|
|
39
|
+
except Exception: # pragma: no cover - lets local static checks import this file with stubs if needed.
|
|
40
|
+
Platform = None # type: ignore
|
|
41
|
+
PlatformConfig = object # type: ignore
|
|
42
|
+
BasePlatformAdapter = object # type: ignore
|
|
43
|
+
MessageEvent = object # type: ignore
|
|
44
|
+
MessageType = object # type: ignore
|
|
45
|
+
SendResult = object # type: ignore
|
|
46
|
+
cache_audio_from_bytes = None # type: ignore
|
|
47
|
+
cache_document_from_bytes = None # type: ignore
|
|
48
|
+
cache_image_from_bytes = None # type: ignore
|
|
49
|
+
cache_video_from_bytes = None # type: ignore
|
|
50
|
+
|
|
51
|
+
def resolve_channel_prompt(config_extra: dict, channel_id: str, parent_id: str | None = None):
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
def resolve_channel_skills(config_extra: dict, channel_id: str, parent_id: str | None = None):
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
from .shadow_sdk import ShadowAsyncClient, ShadowSocketClient, parse_bool, split_csv
|
|
59
|
+
except Exception: # pragma: no cover - Hermes may load adapter.py as a loose module.
|
|
60
|
+
from shadow_sdk import ShadowAsyncClient, ShadowSocketClient, parse_bool, split_csv # type: ignore
|
|
61
|
+
|
|
62
|
+
logger = logging.getLogger(__name__)
|
|
63
|
+
|
|
64
|
+
PLATFORM_NAME = "shadowob"
|
|
65
|
+
|
|
66
|
+
_IMAGE_CT_PREFIXES = ("image/",)
|
|
67
|
+
_AUDIO_CT_PREFIXES = ("audio/",)
|
|
68
|
+
_VIDEO_CT_PREFIXES = ("video/",)
|
|
69
|
+
_DOCUMENT_CT_PREFIXES = ("application/", "text/")
|
|
70
|
+
_SLASH_COMMAND_RE = re.compile(r"^/([a-zA-Z][a-zA-Z0-9._-]{0,63})(?:\s+([\s\S]*))?$")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _extra(config: Any) -> dict[str, Any]:
|
|
74
|
+
value = getattr(config, "extra", None)
|
|
75
|
+
return value if isinstance(value, dict) else {}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _cfg(config: Any, env_name: str, *extra_names: str, default: Any = None) -> Any:
|
|
79
|
+
env_value = os.getenv(env_name)
|
|
80
|
+
if env_value not in (None, ""):
|
|
81
|
+
return env_value
|
|
82
|
+
extra = _extra(config)
|
|
83
|
+
for name in extra_names:
|
|
84
|
+
if name in extra and extra[name] not in (None, ""):
|
|
85
|
+
return extra[name]
|
|
86
|
+
attr = env_name.lower().replace("shadow_", "")
|
|
87
|
+
if hasattr(config, attr):
|
|
88
|
+
value = getattr(config, attr)
|
|
89
|
+
if value not in (None, ""):
|
|
90
|
+
return value
|
|
91
|
+
return default
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _token_from_config(config: Any) -> str:
|
|
95
|
+
return str(
|
|
96
|
+
os.getenv("SHADOW_TOKEN")
|
|
97
|
+
or getattr(config, "token", None)
|
|
98
|
+
or getattr(config, "api_key", None)
|
|
99
|
+
or _extra(config).get("token")
|
|
100
|
+
or _extra(config).get("api_key")
|
|
101
|
+
or ""
|
|
102
|
+
).strip()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _base_url_from_config(config: Any) -> str:
|
|
106
|
+
return str(
|
|
107
|
+
os.getenv("SHADOW_BASE_URL")
|
|
108
|
+
or os.getenv("SHADOW_SERVER_URL")
|
|
109
|
+
or _extra(config).get("base_url")
|
|
110
|
+
or _extra(config).get("server_url")
|
|
111
|
+
or _extra(config).get("serverUrl")
|
|
112
|
+
or ""
|
|
113
|
+
).strip()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _home_channel_id(config: Any) -> str | None:
|
|
117
|
+
raw = os.getenv("SHADOW_HOME_CHANNEL") or _extra(config).get("home_channel")
|
|
118
|
+
if raw:
|
|
119
|
+
if isinstance(raw, dict):
|
|
120
|
+
return str(raw.get("chat_id") or raw.get("channel_id") or raw.get("id") or "") or None
|
|
121
|
+
return str(raw).strip() or None
|
|
122
|
+
hc = getattr(config, "home_channel", None)
|
|
123
|
+
if hc is not None:
|
|
124
|
+
value = getattr(hc, "chat_id", None)
|
|
125
|
+
if value:
|
|
126
|
+
return str(value)
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _channel_ids_from_config(config: Any) -> list[str]:
|
|
131
|
+
values: list[str] = []
|
|
132
|
+
values.extend(split_csv(os.getenv("SHADOW_CHANNEL_IDS")))
|
|
133
|
+
values.extend(split_csv(os.getenv("SHADOW_CHANNEL_ID")))
|
|
134
|
+
extra = _extra(config)
|
|
135
|
+
values.extend(split_csv(extra.get("channel_ids")))
|
|
136
|
+
values.extend(split_csv(extra.get("channels")))
|
|
137
|
+
values.extend(split_csv(extra.get("channel_id")))
|
|
138
|
+
home = _home_channel_id(config)
|
|
139
|
+
if home:
|
|
140
|
+
values.append(home)
|
|
141
|
+
seen: set[str] = set()
|
|
142
|
+
result: list[str] = []
|
|
143
|
+
for item in values:
|
|
144
|
+
if item and item not in seen:
|
|
145
|
+
seen.add(item)
|
|
146
|
+
result.append(item)
|
|
147
|
+
return result
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _server_ids_from_config(config: Any) -> list[str]:
|
|
151
|
+
values: list[str] = []
|
|
152
|
+
values.extend(split_csv(os.getenv("SHADOW_SERVER_IDS")))
|
|
153
|
+
extra = _extra(config)
|
|
154
|
+
values.extend(split_csv(extra.get("server_ids")))
|
|
155
|
+
values.extend(split_csv(extra.get("servers")))
|
|
156
|
+
values.extend(split_csv(extra.get("server_id")))
|
|
157
|
+
seen: set[str] = set()
|
|
158
|
+
result: list[str] = []
|
|
159
|
+
for item in values:
|
|
160
|
+
if item and item not in seen:
|
|
161
|
+
seen.add(item)
|
|
162
|
+
result.append(item)
|
|
163
|
+
return result
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _metadata_thread_id(metadata: dict[str, Any] | None) -> str | None:
|
|
167
|
+
if not metadata:
|
|
168
|
+
return None
|
|
169
|
+
for key in ("thread_id", "threadId", "shadow_thread_id"):
|
|
170
|
+
value = metadata.get(key)
|
|
171
|
+
if value not in (None, ""):
|
|
172
|
+
return str(value)
|
|
173
|
+
source = metadata.get("source")
|
|
174
|
+
if source is not None:
|
|
175
|
+
value = getattr(source, "thread_id", None)
|
|
176
|
+
if value:
|
|
177
|
+
return str(value)
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _metadata_reply_to(metadata: dict[str, Any] | None, fallback: str | None = None) -> str | None:
|
|
182
|
+
if not metadata:
|
|
183
|
+
return fallback
|
|
184
|
+
for key in ("reply_to_message_id", "replyToId", "reply_to", "shadow_reply_to_id"):
|
|
185
|
+
value = metadata.get(key)
|
|
186
|
+
if value not in (None, ""):
|
|
187
|
+
return str(value)
|
|
188
|
+
return fallback
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _metadata_payload(metadata: dict[str, Any] | None) -> dict[str, Any] | None:
|
|
192
|
+
if not metadata:
|
|
193
|
+
return None
|
|
194
|
+
raw = metadata.get("shadow_metadata") or metadata.get("metadata")
|
|
195
|
+
if isinstance(raw, dict):
|
|
196
|
+
return raw
|
|
197
|
+
forwarded: dict[str, Any] = {}
|
|
198
|
+
for key in ("interactive", "commerce", "commerceCard", "commerceCards", "commerceOfferId", "slashCommand"):
|
|
199
|
+
if key in metadata:
|
|
200
|
+
forwarded[key] = metadata[key]
|
|
201
|
+
return forwarded or None
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _parse_json_list(value: Any) -> list[dict[str, Any]]:
|
|
205
|
+
if value in (None, ""):
|
|
206
|
+
return []
|
|
207
|
+
if isinstance(value, list):
|
|
208
|
+
return [item for item in value if isinstance(item, dict)]
|
|
209
|
+
if isinstance(value, str):
|
|
210
|
+
try:
|
|
211
|
+
parsed = json.loads(value)
|
|
212
|
+
except Exception:
|
|
213
|
+
return []
|
|
214
|
+
if isinstance(parsed, list):
|
|
215
|
+
return [item for item in parsed if isinstance(item, dict)]
|
|
216
|
+
if isinstance(parsed, dict) and isinstance(parsed.get("commands"), list):
|
|
217
|
+
return [item for item in parsed["commands"] if isinstance(item, dict)]
|
|
218
|
+
return []
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _normalize_slash_command_name(value: Any) -> str | None:
|
|
222
|
+
if not isinstance(value, str):
|
|
223
|
+
return None
|
|
224
|
+
name = value.strip().lstrip("/")
|
|
225
|
+
return name if re.match(r"^[a-zA-Z][a-zA-Z0-9._-]{0,63}$", name) else None
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _slash_command_match(
|
|
229
|
+
content: str,
|
|
230
|
+
commands: list[dict[str, Any]],
|
|
231
|
+
) -> tuple[dict[str, Any], str, str] | None:
|
|
232
|
+
match = _SLASH_COMMAND_RE.match(content.strip())
|
|
233
|
+
if not match:
|
|
234
|
+
return None
|
|
235
|
+
invoked = match.group(1)
|
|
236
|
+
args = (match.group(2) or "").strip()
|
|
237
|
+
invoked_key = invoked.lower()
|
|
238
|
+
for command in commands:
|
|
239
|
+
name = _normalize_slash_command_name(command.get("name"))
|
|
240
|
+
aliases = [
|
|
241
|
+
alias
|
|
242
|
+
for alias in (_normalize_slash_command_name(item) for item in command.get("aliases") or [])
|
|
243
|
+
if alias
|
|
244
|
+
]
|
|
245
|
+
if name and (name.lower() == invoked_key or invoked_key in {alias.lower() for alias in aliases}):
|
|
246
|
+
return command, invoked, args
|
|
247
|
+
return None
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _format_slash_command_prompt(
|
|
251
|
+
original_text: str,
|
|
252
|
+
match: tuple[dict[str, Any], str, str],
|
|
253
|
+
) -> str:
|
|
254
|
+
command, _invoked, args = match
|
|
255
|
+
name = _normalize_slash_command_name(command.get("name")) or "unknown"
|
|
256
|
+
chunks = [
|
|
257
|
+
f"Slash command /{name} was invoked.",
|
|
258
|
+
f"Description: {command.get('description')}" if command.get("description") else "",
|
|
259
|
+
f"Pack: {command.get('packId')}" if command.get("packId") else "",
|
|
260
|
+
f"Arguments:\n{args or '(none)'}",
|
|
261
|
+
f"Command definition:\n{command.get('body')}" if command.get("body") else "",
|
|
262
|
+
f"Original message:\n{original_text}",
|
|
263
|
+
]
|
|
264
|
+
return "\n\n".join(item for item in chunks if item)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _slash_interactive_block(
|
|
268
|
+
match: tuple[dict[str, Any], str, str],
|
|
269
|
+
message_id: str,
|
|
270
|
+
) -> dict[str, Any] | None:
|
|
271
|
+
command, _invoked, _args = match
|
|
272
|
+
interaction = command.get("interaction")
|
|
273
|
+
if not isinstance(interaction, dict):
|
|
274
|
+
return None
|
|
275
|
+
name = _normalize_slash_command_name(command.get("name")) or "command"
|
|
276
|
+
block = dict(interaction)
|
|
277
|
+
block["id"] = (
|
|
278
|
+
f"{block['id']}:{message_id}"
|
|
279
|
+
if str(block.get("id") or "").strip()
|
|
280
|
+
else f"slash:{command.get('packId') or 'pack'}:{name}:{message_id}"
|
|
281
|
+
)
|
|
282
|
+
return block
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _interactive_response_source_id(message: dict[str, Any]) -> str | None:
|
|
286
|
+
metadata = message.get("metadata")
|
|
287
|
+
if not isinstance(metadata, dict):
|
|
288
|
+
return None
|
|
289
|
+
response = metadata.get("interactiveResponse")
|
|
290
|
+
if not isinstance(response, dict):
|
|
291
|
+
return None
|
|
292
|
+
value = response.get("sourceMessageId") or response.get("source_message_id")
|
|
293
|
+
return str(value) if value else None
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _interactive_response_text(
|
|
297
|
+
text: str,
|
|
298
|
+
message: dict[str, Any],
|
|
299
|
+
source_message: dict[str, Any] | None = None,
|
|
300
|
+
) -> str:
|
|
301
|
+
metadata = message.get("metadata")
|
|
302
|
+
if not isinstance(metadata, dict):
|
|
303
|
+
return text
|
|
304
|
+
response = metadata.get("interactiveResponse")
|
|
305
|
+
if not isinstance(response, dict):
|
|
306
|
+
return text
|
|
307
|
+
source_metadata = source_message.get("metadata") if isinstance(source_message, dict) else None
|
|
308
|
+
source_interactive = source_metadata.get("interactive") if isinstance(source_metadata, dict) else None
|
|
309
|
+
source_slash = source_metadata.get("slashCommand") if isinstance(source_metadata, dict) else None
|
|
310
|
+
source_prompt = source_interactive.get("prompt") if isinstance(source_interactive, dict) else None
|
|
311
|
+
response_prompt = source_interactive.get("responsePrompt") if isinstance(source_interactive, dict) else None
|
|
312
|
+
values = response.get("values")
|
|
313
|
+
summary_lines = [
|
|
314
|
+
"[Shadow interactive response]",
|
|
315
|
+
f"sourceMessage: {source_message.get('content')}" if isinstance(source_message, dict) else "",
|
|
316
|
+
f"sourcePrompt: {source_prompt}" if isinstance(source_prompt, str) and source_prompt.strip() else "",
|
|
317
|
+
f"followUpInstruction: {response_prompt}" if isinstance(response_prompt, str) and response_prompt.strip() else "",
|
|
318
|
+
(
|
|
319
|
+
"sourceSlashCommand: " + json.dumps(source_slash, ensure_ascii=False, sort_keys=True)
|
|
320
|
+
if isinstance(source_slash, dict)
|
|
321
|
+
else ""
|
|
322
|
+
),
|
|
323
|
+
f"blockId: {response.get('blockId') or ''}",
|
|
324
|
+
f"actionId: {response.get('actionId') or ''}",
|
|
325
|
+
f"value: {response.get('value') or ''}",
|
|
326
|
+
(
|
|
327
|
+
"fields: " + json.dumps(values, ensure_ascii=False, sort_keys=True)
|
|
328
|
+
if isinstance(values, dict) and values
|
|
329
|
+
else ""
|
|
330
|
+
),
|
|
331
|
+
]
|
|
332
|
+
summary = "\n".join(item for item in summary_lines if item)
|
|
333
|
+
return f"{text}\n\n{summary}" if text else summary
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _message_created_at(message: dict[str, Any]) -> datetime | None:
|
|
337
|
+
raw = message.get("createdAt") or message.get("created_at")
|
|
338
|
+
if not raw:
|
|
339
|
+
return None
|
|
340
|
+
if isinstance(raw, datetime):
|
|
341
|
+
return raw if raw.tzinfo else raw.replace(tzinfo=timezone.utc)
|
|
342
|
+
try:
|
|
343
|
+
text = str(raw).replace("Z", "+00:00")
|
|
344
|
+
dt = datetime.fromisoformat(text)
|
|
345
|
+
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
|
346
|
+
except Exception:
|
|
347
|
+
return None
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _message_author(message: dict[str, Any]) -> dict[str, Any]:
|
|
351
|
+
author = message.get("author")
|
|
352
|
+
return author if isinstance(author, dict) else {}
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _message_author_name(message: dict[str, Any]) -> str | None:
|
|
356
|
+
author = _message_author(message)
|
|
357
|
+
for key in ("displayName", "display_name", "username", "name"):
|
|
358
|
+
value = author.get(key)
|
|
359
|
+
if value:
|
|
360
|
+
return str(value)
|
|
361
|
+
if message.get("authorId"):
|
|
362
|
+
return str(message.get("authorId"))
|
|
363
|
+
return None
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _message_author_id(message: dict[str, Any]) -> str | None:
|
|
367
|
+
value = message.get("authorId") or message.get("author_id")
|
|
368
|
+
return str(value) if value else None
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _message_id(message: dict[str, Any]) -> str | None:
|
|
372
|
+
value = message.get("id") or message.get("messageId") or message.get("message_id")
|
|
373
|
+
return str(value) if value else None
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _message_channel_id(message: dict[str, Any]) -> str | None:
|
|
377
|
+
value = message.get("channelId") or message.get("channel_id")
|
|
378
|
+
return str(value) if value else None
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _message_thread_id(message: dict[str, Any]) -> str | None:
|
|
382
|
+
value = message.get("threadId") or message.get("thread_id")
|
|
383
|
+
return str(value) if value else None
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _message_reply_to_id(message: dict[str, Any]) -> str | None:
|
|
387
|
+
value = message.get("replyToId") or message.get("reply_to_id") or message.get("replyTo")
|
|
388
|
+
return str(value) if value else None
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _text_without_self_mention(text: str, username: str | None) -> str:
|
|
392
|
+
if not username:
|
|
393
|
+
return text
|
|
394
|
+
import re
|
|
395
|
+
|
|
396
|
+
escaped = re.escape(username.lstrip("@"))
|
|
397
|
+
return re.sub(rf"@{escaped}(?:\s+|$)", "", text, flags=re.I).strip() or text
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _policy_bool(policy: dict[str, Any] | None, key: str, default: bool) -> bool:
|
|
401
|
+
if not isinstance(policy, dict) or key not in policy:
|
|
402
|
+
return default
|
|
403
|
+
return parse_bool(policy.get(key), default)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _policy_config(policy: dict[str, Any] | None) -> dict[str, Any]:
|
|
407
|
+
if not isinstance(policy, dict):
|
|
408
|
+
return {}
|
|
409
|
+
config = policy.get("config")
|
|
410
|
+
return config if isinstance(config, dict) else {}
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _default_policy_from_remote_config(remote_config: dict[str, Any] | None) -> dict[str, Any]:
|
|
414
|
+
active_tenant_ids = []
|
|
415
|
+
owner_id = None
|
|
416
|
+
if isinstance(remote_config, dict):
|
|
417
|
+
active_tenant_ids = remote_config.get("activeTenantIds") or []
|
|
418
|
+
owner_id = remote_config.get("ownerId")
|
|
419
|
+
allowed_trigger_user_ids = []
|
|
420
|
+
if owner_id:
|
|
421
|
+
allowed_trigger_user_ids.append(owner_id)
|
|
422
|
+
allowed_trigger_user_ids.extend(
|
|
423
|
+
item for item in active_tenant_ids if isinstance(item, str) and item not in allowed_trigger_user_ids
|
|
424
|
+
)
|
|
425
|
+
return {
|
|
426
|
+
"listen": True,
|
|
427
|
+
"reply": True,
|
|
428
|
+
"mentionOnly": False,
|
|
429
|
+
"config": {
|
|
430
|
+
"allowedTriggerUserIds": allowed_trigger_user_ids,
|
|
431
|
+
"triggerUserIds": allowed_trigger_user_ids,
|
|
432
|
+
"ownerId": owner_id,
|
|
433
|
+
"activeTenantIds": active_tenant_ids,
|
|
434
|
+
"replyRequiresMention": False,
|
|
435
|
+
},
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _remote_listen_channel_entries(
|
|
440
|
+
remote_config: dict[str, Any] | None,
|
|
441
|
+
) -> list[tuple[str, dict[str, Any], dict[str, Any]]]:
|
|
442
|
+
if not isinstance(remote_config, dict):
|
|
443
|
+
return []
|
|
444
|
+
entries: list[tuple[str, dict[str, Any], dict[str, Any]]] = []
|
|
445
|
+
for server in remote_config.get("servers") or []:
|
|
446
|
+
if not isinstance(server, dict):
|
|
447
|
+
continue
|
|
448
|
+
for channel in server.get("channels") or []:
|
|
449
|
+
if not isinstance(channel, dict):
|
|
450
|
+
continue
|
|
451
|
+
channel_id = str(channel.get("id") or "").strip()
|
|
452
|
+
if not channel_id:
|
|
453
|
+
continue
|
|
454
|
+
policy = channel.get("policy") if isinstance(channel.get("policy"), dict) else {}
|
|
455
|
+
if not _policy_bool(policy, "listen", True):
|
|
456
|
+
continue
|
|
457
|
+
cached_channel = {
|
|
458
|
+
**channel,
|
|
459
|
+
"serverId": server.get("id"),
|
|
460
|
+
"serverName": server.get("name"),
|
|
461
|
+
"serverSlug": server.get("slug") or server.get("id"),
|
|
462
|
+
"kind": channel.get("kind") or channel.get("type") or "channel",
|
|
463
|
+
}
|
|
464
|
+
entries.append((channel_id, cached_channel, policy))
|
|
465
|
+
return entries
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
class ShadowOBAdapter(BasePlatformAdapter):
|
|
469
|
+
"""Hermes ``BasePlatformAdapter`` implementation for Shadow."""
|
|
470
|
+
|
|
471
|
+
def __init__(self, config: PlatformConfig):
|
|
472
|
+
if Platform is None:
|
|
473
|
+
raise RuntimeError("Hermes gateway modules are not importable")
|
|
474
|
+
super().__init__(config, Platform(PLATFORM_NAME))
|
|
475
|
+
self.extra = _extra(config)
|
|
476
|
+
self.base_url = _base_url_from_config(config)
|
|
477
|
+
self.token = _token_from_config(config)
|
|
478
|
+
self.client = ShadowAsyncClient(self.base_url, self.token) if self.base_url and self.token else None
|
|
479
|
+
self.socket: ShadowSocketClient | None = None
|
|
480
|
+
self._poll_task: asyncio.Task | None = None
|
|
481
|
+
self._heartbeat_task: asyncio.Task | None = None
|
|
482
|
+
self._channel_ids: list[str] = _channel_ids_from_config(config)
|
|
483
|
+
self._configured_channel_ids: set[str] = set(self._channel_ids)
|
|
484
|
+
self._remote_channel_ids: set[str] = set()
|
|
485
|
+
self._channel_policies: dict[str, dict[str, Any]] = {}
|
|
486
|
+
self._remote_config: dict[str, Any] | None = None
|
|
487
|
+
self._channel_cache: dict[str, dict[str, Any]] = {}
|
|
488
|
+
self._processed_ids: deque[str] = deque(maxlen=2000)
|
|
489
|
+
self._processed_set: set[str] = set()
|
|
490
|
+
self._last_seen_created_at: dict[str, datetime] = {}
|
|
491
|
+
self._bot_user_id = str(_cfg(config, "SHADOW_BOT_USER_ID", "bot_user_id", default="") or "") or None
|
|
492
|
+
self._bot_username = str(_cfg(config, "SHADOW_BOT_USERNAME", "bot_username", default="") or "") or None
|
|
493
|
+
self._agent_id = str(_cfg(config, "SHADOW_AGENT_ID", "agent_id", default="") or "") or None
|
|
494
|
+
self._heartbeat_interval = float(
|
|
495
|
+
_cfg(config, "SHADOW_HEARTBEAT_INTERVAL_SECONDS", "heartbeat_interval_seconds", default=30) or 30
|
|
496
|
+
)
|
|
497
|
+
self._slash_commands = _parse_json_list(
|
|
498
|
+
_cfg(config, "SHADOW_SLASH_COMMANDS_JSON", "slash_commands", default=[])
|
|
499
|
+
)
|
|
500
|
+
self._download_media = parse_bool(_cfg(config, "SHADOW_DOWNLOAD_MEDIA", "download_media", default=True), True)
|
|
501
|
+
self._mention_only = parse_bool(_cfg(config, "SHADOW_MENTION_ONLY", "mention_only", default=False), False)
|
|
502
|
+
self._reply_to_bots = parse_bool(_cfg(config, "SHADOW_REPLY_TO_BOTS", "reply_to_bots", default=False), False)
|
|
503
|
+
self._rest_only = parse_bool(_cfg(config, "SHADOW_REST_ONLY", "rest_only", default=False), False)
|
|
504
|
+
self._poll_interval = float(_cfg(config, "SHADOW_POLL_INTERVAL_SECONDS", "poll_interval_seconds", default=3) or 3)
|
|
505
|
+
self._catchup_minutes = float(_cfg(config, "SHADOW_CATCHUP_MINUTES", "catchup_minutes", default=0) or 0)
|
|
506
|
+
self._auto_discover = parse_bool(
|
|
507
|
+
_cfg(config, "SHADOW_AUTO_DISCOVER_CHANNELS", "auto_discover_channels", default=False),
|
|
508
|
+
False,
|
|
509
|
+
)
|
|
510
|
+
self._server_ids = _server_ids_from_config(config)
|
|
511
|
+
self._fetch_reply_context = parse_bool(
|
|
512
|
+
_cfg(config, "SHADOW_FETCH_REPLY_CONTEXT", "fetch_reply_context", default=True),
|
|
513
|
+
True,
|
|
514
|
+
)
|
|
515
|
+
self._transports = split_csv(_cfg(config, "SHADOW_SOCKET_TRANSPORTS", "socket_transports", default="websocket")) or ["websocket"]
|
|
516
|
+
|
|
517
|
+
@property
|
|
518
|
+
def name(self) -> str:
|
|
519
|
+
return "Shadow"
|
|
520
|
+
|
|
521
|
+
async def connect(self) -> bool:
|
|
522
|
+
if not self.base_url:
|
|
523
|
+
self._set_fatal_error("config_missing", "SHADOW_BASE_URL is required", retryable=False)
|
|
524
|
+
return False
|
|
525
|
+
if not self.token:
|
|
526
|
+
self._set_fatal_error("config_missing", "SHADOW_TOKEN or platform token is required", retryable=False)
|
|
527
|
+
return False
|
|
528
|
+
if self.client is None:
|
|
529
|
+
self.client = ShadowAsyncClient(self.base_url, self.token)
|
|
530
|
+
|
|
531
|
+
try:
|
|
532
|
+
await self.client.open()
|
|
533
|
+
await self._load_identity()
|
|
534
|
+
await self._register_slash_commands()
|
|
535
|
+
await self._start_heartbeat()
|
|
536
|
+
await self._resolve_channels()
|
|
537
|
+
if not self._channel_ids:
|
|
538
|
+
self._set_fatal_error(
|
|
539
|
+
"config_missing",
|
|
540
|
+
"No Shadow channels are available for this Buddy token. Add the Buddy to a server/channel, open a DM, or verify the remote agent policy.",
|
|
541
|
+
retryable=False,
|
|
542
|
+
)
|
|
543
|
+
return False
|
|
544
|
+
|
|
545
|
+
if self._rest_only:
|
|
546
|
+
await self._start_polling()
|
|
547
|
+
else:
|
|
548
|
+
try:
|
|
549
|
+
await self._start_socket()
|
|
550
|
+
except Exception as exc:
|
|
551
|
+
logger.warning("[Shadow] Socket.IO connection failed, falling back to REST polling: %s", exc)
|
|
552
|
+
await self._start_polling()
|
|
553
|
+
|
|
554
|
+
self._mark_connected()
|
|
555
|
+
logger.info("[Shadow] Connected to %s; channels=%s", self.base_url, ",".join(self._channel_ids))
|
|
556
|
+
return True
|
|
557
|
+
except Exception as exc:
|
|
558
|
+
logger.exception("[Shadow] connect failed")
|
|
559
|
+
self._set_fatal_error("connect_failed", str(exc), retryable=True)
|
|
560
|
+
try:
|
|
561
|
+
if self.client:
|
|
562
|
+
await self.client.close()
|
|
563
|
+
except Exception:
|
|
564
|
+
pass
|
|
565
|
+
return False
|
|
566
|
+
|
|
567
|
+
async def disconnect(self) -> None:
|
|
568
|
+
self._mark_disconnected()
|
|
569
|
+
if self._heartbeat_task is not None and not self._heartbeat_task.done():
|
|
570
|
+
self._heartbeat_task.cancel()
|
|
571
|
+
try:
|
|
572
|
+
await self._heartbeat_task
|
|
573
|
+
except asyncio.CancelledError:
|
|
574
|
+
pass
|
|
575
|
+
self._heartbeat_task = None
|
|
576
|
+
if self._poll_task is not None and not self._poll_task.done():
|
|
577
|
+
self._poll_task.cancel()
|
|
578
|
+
try:
|
|
579
|
+
await self._poll_task
|
|
580
|
+
except asyncio.CancelledError:
|
|
581
|
+
pass
|
|
582
|
+
self._poll_task = None
|
|
583
|
+
if self.socket is not None:
|
|
584
|
+
try:
|
|
585
|
+
try:
|
|
586
|
+
await self.socket.update_presence("offline")
|
|
587
|
+
except Exception:
|
|
588
|
+
pass
|
|
589
|
+
for channel_id in self._channel_ids:
|
|
590
|
+
try:
|
|
591
|
+
await self.socket.leave_channel(channel_id)
|
|
592
|
+
except Exception:
|
|
593
|
+
pass
|
|
594
|
+
await self.socket.disconnect()
|
|
595
|
+
finally:
|
|
596
|
+
self.socket = None
|
|
597
|
+
if self.client is not None:
|
|
598
|
+
await self.client.close()
|
|
599
|
+
|
|
600
|
+
async def send(
|
|
601
|
+
self,
|
|
602
|
+
chat_id: str,
|
|
603
|
+
content: str,
|
|
604
|
+
reply_to: Optional[str] = None,
|
|
605
|
+
metadata: Optional[dict[str, Any]] = None,
|
|
606
|
+
) -> SendResult:
|
|
607
|
+
if self.client is None:
|
|
608
|
+
return SendResult(success=False, error="Shadow client is not initialized", retryable=True)
|
|
609
|
+
try:
|
|
610
|
+
await self._set_activity(str(chat_id), "working")
|
|
611
|
+
thread_id = _metadata_thread_id(metadata)
|
|
612
|
+
reply_to_id = _metadata_reply_to(metadata, reply_to)
|
|
613
|
+
shadow_metadata = _metadata_payload(metadata)
|
|
614
|
+
if thread_id:
|
|
615
|
+
message = await self.client.send_message(
|
|
616
|
+
str(chat_id),
|
|
617
|
+
content,
|
|
618
|
+
thread_id=thread_id,
|
|
619
|
+
reply_to_id=reply_to_id,
|
|
620
|
+
metadata=shadow_metadata,
|
|
621
|
+
)
|
|
622
|
+
else:
|
|
623
|
+
message = await self.client.send_message(
|
|
624
|
+
str(chat_id),
|
|
625
|
+
content,
|
|
626
|
+
reply_to_id=reply_to_id,
|
|
627
|
+
metadata=shadow_metadata,
|
|
628
|
+
)
|
|
629
|
+
return SendResult(success=True, message_id=str(message.get("id") or ""), raw_response=message)
|
|
630
|
+
except Exception as exc:
|
|
631
|
+
logger.warning("[Shadow] send failed: %s", exc)
|
|
632
|
+
return SendResult(success=False, error=str(exc), retryable=self._is_retryable(exc))
|
|
633
|
+
finally:
|
|
634
|
+
await self._set_activity(str(chat_id), None)
|
|
635
|
+
|
|
636
|
+
async def edit_message(
|
|
637
|
+
self,
|
|
638
|
+
chat_id: str,
|
|
639
|
+
message_id: str,
|
|
640
|
+
content: str,
|
|
641
|
+
*,
|
|
642
|
+
finalize: bool = False,
|
|
643
|
+
) -> SendResult:
|
|
644
|
+
if self.client is None:
|
|
645
|
+
return SendResult(success=False, error="Shadow client is not initialized", retryable=True)
|
|
646
|
+
try:
|
|
647
|
+
message = await self.client.edit_message(message_id, content)
|
|
648
|
+
return SendResult(success=True, message_id=str(message.get("id") or message_id), raw_response=message)
|
|
649
|
+
except Exception as exc:
|
|
650
|
+
return SendResult(success=False, error=str(exc), retryable=self._is_retryable(exc))
|
|
651
|
+
|
|
652
|
+
async def delete_message(self, chat_id: str, message_id: str) -> bool:
|
|
653
|
+
if self.client is None:
|
|
654
|
+
return False
|
|
655
|
+
try:
|
|
656
|
+
await self.client.delete_message(message_id)
|
|
657
|
+
return True
|
|
658
|
+
except Exception as exc:
|
|
659
|
+
logger.debug("[Shadow] delete_message failed for %s/%s: %s", chat_id, message_id, exc)
|
|
660
|
+
return False
|
|
661
|
+
|
|
662
|
+
async def send_typing(self, chat_id: str, metadata=None) -> None:
|
|
663
|
+
if self.socket is None:
|
|
664
|
+
return None
|
|
665
|
+
try:
|
|
666
|
+
await self.socket.send_typing(str(chat_id), True)
|
|
667
|
+
await self.socket.update_activity(str(chat_id), "thinking")
|
|
668
|
+
except Exception:
|
|
669
|
+
return None
|
|
670
|
+
|
|
671
|
+
async def stop_typing(self, chat_id: str) -> None:
|
|
672
|
+
if self.socket is None:
|
|
673
|
+
return None
|
|
674
|
+
try:
|
|
675
|
+
await self.socket.send_typing(str(chat_id), False)
|
|
676
|
+
await self.socket.update_activity(str(chat_id), None)
|
|
677
|
+
except Exception:
|
|
678
|
+
return None
|
|
679
|
+
|
|
680
|
+
async def send_image_file(
|
|
681
|
+
self,
|
|
682
|
+
chat_id: str,
|
|
683
|
+
image_path: str,
|
|
684
|
+
caption: Optional[str] = None,
|
|
685
|
+
reply_to: Optional[str] = None,
|
|
686
|
+
metadata: Optional[dict[str, Any]] = None,
|
|
687
|
+
**kwargs,
|
|
688
|
+
) -> SendResult:
|
|
689
|
+
return await self._send_file(chat_id, image_path, caption=caption, reply_to=reply_to, metadata=metadata)
|
|
690
|
+
|
|
691
|
+
async def send_document(
|
|
692
|
+
self,
|
|
693
|
+
chat_id: str,
|
|
694
|
+
file_path: str,
|
|
695
|
+
caption: Optional[str] = None,
|
|
696
|
+
file_name: Optional[str] = None,
|
|
697
|
+
reply_to: Optional[str] = None,
|
|
698
|
+
metadata: Optional[dict[str, Any]] = None,
|
|
699
|
+
**kwargs,
|
|
700
|
+
) -> SendResult:
|
|
701
|
+
return await self._send_file(chat_id, file_path, caption=caption, reply_to=reply_to, metadata=metadata)
|
|
702
|
+
|
|
703
|
+
async def send_video(
|
|
704
|
+
self,
|
|
705
|
+
chat_id: str,
|
|
706
|
+
video_path: str,
|
|
707
|
+
caption: Optional[str] = None,
|
|
708
|
+
reply_to: Optional[str] = None,
|
|
709
|
+
metadata: Optional[dict[str, Any]] = None,
|
|
710
|
+
**kwargs,
|
|
711
|
+
) -> SendResult:
|
|
712
|
+
return await self._send_file(chat_id, video_path, caption=caption, reply_to=reply_to, metadata=metadata)
|
|
713
|
+
|
|
714
|
+
async def send_voice(
|
|
715
|
+
self,
|
|
716
|
+
chat_id: str,
|
|
717
|
+
audio_path: str,
|
|
718
|
+
caption: Optional[str] = None,
|
|
719
|
+
reply_to: Optional[str] = None,
|
|
720
|
+
metadata: Optional[dict[str, Any]] = None,
|
|
721
|
+
**kwargs,
|
|
722
|
+
) -> SendResult:
|
|
723
|
+
return await self._send_file(chat_id, audio_path, caption=caption, reply_to=reply_to, metadata=metadata)
|
|
724
|
+
|
|
725
|
+
async def send_image(
|
|
726
|
+
self,
|
|
727
|
+
chat_id: str,
|
|
728
|
+
image_url: str,
|
|
729
|
+
caption: Optional[str] = None,
|
|
730
|
+
reply_to: Optional[str] = None,
|
|
731
|
+
metadata: Optional[dict[str, Any]] = None,
|
|
732
|
+
) -> SendResult:
|
|
733
|
+
return await self._send_remote_file(chat_id, image_url, caption=caption, reply_to=reply_to, metadata=metadata)
|
|
734
|
+
|
|
735
|
+
async def send_interactive(
|
|
736
|
+
self,
|
|
737
|
+
chat_id: str,
|
|
738
|
+
content: str,
|
|
739
|
+
interactive: dict[str, Any],
|
|
740
|
+
reply_to: Optional[str] = None,
|
|
741
|
+
metadata: Optional[dict[str, Any]] = None,
|
|
742
|
+
) -> SendResult:
|
|
743
|
+
if self.client is None:
|
|
744
|
+
return SendResult(success=False, error="Shadow client is not initialized", retryable=True)
|
|
745
|
+
shadow_metadata = _metadata_payload(metadata) or {}
|
|
746
|
+
shadow_metadata["interactive"] = interactive
|
|
747
|
+
try:
|
|
748
|
+
await self._set_activity(str(chat_id), "working")
|
|
749
|
+
message = await self.client.send_message(
|
|
750
|
+
str(chat_id),
|
|
751
|
+
content or "[interactive]",
|
|
752
|
+
thread_id=_metadata_thread_id(metadata),
|
|
753
|
+
reply_to_id=_metadata_reply_to(metadata, reply_to),
|
|
754
|
+
metadata=shadow_metadata,
|
|
755
|
+
)
|
|
756
|
+
return SendResult(success=True, message_id=str(message.get("id") or ""), raw_response=message)
|
|
757
|
+
except Exception as exc:
|
|
758
|
+
return SendResult(success=False, error=str(exc), retryable=self._is_retryable(exc))
|
|
759
|
+
finally:
|
|
760
|
+
await self._set_activity(str(chat_id), None)
|
|
761
|
+
|
|
762
|
+
async def get_chat_info(self, chat_id: str) -> dict[str, Any]:
|
|
763
|
+
channel = self._channel_cache.get(str(chat_id))
|
|
764
|
+
if channel:
|
|
765
|
+
return {
|
|
766
|
+
"id": chat_id,
|
|
767
|
+
"name": channel.get("name") or channel.get("title") or chat_id,
|
|
768
|
+
"type": channel.get("kind") or channel.get("type") or "channel",
|
|
769
|
+
"raw": channel,
|
|
770
|
+
}
|
|
771
|
+
if self.client is not None:
|
|
772
|
+
try:
|
|
773
|
+
channel = await self.client.get_channel(str(chat_id))
|
|
774
|
+
self._channel_cache[str(chat_id)] = channel
|
|
775
|
+
return {
|
|
776
|
+
"id": chat_id,
|
|
777
|
+
"name": channel.get("name") or channel.get("title") or chat_id,
|
|
778
|
+
"type": channel.get("kind") or channel.get("type") or "channel",
|
|
779
|
+
"raw": channel,
|
|
780
|
+
}
|
|
781
|
+
except Exception:
|
|
782
|
+
pass
|
|
783
|
+
return {"id": chat_id, "name": str(chat_id), "type": "channel"}
|
|
784
|
+
|
|
785
|
+
async def add_reaction(self, message_id: str, emoji: str) -> bool:
|
|
786
|
+
if self.client is None:
|
|
787
|
+
return False
|
|
788
|
+
try:
|
|
789
|
+
await self.client.add_reaction(message_id, emoji)
|
|
790
|
+
return True
|
|
791
|
+
except Exception:
|
|
792
|
+
return False
|
|
793
|
+
|
|
794
|
+
async def remove_reaction(self, message_id: str, emoji: str) -> bool:
|
|
795
|
+
if self.client is None:
|
|
796
|
+
return False
|
|
797
|
+
try:
|
|
798
|
+
await self.client.remove_reaction(message_id, emoji)
|
|
799
|
+
return True
|
|
800
|
+
except Exception:
|
|
801
|
+
return False
|
|
802
|
+
|
|
803
|
+
async def _send_file(
|
|
804
|
+
self,
|
|
805
|
+
chat_id: str,
|
|
806
|
+
path: str,
|
|
807
|
+
*,
|
|
808
|
+
caption: str | None = None,
|
|
809
|
+
reply_to: str | None = None,
|
|
810
|
+
metadata: dict[str, Any] | None = None,
|
|
811
|
+
) -> SendResult:
|
|
812
|
+
if self.client is None:
|
|
813
|
+
return SendResult(success=False, error="Shadow client is not initialized", retryable=True)
|
|
814
|
+
try:
|
|
815
|
+
await self._set_activity(str(chat_id), "working")
|
|
816
|
+
msg = await self.client.send_message(
|
|
817
|
+
str(chat_id),
|
|
818
|
+
caption or "\u200B",
|
|
819
|
+
thread_id=_metadata_thread_id(metadata),
|
|
820
|
+
reply_to_id=_metadata_reply_to(metadata, reply_to),
|
|
821
|
+
metadata=_metadata_payload(metadata),
|
|
822
|
+
)
|
|
823
|
+
await self.client.upload_media_from_path(path, message_id=str(msg.get("id")))
|
|
824
|
+
return SendResult(success=True, message_id=str(msg.get("id") or ""), raw_response=msg)
|
|
825
|
+
except Exception as exc:
|
|
826
|
+
return SendResult(success=False, error=str(exc), retryable=self._is_retryable(exc))
|
|
827
|
+
finally:
|
|
828
|
+
await self._set_activity(str(chat_id), None)
|
|
829
|
+
|
|
830
|
+
async def _send_remote_file(
|
|
831
|
+
self,
|
|
832
|
+
chat_id: str,
|
|
833
|
+
url: str,
|
|
834
|
+
*,
|
|
835
|
+
caption: str | None = None,
|
|
836
|
+
reply_to: str | None = None,
|
|
837
|
+
metadata: dict[str, Any] | None = None,
|
|
838
|
+
) -> SendResult:
|
|
839
|
+
if self.client is None:
|
|
840
|
+
return SendResult(success=False, error="Shadow client is not initialized", retryable=True)
|
|
841
|
+
try:
|
|
842
|
+
await self._set_activity(str(chat_id), "working")
|
|
843
|
+
msg = await self.client.send_message(
|
|
844
|
+
str(chat_id),
|
|
845
|
+
caption or "\u200B",
|
|
846
|
+
thread_id=_metadata_thread_id(metadata),
|
|
847
|
+
reply_to_id=_metadata_reply_to(metadata, reply_to),
|
|
848
|
+
metadata=_metadata_payload(metadata),
|
|
849
|
+
)
|
|
850
|
+
await self.client.upload_media_from_url(url, message_id=str(msg.get("id")))
|
|
851
|
+
return SendResult(success=True, message_id=str(msg.get("id") or ""), raw_response=msg)
|
|
852
|
+
except Exception as exc:
|
|
853
|
+
return SendResult(success=False, error=str(exc), retryable=self._is_retryable(exc))
|
|
854
|
+
finally:
|
|
855
|
+
await self._set_activity(str(chat_id), None)
|
|
856
|
+
|
|
857
|
+
async def _set_activity(self, channel_id: str, activity: str | None) -> None:
|
|
858
|
+
if self.socket is None:
|
|
859
|
+
return
|
|
860
|
+
try:
|
|
861
|
+
await self.socket.update_activity(channel_id, activity)
|
|
862
|
+
except Exception:
|
|
863
|
+
pass
|
|
864
|
+
|
|
865
|
+
async def _load_identity(self) -> None:
|
|
866
|
+
if self.client is None:
|
|
867
|
+
return
|
|
868
|
+
me = await self.client.get_me()
|
|
869
|
+
if not self._bot_user_id:
|
|
870
|
+
self._bot_user_id = str(me.get("id") or me.get("userId") or "") or None
|
|
871
|
+
if not self._bot_username:
|
|
872
|
+
self._bot_username = str(me.get("username") or me.get("name") or "") or None
|
|
873
|
+
if not self._agent_id:
|
|
874
|
+
self._agent_id = str(me.get("agentId") or me.get("agent_id") or "") or None
|
|
875
|
+
logger.info("[Shadow] Authenticated as %s (%s)", self._bot_username, self._bot_user_id)
|
|
876
|
+
|
|
877
|
+
async def _refresh_remote_config(self, *, sync_socket: bool = False) -> None:
|
|
878
|
+
if self.client is None or not self._agent_id:
|
|
879
|
+
return
|
|
880
|
+
old_channel_ids = set(self._channel_ids)
|
|
881
|
+
old_remote_ids = set(self._remote_channel_ids)
|
|
882
|
+
remote_config = await self.client.get_agent_config(self._agent_id)
|
|
883
|
+
self._remote_config = remote_config
|
|
884
|
+
|
|
885
|
+
self._bot_user_id = str(remote_config.get("botUserId") or self._bot_user_id or "") or None
|
|
886
|
+
if not self._slash_commands:
|
|
887
|
+
self._slash_commands = _parse_json_list(remote_config.get("slashCommands"))
|
|
888
|
+
|
|
889
|
+
new_remote_ids: set[str] = set()
|
|
890
|
+
for channel_id, channel, policy in _remote_listen_channel_entries(remote_config):
|
|
891
|
+
new_remote_ids.add(channel_id)
|
|
892
|
+
self._channel_cache[channel_id] = channel
|
|
893
|
+
self._channel_policies[channel_id] = policy
|
|
894
|
+
if channel_id not in self._channel_ids:
|
|
895
|
+
self._channel_ids.append(channel_id)
|
|
896
|
+
|
|
897
|
+
removed_remote_ids = old_remote_ids - new_remote_ids
|
|
898
|
+
if removed_remote_ids:
|
|
899
|
+
self._channel_ids = [
|
|
900
|
+
channel_id
|
|
901
|
+
for channel_id in self._channel_ids
|
|
902
|
+
if channel_id not in removed_remote_ids or channel_id in self._configured_channel_ids
|
|
903
|
+
]
|
|
904
|
+
for channel_id in removed_remote_ids:
|
|
905
|
+
self._channel_policies.pop(channel_id, None)
|
|
906
|
+
if channel_id not in self._configured_channel_ids:
|
|
907
|
+
self._channel_cache.pop(channel_id, None)
|
|
908
|
+
|
|
909
|
+
self._remote_channel_ids = new_remote_ids
|
|
910
|
+
logger.info("[Shadow] Refreshed remote config for agent %s; channels=%s", self._agent_id, len(new_remote_ids))
|
|
911
|
+
|
|
912
|
+
if sync_socket and self.socket is not None:
|
|
913
|
+
next_channel_ids = set(self._channel_ids)
|
|
914
|
+
for channel_id in old_channel_ids - next_channel_ids:
|
|
915
|
+
try:
|
|
916
|
+
await self.socket.leave_channel(channel_id)
|
|
917
|
+
except Exception:
|
|
918
|
+
pass
|
|
919
|
+
for channel_id in next_channel_ids - old_channel_ids:
|
|
920
|
+
try:
|
|
921
|
+
ack = await self.socket.join_channel(channel_id)
|
|
922
|
+
logger.info("[Shadow] Joined channel %s after config refresh ack=%s", channel_id, ack)
|
|
923
|
+
except Exception as exc:
|
|
924
|
+
logger.warning("[Shadow] Failed to join refreshed channel %s: %s", channel_id, exc)
|
|
925
|
+
|
|
926
|
+
async def _register_slash_commands(self) -> None:
|
|
927
|
+
if self.client is None or not self._agent_id or not self._slash_commands:
|
|
928
|
+
return
|
|
929
|
+
try:
|
|
930
|
+
payload = await self.client.update_agent_slash_commands(self._agent_id, self._slash_commands)
|
|
931
|
+
count = len(payload.get("commands") or self._slash_commands)
|
|
932
|
+
logger.info("[Shadow] Registered %s slash command(s) for agent %s", count, self._agent_id)
|
|
933
|
+
except Exception as exc:
|
|
934
|
+
logger.warning("[Shadow] Failed to register slash commands for agent %s: %s", self._agent_id, exc)
|
|
935
|
+
|
|
936
|
+
async def _start_heartbeat(self) -> None:
|
|
937
|
+
if self.client is None or not self._agent_id:
|
|
938
|
+
return
|
|
939
|
+
await self._send_heartbeat()
|
|
940
|
+
self._heartbeat_task = asyncio.create_task(self._heartbeat_loop(), name="shadowob-heartbeat")
|
|
941
|
+
|
|
942
|
+
async def _heartbeat_loop(self) -> None:
|
|
943
|
+
while self._running and not self.has_fatal_error:
|
|
944
|
+
await asyncio.sleep(max(5.0, self._heartbeat_interval))
|
|
945
|
+
await self._send_heartbeat()
|
|
946
|
+
|
|
947
|
+
async def _send_heartbeat(self) -> None:
|
|
948
|
+
if self.client is None or not self._agent_id:
|
|
949
|
+
return
|
|
950
|
+
try:
|
|
951
|
+
await self.client.heartbeat_agent(self._agent_id)
|
|
952
|
+
except Exception as exc:
|
|
953
|
+
logger.debug("[Shadow] heartbeat failed for agent %s: %s", self._agent_id, exc)
|
|
954
|
+
|
|
955
|
+
async def _send_slash_interactive_prompt(
|
|
956
|
+
self,
|
|
957
|
+
match: tuple[dict[str, Any], str, str],
|
|
958
|
+
*,
|
|
959
|
+
message_id: str,
|
|
960
|
+
channel_id: str,
|
|
961
|
+
thread_id: str | None,
|
|
962
|
+
) -> bool:
|
|
963
|
+
if self.client is None:
|
|
964
|
+
return False
|
|
965
|
+
block = _slash_interactive_block(match, message_id)
|
|
966
|
+
if not block:
|
|
967
|
+
return False
|
|
968
|
+
command, invoked, args = match
|
|
969
|
+
name = _normalize_slash_command_name(command.get("name")) or invoked
|
|
970
|
+
content = str(block.get("prompt") or f"/{name} needs input before the Buddy can continue.")
|
|
971
|
+
await self.client.send_message(
|
|
972
|
+
channel_id,
|
|
973
|
+
content,
|
|
974
|
+
thread_id=thread_id,
|
|
975
|
+
reply_to_id=message_id,
|
|
976
|
+
metadata={
|
|
977
|
+
"interactive": block,
|
|
978
|
+
"slashCommand": {
|
|
979
|
+
"name": name,
|
|
980
|
+
"invokedName": invoked,
|
|
981
|
+
"args": args,
|
|
982
|
+
"packId": command.get("packId"),
|
|
983
|
+
},
|
|
984
|
+
},
|
|
985
|
+
)
|
|
986
|
+
logger.info("[Shadow] Sent interactive prompt for slash command /%s", name)
|
|
987
|
+
return True
|
|
988
|
+
|
|
989
|
+
async def _resolve_channels(self) -> None:
|
|
990
|
+
if self.client is None:
|
|
991
|
+
return
|
|
992
|
+
if self._agent_id:
|
|
993
|
+
try:
|
|
994
|
+
await self._refresh_remote_config()
|
|
995
|
+
except Exception as exc:
|
|
996
|
+
logger.warning("[Shadow] Failed to load remote agent config for %s: %s", self._agent_id, exc)
|
|
997
|
+
|
|
998
|
+
seen = set(self._channel_ids)
|
|
999
|
+
for server_id in self._server_ids:
|
|
1000
|
+
try:
|
|
1001
|
+
for channel in await self.client.get_server_channels(server_id):
|
|
1002
|
+
channel_id = str(channel.get("id") or "")
|
|
1003
|
+
if not channel_id:
|
|
1004
|
+
continue
|
|
1005
|
+
self._channel_cache[channel_id] = channel
|
|
1006
|
+
if channel_id not in seen:
|
|
1007
|
+
seen.add(channel_id)
|
|
1008
|
+
self._channel_ids.append(channel_id)
|
|
1009
|
+
except Exception as exc:
|
|
1010
|
+
logger.warning("[Shadow] Failed to discover channels for server %s: %s", server_id, exc)
|
|
1011
|
+
|
|
1012
|
+
if self._auto_discover and not self._server_ids:
|
|
1013
|
+
try:
|
|
1014
|
+
for server in await self.client.list_servers():
|
|
1015
|
+
server_id = str(server.get("id") or server.get("slug") or "")
|
|
1016
|
+
if not server_id:
|
|
1017
|
+
continue
|
|
1018
|
+
try:
|
|
1019
|
+
for channel in await self.client.get_server_channels(server_id):
|
|
1020
|
+
channel_id = str(channel.get("id") or "")
|
|
1021
|
+
if channel_id:
|
|
1022
|
+
self._channel_cache[channel_id] = channel
|
|
1023
|
+
if channel_id and channel_id not in seen:
|
|
1024
|
+
seen.add(channel_id)
|
|
1025
|
+
self._channel_ids.append(channel_id)
|
|
1026
|
+
except Exception as exc:
|
|
1027
|
+
logger.debug("[Shadow] Failed to discover server channels for %s: %s", server_id, exc)
|
|
1028
|
+
except Exception as exc:
|
|
1029
|
+
logger.warning("[Shadow] Failed to list servers for auto-discovery: %s", exc)
|
|
1030
|
+
|
|
1031
|
+
if self._auto_discover or self._agent_id:
|
|
1032
|
+
try:
|
|
1033
|
+
for channel in await self.client.list_direct_channels():
|
|
1034
|
+
channel_id = str(channel.get("id") or "")
|
|
1035
|
+
if channel_id:
|
|
1036
|
+
self._channel_cache[channel_id] = channel
|
|
1037
|
+
self._channel_policies.setdefault(
|
|
1038
|
+
channel_id,
|
|
1039
|
+
_default_policy_from_remote_config(self._remote_config),
|
|
1040
|
+
)
|
|
1041
|
+
if channel_id and channel_id not in seen:
|
|
1042
|
+
seen.add(channel_id)
|
|
1043
|
+
self._channel_ids.append(channel_id)
|
|
1044
|
+
except Exception as exc:
|
|
1045
|
+
logger.debug("[Shadow] Failed to list direct channels: %s", exc)
|
|
1046
|
+
|
|
1047
|
+
# Best-effort metadata cache for explicitly configured channels.
|
|
1048
|
+
for channel_id in list(self._channel_ids):
|
|
1049
|
+
if channel_id in self._channel_cache:
|
|
1050
|
+
continue
|
|
1051
|
+
try:
|
|
1052
|
+
self._channel_cache[channel_id] = await self.client.get_channel(channel_id)
|
|
1053
|
+
except Exception:
|
|
1054
|
+
self._channel_cache[channel_id] = {"id": channel_id, "name": channel_id, "kind": "channel"}
|
|
1055
|
+
|
|
1056
|
+
async def _start_socket(self) -> None:
|
|
1057
|
+
self.socket = ShadowSocketClient(self.base_url, self.token, transports=self._transports, logger=logger)
|
|
1058
|
+
self.socket.on("connect", self._on_socket_connect)
|
|
1059
|
+
self.socket.on("disconnect", self._on_socket_disconnect)
|
|
1060
|
+
self.socket.on("connect_error", self._on_socket_error)
|
|
1061
|
+
self.socket.on("message:new", self._on_socket_message_new)
|
|
1062
|
+
# Shadow shared constants use message:update/delete. Keep the old
|
|
1063
|
+
# updated/deleted aliases for compatibility with older deployments.
|
|
1064
|
+
self.socket.on("message:update", self._on_socket_message_updated)
|
|
1065
|
+
self.socket.on("message:delete", self._on_socket_message_deleted)
|
|
1066
|
+
self.socket.on("message:updated", self._on_socket_message_updated)
|
|
1067
|
+
self.socket.on("message:deleted", self._on_socket_message_deleted)
|
|
1068
|
+
self.socket.on("channel:member-added", self._on_channel_member_added)
|
|
1069
|
+
self.socket.on("channel:member-removed", self._on_channel_member_removed)
|
|
1070
|
+
self.socket.on("server:joined", self._on_server_joined)
|
|
1071
|
+
self.socket.on("agent:policy-changed", self._on_agent_policy_changed)
|
|
1072
|
+
await self.socket.connect()
|
|
1073
|
+
await self.socket.update_presence("online")
|
|
1074
|
+
for channel_id in self._channel_ids:
|
|
1075
|
+
ack = await self.socket.join_channel(channel_id)
|
|
1076
|
+
logger.info("[Shadow] Joined channel %s ack=%s", channel_id, ack)
|
|
1077
|
+
if self._catchup_minutes > 0:
|
|
1078
|
+
await self._catchup_recent_messages()
|
|
1079
|
+
|
|
1080
|
+
async def _start_polling(self) -> None:
|
|
1081
|
+
if self._catchup_minutes > 0:
|
|
1082
|
+
await self._catchup_recent_messages()
|
|
1083
|
+
else:
|
|
1084
|
+
await self._prime_poll_watermarks()
|
|
1085
|
+
self._poll_task = asyncio.create_task(self._poll_loop(), name="shadowob-poll")
|
|
1086
|
+
|
|
1087
|
+
async def _prime_poll_watermarks(self) -> None:
|
|
1088
|
+
if self.client is None:
|
|
1089
|
+
return
|
|
1090
|
+
for channel_id in self._channel_ids:
|
|
1091
|
+
try:
|
|
1092
|
+
payload = await self.client.get_messages(channel_id, limit=1)
|
|
1093
|
+
messages = payload.get("messages") or []
|
|
1094
|
+
if messages:
|
|
1095
|
+
dt = _message_created_at(messages[0])
|
|
1096
|
+
if dt:
|
|
1097
|
+
self._last_seen_created_at[channel_id] = dt
|
|
1098
|
+
except Exception as exc:
|
|
1099
|
+
logger.debug("[Shadow] Failed to prime watermark for %s: %s", channel_id, exc)
|
|
1100
|
+
|
|
1101
|
+
async def _catchup_recent_messages(self) -> None:
|
|
1102
|
+
for channel_id in list(self._channel_ids):
|
|
1103
|
+
try:
|
|
1104
|
+
await self._catchup_channel_recent_messages(channel_id)
|
|
1105
|
+
except Exception as exc:
|
|
1106
|
+
logger.warning("[Shadow] Catch-up failed for %s: %s", channel_id, exc)
|
|
1107
|
+
|
|
1108
|
+
async def _catchup_channel_recent_messages(self, channel_id: str) -> None:
|
|
1109
|
+
if self.client is None:
|
|
1110
|
+
return
|
|
1111
|
+
cutoff = datetime.now(timezone.utc) - timedelta(minutes=self._catchup_minutes)
|
|
1112
|
+
payload = await self.client.get_messages(channel_id, limit=50)
|
|
1113
|
+
messages = list(payload.get("messages") or [])
|
|
1114
|
+
for message in sorted(messages, key=lambda item: _message_created_at(item) or datetime.min.replace(tzinfo=timezone.utc)):
|
|
1115
|
+
created = _message_created_at(message)
|
|
1116
|
+
if created and created >= cutoff:
|
|
1117
|
+
await self._handle_shadow_message(message, source="catchup")
|
|
1118
|
+
if created:
|
|
1119
|
+
self._last_seen_created_at[channel_id] = max(self._last_seen_created_at.get(channel_id, created), created)
|
|
1120
|
+
|
|
1121
|
+
async def _poll_loop(self) -> None:
|
|
1122
|
+
assert self.client is not None
|
|
1123
|
+
while self._running and not self.has_fatal_error:
|
|
1124
|
+
try:
|
|
1125
|
+
for channel_id in self._channel_ids:
|
|
1126
|
+
payload = await self.client.get_messages(channel_id, limit=25)
|
|
1127
|
+
messages = list(payload.get("messages") or [])
|
|
1128
|
+
messages.sort(key=lambda item: _message_created_at(item) or datetime.min.replace(tzinfo=timezone.utc))
|
|
1129
|
+
last_seen = self._last_seen_created_at.get(channel_id)
|
|
1130
|
+
for message in messages:
|
|
1131
|
+
created = _message_created_at(message)
|
|
1132
|
+
if last_seen and created and created <= last_seen:
|
|
1133
|
+
continue
|
|
1134
|
+
if created:
|
|
1135
|
+
self._last_seen_created_at[channel_id] = created
|
|
1136
|
+
await self._handle_shadow_message(message, source="poll")
|
|
1137
|
+
except asyncio.CancelledError:
|
|
1138
|
+
raise
|
|
1139
|
+
except Exception as exc:
|
|
1140
|
+
logger.warning("[Shadow] polling failed: %s", exc)
|
|
1141
|
+
await asyncio.sleep(max(0.5, self._poll_interval))
|
|
1142
|
+
|
|
1143
|
+
async def _on_socket_connect(self) -> None:
|
|
1144
|
+
logger.info("[Shadow] Socket connected")
|
|
1145
|
+
|
|
1146
|
+
async def _on_socket_disconnect(self, reason: str | None = None) -> None:
|
|
1147
|
+
logger.info("[Shadow] Socket disconnected: %s", reason)
|
|
1148
|
+
|
|
1149
|
+
async def _on_socket_error(self, error: Any) -> None:
|
|
1150
|
+
logger.warning("[Shadow] Socket error: %s", error)
|
|
1151
|
+
|
|
1152
|
+
async def _on_socket_message_new(self, message: dict[str, Any]) -> None:
|
|
1153
|
+
await self._handle_shadow_message(message, source="socket")
|
|
1154
|
+
|
|
1155
|
+
async def _on_socket_message_updated(self, message: dict[str, Any]) -> None:
|
|
1156
|
+
# Hermes does not currently consume external edits as conversation turns.
|
|
1157
|
+
return None
|
|
1158
|
+
|
|
1159
|
+
async def _on_socket_message_deleted(self, payload: dict[str, Any]) -> None:
|
|
1160
|
+
return None
|
|
1161
|
+
|
|
1162
|
+
async def _on_channel_member_added(self, payload: dict[str, Any]) -> None:
|
|
1163
|
+
channel_id = str(payload.get("channelId") or payload.get("channel_id") or "").strip()
|
|
1164
|
+
if not channel_id:
|
|
1165
|
+
return
|
|
1166
|
+
if channel_id not in self._channel_ids:
|
|
1167
|
+
self._channel_ids.append(channel_id)
|
|
1168
|
+
if self.client is not None:
|
|
1169
|
+
try:
|
|
1170
|
+
self._channel_cache[channel_id] = await self.client.get_channel(channel_id)
|
|
1171
|
+
except Exception:
|
|
1172
|
+
self._channel_cache[channel_id] = {"id": channel_id, "name": channel_id, "kind": "channel"}
|
|
1173
|
+
if self.socket is not None:
|
|
1174
|
+
try:
|
|
1175
|
+
ack = await self.socket.join_channel(channel_id)
|
|
1176
|
+
logger.info("[Shadow] Joined newly added channel %s ack=%s", channel_id, ack)
|
|
1177
|
+
except Exception as exc:
|
|
1178
|
+
logger.warning("[Shadow] Failed to join newly added channel %s: %s", channel_id, exc)
|
|
1179
|
+
if self._catchup_minutes > 0:
|
|
1180
|
+
try:
|
|
1181
|
+
await self._catchup_channel_recent_messages(channel_id)
|
|
1182
|
+
except Exception as exc:
|
|
1183
|
+
logger.debug("[Shadow] Failed member-added catch-up for %s: %s", channel_id, exc)
|
|
1184
|
+
|
|
1185
|
+
async def _on_channel_member_removed(self, payload: dict[str, Any]) -> None:
|
|
1186
|
+
channel_id = str(payload.get("channelId") or payload.get("channel_id") or "").strip()
|
|
1187
|
+
if not channel_id:
|
|
1188
|
+
return
|
|
1189
|
+
self._channel_ids = [item for item in self._channel_ids if item != channel_id]
|
|
1190
|
+
self._channel_cache.pop(channel_id, None)
|
|
1191
|
+
self._last_seen_created_at.pop(channel_id, None)
|
|
1192
|
+
if self.socket is not None:
|
|
1193
|
+
try:
|
|
1194
|
+
await self.socket.leave_channel(channel_id)
|
|
1195
|
+
except Exception:
|
|
1196
|
+
pass
|
|
1197
|
+
|
|
1198
|
+
async def _on_server_joined(self, payload: dict[str, Any]) -> None:
|
|
1199
|
+
payload_agent_id = str(payload.get("agentId") or payload.get("agent_id") or "").strip()
|
|
1200
|
+
if payload_agent_id and self._agent_id and payload_agent_id != self._agent_id:
|
|
1201
|
+
return
|
|
1202
|
+
try:
|
|
1203
|
+
await self._refresh_remote_config(sync_socket=True)
|
|
1204
|
+
except Exception as exc:
|
|
1205
|
+
logger.warning("[Shadow] Failed to refresh remote config after server join: %s", exc)
|
|
1206
|
+
|
|
1207
|
+
async def _on_agent_policy_changed(self, payload: dict[str, Any]) -> None:
|
|
1208
|
+
payload_agent_id = str(payload.get("agentId") or payload.get("agent_id") or "").strip()
|
|
1209
|
+
if payload_agent_id and self._agent_id and payload_agent_id != self._agent_id:
|
|
1210
|
+
return
|
|
1211
|
+
try:
|
|
1212
|
+
await self._refresh_remote_config(sync_socket=True)
|
|
1213
|
+
except Exception as exc:
|
|
1214
|
+
logger.warning("[Shadow] Failed to refresh remote config after policy change: %s", exc)
|
|
1215
|
+
|
|
1216
|
+
async def _handle_shadow_message(self, message: dict[str, Any], *, source: str) -> None:
|
|
1217
|
+
message_id = _message_id(message)
|
|
1218
|
+
if not message_id:
|
|
1219
|
+
return
|
|
1220
|
+
if message_id in self._processed_set:
|
|
1221
|
+
return
|
|
1222
|
+
self._remember_processed(message_id)
|
|
1223
|
+
|
|
1224
|
+
channel_id = _message_channel_id(message)
|
|
1225
|
+
if not channel_id:
|
|
1226
|
+
return
|
|
1227
|
+
if self._channel_ids and channel_id not in self._channel_ids:
|
|
1228
|
+
return
|
|
1229
|
+
policy = self._channel_policies.get(channel_id)
|
|
1230
|
+
policy_config = _policy_config(policy)
|
|
1231
|
+
|
|
1232
|
+
author_id = _message_author_id(message)
|
|
1233
|
+
author = _message_author(message)
|
|
1234
|
+
if self._bot_user_id and author_id == self._bot_user_id:
|
|
1235
|
+
logger.debug("[Shadow] skipping own message %s", message_id)
|
|
1236
|
+
return
|
|
1237
|
+
reply_to_buddy = parse_bool(policy_config.get("replyToBuddy"), False)
|
|
1238
|
+
if author.get("isBot") and not (self._reply_to_bots or reply_to_buddy):
|
|
1239
|
+
logger.debug("[Shadow] skipping bot-authored message %s", message_id)
|
|
1240
|
+
return
|
|
1241
|
+
if policy and not _policy_bool(policy, "listen", True):
|
|
1242
|
+
logger.debug("[Shadow] policy listen=false skipped message %s", message_id)
|
|
1243
|
+
return
|
|
1244
|
+
if policy and not _policy_bool(policy, "reply", True):
|
|
1245
|
+
logger.debug("[Shadow] policy reply=false skipped message %s", message_id)
|
|
1246
|
+
return
|
|
1247
|
+
trigger_user_ids = policy_config.get("allowedTriggerUserIds") or policy_config.get("triggerUserIds")
|
|
1248
|
+
if isinstance(trigger_user_ids, list):
|
|
1249
|
+
allowed = {str(item) for item in trigger_user_ids if item}
|
|
1250
|
+
if allowed and (not author_id or author_id not in allowed):
|
|
1251
|
+
logger.debug("[Shadow] policy trigger users skipped message %s", message_id)
|
|
1252
|
+
return
|
|
1253
|
+
|
|
1254
|
+
source_message: dict[str, Any] | None = None
|
|
1255
|
+
source_message_id = _interactive_response_source_id(message)
|
|
1256
|
+
if source_message_id and self.client is not None:
|
|
1257
|
+
try:
|
|
1258
|
+
source_message = await self.client.get_message(source_message_id)
|
|
1259
|
+
except Exception as exc:
|
|
1260
|
+
logger.debug("[Shadow] failed to load interactive source %s: %s", source_message_id, exc)
|
|
1261
|
+
|
|
1262
|
+
text = _interactive_response_text(str(message.get("content") or ""), message, source_message)
|
|
1263
|
+
mention_only = self._mention_only or _policy_bool(policy, "mentionOnly", False)
|
|
1264
|
+
if mention_only and not self._message_mentions_self(message):
|
|
1265
|
+
# DMs are allowed even in mention-only mode.
|
|
1266
|
+
channel = self._channel_cache.get(channel_id, {})
|
|
1267
|
+
kind = str(channel.get("kind") or channel.get("type") or "").lower()
|
|
1268
|
+
if kind not in {"dm", "direct"}:
|
|
1269
|
+
logger.debug("[Shadow] mention-only skipped message %s", message_id)
|
|
1270
|
+
return
|
|
1271
|
+
text = _text_without_self_mention(text, self._bot_username)
|
|
1272
|
+
|
|
1273
|
+
slash_match = _slash_command_match(text, self._slash_commands)
|
|
1274
|
+
if slash_match:
|
|
1275
|
+
command, invoked, args = slash_match
|
|
1276
|
+
logger.info("[Shadow] Matched slash command /%s -> /%s", invoked, command.get("name") or invoked)
|
|
1277
|
+
thread_id = _message_thread_id(message)
|
|
1278
|
+
if command.get("interaction") and not args.strip():
|
|
1279
|
+
sent = await self._send_slash_interactive_prompt(
|
|
1280
|
+
slash_match,
|
|
1281
|
+
message_id=message_id,
|
|
1282
|
+
channel_id=channel_id,
|
|
1283
|
+
thread_id=thread_id,
|
|
1284
|
+
)
|
|
1285
|
+
if sent:
|
|
1286
|
+
return
|
|
1287
|
+
text = _format_slash_command_prompt(text, slash_match)
|
|
1288
|
+
elif text.strip().startswith("/"):
|
|
1289
|
+
logger.info("[Shadow] Unknown slash command in message %s; treating as text", message_id)
|
|
1290
|
+
|
|
1291
|
+
media_paths, media_types, message_type = await self._resolve_inbound_media(message)
|
|
1292
|
+
reply_to_id = _message_reply_to_id(message)
|
|
1293
|
+
reply_to_text = await self._fetch_reply_text(reply_to_id) if reply_to_id else None
|
|
1294
|
+
|
|
1295
|
+
thread_id = _message_thread_id(message)
|
|
1296
|
+
channel = self._channel_cache.get(channel_id, {})
|
|
1297
|
+
chat_name = str(channel.get("name") or channel.get("title") or channel_id)
|
|
1298
|
+
channel_kind = str(channel.get("kind") or channel.get("type") or "channel").lower()
|
|
1299
|
+
chat_type = "thread" if thread_id else ("dm" if channel_kind in {"dm", "direct"} else "group")
|
|
1300
|
+
|
|
1301
|
+
source_obj = self.build_source(
|
|
1302
|
+
chat_id=channel_id,
|
|
1303
|
+
chat_name=chat_name,
|
|
1304
|
+
chat_type=chat_type,
|
|
1305
|
+
user_id=author_id,
|
|
1306
|
+
user_name=_message_author_name(message),
|
|
1307
|
+
thread_id=thread_id,
|
|
1308
|
+
parent_chat_id=channel_id if thread_id else None,
|
|
1309
|
+
message_id=message_id,
|
|
1310
|
+
is_bot=bool(author.get("isBot")),
|
|
1311
|
+
)
|
|
1312
|
+
|
|
1313
|
+
parent_for_bindings = channel_id if thread_id else None
|
|
1314
|
+
config_extra = _extra(self.config)
|
|
1315
|
+
event = MessageEvent(
|
|
1316
|
+
text=text or ("[Media attached]" if media_paths else ""),
|
|
1317
|
+
message_type=message_type,
|
|
1318
|
+
source=source_obj,
|
|
1319
|
+
raw_message={"shadow": message, "source": source},
|
|
1320
|
+
message_id=message_id,
|
|
1321
|
+
media_urls=media_paths,
|
|
1322
|
+
media_types=media_types,
|
|
1323
|
+
reply_to_message_id=reply_to_id,
|
|
1324
|
+
reply_to_text=reply_to_text,
|
|
1325
|
+
auto_skill=resolve_channel_skills(config_extra, thread_id or channel_id, parent_for_bindings),
|
|
1326
|
+
channel_prompt=resolve_channel_prompt(config_extra, thread_id or channel_id, parent_for_bindings),
|
|
1327
|
+
)
|
|
1328
|
+
await self.handle_message(event)
|
|
1329
|
+
|
|
1330
|
+
def _remember_processed(self, message_id: str) -> None:
|
|
1331
|
+
if len(self._processed_ids) == self._processed_ids.maxlen:
|
|
1332
|
+
old = self._processed_ids.popleft()
|
|
1333
|
+
self._processed_set.discard(old)
|
|
1334
|
+
self._processed_ids.append(message_id)
|
|
1335
|
+
self._processed_set.add(message_id)
|
|
1336
|
+
|
|
1337
|
+
def _message_mentions_self(self, message: dict[str, Any]) -> bool:
|
|
1338
|
+
if not self._bot_user_id and not self._bot_username:
|
|
1339
|
+
return False
|
|
1340
|
+
text = str(message.get("content") or "")
|
|
1341
|
+
if self._bot_username and f"@{self._bot_username}".lower() in text.lower():
|
|
1342
|
+
return True
|
|
1343
|
+
metadata = message.get("metadata")
|
|
1344
|
+
mentions = []
|
|
1345
|
+
if isinstance(metadata, dict):
|
|
1346
|
+
raw_mentions = metadata.get("mentions") or []
|
|
1347
|
+
if isinstance(raw_mentions, list):
|
|
1348
|
+
mentions = raw_mentions
|
|
1349
|
+
for mention in mentions:
|
|
1350
|
+
if not isinstance(mention, dict):
|
|
1351
|
+
continue
|
|
1352
|
+
target_id = mention.get("id") or mention.get("userId") or mention.get("targetId")
|
|
1353
|
+
username = mention.get("username") or mention.get("name")
|
|
1354
|
+
if self._bot_user_id and target_id and str(target_id) == self._bot_user_id:
|
|
1355
|
+
return True
|
|
1356
|
+
if self._bot_username and username and str(username).lower() == self._bot_username.lower():
|
|
1357
|
+
return True
|
|
1358
|
+
return False
|
|
1359
|
+
|
|
1360
|
+
async def _fetch_reply_text(self, message_id: str | None) -> str | None:
|
|
1361
|
+
if not message_id or not self._fetch_reply_context or self.client is None:
|
|
1362
|
+
return None
|
|
1363
|
+
try:
|
|
1364
|
+
msg = await self.client.get_message(message_id)
|
|
1365
|
+
return str(msg.get("content") or "") or None
|
|
1366
|
+
except Exception:
|
|
1367
|
+
return None
|
|
1368
|
+
|
|
1369
|
+
async def _resolve_inbound_media(self, message: dict[str, Any]) -> tuple[list[str], list[str], Any]:
|
|
1370
|
+
attachments = message.get("attachments") or []
|
|
1371
|
+
if not isinstance(attachments, list) or not attachments:
|
|
1372
|
+
return [], [], MessageType.TEXT
|
|
1373
|
+
if not self._download_media or self.client is None:
|
|
1374
|
+
return [str(a.get("url")) for a in attachments if isinstance(a, dict) and a.get("url")], [str(a.get("contentType") or a.get("content_type") or "application/octet-stream") for a in attachments if isinstance(a, dict)], MessageType.DOCUMENT
|
|
1375
|
+
|
|
1376
|
+
paths: list[str] = []
|
|
1377
|
+
types: list[str] = []
|
|
1378
|
+
dominant = MessageType.DOCUMENT
|
|
1379
|
+
for attachment in attachments:
|
|
1380
|
+
if not isinstance(attachment, dict):
|
|
1381
|
+
continue
|
|
1382
|
+
url = attachment.get("url")
|
|
1383
|
+
if not url:
|
|
1384
|
+
continue
|
|
1385
|
+
content_type = str(attachment.get("contentType") or attachment.get("content_type") or "application/octet-stream")
|
|
1386
|
+
filename = str(attachment.get("filename") or Path(str(url)).name or "file")
|
|
1387
|
+
try:
|
|
1388
|
+
download_url = str(url)
|
|
1389
|
+
attachment_id = attachment.get("id") or attachment.get("attachmentId") or attachment.get("attachment_id")
|
|
1390
|
+
if download_url.startswith("/shadow/uploads/") and attachment_id:
|
|
1391
|
+
resolved = await self.client.resolve_attachment_media_url(str(attachment_id), disposition="inline")
|
|
1392
|
+
download_url = str(resolved.get("url") or resolved.get("signedUrl") or download_url)
|
|
1393
|
+
downloaded = await self.client.download_file(download_url)
|
|
1394
|
+
content_type = downloaded.content_type or content_type
|
|
1395
|
+
filename = downloaded.filename or filename
|
|
1396
|
+
local_path = self._cache_downloaded_media(downloaded.data, filename, content_type)
|
|
1397
|
+
paths.append(local_path)
|
|
1398
|
+
types.append(content_type)
|
|
1399
|
+
dominant = self._message_type_for_content_type(content_type, filename)
|
|
1400
|
+
except Exception as exc:
|
|
1401
|
+
logger.warning("[Shadow] failed to cache inbound attachment %s: %s", url, exc)
|
|
1402
|
+
paths.append(str(url))
|
|
1403
|
+
types.append(content_type)
|
|
1404
|
+
return paths, types, dominant
|
|
1405
|
+
|
|
1406
|
+
def _cache_downloaded_media(self, data: bytes, filename: str, content_type: str) -> str:
|
|
1407
|
+
suffix = Path(filename).suffix or self._extension_for_content_type(content_type)
|
|
1408
|
+
if content_type.startswith(_IMAGE_CT_PREFIXES) and cache_image_from_bytes:
|
|
1409
|
+
return cache_image_from_bytes(data, suffix or ".jpg")
|
|
1410
|
+
if content_type.startswith(_AUDIO_CT_PREFIXES) and cache_audio_from_bytes:
|
|
1411
|
+
return cache_audio_from_bytes(data, suffix or ".ogg")
|
|
1412
|
+
if content_type.startswith(_VIDEO_CT_PREFIXES) and cache_video_from_bytes:
|
|
1413
|
+
return cache_video_from_bytes(data, suffix or ".mp4")
|
|
1414
|
+
if cache_document_from_bytes:
|
|
1415
|
+
return cache_document_from_bytes(data, filename or f"document{suffix or '.bin'}")
|
|
1416
|
+
# Fallback outside Hermes runtime.
|
|
1417
|
+
cache_dir = Path.home() / ".hermes" / "cache" / "documents"
|
|
1418
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
1419
|
+
path = cache_dir / filename
|
|
1420
|
+
path.write_bytes(data)
|
|
1421
|
+
return str(path)
|
|
1422
|
+
|
|
1423
|
+
def _message_type_for_content_type(self, content_type: str, filename: str = "") -> Any:
|
|
1424
|
+
ct = content_type.lower()
|
|
1425
|
+
if ct.startswith("image/"):
|
|
1426
|
+
return MessageType.PHOTO
|
|
1427
|
+
if ct.startswith("video/"):
|
|
1428
|
+
return MessageType.VIDEO
|
|
1429
|
+
if ct.startswith("audio/"):
|
|
1430
|
+
return MessageType.AUDIO
|
|
1431
|
+
return MessageType.DOCUMENT
|
|
1432
|
+
|
|
1433
|
+
def _extension_for_content_type(self, content_type: str) -> str:
|
|
1434
|
+
import mimetypes
|
|
1435
|
+
|
|
1436
|
+
return mimetypes.guess_extension(content_type.split(";", 1)[0].strip()) or ".bin"
|
|
1437
|
+
|
|
1438
|
+
def _is_retryable(self, exc: Exception) -> bool:
|
|
1439
|
+
text = str(exc).lower()
|
|
1440
|
+
return any(token in text for token in ("connection", "connect", "network", "temporar", "timeout", "503", "502", "504"))
|
|
1441
|
+
|
|
1442
|
+
|
|
1443
|
+
# ── Plugin registry entrypoint ───────────────────────────────────────────────
|
|
1444
|
+
|
|
1445
|
+
|
|
1446
|
+
def _env_has_minimum_config() -> bool:
|
|
1447
|
+
return bool(
|
|
1448
|
+
(os.getenv("SHADOW_BASE_URL") or os.getenv("SHADOW_SERVER_URL"))
|
|
1449
|
+
and os.getenv("SHADOW_TOKEN")
|
|
1450
|
+
)
|
|
1451
|
+
|
|
1452
|
+
|
|
1453
|
+
def check_requirements() -> bool:
|
|
1454
|
+
try:
|
|
1455
|
+
import httpx # noqa: F401
|
|
1456
|
+
except Exception:
|
|
1457
|
+
return False
|
|
1458
|
+
# Hermes uses check_fn for env-only auto-enablement. Config.yaml users can
|
|
1459
|
+
# still enable the platform explicitly and validate_config() will judge that.
|
|
1460
|
+
return _env_has_minimum_config()
|
|
1461
|
+
|
|
1462
|
+
|
|
1463
|
+
def validate_config(config: PlatformConfig) -> bool:
|
|
1464
|
+
return bool(_base_url_from_config(config) and _token_from_config(config))
|
|
1465
|
+
|
|
1466
|
+
|
|
1467
|
+
def _is_connected(config: PlatformConfig) -> bool:
|
|
1468
|
+
return validate_config(config)
|
|
1469
|
+
|
|
1470
|
+
|
|
1471
|
+
def _env_enablement() -> dict[str, Any] | None:
|
|
1472
|
+
base_url = os.getenv("SHADOW_BASE_URL") or os.getenv("SHADOW_SERVER_URL")
|
|
1473
|
+
token = os.getenv("SHADOW_TOKEN")
|
|
1474
|
+
if not base_url or not token:
|
|
1475
|
+
return None
|
|
1476
|
+
|
|
1477
|
+
channel_ids = split_csv(os.getenv("SHADOW_CHANNEL_IDS") or os.getenv("SHADOW_CHANNEL_ID"))
|
|
1478
|
+
home = os.getenv("SHADOW_HOME_CHANNEL")
|
|
1479
|
+
if home and home not in channel_ids:
|
|
1480
|
+
channel_ids.append(home)
|
|
1481
|
+
|
|
1482
|
+
seed: dict[str, Any] = {
|
|
1483
|
+
"base_url": base_url,
|
|
1484
|
+
"token": token,
|
|
1485
|
+
"mention_only": parse_bool(os.getenv("SHADOW_MENTION_ONLY"), False),
|
|
1486
|
+
"reply_to_bots": parse_bool(os.getenv("SHADOW_REPLY_TO_BOTS"), False),
|
|
1487
|
+
"rest_only": parse_bool(os.getenv("SHADOW_REST_ONLY"), False),
|
|
1488
|
+
"download_media": parse_bool(os.getenv("SHADOW_DOWNLOAD_MEDIA"), True),
|
|
1489
|
+
}
|
|
1490
|
+
if channel_ids:
|
|
1491
|
+
seed["channel_ids"] = channel_ids
|
|
1492
|
+
agent_id = os.getenv("SHADOW_AGENT_ID")
|
|
1493
|
+
if agent_id:
|
|
1494
|
+
seed["agent_id"] = agent_id
|
|
1495
|
+
server_ids = split_csv(os.getenv("SHADOW_SERVER_IDS"))
|
|
1496
|
+
if server_ids:
|
|
1497
|
+
seed["server_ids"] = server_ids
|
|
1498
|
+
auto_discover = parse_bool(os.getenv("SHADOW_AUTO_DISCOVER_CHANNELS"), False)
|
|
1499
|
+
if auto_discover:
|
|
1500
|
+
seed["auto_discover_channels"] = auto_discover
|
|
1501
|
+
heartbeat_interval = os.getenv("SHADOW_HEARTBEAT_INTERVAL_SECONDS")
|
|
1502
|
+
if heartbeat_interval:
|
|
1503
|
+
seed["heartbeat_interval_seconds"] = heartbeat_interval
|
|
1504
|
+
slash_commands = _parse_json_list(os.getenv("SHADOW_SLASH_COMMANDS_JSON"))
|
|
1505
|
+
if slash_commands:
|
|
1506
|
+
seed["slash_commands"] = slash_commands
|
|
1507
|
+
poll_interval = os.getenv("SHADOW_POLL_INTERVAL_SECONDS")
|
|
1508
|
+
if poll_interval:
|
|
1509
|
+
seed["poll_interval_seconds"] = poll_interval
|
|
1510
|
+
catchup_minutes = os.getenv("SHADOW_CATCHUP_MINUTES")
|
|
1511
|
+
if catchup_minutes:
|
|
1512
|
+
seed["catchup_minutes"] = catchup_minutes
|
|
1513
|
+
bot_user_id = os.getenv("SHADOW_BOT_USER_ID")
|
|
1514
|
+
if bot_user_id:
|
|
1515
|
+
seed["bot_user_id"] = bot_user_id
|
|
1516
|
+
bot_username = os.getenv("SHADOW_BOT_USERNAME")
|
|
1517
|
+
if bot_username:
|
|
1518
|
+
seed["bot_username"] = bot_username
|
|
1519
|
+
if home:
|
|
1520
|
+
seed["home_channel"] = {"chat_id": home, "name": "Shadow Home"}
|
|
1521
|
+
return seed
|
|
1522
|
+
|
|
1523
|
+
|
|
1524
|
+
async def _standalone_send(
|
|
1525
|
+
pconfig: PlatformConfig,
|
|
1526
|
+
chat_id: str,
|
|
1527
|
+
message: str,
|
|
1528
|
+
*,
|
|
1529
|
+
thread_id: str | None = None,
|
|
1530
|
+
media_files: list[str] | None = None,
|
|
1531
|
+
force_document: bool = False,
|
|
1532
|
+
) -> dict[str, Any]:
|
|
1533
|
+
base_url = _base_url_from_config(pconfig)
|
|
1534
|
+
token = _token_from_config(pconfig)
|
|
1535
|
+
if not base_url or not token:
|
|
1536
|
+
return {"success": False, "error": "SHADOW_BASE_URL and SHADOW_TOKEN are required"}
|
|
1537
|
+
async with ShadowAsyncClient(base_url, token) as client:
|
|
1538
|
+
try:
|
|
1539
|
+
msg = await client.send_message(chat_id, message, thread_id=thread_id)
|
|
1540
|
+
uploaded: list[dict[str, Any]] = []
|
|
1541
|
+
for path in media_files or []:
|
|
1542
|
+
uploaded.append(await client.upload_media_from_url(path, message_id=str(msg.get("id"))))
|
|
1543
|
+
return {"success": True, "message_id": str(msg.get("id") or ""), "raw_response": msg, "uploaded": uploaded}
|
|
1544
|
+
except Exception as exc:
|
|
1545
|
+
return {"success": False, "error": str(exc)}
|
|
1546
|
+
|
|
1547
|
+
|
|
1548
|
+
def register(ctx) -> None:
|
|
1549
|
+
ctx.register_platform(
|
|
1550
|
+
name=PLATFORM_NAME,
|
|
1551
|
+
label="Shadow",
|
|
1552
|
+
adapter_factory=lambda cfg: ShadowOBAdapter(cfg),
|
|
1553
|
+
check_fn=check_requirements,
|
|
1554
|
+
validate_config=validate_config,
|
|
1555
|
+
is_connected=_is_connected,
|
|
1556
|
+
required_env=["SHADOW_BASE_URL", "SHADOW_TOKEN"],
|
|
1557
|
+
install_hint="pip install -r ~/.hermes/plugins/shadowob/requirements.txt",
|
|
1558
|
+
env_enablement_fn=_env_enablement,
|
|
1559
|
+
cron_deliver_env_var="SHADOW_HOME_CHANNEL",
|
|
1560
|
+
standalone_sender_fn=_standalone_send,
|
|
1561
|
+
allowed_users_env="SHADOW_ALLOWED_USERS",
|
|
1562
|
+
allow_all_env="SHADOW_ALLOW_ALL_USERS",
|
|
1563
|
+
max_message_length=8000,
|
|
1564
|
+
emoji="🌑",
|
|
1565
|
+
pii_safe=False,
|
|
1566
|
+
allow_update_command=True,
|
|
1567
|
+
platform_hint=(
|
|
1568
|
+
"You are chatting through Shadow/OpenClaw Buddy. "
|
|
1569
|
+
"Treat channel/thread context as persistent collaborative chat. "
|
|
1570
|
+
"Keep replies concise unless the user asks for implementation detail."
|
|
1571
|
+
),
|
|
1572
|
+
)
|