@shadowob/connector 1.1.6 → 1.1.8
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/README.md +39 -7
- package/dist/cli.js +9756 -48
- package/dist/index.cjs +199 -6
- package/dist/index.d.cts +24 -1
- package/dist/index.d.ts +24 -1
- package/dist/index.js +194 -6
- package/hermes-shadowob-plugin/adapter.py +308 -13
- package/hermes-shadowob-plugin/shadow_sdk.py +100 -3
- package/package.json +2 -1
- package/skills/shadowob/SKILL.md +485 -0
|
@@ -55,9 +55,9 @@ except Exception: # pragma: no cover - lets local static checks import this fil
|
|
|
55
55
|
return None
|
|
56
56
|
|
|
57
57
|
try:
|
|
58
|
-
from .shadow_sdk import ShadowAsyncClient, ShadowSocketClient, parse_bool, split_csv
|
|
58
|
+
from .shadow_sdk import ShadowApiError, ShadowAsyncClient, ShadowSocketClient, parse_bool, split_csv
|
|
59
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
|
|
60
|
+
from shadow_sdk import ShadowApiError, ShadowAsyncClient, ShadowSocketClient, parse_bool, split_csv # type: ignore
|
|
61
61
|
|
|
62
62
|
logger = logging.getLogger(__name__)
|
|
63
63
|
|
|
@@ -68,6 +68,11 @@ _AUDIO_CT_PREFIXES = ("audio/",)
|
|
|
68
68
|
_VIDEO_CT_PREFIXES = ("video/",)
|
|
69
69
|
_DOCUMENT_CT_PREFIXES = ("application/", "text/")
|
|
70
70
|
_SLASH_COMMAND_RE = re.compile(r"^/([a-zA-Z][a-zA-Z0-9._-]{0,63})(?:\s+([\s\S]*))?$")
|
|
71
|
+
_TERMINAL_TASK_STATUSES = {"completed", "failed", "canceled", "transferred"}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _visible_text(text: str) -> str:
|
|
75
|
+
return text.replace("\u200b", "").strip()
|
|
71
76
|
|
|
72
77
|
|
|
73
78
|
def _extra(config: Any) -> dict[str, Any]:
|
|
@@ -218,6 +223,36 @@ def _parse_json_list(value: Any) -> list[dict[str, Any]]:
|
|
|
218
223
|
return []
|
|
219
224
|
|
|
220
225
|
|
|
226
|
+
def _parse_int(value: Any) -> int | None:
|
|
227
|
+
if value in (None, ""):
|
|
228
|
+
return None
|
|
229
|
+
try:
|
|
230
|
+
parsed = int(float(str(value)))
|
|
231
|
+
return parsed if parsed >= 0 else None
|
|
232
|
+
except Exception:
|
|
233
|
+
return None
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _parse_waveform_peaks(value: Any) -> list[int] | None:
|
|
237
|
+
if value in (None, ""):
|
|
238
|
+
return None
|
|
239
|
+
raw = value
|
|
240
|
+
if isinstance(value, str):
|
|
241
|
+
try:
|
|
242
|
+
raw = json.loads(value)
|
|
243
|
+
except Exception:
|
|
244
|
+
return None
|
|
245
|
+
if not isinstance(raw, list):
|
|
246
|
+
return None
|
|
247
|
+
peaks: list[int] = []
|
|
248
|
+
for item in raw:
|
|
249
|
+
parsed = _parse_int(item)
|
|
250
|
+
if parsed is None or parsed < 0 or parsed > 100:
|
|
251
|
+
return None
|
|
252
|
+
peaks.append(parsed)
|
|
253
|
+
return peaks if 32 <= len(peaks) <= 96 else None
|
|
254
|
+
|
|
255
|
+
|
|
221
256
|
def _normalize_slash_command_name(value: Any) -> str | None:
|
|
222
257
|
if not isinstance(value, str):
|
|
223
258
|
return None
|
|
@@ -388,6 +423,119 @@ def _message_reply_to_id(message: dict[str, Any]) -> str | None:
|
|
|
388
423
|
return str(value) if value else None
|
|
389
424
|
|
|
390
425
|
|
|
426
|
+
def _message_cards(message: dict[str, Any]) -> list[dict[str, Any]]:
|
|
427
|
+
metadata = message.get("metadata")
|
|
428
|
+
if not isinstance(metadata, dict):
|
|
429
|
+
return []
|
|
430
|
+
cards = metadata.get("cards")
|
|
431
|
+
if not isinstance(cards, list):
|
|
432
|
+
return []
|
|
433
|
+
return [card for card in cards if isinstance(card, dict)]
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _card_id(card: dict[str, Any]) -> str | None:
|
|
437
|
+
value = card.get("id") or card.get("cardId") or card.get("card_id")
|
|
438
|
+
return str(value) if value else None
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _task_card_by_id(message: dict[str, Any], card_id: str | None) -> dict[str, Any] | None:
|
|
442
|
+
if not card_id:
|
|
443
|
+
return None
|
|
444
|
+
for card in _message_cards(message):
|
|
445
|
+
if str(card.get("id") or "") == card_id:
|
|
446
|
+
return card
|
|
447
|
+
return None
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _task_card_claim_expired(card: dict[str, Any]) -> bool:
|
|
451
|
+
claim = card.get("claim")
|
|
452
|
+
expires_at = claim.get("expiresAt") if isinstance(claim, dict) else None
|
|
453
|
+
if not expires_at:
|
|
454
|
+
return True
|
|
455
|
+
try:
|
|
456
|
+
expires = datetime.fromisoformat(str(expires_at).replace("Z", "+00:00"))
|
|
457
|
+
if expires.tzinfo is None:
|
|
458
|
+
expires = expires.replace(tzinfo=timezone.utc)
|
|
459
|
+
return expires <= datetime.now(timezone.utc)
|
|
460
|
+
except Exception:
|
|
461
|
+
return True
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _task_card_matches_self(card: dict[str, Any], *, bot_user_id: str | None, agent_id: str | None) -> bool:
|
|
465
|
+
if card.get("kind") != "task":
|
|
466
|
+
return False
|
|
467
|
+
status = str(card.get("status") or "").lower()
|
|
468
|
+
if status in _TERMINAL_TASK_STATUSES:
|
|
469
|
+
return False
|
|
470
|
+
assignee = card.get("assignee")
|
|
471
|
+
if not isinstance(assignee, dict):
|
|
472
|
+
return True
|
|
473
|
+
assigned_user = assignee.get("userId") or assignee.get("user_id")
|
|
474
|
+
assigned_agent = assignee.get("agentId") or assignee.get("agent_id")
|
|
475
|
+
if bot_user_id and assigned_user and str(assigned_user) == bot_user_id:
|
|
476
|
+
return True
|
|
477
|
+
if agent_id and assigned_agent and str(assigned_agent) == agent_id:
|
|
478
|
+
return True
|
|
479
|
+
return not assigned_user and not assigned_agent
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _message_task_card_for_self(
|
|
483
|
+
message: dict[str, Any],
|
|
484
|
+
*,
|
|
485
|
+
bot_user_id: str | None,
|
|
486
|
+
agent_id: str | None,
|
|
487
|
+
) -> dict[str, Any] | None:
|
|
488
|
+
for card in _message_cards(message):
|
|
489
|
+
if _task_card_matches_self(card, bot_user_id=bot_user_id, agent_id=agent_id):
|
|
490
|
+
return card
|
|
491
|
+
return None
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _format_task_card_prompt(
|
|
495
|
+
text: str,
|
|
496
|
+
card: dict[str, Any],
|
|
497
|
+
*,
|
|
498
|
+
message_id: str | None = None,
|
|
499
|
+
) -> str:
|
|
500
|
+
title = str(card.get("title") or "Inbox task").strip()
|
|
501
|
+
body = str(card.get("body") or "").strip()
|
|
502
|
+
priority = str(card.get("priority") or "").strip()
|
|
503
|
+
source = card.get("source") if isinstance(card.get("source"), dict) else {}
|
|
504
|
+
source_label = str(source.get("label") or source.get("command") or "").strip()
|
|
505
|
+
card_id = str(card.get("id") or "").strip()
|
|
506
|
+
claim = card.get("claim") if isinstance(card.get("claim"), dict) else {}
|
|
507
|
+
claim_id = str(claim.get("id") or "").strip()
|
|
508
|
+
data = card.get("data") if isinstance(card.get("data"), dict) else {}
|
|
509
|
+
task_data = data.get("task") if isinstance(data.get("task"), dict) else {}
|
|
510
|
+
workspace_id = str(task_data.get("workspaceId") or "").strip()
|
|
511
|
+
lines = ["[Shadow Inbox task]", f"Title: {title}"]
|
|
512
|
+
if message_id:
|
|
513
|
+
lines.append(f"Task message id: {message_id}")
|
|
514
|
+
if card_id:
|
|
515
|
+
lines.append(f"Task card id: {card_id}")
|
|
516
|
+
if claim_id:
|
|
517
|
+
lines.append(f"Task claim id: {claim_id}")
|
|
518
|
+
if workspace_id:
|
|
519
|
+
lines.append(f"Task workspace id: {workspace_id}")
|
|
520
|
+
if priority:
|
|
521
|
+
lines.append(f"Priority: {priority}")
|
|
522
|
+
if source_label:
|
|
523
|
+
lines.append(f"Source: {source_label}")
|
|
524
|
+
if message_id and card_id and claim_id:
|
|
525
|
+
lines.extend(
|
|
526
|
+
[
|
|
527
|
+
"",
|
|
528
|
+
"Bind Shadow Server App command calls for this task with:",
|
|
529
|
+
f"--task-message-id {message_id} --task-card-id {card_id} --task-claim-id {claim_id}",
|
|
530
|
+
]
|
|
531
|
+
)
|
|
532
|
+
if body:
|
|
533
|
+
lines.extend(["", body])
|
|
534
|
+
if text and text.strip() and text.strip() not in {title, body}:
|
|
535
|
+
lines.extend(["", "Original message:", text.strip()])
|
|
536
|
+
return "\n".join(lines)
|
|
537
|
+
|
|
538
|
+
|
|
391
539
|
def _text_without_self_mention(text: str, username: str | None) -> str:
|
|
392
540
|
if not username:
|
|
393
541
|
return text
|
|
@@ -735,7 +883,19 @@ class ShadowOBAdapter(BasePlatformAdapter):
|
|
|
735
883
|
metadata: Optional[dict[str, Any]] = None,
|
|
736
884
|
**kwargs,
|
|
737
885
|
) -> SendResult:
|
|
738
|
-
return await self._send_file(
|
|
886
|
+
return await self._send_file(
|
|
887
|
+
chat_id,
|
|
888
|
+
audio_path,
|
|
889
|
+
caption=caption,
|
|
890
|
+
reply_to=reply_to,
|
|
891
|
+
metadata=metadata,
|
|
892
|
+
attachment_kind="voice",
|
|
893
|
+
duration_ms=_parse_int(kwargs.get("duration_ms") or kwargs.get("durationMs")),
|
|
894
|
+
waveform_peaks=_parse_waveform_peaks(kwargs.get("waveform_peaks") or kwargs.get("waveformPeaks")),
|
|
895
|
+
transcript_text=kwargs.get("transcript") or kwargs.get("transcript_text"),
|
|
896
|
+
transcript_language=kwargs.get("transcript_language") or kwargs.get("transcriptLanguage"),
|
|
897
|
+
transcript_source="runtime",
|
|
898
|
+
)
|
|
739
899
|
|
|
740
900
|
async def send_image(
|
|
741
901
|
self,
|
|
@@ -823,6 +983,12 @@ class ShadowOBAdapter(BasePlatformAdapter):
|
|
|
823
983
|
caption: str | None = None,
|
|
824
984
|
reply_to: str | None = None,
|
|
825
985
|
metadata: dict[str, Any] | None = None,
|
|
986
|
+
attachment_kind: str | None = None,
|
|
987
|
+
duration_ms: int | None = None,
|
|
988
|
+
waveform_peaks: list[int] | None = None,
|
|
989
|
+
transcript_text: str | None = None,
|
|
990
|
+
transcript_language: str | None = None,
|
|
991
|
+
transcript_source: str | None = None,
|
|
826
992
|
) -> SendResult:
|
|
827
993
|
if self.client is None:
|
|
828
994
|
return SendResult(success=False, error="Shadow client is not initialized", retryable=True)
|
|
@@ -835,7 +1001,16 @@ class ShadowOBAdapter(BasePlatformAdapter):
|
|
|
835
1001
|
reply_to_id=_metadata_reply_to(metadata, reply_to),
|
|
836
1002
|
metadata=_metadata_payload(metadata),
|
|
837
1003
|
)
|
|
838
|
-
await self.client.upload_media_from_path(
|
|
1004
|
+
await self.client.upload_media_from_path(
|
|
1005
|
+
path,
|
|
1006
|
+
message_id=str(msg.get("id")),
|
|
1007
|
+
kind=attachment_kind,
|
|
1008
|
+
duration_ms=duration_ms,
|
|
1009
|
+
waveform_peaks=waveform_peaks,
|
|
1010
|
+
transcript_text=str(transcript_text) if transcript_text else None,
|
|
1011
|
+
transcript_language=str(transcript_language) if transcript_language else None,
|
|
1012
|
+
transcript_source=transcript_source,
|
|
1013
|
+
)
|
|
839
1014
|
return SendResult(success=True, message_id=str(msg.get("id") or ""), raw_response=msg)
|
|
840
1015
|
except Exception as exc:
|
|
841
1016
|
return SendResult(success=False, error=str(exc), retryable=self._is_retryable(exc))
|
|
@@ -1305,6 +1480,71 @@ class ShadowOBAdapter(BasePlatformAdapter):
|
|
|
1305
1480
|
except Exception as exc:
|
|
1306
1481
|
logger.warning("[Shadow] Failed to refresh remote config after policy change: %s", exc)
|
|
1307
1482
|
|
|
1483
|
+
async def _activate_task_card(
|
|
1484
|
+
self,
|
|
1485
|
+
message: dict[str, Any],
|
|
1486
|
+
card: dict[str, Any] | None,
|
|
1487
|
+
) -> dict[str, Any] | None:
|
|
1488
|
+
if self.client is None or not card:
|
|
1489
|
+
return card
|
|
1490
|
+
message_id = _message_id(message)
|
|
1491
|
+
card_id = _card_id(card)
|
|
1492
|
+
if not message_id or not card_id:
|
|
1493
|
+
return None
|
|
1494
|
+
|
|
1495
|
+
status = str(card.get("status") or "").lower()
|
|
1496
|
+
try:
|
|
1497
|
+
if status == "queued" or (status in {"claimed", "running"} and _task_card_claim_expired(card)):
|
|
1498
|
+
updated = await self.client.claim_task_card(
|
|
1499
|
+
message_id,
|
|
1500
|
+
card_id,
|
|
1501
|
+
ttl_seconds=3600,
|
|
1502
|
+
note="Hermes accepted the Inbox task.",
|
|
1503
|
+
)
|
|
1504
|
+
message = updated if isinstance(updated, dict) else message
|
|
1505
|
+
card = _task_card_by_id(message, card_id) or card
|
|
1506
|
+
status = str(card.get("status") or status).lower()
|
|
1507
|
+
|
|
1508
|
+
if status in {"queued", "claimed"}:
|
|
1509
|
+
updated = await self.client.update_task_card(
|
|
1510
|
+
message_id,
|
|
1511
|
+
card_id,
|
|
1512
|
+
status="running",
|
|
1513
|
+
note="Hermes started working on the task.",
|
|
1514
|
+
)
|
|
1515
|
+
message = updated if isinstance(updated, dict) else message
|
|
1516
|
+
card = _task_card_by_id(message, card_id) or card
|
|
1517
|
+
return card
|
|
1518
|
+
except ShadowApiError as exc:
|
|
1519
|
+
if exc.status_code == 409:
|
|
1520
|
+
logger.info("[Shadow] Inbox task card %s is already claimed; skipping message %s", card_id, message_id)
|
|
1521
|
+
return None
|
|
1522
|
+
logger.warning("[Shadow] Failed to activate Inbox task card %s/%s: %s", message_id, card_id, exc)
|
|
1523
|
+
return None
|
|
1524
|
+
except Exception as exc:
|
|
1525
|
+
logger.warning("[Shadow] Failed to activate Inbox task card %s/%s: %s", message_id, card_id, exc)
|
|
1526
|
+
return None
|
|
1527
|
+
|
|
1528
|
+
async def _complete_task_card(
|
|
1529
|
+
self,
|
|
1530
|
+
message_id: str | None,
|
|
1531
|
+
card_id: str | None,
|
|
1532
|
+
*,
|
|
1533
|
+
failed: bool = False,
|
|
1534
|
+
note: str | None = None,
|
|
1535
|
+
) -> None:
|
|
1536
|
+
if self.client is None or not message_id or not card_id:
|
|
1537
|
+
return
|
|
1538
|
+
try:
|
|
1539
|
+
await self.client.update_task_card(
|
|
1540
|
+
message_id,
|
|
1541
|
+
card_id,
|
|
1542
|
+
status="failed" if failed else "completed",
|
|
1543
|
+
note=(note or ("Hermes failed while processing this task." if failed else "Hermes finished processing this task."))[:4000],
|
|
1544
|
+
)
|
|
1545
|
+
except Exception as exc:
|
|
1546
|
+
logger.debug("[Shadow] Failed to update Inbox task card %s/%s completion: %s", message_id, card_id, exc)
|
|
1547
|
+
|
|
1308
1548
|
async def _handle_shadow_message(self, message: dict[str, Any], *, source: str) -> None:
|
|
1309
1549
|
message_id = _message_id(message)
|
|
1310
1550
|
if not message_id:
|
|
@@ -1336,10 +1576,15 @@ class ShadowOBAdapter(BasePlatformAdapter):
|
|
|
1336
1576
|
if policy and not _policy_bool(policy, "reply", True):
|
|
1337
1577
|
logger.debug("[Shadow] policy reply=false skipped message %s", message_id)
|
|
1338
1578
|
return
|
|
1579
|
+
task_card = _message_task_card_for_self(
|
|
1580
|
+
message,
|
|
1581
|
+
bot_user_id=self._bot_user_id,
|
|
1582
|
+
agent_id=self._agent_id,
|
|
1583
|
+
)
|
|
1339
1584
|
trigger_user_ids = policy_config.get("allowedTriggerUserIds") or policy_config.get("triggerUserIds")
|
|
1340
1585
|
if isinstance(trigger_user_ids, list):
|
|
1341
1586
|
allowed = {str(item) for item in trigger_user_ids if item}
|
|
1342
|
-
if allowed and (not author_id or author_id not in allowed):
|
|
1587
|
+
if allowed and not task_card and (not author_id or author_id not in allowed):
|
|
1343
1588
|
logger.debug("[Shadow] policy trigger users skipped message %s", message_id)
|
|
1344
1589
|
return
|
|
1345
1590
|
|
|
@@ -1353,7 +1598,7 @@ class ShadowOBAdapter(BasePlatformAdapter):
|
|
|
1353
1598
|
|
|
1354
1599
|
text = _interactive_response_text(str(message.get("content") or ""), message, source_message)
|
|
1355
1600
|
mention_only = self._mention_only or _policy_bool(policy, "mentionOnly", False)
|
|
1356
|
-
if mention_only and not self._message_mentions_self(message):
|
|
1601
|
+
if mention_only and not self._message_mentions_self(message) and not task_card:
|
|
1357
1602
|
# DMs are allowed even in mention-only mode.
|
|
1358
1603
|
channel = self._channel_cache.get(channel_id, {})
|
|
1359
1604
|
kind = str(channel.get("kind") or channel.get("type") or "").lower()
|
|
@@ -1380,7 +1625,21 @@ class ShadowOBAdapter(BasePlatformAdapter):
|
|
|
1380
1625
|
elif text.strip().startswith("/"):
|
|
1381
1626
|
logger.info("[Shadow] Unknown slash command in message %s; treating as text", message_id)
|
|
1382
1627
|
|
|
1383
|
-
|
|
1628
|
+
if task_card:
|
|
1629
|
+
task_card = await self._activate_task_card(message, task_card)
|
|
1630
|
+
if not task_card:
|
|
1631
|
+
return
|
|
1632
|
+
text = _format_task_card_prompt(text, task_card, message_id=message_id)
|
|
1633
|
+
|
|
1634
|
+
media_paths, media_types, message_type, media_metadata = await self._resolve_inbound_media(message)
|
|
1635
|
+
voice_metadata = media_metadata.get("voice") if isinstance(media_metadata, dict) else None
|
|
1636
|
+
voice_transcript = (
|
|
1637
|
+
voice_metadata.get("transcript")
|
|
1638
|
+
if isinstance(voice_metadata, dict) and voice_metadata.get("transcript_status") == "ready"
|
|
1639
|
+
else None
|
|
1640
|
+
)
|
|
1641
|
+
if voice_transcript and not _visible_text(text):
|
|
1642
|
+
text = str(voice_transcript)
|
|
1384
1643
|
reply_to_id = _message_reply_to_id(message)
|
|
1385
1644
|
reply_to_text = await self._fetch_reply_text(reply_to_id) if reply_to_id else None
|
|
1386
1645
|
|
|
@@ -1408,7 +1667,7 @@ class ShadowOBAdapter(BasePlatformAdapter):
|
|
|
1408
1667
|
text=text or ("[Media attached]" if media_paths else ""),
|
|
1409
1668
|
message_type=message_type,
|
|
1410
1669
|
source=source_obj,
|
|
1411
|
-
raw_message={"shadow": message, "source": source},
|
|
1670
|
+
raw_message={"shadow": message, "source": source, "media": media_metadata},
|
|
1412
1671
|
message_id=message_id,
|
|
1413
1672
|
media_urls=media_paths,
|
|
1414
1673
|
media_types=media_types,
|
|
@@ -1417,7 +1676,14 @@ class ShadowOBAdapter(BasePlatformAdapter):
|
|
|
1417
1676
|
auto_skill=resolve_channel_skills(config_extra, thread_id or channel_id, parent_for_bindings),
|
|
1418
1677
|
channel_prompt=resolve_channel_prompt(config_extra, thread_id or channel_id, parent_for_bindings),
|
|
1419
1678
|
)
|
|
1420
|
-
|
|
1679
|
+
task_card_id = _card_id(task_card) if task_card else None
|
|
1680
|
+
try:
|
|
1681
|
+
await self.handle_message(event)
|
|
1682
|
+
except Exception as exc:
|
|
1683
|
+
await self._complete_task_card(message_id, task_card_id, failed=True, note=str(exc))
|
|
1684
|
+
raise
|
|
1685
|
+
if task_card_id:
|
|
1686
|
+
await self._complete_task_card(message_id, task_card_id)
|
|
1421
1687
|
|
|
1422
1688
|
def _remember_processed(self, message_id: str) -> None:
|
|
1423
1689
|
if len(self._processed_ids) == self._processed_ids.maxlen:
|
|
@@ -1458,15 +1724,40 @@ class ShadowOBAdapter(BasePlatformAdapter):
|
|
|
1458
1724
|
except Exception:
|
|
1459
1725
|
return None
|
|
1460
1726
|
|
|
1461
|
-
|
|
1727
|
+
def _voice_attachment_metadata(self, attachment: dict[str, Any], path: str | None = None) -> dict[str, Any]:
|
|
1728
|
+
transcript = attachment.get("transcript")
|
|
1729
|
+
return {
|
|
1730
|
+
"voice": True,
|
|
1731
|
+
"attachment_id": attachment.get("id") or attachment.get("attachmentId") or attachment.get("attachment_id"),
|
|
1732
|
+
"path": path,
|
|
1733
|
+
"duration_ms": attachment.get("durationMs") or attachment.get("duration_ms"),
|
|
1734
|
+
"waveform_peaks": attachment.get("waveformPeaks") or attachment.get("waveform_peaks"),
|
|
1735
|
+
"transcript": transcript.get("text") if isinstance(transcript, dict) else None,
|
|
1736
|
+
"transcript_status": transcript.get("status") if isinstance(transcript, dict) else None,
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
async def _resolve_inbound_media(self, message: dict[str, Any]) -> tuple[list[str], list[str], Any, dict[str, Any]]:
|
|
1462
1740
|
attachments = message.get("attachments") or []
|
|
1463
1741
|
if not isinstance(attachments, list) or not attachments:
|
|
1464
|
-
return [], [], MessageType.TEXT
|
|
1742
|
+
return [], [], MessageType.TEXT, {}
|
|
1465
1743
|
if not self._download_media or self.client is None:
|
|
1466
|
-
|
|
1744
|
+
urls = [str(a.get("url")) for a in attachments if isinstance(a, dict) and a.get("url")]
|
|
1745
|
+
types = [str(a.get("contentType") or a.get("content_type") or "application/octet-stream") for a in attachments if isinstance(a, dict)]
|
|
1746
|
+
voice_attachment = next(
|
|
1747
|
+
(
|
|
1748
|
+
a
|
|
1749
|
+
for a in attachments
|
|
1750
|
+
if isinstance(a, dict)
|
|
1751
|
+
and (a.get("kind") == "voice" or str(a.get("contentType") or a.get("content_type") or "").startswith("audio/"))
|
|
1752
|
+
),
|
|
1753
|
+
None,
|
|
1754
|
+
)
|
|
1755
|
+
metadata = {"voice": self._voice_attachment_metadata(voice_attachment)} if voice_attachment else {}
|
|
1756
|
+
return urls, types, MessageType.DOCUMENT, metadata
|
|
1467
1757
|
|
|
1468
1758
|
paths: list[str] = []
|
|
1469
1759
|
types: list[str] = []
|
|
1760
|
+
voice_metadata: dict[str, Any] | None = None
|
|
1470
1761
|
dominant = MessageType.DOCUMENT
|
|
1471
1762
|
for attachment in attachments:
|
|
1472
1763
|
if not isinstance(attachment, dict):
|
|
@@ -1489,11 +1780,15 @@ class ShadowOBAdapter(BasePlatformAdapter):
|
|
|
1489
1780
|
paths.append(local_path)
|
|
1490
1781
|
types.append(content_type)
|
|
1491
1782
|
dominant = self._message_type_for_content_type(content_type, filename)
|
|
1783
|
+
if attachment.get("kind") == "voice" or content_type.startswith(_AUDIO_CT_PREFIXES):
|
|
1784
|
+
voice_metadata = self._voice_attachment_metadata(attachment, local_path)
|
|
1492
1785
|
except Exception as exc:
|
|
1493
1786
|
logger.warning("[Shadow] failed to cache inbound attachment %s: %s", url, exc)
|
|
1494
1787
|
paths.append(str(url))
|
|
1495
1788
|
types.append(content_type)
|
|
1496
|
-
|
|
1789
|
+
if attachment.get("kind") == "voice" or content_type.startswith(_AUDIO_CT_PREFIXES):
|
|
1790
|
+
voice_metadata = self._voice_attachment_metadata(attachment, str(url))
|
|
1791
|
+
return paths, types, dominant, {"voice": voice_metadata} if voice_metadata else {}
|
|
1497
1792
|
|
|
1498
1793
|
def _cache_downloaded_media(self, data: bytes, filename: str, content_type: str) -> str:
|
|
1499
1794
|
suffix = Path(filename).suffix or self._extension_for_content_type(content_type)
|
|
@@ -278,6 +278,42 @@ class ShadowAsyncClient:
|
|
|
278
278
|
async def get_message(self, message_id: str) -> JsonDict:
|
|
279
279
|
return await self.request("GET", f"/api/messages/{quote(str(message_id), safe='')}")
|
|
280
280
|
|
|
281
|
+
async def claim_task_card(
|
|
282
|
+
self,
|
|
283
|
+
message_id: str,
|
|
284
|
+
card_id: str,
|
|
285
|
+
*,
|
|
286
|
+
ttl_seconds: int | None = None,
|
|
287
|
+
note: str | None = None,
|
|
288
|
+
) -> JsonDict:
|
|
289
|
+
body: JsonDict = {}
|
|
290
|
+
if ttl_seconds is not None:
|
|
291
|
+
body["ttlSeconds"] = int(ttl_seconds)
|
|
292
|
+
if note:
|
|
293
|
+
body["note"] = note
|
|
294
|
+
return await self.request(
|
|
295
|
+
"POST",
|
|
296
|
+
f"/api/messages/{quote(str(message_id), safe='')}/cards/{quote(str(card_id), safe='')}/claim",
|
|
297
|
+
json_body=body,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
async def update_task_card(
|
|
301
|
+
self,
|
|
302
|
+
message_id: str,
|
|
303
|
+
card_id: str,
|
|
304
|
+
*,
|
|
305
|
+
status: str,
|
|
306
|
+
note: str | None = None,
|
|
307
|
+
) -> JsonDict:
|
|
308
|
+
body: JsonDict = {"status": status}
|
|
309
|
+
if note:
|
|
310
|
+
body["note"] = note
|
|
311
|
+
return await self.request(
|
|
312
|
+
"PATCH",
|
|
313
|
+
f"/api/messages/{quote(str(message_id), safe='')}/cards/{quote(str(card_id), safe='')}",
|
|
314
|
+
json_body=body,
|
|
315
|
+
)
|
|
316
|
+
|
|
281
317
|
async def resolve_attachment_media_url(
|
|
282
318
|
self,
|
|
283
319
|
attachment_id: str,
|
|
@@ -374,6 +410,12 @@ class ShadowAsyncClient:
|
|
|
374
410
|
content_type: str | None = None,
|
|
375
411
|
*,
|
|
376
412
|
message_id: str | None = None,
|
|
413
|
+
kind: str | None = None,
|
|
414
|
+
duration_ms: int | None = None,
|
|
415
|
+
waveform_peaks: list[int] | None = None,
|
|
416
|
+
transcript_text: str | None = None,
|
|
417
|
+
transcript_language: str | None = None,
|
|
418
|
+
transcript_source: str | None = None,
|
|
377
419
|
) -> JsonDict:
|
|
378
420
|
await self.open()
|
|
379
421
|
content_type = content_type or infer_content_type(filename)
|
|
@@ -381,6 +423,18 @@ class ShadowAsyncClient:
|
|
|
381
423
|
form_data: dict[str, str] = {}
|
|
382
424
|
if message_id:
|
|
383
425
|
form_data["messageId"] = message_id
|
|
426
|
+
if kind:
|
|
427
|
+
form_data["kind"] = kind
|
|
428
|
+
if duration_ms is not None:
|
|
429
|
+
form_data["durationMs"] = str(int(duration_ms))
|
|
430
|
+
if waveform_peaks is not None:
|
|
431
|
+
form_data["waveformPeaks"] = json.dumps(waveform_peaks)
|
|
432
|
+
if transcript_text:
|
|
433
|
+
form_data["transcriptText"] = transcript_text
|
|
434
|
+
if transcript_language:
|
|
435
|
+
form_data["transcriptLanguage"] = transcript_language
|
|
436
|
+
if transcript_source:
|
|
437
|
+
form_data["transcriptSource"] = transcript_source
|
|
384
438
|
response = await self.client.post(
|
|
385
439
|
self._url("/api/media/upload"),
|
|
386
440
|
headers=self._headers(json_content=False),
|
|
@@ -391,7 +445,18 @@ class ShadowAsyncClient:
|
|
|
391
445
|
raise ShadowApiError("POST", "/api/media/upload", response.status_code, response.text)
|
|
392
446
|
return response.json()
|
|
393
447
|
|
|
394
|
-
async def upload_media_from_path(
|
|
448
|
+
async def upload_media_from_path(
|
|
449
|
+
self,
|
|
450
|
+
path: str | os.PathLike[str],
|
|
451
|
+
*,
|
|
452
|
+
message_id: str | None = None,
|
|
453
|
+
kind: str | None = None,
|
|
454
|
+
duration_ms: int | None = None,
|
|
455
|
+
waveform_peaks: list[int] | None = None,
|
|
456
|
+
transcript_text: str | None = None,
|
|
457
|
+
transcript_language: str | None = None,
|
|
458
|
+
transcript_source: str | None = None,
|
|
459
|
+
) -> JsonDict:
|
|
395
460
|
p = Path(path).expanduser()
|
|
396
461
|
data = p.read_bytes()
|
|
397
462
|
return await self.upload_media(
|
|
@@ -399,22 +464,54 @@ class ShadowAsyncClient:
|
|
|
399
464
|
p.name,
|
|
400
465
|
infer_content_type(p.name),
|
|
401
466
|
message_id=message_id,
|
|
467
|
+
kind=kind,
|
|
468
|
+
duration_ms=duration_ms,
|
|
469
|
+
waveform_peaks=waveform_peaks,
|
|
470
|
+
transcript_text=transcript_text,
|
|
471
|
+
transcript_language=transcript_language,
|
|
472
|
+
transcript_source=transcript_source,
|
|
402
473
|
)
|
|
403
474
|
|
|
404
|
-
async def upload_media_from_url(
|
|
475
|
+
async def upload_media_from_url(
|
|
476
|
+
self,
|
|
477
|
+
url_or_path: str,
|
|
478
|
+
*,
|
|
479
|
+
message_id: str | None = None,
|
|
480
|
+
kind: str | None = None,
|
|
481
|
+
duration_ms: int | None = None,
|
|
482
|
+
waveform_peaks: list[int] | None = None,
|
|
483
|
+
transcript_text: str | None = None,
|
|
484
|
+
transcript_language: str | None = None,
|
|
485
|
+
transcript_source: str | None = None,
|
|
486
|
+
) -> JsonDict:
|
|
405
487
|
value = str(url_or_path).strip()
|
|
406
488
|
if value.upper().startswith("MEDIA:"):
|
|
407
489
|
value = value.split(":", 1)[1].strip()
|
|
408
490
|
if value.startswith("file://"):
|
|
409
491
|
value = value[7:]
|
|
410
492
|
if value.startswith("~") or value.startswith("/") or not urlparse(value).scheme:
|
|
411
|
-
return await self.upload_media_from_path(
|
|
493
|
+
return await self.upload_media_from_path(
|
|
494
|
+
value,
|
|
495
|
+
message_id=message_id,
|
|
496
|
+
kind=kind,
|
|
497
|
+
duration_ms=duration_ms,
|
|
498
|
+
waveform_peaks=waveform_peaks,
|
|
499
|
+
transcript_text=transcript_text,
|
|
500
|
+
transcript_language=transcript_language,
|
|
501
|
+
transcript_source=transcript_source,
|
|
502
|
+
)
|
|
412
503
|
downloaded = await self.download_file(value)
|
|
413
504
|
return await self.upload_media(
|
|
414
505
|
downloaded.data,
|
|
415
506
|
downloaded.filename,
|
|
416
507
|
downloaded.content_type,
|
|
417
508
|
message_id=message_id,
|
|
509
|
+
kind=kind,
|
|
510
|
+
duration_ms=duration_ms,
|
|
511
|
+
waveform_peaks=waveform_peaks,
|
|
512
|
+
transcript_text=transcript_text,
|
|
513
|
+
transcript_language=transcript_language,
|
|
514
|
+
transcript_source=transcript_source,
|
|
418
515
|
)
|
|
419
516
|
|
|
420
517
|
async def download_file(self, file_url: str) -> DownloadedFile:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shadowob/connector",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.8",
|
|
4
4
|
"description": "Shadow connector helpers for OpenClaw, Hermes Agent, and cc-connect",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"files": [
|
|
23
23
|
"dist",
|
|
24
24
|
"hermes-shadowob-plugin",
|
|
25
|
+
"skills",
|
|
25
26
|
"README.md"
|
|
26
27
|
],
|
|
27
28
|
"keywords": [
|