oneshot-python 0.24.0__tar.gz → 0.25.0__tar.gz
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.
- {oneshot_python-0.24.0 → oneshot_python-0.25.0}/PKG-INFO +2 -2
- {oneshot_python-0.24.0 → oneshot_python-0.25.0}/README.md +1 -1
- {oneshot_python-0.24.0 → oneshot_python-0.25.0}/oneshot/client.py +167 -16
- {oneshot_python-0.24.0 → oneshot_python-0.25.0}/pyproject.toml +2 -2
- oneshot_python-0.25.0/tests/test_linkedin.py +72 -0
- {oneshot_python-0.24.0 → oneshot_python-0.25.0}/.gitignore +0 -0
- {oneshot_python-0.24.0 → oneshot_python-0.25.0}/oneshot/__init__.py +0 -0
- {oneshot_python-0.24.0 → oneshot_python-0.25.0}/oneshot/_errors.py +0 -0
- {oneshot_python-0.24.0 → oneshot_python-0.25.0}/oneshot/_types.py +0 -0
- {oneshot_python-0.24.0 → oneshot_python-0.25.0}/oneshot/physical_mail.py +0 -0
- {oneshot_python-0.24.0 → oneshot_python-0.25.0}/oneshot/x402.py +0 -0
- {oneshot_python-0.24.0 → oneshot_python-0.25.0}/tests/__init__.py +0 -0
- {oneshot_python-0.24.0 → oneshot_python-0.25.0}/tests/test_balance.py +0 -0
- {oneshot_python-0.24.0 → oneshot_python-0.25.0}/tests/test_browser.py +0 -0
- {oneshot_python-0.24.0 → oneshot_python-0.25.0}/tests/test_budgets.py +0 -0
- {oneshot_python-0.24.0 → oneshot_python-0.25.0}/tests/test_charge_amount.py +0 -0
- {oneshot_python-0.24.0 → oneshot_python-0.25.0}/tests/test_compute.py +0 -0
- {oneshot_python-0.24.0 → oneshot_python-0.25.0}/tests/test_domains.py +0 -0
- {oneshot_python-0.24.0 → oneshot_python-0.25.0}/tests/test_email_payload.py +0 -0
- {oneshot_python-0.24.0 → oneshot_python-0.25.0}/tests/test_emergency_error.py +0 -0
- {oneshot_python-0.24.0 → oneshot_python-0.25.0}/tests/test_max_cost_header.py +0 -0
- {oneshot_python-0.24.0 → oneshot_python-0.25.0}/tests/test_payment_rejection.py +0 -0
- {oneshot_python-0.24.0 → oneshot_python-0.25.0}/tests/test_phones_pending.py +0 -0
- {oneshot_python-0.24.0 → oneshot_python-0.25.0}/tests/test_physical_mail.py +0 -0
- {oneshot_python-0.24.0 → oneshot_python-0.25.0}/tests/test_poll_backoff.py +0 -0
- {oneshot_python-0.24.0 → oneshot_python-0.25.0}/tests/test_request_id.py +0 -0
- {oneshot_python-0.24.0 → oneshot_python-0.25.0}/tests/test_tag_receipt_value.py +0 -0
- {oneshot_python-0.24.0 → oneshot_python-0.25.0}/tests/test_wait_false.py +0 -0
- {oneshot_python-0.24.0 → oneshot_python-0.25.0}/tests/test_x402.py +0 -0
- {oneshot_python-0.24.0 → oneshot_python-0.25.0}/uv.lock +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: oneshot-python
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: Core Python SDK for the OneShot API — HTTP client with x402 payment handling —
|
|
3
|
+
Version: 0.25.0
|
|
4
|
+
Summary: Core Python SDK for the OneShot API — HTTP client with x402 payment handling — 43 tools
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Requires-Python: >=3.10
|
|
7
7
|
Requires-Dist: eth-account>=0.13.0
|
|
@@ -192,7 +192,7 @@ Every method has an `a*` async mirror (`acompute`, `aget_compute_goal`, …).
|
|
|
192
192
|
## Links
|
|
193
193
|
|
|
194
194
|
- [Documentation](https://docs.oneshotagent.com/sdk/installation#install-via-pip-python)
|
|
195
|
-
- [LangChain integration](https://pypi.org/project/langchain-oneshot/) —
|
|
195
|
+
- [LangChain integration](https://pypi.org/project/langchain-oneshot/) — 43 tools as LangChain BaseTool
|
|
196
196
|
- [GAME plugin](https://pypi.org/project/game-plugin-oneshot/) — Virtuals Protocol integration
|
|
197
197
|
- [TypeScript SDK](https://www.npmjs.com/package/@oneshot-agent/sdk)
|
|
198
198
|
- [MCP Server](https://www.npmjs.com/package/@oneshot-agent/mcp-server)
|
|
@@ -51,7 +51,7 @@ try:
|
|
|
51
51
|
|
|
52
52
|
SDK_VERSION = _pkg_version("oneshot-python")
|
|
53
53
|
except Exception: # pragma: no cover - editable/source runs without dist metadata
|
|
54
|
-
SDK_VERSION = "0.
|
|
54
|
+
SDK_VERSION = "0.25.0"
|
|
55
55
|
|
|
56
56
|
# ---------------------------------------------------------------------------
|
|
57
57
|
# Environment configuration
|
|
@@ -312,12 +312,16 @@ class OneShotClient:
|
|
|
312
312
|
# Headers
|
|
313
313
|
# ------------------------------------------------------------------
|
|
314
314
|
|
|
315
|
-
def _headers(self, max_cost: Optional[float] = None) -> dict[str, str]:
|
|
315
|
+
def _headers(self, max_cost: Optional[float] = None, idempotency_key: Optional[str] = None) -> dict[str, str]:
|
|
316
316
|
headers = {
|
|
317
317
|
"Content-Type": "application/json",
|
|
318
318
|
"X-Agent-ID": self.address,
|
|
319
319
|
"X-OneShot-SDK-Version": SDK_VERSION,
|
|
320
320
|
}
|
|
321
|
+
# Sent on BOTH x402 legs: the server replays a retry instead of
|
|
322
|
+
# settling a second payment and repeating the side effect.
|
|
323
|
+
if idempotency_key:
|
|
324
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
321
325
|
# Server-side budget cap. The API rejects with HTTP 400
|
|
322
326
|
# `exceeds_caller_budget` before requesting payment if the quoted
|
|
323
327
|
# total exceeds this cap. The local fast-fail below stays as defense
|
|
@@ -331,16 +335,17 @@ class OneShotClient:
|
|
|
331
335
|
from oneshot.physical_mail import PhysicalMail
|
|
332
336
|
return PhysicalMail(self)
|
|
333
337
|
|
|
334
|
-
def _read_headers(self) -> dict[str, str]:
|
|
338
|
+
def _read_headers(self, scope: str = "read") -> dict[str, str]:
|
|
335
339
|
"""Auth headers plus a signed EIP-712 read proof (x-agent-proof) for the
|
|
336
340
|
free READ routes (inbox, sms inbox, notifications, balance, browser
|
|
337
341
|
profiles). These identify the caller by wallet, and wallet addresses are
|
|
338
342
|
public, so without a proof anyone could read another agent's data by
|
|
339
343
|
supplying its address. Signing failure falls back to plain headers (the
|
|
340
|
-
server runs log-only until read-proof enforcement is enabled).
|
|
344
|
+
server runs log-only until read-proof enforcement is enabled).
|
|
345
|
+
Free MUTATION routes (e.g. LinkedIn connect/revoke) require scope="write"."""
|
|
341
346
|
headers = self._headers()
|
|
342
347
|
try:
|
|
343
|
-
headers["x-agent-proof"] = sign_read_proof(self._private_key, self.address)
|
|
348
|
+
headers["x-agent-proof"] = sign_read_proof(self._private_key, self.address, scope=scope)
|
|
344
349
|
except Exception as e: # noqa: BLE001
|
|
345
350
|
self._log(f"Failed to sign read proof (continuing without): {e}")
|
|
346
351
|
return headers
|
|
@@ -446,6 +451,7 @@ class OneShotClient:
|
|
|
446
451
|
wait: bool = True,
|
|
447
452
|
wait_for_phones: bool = False,
|
|
448
453
|
phone_timeout_sec: int = 360,
|
|
454
|
+
idempotency_key: Optional[str] = None,
|
|
449
455
|
) -> Any:
|
|
450
456
|
"""Execute a paid tool call (blocking). Handles the full x402 flow.
|
|
451
457
|
|
|
@@ -467,6 +473,7 @@ class OneShotClient:
|
|
|
467
473
|
wait=wait,
|
|
468
474
|
wait_for_phones=wait_for_phones,
|
|
469
475
|
phone_timeout_sec=phone_timeout_sec,
|
|
476
|
+
idempotency_key=idempotency_key,
|
|
470
477
|
)
|
|
471
478
|
)
|
|
472
479
|
|
|
@@ -519,6 +526,7 @@ class OneShotClient:
|
|
|
519
526
|
wait_for_phones: bool = False,
|
|
520
527
|
phone_timeout_sec: int = 360,
|
|
521
528
|
quote_id: Optional[str] = None,
|
|
529
|
+
idempotency_key: Optional[str] = None,
|
|
522
530
|
) -> Any:
|
|
523
531
|
"""Execute a paid tool call (async). Handles the full x402 flow.
|
|
524
532
|
|
|
@@ -564,7 +572,7 @@ class OneShotClient:
|
|
|
564
572
|
# before they ever get to the 402 dance unless x-quote-id rides
|
|
565
573
|
# on this very first attempt, so an already-obtained quote_id
|
|
566
574
|
# must be sent from the start, not just on the paid retry.
|
|
567
|
-
initial_headers = self._headers(max_cost=max_cost)
|
|
575
|
+
initial_headers = self._headers(max_cost=max_cost, idempotency_key=idempotency_key)
|
|
568
576
|
if quote_id:
|
|
569
577
|
initial_headers["x-quote-id"] = quote_id
|
|
570
578
|
resp = await client.post(url, headers=initial_headers, json=payload)
|
|
@@ -653,7 +661,7 @@ class OneShotClient:
|
|
|
653
661
|
extensions=parsed_req.get("extensions"),
|
|
654
662
|
)
|
|
655
663
|
headers = {
|
|
656
|
-
**self._headers(max_cost=max_cost),
|
|
664
|
+
**self._headers(max_cost=max_cost, idempotency_key=idempotency_key),
|
|
657
665
|
"payment-signature": encode_payment_header(auth),
|
|
658
666
|
}
|
|
659
667
|
if quote_id:
|
|
@@ -675,7 +683,7 @@ class OneShotClient:
|
|
|
675
683
|
|
|
676
684
|
# Step 4 — Re-POST with payment headers (x402 format)
|
|
677
685
|
headers = {
|
|
678
|
-
**self._headers(max_cost=max_cost),
|
|
686
|
+
**self._headers(max_cost=max_cost, idempotency_key=idempotency_key),
|
|
679
687
|
"payment-signature": encode_payment_header(auth),
|
|
680
688
|
}
|
|
681
689
|
if quote_id:
|
|
@@ -750,21 +758,25 @@ class OneShotClient:
|
|
|
750
758
|
self,
|
|
751
759
|
endpoint: str,
|
|
752
760
|
payload: Optional[dict[str, Any]] = None,
|
|
761
|
+
*,
|
|
762
|
+
scope: str = "read",
|
|
753
763
|
) -> Any:
|
|
754
|
-
"""POST to a free endpoint (blocking)."""
|
|
764
|
+
"""POST to a free endpoint (blocking). Mutations pass scope="write"."""
|
|
755
765
|
return asyncio.get_event_loop().run_until_complete(
|
|
756
|
-
self.acall_free_post(endpoint, payload)
|
|
766
|
+
self.acall_free_post(endpoint, payload, scope=scope)
|
|
757
767
|
)
|
|
758
768
|
|
|
759
769
|
async def acall_free_post(
|
|
760
770
|
self,
|
|
761
771
|
endpoint: str,
|
|
762
772
|
payload: Optional[dict[str, Any]] = None,
|
|
773
|
+
*,
|
|
774
|
+
scope: str = "read",
|
|
763
775
|
) -> Any:
|
|
764
|
-
"""POST to a free endpoint (async)."""
|
|
776
|
+
"""POST to a free endpoint (async). Mutations pass scope="write"."""
|
|
765
777
|
url = f"{self.base_url}{endpoint}"
|
|
766
778
|
async with httpx.AsyncClient(timeout=httpx.Timeout(150.0 if endpoint.startswith("/v1/tools/browser/profiles") else 30.0)) as client:
|
|
767
|
-
resp = await client.post(url, headers=self._read_headers(), json=payload or {})
|
|
779
|
+
resp = await client.post(url, headers=self._read_headers(scope), json=payload or {})
|
|
768
780
|
if not resp.is_success:
|
|
769
781
|
raise ToolError(f"POST {endpoint} failed", resp.status_code, resp.text)
|
|
770
782
|
return resp.json()
|
|
@@ -821,20 +833,24 @@ class OneShotClient:
|
|
|
821
833
|
def call_free_delete(
|
|
822
834
|
self,
|
|
823
835
|
endpoint: str,
|
|
836
|
+
*,
|
|
837
|
+
scope: str = "read",
|
|
824
838
|
) -> Any:
|
|
825
|
-
"""DELETE a free endpoint (blocking)."""
|
|
839
|
+
"""DELETE a free endpoint (blocking). Mutations pass scope="write"."""
|
|
826
840
|
return asyncio.get_event_loop().run_until_complete(
|
|
827
|
-
self.acall_free_delete(endpoint)
|
|
841
|
+
self.acall_free_delete(endpoint, scope=scope)
|
|
828
842
|
)
|
|
829
843
|
|
|
830
844
|
async def acall_free_delete(
|
|
831
845
|
self,
|
|
832
846
|
endpoint: str,
|
|
847
|
+
*,
|
|
848
|
+
scope: str = "read",
|
|
833
849
|
) -> Any:
|
|
834
|
-
"""DELETE a free endpoint (async)."""
|
|
850
|
+
"""DELETE a free endpoint (async). Mutations pass scope="write"."""
|
|
835
851
|
url = f"{self.base_url}{endpoint}"
|
|
836
852
|
async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as client:
|
|
837
|
-
resp = await client.delete(url, headers=self._read_headers())
|
|
853
|
+
resp = await client.delete(url, headers=self._read_headers(scope))
|
|
838
854
|
if not resp.is_success:
|
|
839
855
|
raise ToolError(f"DELETE {endpoint} failed", resp.status_code, resp.text)
|
|
840
856
|
if resp.status_code == 204 or not resp.text:
|
|
@@ -1160,6 +1176,107 @@ class OneShotClient:
|
|
|
1160
1176
|
"""Delete a browser profile. Async."""
|
|
1161
1177
|
return await self.acall_free_delete(f"/v1/tools/browser/profiles/{profile_id}")
|
|
1162
1178
|
|
|
1179
|
+
# ------------------------------------------------------------------
|
|
1180
|
+
# LinkedIn — messaging through a human-connected account (issue #756)
|
|
1181
|
+
# ------------------------------------------------------------------
|
|
1182
|
+
|
|
1183
|
+
def linkedin_connect(self, requested_actions: list[str], *, success_redirect_url: Optional[str] = None, failure_redirect_url: Optional[str] = None) -> dict:
|
|
1184
|
+
"""Create a hosted LinkedIn login link for a human to connect THEIR OWN account and grant `requested_actions`
|
|
1185
|
+
(read, reply, view_profile, react). The returned url is shown to the human once and expires in 30 minutes. Free."""
|
|
1186
|
+
return self.call_free_post("/v1/tools/linkedin/connect", _linkedin_connect_payload(requested_actions, success_redirect_url, failure_redirect_url), scope="write")
|
|
1187
|
+
|
|
1188
|
+
async def alinkedin_connect(self, requested_actions: list[str], *, success_redirect_url: Optional[str] = None, failure_redirect_url: Optional[str] = None) -> dict:
|
|
1189
|
+
return await self.acall_free_post("/v1/tools/linkedin/connect", _linkedin_connect_payload(requested_actions, success_redirect_url, failure_redirect_url), scope="write")
|
|
1190
|
+
|
|
1191
|
+
def get_linkedin_connection(self, intent_id: str) -> dict:
|
|
1192
|
+
"""Status of a connection intent: pending | verifying | completed (with `account`) | failed (`failure_reason`). Free."""
|
|
1193
|
+
return self.call_free_get(f"/v1/tools/linkedin/connect/{quote(intent_id, safe='')}")
|
|
1194
|
+
|
|
1195
|
+
async def aget_linkedin_connection(self, intent_id: str) -> dict:
|
|
1196
|
+
return await self.acall_free_get(f"/v1/tools/linkedin/connect/{quote(intent_id, safe='')}")
|
|
1197
|
+
|
|
1198
|
+
def list_linkedin_accounts(self, *, include_revoked: bool = False) -> dict:
|
|
1199
|
+
"""Connected LinkedIn accounts for this agent (`{"accounts": [...]}`). Never exposes upstream ids. Free."""
|
|
1200
|
+
return self.call_free_get("/v1/tools/linkedin/accounts", {"include_revoked": "true"} if include_revoked else None)
|
|
1201
|
+
|
|
1202
|
+
async def alist_linkedin_accounts(self, *, include_revoked: bool = False) -> dict:
|
|
1203
|
+
return await self.acall_free_get("/v1/tools/linkedin/accounts", {"include_revoked": "true"} if include_revoked else None)
|
|
1204
|
+
|
|
1205
|
+
def get_linkedin_account(self, account_id: str) -> dict:
|
|
1206
|
+
return self.call_free_get(f"/v1/tools/linkedin/accounts/{quote(account_id, safe='')}")
|
|
1207
|
+
|
|
1208
|
+
async def aget_linkedin_account(self, account_id: str) -> dict:
|
|
1209
|
+
return await self.acall_free_get(f"/v1/tools/linkedin/accounts/{quote(account_id, safe='')}")
|
|
1210
|
+
|
|
1211
|
+
def reconnect_linkedin_account(self, account_id: str, *, requested_actions: Optional[list[str]] = None, success_redirect_url: Optional[str] = None, failure_redirect_url: Optional[str] = None) -> dict:
|
|
1212
|
+
"""Hosted reconnect link for an existing account (keeps ownership; may narrow, never widen, the grant). Free."""
|
|
1213
|
+
return self.call_free_post(f"/v1/tools/linkedin/accounts/{quote(account_id, safe='')}/reconnect", _linkedin_connect_payload(requested_actions, success_redirect_url, failure_redirect_url), scope="write")
|
|
1214
|
+
|
|
1215
|
+
async def areconnect_linkedin_account(self, account_id: str, *, requested_actions: Optional[list[str]] = None, success_redirect_url: Optional[str] = None, failure_redirect_url: Optional[str] = None) -> dict:
|
|
1216
|
+
return await self.acall_free_post(f"/v1/tools/linkedin/accounts/{quote(account_id, safe='')}/reconnect", _linkedin_connect_payload(requested_actions, success_redirect_url, failure_redirect_url), scope="write")
|
|
1217
|
+
|
|
1218
|
+
def revoke_linkedin_account(self, account_id: str) -> dict:
|
|
1219
|
+
"""End the grant, cancel queued writes, delete the upstream connection. Free."""
|
|
1220
|
+
return self.call_free_delete(f"/v1/tools/linkedin/accounts/{quote(account_id, safe='')}", scope="write")
|
|
1221
|
+
|
|
1222
|
+
async def arevoke_linkedin_account(self, account_id: str) -> dict:
|
|
1223
|
+
return await self.acall_free_delete(f"/v1/tools/linkedin/accounts/{quote(account_id, safe='')}", scope="write")
|
|
1224
|
+
|
|
1225
|
+
def get_linkedin_sync(self, account_id: str) -> dict:
|
|
1226
|
+
"""Coverage, sync_state and recent runs for an account. Free."""
|
|
1227
|
+
return self.call_free_get(f"/v1/tools/linkedin/accounts/{quote(account_id, safe='')}/sync")
|
|
1228
|
+
|
|
1229
|
+
async def aget_linkedin_sync(self, account_id: str) -> dict:
|
|
1230
|
+
return await self.acall_free_get(f"/v1/tools/linkedin/accounts/{quote(account_id, safe='')}/sync")
|
|
1231
|
+
|
|
1232
|
+
def linkedin_sync(self, account_id: str, *, mode: Optional[str] = None, max_pages: Optional[int] = None, max_cost: Optional[float] = None, timeout_sec: int = 600, wait: bool = True, **kwargs: Any) -> Any:
|
|
1233
|
+
"""One bounded, paid history-sync run (up to max_pages x 250 messages). Blocking."""
|
|
1234
|
+
return self.call_tool("/v1/tools/linkedin/sync", _linkedin_sync_payload(account_id, mode, max_pages, kwargs), max_cost=max_cost, timeout_sec=timeout_sec, wait=wait)
|
|
1235
|
+
|
|
1236
|
+
async def alinkedin_sync(self, account_id: str, *, mode: Optional[str] = None, max_pages: Optional[int] = None, max_cost: Optional[float] = None, timeout_sec: int = 600, wait: bool = True, **kwargs: Any) -> Any:
|
|
1237
|
+
return await self.acall_tool("/v1/tools/linkedin/sync", _linkedin_sync_payload(account_id, mode, max_pages, kwargs), max_cost=max_cost, timeout_sec=timeout_sec, wait=wait)
|
|
1238
|
+
|
|
1239
|
+
def linkedin_conversations(self, account_id: str, **params: Any) -> dict:
|
|
1240
|
+
"""Synced conversations, newest activity first; carries coverage + sync_state. Params: cursor, limit, since, unread. Free."""
|
|
1241
|
+
return self.call_free_get(f"/v1/tools/linkedin/accounts/{quote(account_id, safe='')}/conversations", _linkedin_query(params))
|
|
1242
|
+
|
|
1243
|
+
async def alinkedin_conversations(self, account_id: str, **params: Any) -> dict:
|
|
1244
|
+
return await self.acall_free_get(f"/v1/tools/linkedin/accounts/{quote(account_id, safe='')}/conversations", _linkedin_query(params))
|
|
1245
|
+
|
|
1246
|
+
def linkedin_messages(self, account_id: str, **params: Any) -> dict:
|
|
1247
|
+
"""Synced messages, newest first. Params: conversation_id, direction, since, changed_since, cursor, limit, include_deleted. Free."""
|
|
1248
|
+
return self.call_free_get(f"/v1/tools/linkedin/accounts/{quote(account_id, safe='')}/messages", _linkedin_query(params))
|
|
1249
|
+
|
|
1250
|
+
async def alinkedin_messages(self, account_id: str, **params: Any) -> dict:
|
|
1251
|
+
return await self.acall_free_get(f"/v1/tools/linkedin/accounts/{quote(account_id, safe='')}/messages", _linkedin_query(params))
|
|
1252
|
+
|
|
1253
|
+
def linkedin_reply(self, account_id: str, conversation_id: str, text: str, *, idempotency_key: Optional[str] = None, max_cost: Optional[float] = None, timeout_sec: int = 180, wait: bool = True, **kwargs: Any) -> Any:
|
|
1254
|
+
"""Send a reply through the connected account. An Idempotency-Key is generated when not supplied so a retry never double-sends. Blocking."""
|
|
1255
|
+
return self.call_tool("/v1/tools/linkedin/reply", {"account_id": account_id, "conversation_id": conversation_id, "text": text, **kwargs},
|
|
1256
|
+
max_cost=max_cost, timeout_sec=timeout_sec, wait=wait, idempotency_key=idempotency_key or _new_idempotency_key())
|
|
1257
|
+
|
|
1258
|
+
async def alinkedin_reply(self, account_id: str, conversation_id: str, text: str, *, idempotency_key: Optional[str] = None, max_cost: Optional[float] = None, timeout_sec: int = 180, wait: bool = True, **kwargs: Any) -> Any:
|
|
1259
|
+
return await self.acall_tool("/v1/tools/linkedin/reply", {"account_id": account_id, "conversation_id": conversation_id, "text": text, **kwargs},
|
|
1260
|
+
max_cost=max_cost, timeout_sec=timeout_sec, wait=wait, idempotency_key=idempotency_key or _new_idempotency_key())
|
|
1261
|
+
|
|
1262
|
+
def linkedin_view_profile(self, account_id: str, identifier: str, *, notify: bool = False, idempotency_key: Optional[str] = None, max_cost: Optional[float] = None, timeout_sec: int = 180, wait: bool = True, **kwargs: Any) -> Any:
|
|
1263
|
+
"""View a profile through the connected account (public-safe summary). notify=True cannot be undone. Blocking."""
|
|
1264
|
+
return self.call_tool("/v1/tools/linkedin/profile-view", {"account_id": account_id, "identifier": identifier, "notify": bool(notify), **kwargs},
|
|
1265
|
+
max_cost=max_cost, timeout_sec=timeout_sec, wait=wait, idempotency_key=idempotency_key or _new_idempotency_key())
|
|
1266
|
+
|
|
1267
|
+
async def alinkedin_view_profile(self, account_id: str, identifier: str, *, notify: bool = False, idempotency_key: Optional[str] = None, max_cost: Optional[float] = None, timeout_sec: int = 180, wait: bool = True, **kwargs: Any) -> Any:
|
|
1268
|
+
return await self.acall_tool("/v1/tools/linkedin/profile-view", {"account_id": account_id, "identifier": identifier, "notify": bool(notify), **kwargs},
|
|
1269
|
+
max_cost=max_cost, timeout_sec=timeout_sec, wait=wait, idempotency_key=idempotency_key or _new_idempotency_key())
|
|
1270
|
+
|
|
1271
|
+
def linkedin_react(self, account_id: str, post_id: str, *, reaction_type: str = "like", idempotency_key: Optional[str] = None, max_cost: Optional[float] = None, timeout_sec: int = 180, wait: bool = True, **kwargs: Any) -> Any:
|
|
1272
|
+
"""React to a post through the connected account. Blocking."""
|
|
1273
|
+
return self.call_tool("/v1/tools/linkedin/react", {"account_id": account_id, "post_id": post_id, "reaction_type": reaction_type, **kwargs},
|
|
1274
|
+
max_cost=max_cost, timeout_sec=timeout_sec, wait=wait, idempotency_key=idempotency_key or _new_idempotency_key())
|
|
1275
|
+
|
|
1276
|
+
async def alinkedin_react(self, account_id: str, post_id: str, *, reaction_type: str = "like", idempotency_key: Optional[str] = None, max_cost: Optional[float] = None, timeout_sec: int = 180, wait: bool = True, **kwargs: Any) -> Any:
|
|
1277
|
+
return await self.acall_tool("/v1/tools/linkedin/react", {"account_id": account_id, "post_id": post_id, "reaction_type": reaction_type, **kwargs},
|
|
1278
|
+
max_cost=max_cost, timeout_sec=timeout_sec, wait=wait, idempotency_key=idempotency_key or _new_idempotency_key())
|
|
1279
|
+
|
|
1163
1280
|
# ------------------------------------------------------------------
|
|
1164
1281
|
# Paid tool convenience methods
|
|
1165
1282
|
# ------------------------------------------------------------------
|
|
@@ -1765,3 +1882,37 @@ class OneShotClient:
|
|
|
1765
1882
|
# Timeout — return the last snapshot. phones_pending is still True
|
|
1766
1883
|
# so the caller knows phones never arrived.
|
|
1767
1884
|
return last_result
|
|
1885
|
+
|
|
1886
|
+
|
|
1887
|
+
# ---------------------------------------------------------------------------
|
|
1888
|
+
# LinkedIn helpers (module-level so the sync/async twins share them)
|
|
1889
|
+
# ---------------------------------------------------------------------------
|
|
1890
|
+
|
|
1891
|
+
def _new_idempotency_key() -> str:
|
|
1892
|
+
import secrets
|
|
1893
|
+
return secrets.token_hex(16)
|
|
1894
|
+
|
|
1895
|
+
|
|
1896
|
+
def _linkedin_connect_payload(requested_actions: Optional[list[str]], success_redirect_url: Optional[str], failure_redirect_url: Optional[str]) -> dict[str, Any]:
|
|
1897
|
+
payload: dict[str, Any] = {}
|
|
1898
|
+
if requested_actions is not None:
|
|
1899
|
+
payload["requested_actions"] = list(requested_actions)
|
|
1900
|
+
if success_redirect_url:
|
|
1901
|
+
payload["success_redirect_url"] = success_redirect_url
|
|
1902
|
+
if failure_redirect_url:
|
|
1903
|
+
payload["failure_redirect_url"] = failure_redirect_url
|
|
1904
|
+
return payload
|
|
1905
|
+
|
|
1906
|
+
|
|
1907
|
+
def _linkedin_sync_payload(account_id: str, mode: Optional[str], max_pages: Optional[int], extra: dict[str, Any]) -> dict[str, Any]:
|
|
1908
|
+
payload: dict[str, Any] = {"account_id": account_id, **extra}
|
|
1909
|
+
if mode:
|
|
1910
|
+
payload["mode"] = mode
|
|
1911
|
+
if max_pages is not None:
|
|
1912
|
+
payload["max_pages"] = int(max_pages)
|
|
1913
|
+
return payload
|
|
1914
|
+
|
|
1915
|
+
|
|
1916
|
+
def _linkedin_query(params: dict[str, Any]) -> Optional[dict[str, str]]:
|
|
1917
|
+
query = {k: ("true" if v is True else "false" if v is False else str(v)) for k, v in params.items() if v is not None}
|
|
1918
|
+
return query or None
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "oneshot-python"
|
|
3
|
-
version = "0.
|
|
4
|
-
description = "Core Python SDK for the OneShot API — HTTP client with x402 payment handling —
|
|
3
|
+
version = "0.25.0"
|
|
4
|
+
description = "Core Python SDK for the OneShot API — HTTP client with x402 payment handling — 43 tools"
|
|
5
5
|
readme = {text = "Core Python SDK for the OneShot API", content-type = "text/plain"}
|
|
6
6
|
license = "MIT"
|
|
7
7
|
requires-python = ">=3.10"
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""LinkedIn client methods route to the right endpoints; writes always carry an Idempotency-Key."""
|
|
2
|
+
from unittest.mock import AsyncMock
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from oneshot.client import OneShotClient
|
|
7
|
+
|
|
8
|
+
TEST_PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.mark.asyncio
|
|
12
|
+
async def test_connect_and_reads_use_free_endpoints():
|
|
13
|
+
client = OneShotClient(TEST_PRIVATE_KEY)
|
|
14
|
+
client.acall_free_post = AsyncMock(return_value={"intent_id": "i1", "url": "https://account.unipile.com/x"})
|
|
15
|
+
client.acall_free_get = AsyncMock(return_value={"accounts": []})
|
|
16
|
+
client.acall_free_delete = AsyncMock(return_value={"status": "revoked"})
|
|
17
|
+
|
|
18
|
+
await client.alinkedin_connect(["read", "reply"], success_redirect_url="https://app/ok")
|
|
19
|
+
client.acall_free_post.assert_awaited_once_with(
|
|
20
|
+
"/v1/tools/linkedin/connect", {"requested_actions": ["read", "reply"], "success_redirect_url": "https://app/ok"}, scope="write"
|
|
21
|
+
)
|
|
22
|
+
await client.aget_linkedin_connection("i 1")
|
|
23
|
+
client.acall_free_get.assert_awaited_with("/v1/tools/linkedin/connect/i%201")
|
|
24
|
+
await client.alist_linkedin_accounts(include_revoked=True)
|
|
25
|
+
client.acall_free_get.assert_awaited_with("/v1/tools/linkedin/accounts", {"include_revoked": "true"})
|
|
26
|
+
await client.alinkedin_messages("acc", direction="inbound", since="2026-09-15T00:00:00Z", include_deleted=False, limit=10)
|
|
27
|
+
client.acall_free_get.assert_awaited_with(
|
|
28
|
+
"/v1/tools/linkedin/accounts/acc/messages",
|
|
29
|
+
{"direction": "inbound", "since": "2026-09-15T00:00:00Z", "include_deleted": "false", "limit": "10"},
|
|
30
|
+
)
|
|
31
|
+
await client.arevoke_linkedin_account("acc")
|
|
32
|
+
client.acall_free_delete.assert_awaited_once_with("/v1/tools/linkedin/accounts/acc", scope="write")
|
|
33
|
+
await client.areconnect_linkedin_account("acc", requested_actions=["read"])
|
|
34
|
+
client.acall_free_post.assert_awaited_with("/v1/tools/linkedin/accounts/acc/reconnect", {"requested_actions": ["read"]}, scope="write")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@pytest.mark.asyncio
|
|
38
|
+
async def test_writes_are_paid_and_idempotency_keyed():
|
|
39
|
+
client = OneShotClient(TEST_PRIVATE_KEY)
|
|
40
|
+
client.acall_tool = AsyncMock(return_value={"status": "sent"})
|
|
41
|
+
|
|
42
|
+
await client.alinkedin_reply("acc", "conv", "hi", memo="m")
|
|
43
|
+
args, kwargs = client.acall_tool.await_args
|
|
44
|
+
assert args == ("/v1/tools/linkedin/reply", {"account_id": "acc", "conversation_id": "conv", "text": "hi", "memo": "m"})
|
|
45
|
+
assert len(kwargs["idempotency_key"]) == 32 # auto-generated
|
|
46
|
+
|
|
47
|
+
await client.alinkedin_reply("acc", "conv", "hi", idempotency_key="fixed")
|
|
48
|
+
assert client.acall_tool.await_args.kwargs["idempotency_key"] == "fixed"
|
|
49
|
+
|
|
50
|
+
await client.alinkedin_view_profile("acc", "https://linkedin.com/in/jane", notify=True)
|
|
51
|
+
args, kwargs = client.acall_tool.await_args
|
|
52
|
+
assert args[0] == "/v1/tools/linkedin/profile-view" and args[1]["notify"] is True and kwargs["idempotency_key"]
|
|
53
|
+
|
|
54
|
+
await client.alinkedin_react("acc", "urn:li:activity:1")
|
|
55
|
+
args, _ = client.acall_tool.await_args
|
|
56
|
+
assert args == ("/v1/tools/linkedin/react", {"account_id": "acc", "post_id": "urn:li:activity:1", "reaction_type": "like"})
|
|
57
|
+
|
|
58
|
+
await client.alinkedin_sync("acc", mode="continue", max_pages=5)
|
|
59
|
+
args, kwargs = client.acall_tool.await_args
|
|
60
|
+
assert args == ("/v1/tools/linkedin/sync", {"account_id": "acc", "mode": "continue", "max_pages": 5})
|
|
61
|
+
assert "idempotency_key" not in kwargs # a sync run is not a side-effecting write
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_free_mutations_sign_a_write_scoped_proof():
|
|
65
|
+
client = OneShotClient(TEST_PRIVATE_KEY)
|
|
66
|
+
assert client._read_headers("write")["x-agent-proof"] != client._read_headers("read")["x-agent-proof"]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_idempotency_key_header_is_set_on_paid_requests():
|
|
70
|
+
client = OneShotClient(TEST_PRIVATE_KEY)
|
|
71
|
+
assert client._headers(idempotency_key="k1")["Idempotency-Key"] == "k1"
|
|
72
|
+
assert "Idempotency-Key" not in client._headers()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|