socialsforai 0.1.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.
@@ -0,0 +1,5 @@
1
+ .venv*/
2
+ dist/
3
+ __pycache__/
4
+ *.egg-info/
5
+ .pytest_cache/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Codehorts LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,111 @@
1
+ Metadata-Version: 2.5
2
+ Name: socialsforai
3
+ Version: 0.1.0
4
+ Summary: Python client and agent tools for SocialsForAI: post to X, LinkedIn, Bluesky, Mastodon, Telegram, Discord and more from code or AI agents.
5
+ Project-URL: Homepage, https://socialsforai.com
6
+ Project-URL: Documentation, https://socialsforai.com/docs
7
+ Project-URL: Source, https://github.com/SocialsForAi/socialsforai
8
+ Author-email: Codehorts LLC <lee@socialsforai.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: ai-agents,bluesky,crewai,langchain,linkedin,mcp,openai-agents,social-media,social-media-api,twitter
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Internet
15
+ Classifier: Typing :: Typed
16
+ Requires-Python: >=3.9
17
+ Provides-Extra: crewai
18
+ Requires-Dist: crewai>=0.80; extra == 'crewai'
19
+ Provides-Extra: langchain
20
+ Requires-Dist: langchain-core>=0.3; extra == 'langchain'
21
+ Provides-Extra: openai-agents
22
+ Requires-Dist: openai-agents>=0.1; extra == 'openai-agents'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # socialsforai
26
+
27
+ Python client and agent tools for [SocialsForAI](https://socialsforai.com): one API to publish, schedule and measure posts on X, LinkedIn, Bluesky, Mastodon, Telegram, Discord, Slack, Nostr and more, built for AI agents.
28
+
29
+ - No dependencies. Python 3.9+.
30
+ - Safe retries: rate limits, server errors and network errors are retried, and every post gets an idempotency key, so a retry never publishes twice.
31
+ - Every error has a stable `code` and a `fix` that says what to do next.
32
+ - Ready-made tools for LangChain, CrewAI and the OpenAI Agents SDK.
33
+
34
+ ```bash
35
+ pip install socialsforai
36
+ ```
37
+
38
+ ## Quickstart
39
+
40
+ ```python
41
+ from socialsforai import SocialsForAI
42
+
43
+ socials = SocialsForAI() # reads SOCIALSFORAI_API_KEY
44
+
45
+ # No key yet? An agent can sign itself up:
46
+ # socials.workspaces.create(name="My agent", email="owner@example.com")
47
+
48
+ # Connect accounts: send this link to the account owner.
49
+ link = socials.connect_links.create(platforms=["x", "linkedin", "bluesky"])
50
+ print(link["url"])
51
+ socials.connect_links.wait_for(link["id"])
52
+
53
+ # Check, then publish. One result per account.
54
+ check = socials.posts.create(["linkedin", "bluesky"], "We just shipped webhooks.", dry_run=True)
55
+ print(check["ok"], check["issues"])
56
+ post = socials.posts.create(["linkedin", "bluesky"], "We just shipped webhooks.", wait=True)
57
+ for t in post["targets"]:
58
+ print(t["platform"], t["status"], t["url"])
59
+ ```
60
+
61
+ Any API field works as a keyword on `posts.create`: `media`, `link`, `thread`, `auto_thread=True` (split long text into a thread), `scheduled_at="best"`, `timezone`, `repeat={"every": "week", "count": 4}`, `approval="required"`, `platform_options`, `reply_to` and `reply_to_comment`. The full list is in the [agent guide](https://socialsforai.com/llms.txt).
62
+
63
+ | Group | Methods |
64
+ | --- | --- |
65
+ | Posts | `posts.create`, `posts.create_many` (up to 50), `posts.list`, `posts.get`, `posts.update`, `posts.delete`, `posts.metrics`, `posts.replies`, `posts.wait_for_approval` |
66
+ | Accounts | `accounts.list`, `accounts.get`, `accounts.connect`, `accounts.disconnect`, `connect_links.create`, `connect_links.get`, `connect_links.wait_for` |
67
+ | Media | `media.upload` (≤4 MB), `media.upload_file` (any size) |
68
+ | Events | `events.list` (what happened since an event id; for when you can't receive webhooks) |
69
+ | Workspace | `workspaces.create`, `workspace.get`, `workspace.set_owner_email`, `usage()`, `analytics()`, `platforms()`, `health()`, `upgrade_link(plan)` |
70
+ | Admin | `keys.list/create/revoke`, `webhooks.list/create/delete`, `verify_webhook(secret, header, raw_body)` |
71
+
72
+ ## Agent tools
73
+
74
+ The same tools work in each framework: `list_accounts`, `list_platforms`, `create_connect_link`, `create_post`, `create_posts`, `get_post`, `list_posts`, `get_post_metrics`, `list_replies`, `list_events`, and `delete_post`. `delete_post` is off unless you pass `include_destructive=True`. Errors come back to the model as `{"error": {"code", "message", "fix"}}` so it can act on the fix.
75
+
76
+ **LangChain / LangGraph** (`pip install "socialsforai[langchain]"`)
77
+
78
+ ```python
79
+ from langgraph.prebuilt import create_react_agent
80
+ from socialsforai.integrations.langchain import socials_tools
81
+
82
+ agent = create_react_agent(model, socials_tools())
83
+ ```
84
+
85
+ **CrewAI** (`pip install "socialsforai[crewai]"`)
86
+
87
+ ```python
88
+ from crewai import Agent
89
+ from socialsforai.integrations.crewai import socials_tools
90
+
91
+ manager = Agent(role="Social media manager", goal="Publish our launch", backstory="...", tools=socials_tools())
92
+ ```
93
+
94
+ **OpenAI Agents SDK** (`pip install "socialsforai[openai-agents]"`)
95
+
96
+ ```python
97
+ from agents import Agent
98
+ from socialsforai.integrations.openai_agents import socials_tools
99
+
100
+ agent = Agent(name="Social media", instructions="Dry-run before publishing.", tools=socials_tools())
101
+ ```
102
+
103
+ **Anything else:** `socialsforai.tools.TOOLS` has each tool's name, description and JSON Schema, and `run_tool(client, name, args)` runs one.
104
+
105
+ Limit what an agent can do with `socials_tools(tools=["list_accounts", "create_post"])`, or give it a scoped key: `socials.keys.create(name="drafts", scopes=["posts:write"], require_approval=True)`.
106
+
107
+ ## Links
108
+
109
+ - Docs: https://socialsforai.com/docs
110
+ - MCP server: https://socialsforai.com/api/mcp
111
+ - JavaScript SDK: `npm install @socialsforai/sdk`
@@ -0,0 +1,87 @@
1
+ # socialsforai
2
+
3
+ Python client and agent tools for [SocialsForAI](https://socialsforai.com): one API to publish, schedule and measure posts on X, LinkedIn, Bluesky, Mastodon, Telegram, Discord, Slack, Nostr and more, built for AI agents.
4
+
5
+ - No dependencies. Python 3.9+.
6
+ - Safe retries: rate limits, server errors and network errors are retried, and every post gets an idempotency key, so a retry never publishes twice.
7
+ - Every error has a stable `code` and a `fix` that says what to do next.
8
+ - Ready-made tools for LangChain, CrewAI and the OpenAI Agents SDK.
9
+
10
+ ```bash
11
+ pip install socialsforai
12
+ ```
13
+
14
+ ## Quickstart
15
+
16
+ ```python
17
+ from socialsforai import SocialsForAI
18
+
19
+ socials = SocialsForAI() # reads SOCIALSFORAI_API_KEY
20
+
21
+ # No key yet? An agent can sign itself up:
22
+ # socials.workspaces.create(name="My agent", email="owner@example.com")
23
+
24
+ # Connect accounts: send this link to the account owner.
25
+ link = socials.connect_links.create(platforms=["x", "linkedin", "bluesky"])
26
+ print(link["url"])
27
+ socials.connect_links.wait_for(link["id"])
28
+
29
+ # Check, then publish. One result per account.
30
+ check = socials.posts.create(["linkedin", "bluesky"], "We just shipped webhooks.", dry_run=True)
31
+ print(check["ok"], check["issues"])
32
+ post = socials.posts.create(["linkedin", "bluesky"], "We just shipped webhooks.", wait=True)
33
+ for t in post["targets"]:
34
+ print(t["platform"], t["status"], t["url"])
35
+ ```
36
+
37
+ Any API field works as a keyword on `posts.create`: `media`, `link`, `thread`, `auto_thread=True` (split long text into a thread), `scheduled_at="best"`, `timezone`, `repeat={"every": "week", "count": 4}`, `approval="required"`, `platform_options`, `reply_to` and `reply_to_comment`. The full list is in the [agent guide](https://socialsforai.com/llms.txt).
38
+
39
+ | Group | Methods |
40
+ | --- | --- |
41
+ | Posts | `posts.create`, `posts.create_many` (up to 50), `posts.list`, `posts.get`, `posts.update`, `posts.delete`, `posts.metrics`, `posts.replies`, `posts.wait_for_approval` |
42
+ | Accounts | `accounts.list`, `accounts.get`, `accounts.connect`, `accounts.disconnect`, `connect_links.create`, `connect_links.get`, `connect_links.wait_for` |
43
+ | Media | `media.upload` (≤4 MB), `media.upload_file` (any size) |
44
+ | Events | `events.list` (what happened since an event id; for when you can't receive webhooks) |
45
+ | Workspace | `workspaces.create`, `workspace.get`, `workspace.set_owner_email`, `usage()`, `analytics()`, `platforms()`, `health()`, `upgrade_link(plan)` |
46
+ | Admin | `keys.list/create/revoke`, `webhooks.list/create/delete`, `verify_webhook(secret, header, raw_body)` |
47
+
48
+ ## Agent tools
49
+
50
+ The same tools work in each framework: `list_accounts`, `list_platforms`, `create_connect_link`, `create_post`, `create_posts`, `get_post`, `list_posts`, `get_post_metrics`, `list_replies`, `list_events`, and `delete_post`. `delete_post` is off unless you pass `include_destructive=True`. Errors come back to the model as `{"error": {"code", "message", "fix"}}` so it can act on the fix.
51
+
52
+ **LangChain / LangGraph** (`pip install "socialsforai[langchain]"`)
53
+
54
+ ```python
55
+ from langgraph.prebuilt import create_react_agent
56
+ from socialsforai.integrations.langchain import socials_tools
57
+
58
+ agent = create_react_agent(model, socials_tools())
59
+ ```
60
+
61
+ **CrewAI** (`pip install "socialsforai[crewai]"`)
62
+
63
+ ```python
64
+ from crewai import Agent
65
+ from socialsforai.integrations.crewai import socials_tools
66
+
67
+ manager = Agent(role="Social media manager", goal="Publish our launch", backstory="...", tools=socials_tools())
68
+ ```
69
+
70
+ **OpenAI Agents SDK** (`pip install "socialsforai[openai-agents]"`)
71
+
72
+ ```python
73
+ from agents import Agent
74
+ from socialsforai.integrations.openai_agents import socials_tools
75
+
76
+ agent = Agent(name="Social media", instructions="Dry-run before publishing.", tools=socials_tools())
77
+ ```
78
+
79
+ **Anything else:** `socialsforai.tools.TOOLS` has each tool's name, description and JSON Schema, and `run_tool(client, name, args)` runs one.
80
+
81
+ Limit what an agent can do with `socials_tools(tools=["list_accounts", "create_post"])`, or give it a scoped key: `socials.keys.create(name="drafts", scopes=["posts:write"], require_approval=True)`.
82
+
83
+ ## Links
84
+
85
+ - Docs: https://socialsforai.com/docs
86
+ - MCP server: https://socialsforai.com/api/mcp
87
+ - JavaScript SDK: `npm install @socialsforai/sdk`
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "socialsforai"
7
+ version = "0.1.0"
8
+ description = "Python client and agent tools for SocialsForAI: post to X, LinkedIn, Bluesky, Mastodon, Telegram, Discord and more from code or AI agents."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.9"
12
+ authors = [{ name = "Codehorts LLC", email = "lee@socialsforai.com" }]
13
+ keywords = ["social-media", "social-media-api", "ai-agents", "mcp", "langchain", "crewai", "openai-agents", "twitter", "linkedin", "bluesky"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "Intended Audience :: Developers",
17
+ "Topic :: Internet",
18
+ "Typing :: Typed",
19
+ ]
20
+ dependencies = []
21
+
22
+ [project.optional-dependencies]
23
+ langchain = ["langchain-core>=0.3"]
24
+ crewai = ["crewai>=0.80"]
25
+ openai-agents = ["openai-agents>=0.1"]
26
+
27
+ [project.urls]
28
+ Homepage = "https://socialsforai.com"
29
+ Documentation = "https://socialsforai.com/docs"
30
+ Source = "https://github.com/SocialsForAi/socialsforai"
31
+
32
+ [tool.hatch.build.targets.wheel]
33
+ packages = ["src/socialsforai"]
34
+
35
+ [tool.hatch.build.targets.sdist]
36
+ include = ["src", "tests", "README.md", "LICENSE", "pyproject.toml"]
@@ -0,0 +1,13 @@
1
+ """SocialsForAI: post, schedule and measure on social networks from Python and AI agents.
2
+
3
+ from socialsforai import SocialsForAI
4
+ socials = SocialsForAI() # reads SOCIALSFORAI_API_KEY
5
+ socials.posts.create(["linkedin", "bluesky"], "We just shipped webhooks.", dry_run=True)
6
+
7
+ Docs: https://socialsforai.com/docs · Agent guide: https://socialsforai.com/llms.txt
8
+ """
9
+
10
+ from ._client import DEFAULT_BASE_URL, VERSION, SocialsError, SocialsForAI, verify_webhook
11
+
12
+ __all__ = ["SocialsForAI", "SocialsError", "verify_webhook", "DEFAULT_BASE_URL", "VERSION"]
13
+ __version__ = VERSION
@@ -0,0 +1,335 @@
1
+ """The HTTP client. Standard library only, so it installs anywhere an agent runs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import hmac
7
+ import json
8
+ import os
9
+ import time
10
+ import uuid
11
+ from typing import Any, Mapping
12
+ from urllib import error as urlerror
13
+ from urllib import parse, request
14
+
15
+ DEFAULT_BASE_URL = "https://socialsforai.com"
16
+ VERSION = "0.1.0"
17
+
18
+
19
+ class SocialsError(Exception):
20
+ """An error the API returned. `code` is stable, `fix` says what to do, `issues` lists failed checks."""
21
+
22
+ def __init__(self, status: int, code: str | None, message: str, fix: str | None = None, issues: list[dict] | None = None, request_id: str | None = None):
23
+ super().__init__(message)
24
+ self.status = status
25
+ self.code = code or "http_error"
26
+ self.message = message
27
+ self.fix = fix
28
+ self.issues = issues or []
29
+ self.request_id = request_id
30
+
31
+ def to_dict(self) -> dict:
32
+ """The error as the API sent it, for handing back to a model."""
33
+ out: dict[str, Any] = {"code": self.code, "message": self.message}
34
+ if self.fix:
35
+ out["fix"] = self.fix
36
+ if self.issues:
37
+ out["issues"] = self.issues
38
+ if self.request_id:
39
+ out["request_id"] = self.request_id
40
+ return {"error": out}
41
+
42
+ def __str__(self) -> str:
43
+ return f"{self.code}: {self.message}" + (f" Fix: {self.fix}" if self.fix else "")
44
+
45
+
46
+ def _query(params: Mapping[str, Any] | None) -> str:
47
+ if not params:
48
+ return ""
49
+ pairs = []
50
+ for k, v in params.items():
51
+ if v is None:
52
+ continue
53
+ if isinstance(v, bool):
54
+ v = "true" if v else "false"
55
+ elif isinstance(v, (list, tuple)):
56
+ v = ",".join(str(x) for x in v)
57
+ pairs.append((k, str(v)))
58
+ return ("?" + parse.urlencode(pairs)) if pairs else ""
59
+
60
+
61
+ def _quote(s: str) -> str:
62
+ return parse.quote(str(s), safe="")
63
+
64
+
65
+ class _Group:
66
+ def __init__(self, client: "SocialsForAI"):
67
+ self._c = client
68
+
69
+
70
+ class Workspaces(_Group):
71
+ def create(self, name: str | None = None, email: str | None = None) -> dict:
72
+ """Create a workspace and get an API key. Needs no key. The client starts using the new key."""
73
+ body = {k: v for k, v in {"name": name, "email": email}.items() if v}
74
+ ws = self._c.request("POST", "/v1/workspaces", body=body, auth=False)
75
+ if ws.get("api_key") and not self._c.api_key:
76
+ self._c.api_key = ws["api_key"]
77
+ return ws
78
+
79
+
80
+ class Workspace(_Group):
81
+ def get(self) -> dict:
82
+ return self._c.request("GET", "/v1/workspace")
83
+
84
+ def set_owner_email(self, email: str) -> dict:
85
+ """Attach the owner's email (only while none is set): dashboard sign-in, recovery, paying for a plan."""
86
+ return self._c.request("PATCH", "/v1/workspace", body={"email": email})
87
+
88
+
89
+ class Accounts(_Group):
90
+ def list(self, platform: str | None = None, external_ref: str | None = None, status: str | None = None) -> list[dict]:
91
+ return self._c.request("GET", "/v1/accounts" + _query({"platform": platform, "external_ref": external_ref, "status": status}))["data"]
92
+
93
+ def get(self, id: str) -> dict:
94
+ return self._c.request("GET", f"/v1/accounts/{_quote(id)}")
95
+
96
+ def connect(self, platform: str, credentials: Mapping[str, str], external_ref: str | None = None) -> dict:
97
+ """Connect Bluesky, Mastodon, Telegram, Discord, Slack or Nostr with credentials the owner gave you (or "sandbox" with {})."""
98
+ body: dict[str, Any] = {"platform": platform, "credentials": dict(credentials)}
99
+ if external_ref:
100
+ body["external_ref"] = external_ref
101
+ return self._c.request("POST", "/v1/accounts", body=body)
102
+
103
+ def disconnect(self, id: str) -> dict:
104
+ return self._c.request("DELETE", f"/v1/accounts/{_quote(id)}")
105
+
106
+
107
+ class ConnectLinks(_Group):
108
+ def create(self, platforms: list[str] | None = None, external_ref: str | None = None, redirect_url: str | None = None, expires_in: int | None = None) -> dict:
109
+ body = {k: v for k, v in {"platforms": platforms, "external_ref": external_ref, "redirect_url": redirect_url, "expires_in": expires_in}.items() if v is not None}
110
+ return self._c.request("POST", "/v1/connect-links", body=body)
111
+
112
+ def get(self, id: str, wait_seconds: int | None = None, until: str | None = None) -> dict:
113
+ """The link's status. With wait_seconds (max 120) it holds until the owner finishes."""
114
+ return self._c.request("GET", f"/v1/connect-links/{_quote(id)}" + _query({"wait": wait_seconds, "until": until}))
115
+
116
+ def wait_for(self, id: str, until: str = "all", timeout: float = 900) -> dict:
117
+ """Keep waiting until the owner has connected (all networks, or any with until="any") or the link expired."""
118
+ deadline = time.monotonic() + timeout
119
+ while True:
120
+ s = self.get(id, wait_seconds=60, until=until)
121
+ if s.get("status") in ("connected", "expired") or (until == "any" and s.get("status") == "partially_connected") or time.monotonic() >= deadline:
122
+ return s
123
+
124
+
125
+ class Posts(_Group):
126
+ def create(self, accounts: list[str], text: str = "", *, idempotency_key: str | None = None, **fields: Any) -> dict:
127
+ """Publish now or schedule. Any API field works as a keyword: media, link, thread, auto_thread, poll,
128
+ scheduled_at ("best" too), timezone, repeat, reply_to, reply_to_comment, first_comment, platform_options,
129
+ approval="required", dry_run=True, wait=True. An idempotency key is generated when you don't pass one,
130
+ so retries never publish twice; pass your own to make your own retries safe too."""
131
+ body = {"accounts": accounts, "text": text, **fields}
132
+ key = idempotency_key or body.pop("idempotency_key", None) or (None if body.get("dry_run") else str(uuid.uuid4()))
133
+ return self._c.request("POST", "/v1/posts", body=body, headers={"Idempotency-Key": key} if key else None)
134
+
135
+ def create_many(self, posts: list[dict], *, dry_run: bool = False, idempotency_key: str | None = None) -> dict:
136
+ """Up to 50 posts in one request (a content calendar). Each is checked and created by itself."""
137
+ key = idempotency_key or (None if dry_run else str(uuid.uuid4()))
138
+ return self._c.request("POST", "/v1/posts/batch", body={"posts": posts, "dry_run": dry_run}, headers={"Idempotency-Key": key} if key else None)
139
+
140
+ def list(self, status: str | None = None, series_id: str | None = None, limit: int | None = None, before: str | None = None) -> dict:
141
+ return self._c.request("GET", "/v1/posts" + _query({"status": status, "series_id": series_id, "limit": limit, "before": before}))
142
+
143
+ def get(self, id: str) -> dict:
144
+ return self._c.request("GET", f"/v1/posts/{_quote(id)}")
145
+
146
+ def update(self, id: str, **fields: Any) -> dict:
147
+ """Edit a post that hasn't started publishing. Fields left out stay; scheduled_at=None publishes now."""
148
+ return self._c.request("PATCH", f"/v1/posts/{_quote(id)}", body=fields)
149
+
150
+ def delete(self, id: str, accounts: list[str] | None = None, series: bool = False) -> dict:
151
+ """Delete it everywhere it went out (or only from some accounts). series=True cancels the rest of a repeat series."""
152
+ return self._c.request("DELETE", f"/v1/posts/{_quote(id)}" + _query({"accounts": accounts, "series": True if series else None}))
153
+
154
+ def metrics(self, id: str) -> dict:
155
+ return self._c.request("GET", f"/v1/posts/{_quote(id)}/metrics")
156
+
157
+ def replies(self, id: str) -> dict:
158
+ """What people replied under each published copy. Answer one with create(..., reply_to=id, reply_to_comment=reply["id"])."""
159
+ return self._c.request("GET", f"/v1/posts/{_quote(id)}/replies")
160
+
161
+ def wait_for_approval(self, id: str, timeout: float = 3600) -> dict:
162
+ """Wait while the post is pending_approval; returns it once a person decides (or the timeout passes)."""
163
+ deadline = time.monotonic() + timeout
164
+ while True:
165
+ p = self._c.request("GET", f"/v1/posts/{_quote(id)}?wait=60")
166
+ if p.get("status") != "pending_approval" or time.monotonic() >= deadline:
167
+ return p
168
+
169
+
170
+ class Media(_Group):
171
+ def upload(self, data: bytes, content_type: str) -> dict:
172
+ """Upload a file of up to 4 MB and get a url to use in posts."""
173
+ return self._c.request("POST", "/v1/media", raw=data, headers={"Content-Type": content_type})
174
+
175
+ def upload_file(self, path: str, content_type: str | None = None) -> dict:
176
+ """Upload a local file of any supported size; returns {url}."""
177
+ import mimetypes
178
+
179
+ ctype = content_type or mimetypes.guess_type(path)[0]
180
+ if not ctype:
181
+ raise ValueError(f"Can't tell the type of {path}; pass content_type.")
182
+ with open(path, "rb") as f:
183
+ data = f.read()
184
+ if len(data) <= 4 * 1024 * 1024:
185
+ return self.upload(data, ctype)
186
+ up = self._c.request("POST", "/v1/media/uploads", body={"content_type": ctype, "size_bytes": len(data)})
187
+ req = request.Request(up["upload_url"], data=data, method=up.get("method", "PUT"), headers=up.get("headers") or {"Content-Type": ctype})
188
+ with request.urlopen(req, timeout=self._c.timeout) as res:
189
+ if res.status >= 300:
190
+ raise SocialsError(res.status, "upload_failed", f"The storage upload answered {res.status}.", "Upload again for a fresh URL.")
191
+ return {"url": up["url"]}
192
+
193
+
194
+ class Events(_Group):
195
+ def list(self, after: str | None = None, types: list[str] | None = None, limit: int | None = None, wait_seconds: int | None = None) -> dict:
196
+ """What happened since `after` (an event id), oldest first. Keep last_id for the next call."""
197
+ return self._c.request("GET", "/v1/events" + _query({"after": after, "types": types, "limit": limit, "wait": wait_seconds}))
198
+
199
+
200
+ class Webhooks(_Group):
201
+ def list(self) -> list[dict]:
202
+ return self._c.request("GET", "/v1/webhooks")["data"]
203
+
204
+ def create(self, url: str, events: list[str] | None = None) -> dict:
205
+ return self._c.request("POST", "/v1/webhooks", body={"url": url, "events": events or ["*"]})
206
+
207
+ def delete(self, id: str) -> dict:
208
+ return self._c.request("DELETE", f"/v1/webhooks/{_quote(id)}")
209
+
210
+
211
+ class Keys(_Group):
212
+ def list(self) -> list[dict]:
213
+ return self._c.request("GET", "/v1/keys")["data"]
214
+
215
+ def create(self, **fields: Any) -> dict:
216
+ """name, scopes, account_ids, require_approval, daily_post_limit. The key is returned once."""
217
+ return self._c.request("POST", "/v1/keys", body=fields)
218
+
219
+ def revoke(self, id: str) -> dict:
220
+ return self._c.request("DELETE", f"/v1/keys/{_quote(id)}")
221
+
222
+
223
+ class SocialsForAI:
224
+ """Client for the SocialsForAI API.
225
+
226
+ from socialsforai import SocialsForAI
227
+ socials = SocialsForAI() # reads SOCIALSFORAI_API_KEY
228
+ post = socials.posts.create(["linkedin"], "Hello", wait=True)
229
+ """
230
+
231
+ def __init__(self, api_key: str | None = None, *, base_url: str | None = None, max_retries: int = 2, timeout: float = 330):
232
+ self.api_key = api_key or os.environ.get("SOCIALSFORAI_API_KEY") or os.environ.get("SOCIALS_API_KEY")
233
+ self.base_url = (base_url or os.environ.get("SOCIALSFORAI_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
234
+ self.max_retries = max_retries
235
+ self.timeout = timeout
236
+ #: The rate limit as of the last response: {"limit", "remaining", "reset"}, or None.
237
+ self.rate_limit: dict | None = None
238
+ self.workspaces = Workspaces(self)
239
+ self.workspace = Workspace(self)
240
+ self.accounts = Accounts(self)
241
+ self.connect_links = ConnectLinks(self)
242
+ self.posts = Posts(self)
243
+ self.media = Media(self)
244
+ self.events = Events(self)
245
+ self.webhooks = Webhooks(self)
246
+ self.keys = Keys(self)
247
+
248
+ # One-call groups
249
+ def platforms(self) -> list[dict]:
250
+ return self.request("GET", "/v1/platforms", auth=False)["data"]
251
+
252
+ def usage(self) -> dict:
253
+ return self.request("GET", "/v1/usage")
254
+
255
+ def analytics(self, **params: Any) -> dict:
256
+ return self.request("GET", "/v1/analytics" + _query(params))
257
+
258
+ def health(self) -> dict:
259
+ return self.request("GET", "/v1/health", auth=False, ok_statuses=(503,))
260
+
261
+ def upgrade_link(self, plan: str) -> dict:
262
+ """A payment link for a human: you can't pay, the workspace owner can."""
263
+ return self.request("POST", "/v1/upgrade-links", body={"plan": plan})
264
+
265
+ def request(self, method: str, path: str, *, body: Any = None, raw: bytes | None = None, headers: Mapping[str, str] | None = None, auth: bool = True, ok_statuses: tuple[int, ...] = ()) -> Any:
266
+ """Low-level request. Retries 429, 5xx and network errors (honouring Retry-After); never retries other errors."""
267
+ if auth and not self.api_key:
268
+ raise SocialsError(0, "missing_api_key", "No API key.", "Pass api_key or set SOCIALSFORAI_API_KEY. Get one with socials.workspaces.create().")
269
+ h = {"Accept": "application/json", "User-Agent": f"socialsforai-sdk-python/{VERSION}"}
270
+ if auth:
271
+ h["Authorization"] = f"Bearer {self.api_key}"
272
+ data = raw
273
+ if body is not None and raw is None:
274
+ data = json.dumps(body).encode()
275
+ h["Content-Type"] = "application/json"
276
+ h.update(headers or {})
277
+ attempt = 0
278
+ while True:
279
+ req = request.Request(self.base_url + path, data=data, method=method, headers=h)
280
+ try:
281
+ with request.urlopen(req, timeout=self.timeout) as res:
282
+ return self._finish(res.status, res.headers, res.read())
283
+ except urlerror.HTTPError as e:
284
+ status, hdrs, payload = e.code, e.headers, e.read()
285
+ if status in ok_statuses:
286
+ return self._finish(status, hdrs, payload)
287
+ if (status == 429 or status >= 500) and attempt < self.max_retries:
288
+ after = hdrs.get("Retry-After")
289
+ time.sleep(min(float(after), 60) if after and after.replace(".", "", 1).isdigit() else 0.5 * 2**attempt)
290
+ attempt += 1
291
+ continue
292
+ self._note_rate(hdrs)
293
+ err = {}
294
+ try:
295
+ err = json.loads(payload or b"{}").get("error") or {}
296
+ except ValueError:
297
+ pass
298
+ raise SocialsError(status, err.get("code"), err.get("message") or f"HTTP {status}", err.get("fix"), err.get("issues"), err.get("request_id") or hdrs.get("Request-Id")) from None
299
+ except (urlerror.URLError, TimeoutError, ConnectionError) as e:
300
+ if attempt < self.max_retries:
301
+ time.sleep(0.5 * 2**attempt)
302
+ attempt += 1
303
+ continue
304
+ reason = getattr(e, "reason", e)
305
+ raise SocialsError(0, "network_error", f"Could not reach {self.base_url}: {reason}", "Check the network and retry; creating a post is safe to retry with the same idempotency key.") from None
306
+
307
+ def _note_rate(self, headers: Any) -> None:
308
+ limit = headers.get("RateLimit-Limit") if headers else None
309
+ if limit:
310
+ self.rate_limit = {"limit": int(limit), "remaining": int(headers.get("RateLimit-Remaining") or 0), "reset": int(headers.get("RateLimit-Reset") or 0)}
311
+
312
+ def _finish(self, status: int, headers: Any, payload: bytes) -> Any:
313
+ self._note_rate(headers)
314
+ if not payload:
315
+ return None
316
+ try:
317
+ return json.loads(payload)
318
+ except ValueError:
319
+ return None
320
+
321
+
322
+ def verify_webhook(secret: str, header: str | None, raw_body: bytes | str, *, tolerance_seconds: int = 300, now: float | None = None) -> bool:
323
+ """Check a delivery's Socials-Signature header ("t=<unix>,v1=<hex>"): HMAC-SHA256 of "<t>.<raw body>".
324
+ Pass the raw request body as received, before parsing it."""
325
+ parts = dict(p.strip().split("=", 1) for p in (header or "").split(",") if "=" in p)
326
+ try:
327
+ t = int(parts["t"])
328
+ v1 = parts["v1"]
329
+ except (KeyError, ValueError):
330
+ return False
331
+ if abs((now if now is not None else time.time()) - t) > tolerance_seconds:
332
+ return False
333
+ body = raw_body.encode() if isinstance(raw_body, str) else raw_body
334
+ expected = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
335
+ return hmac.compare_digest(expected, v1)
@@ -0,0 +1,2 @@
1
+ """Adapters that turn socialsforai.tools into tools for agent frameworks. Each imports its framework
2
+ only when used, so installing socialsforai never pulls them in."""
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any, Iterable
5
+
6
+ from .._client import SocialsForAI
7
+ from ..tools import TOOLS
8
+
9
+
10
+ def pick(names: Iterable[str] | None, include_destructive: bool) -> list[dict[str, Any]]:
11
+ """The tool specs to expose: all, or the named ones. Destructive tools (delete_post) need opting in."""
12
+ wanted = set(names) if names is not None else None
13
+ out = [t for t in TOOLS if (wanted is None or t["name"] in wanted) and (include_destructive or not t.get("destructive") or (wanted is not None and t["name"] in wanted))]
14
+ if wanted is not None:
15
+ missing = wanted - {t["name"] for t in TOOLS}
16
+ if missing:
17
+ raise ValueError(f"Unknown tools: {', '.join(sorted(missing))}. Available: {', '.join(t['name'] for t in TOOLS)}.")
18
+ return out
19
+
20
+
21
+ def client_or_default(client: SocialsForAI | None) -> SocialsForAI:
22
+ return client or SocialsForAI()
23
+
24
+
25
+ def as_text(result: dict[str, Any]) -> str:
26
+ return json.dumps(result, ensure_ascii=False)