@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.
@@ -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(chat_id, audio_path, caption=caption, reply_to=reply_to, metadata=metadata)
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(path, message_id=str(msg.get("id")))
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
- media_paths, media_types, message_type = await self._resolve_inbound_media(message)
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
- await self.handle_message(event)
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
- async def _resolve_inbound_media(self, message: dict[str, Any]) -> tuple[list[str], list[str], Any]:
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
- 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
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
- return paths, types, dominant
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(self, path: str | os.PathLike[str], *, message_id: str | None = None) -> JsonDict:
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(self, url_or_path: str, *, message_id: str | None = None) -> JsonDict:
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(value, message_id=message_id)
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.6",
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": [