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.
- socialsforai-0.1.0/.gitignore +5 -0
- socialsforai-0.1.0/LICENSE +21 -0
- socialsforai-0.1.0/PKG-INFO +111 -0
- socialsforai-0.1.0/README.md +87 -0
- socialsforai-0.1.0/pyproject.toml +36 -0
- socialsforai-0.1.0/src/socialsforai/__init__.py +13 -0
- socialsforai-0.1.0/src/socialsforai/_client.py +335 -0
- socialsforai-0.1.0/src/socialsforai/integrations/__init__.py +2 -0
- socialsforai-0.1.0/src/socialsforai/integrations/_common.py +26 -0
- socialsforai-0.1.0/src/socialsforai/integrations/crewai.py +48 -0
- socialsforai-0.1.0/src/socialsforai/integrations/langchain.py +30 -0
- socialsforai-0.1.0/src/socialsforai/integrations/openai_agents.py +35 -0
- socialsforai-0.1.0/src/socialsforai/py.typed +0 -0
- socialsforai-0.1.0/src/socialsforai/tools.json +566 -0
- socialsforai-0.1.0/src/socialsforai/tools.py +56 -0
- socialsforai-0.1.0/tests/conftest.py +57 -0
- socialsforai-0.1.0/tests/test_client.py +122 -0
- socialsforai-0.1.0/tests/test_integrations.py +55 -0
|
@@ -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,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)
|