@shadowob/connector 1.1.3-dev.251

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ )