socialsforai 0.1.0__py3-none-any.whl
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/__init__.py +13 -0
- socialsforai/_client.py +335 -0
- socialsforai/integrations/__init__.py +2 -0
- socialsforai/integrations/_common.py +26 -0
- socialsforai/integrations/crewai.py +48 -0
- socialsforai/integrations/langchain.py +30 -0
- socialsforai/integrations/openai_agents.py +35 -0
- socialsforai/py.typed +0 -0
- socialsforai/tools.json +566 -0
- socialsforai/tools.py +56 -0
- socialsforai-0.1.0.dist-info/METADATA +111 -0
- socialsforai-0.1.0.dist-info/RECORD +14 -0
- socialsforai-0.1.0.dist-info/WHEEL +4 -0
- socialsforai-0.1.0.dist-info/licenses/LICENSE +21 -0
socialsforai/__init__.py
ADDED
|
@@ -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
|
socialsforai/_client.py
ADDED
|
@@ -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)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""CrewAI tools.
|
|
2
|
+
|
|
3
|
+
from socialsforai.integrations.crewai import socials_tools
|
|
4
|
+
agent = Agent(role="Social media manager", tools=socials_tools(), ...)
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any, Iterable
|
|
10
|
+
|
|
11
|
+
from .._client import SocialsForAI
|
|
12
|
+
from ..tools import run_tool
|
|
13
|
+
from ._common import as_text, client_or_default, pick
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _model_for(spec: dict[str, Any]):
|
|
17
|
+
"""A pydantic model for the tool's input (CrewAI validates arguments with it)."""
|
|
18
|
+
from pydantic import ConfigDict, Field, create_model
|
|
19
|
+
|
|
20
|
+
types = {"string": str, "integer": int, "boolean": bool, "array": list, "object": dict}
|
|
21
|
+
props = spec["input"].get("properties", {})
|
|
22
|
+
required = set(spec["input"].get("required", []))
|
|
23
|
+
fields = {}
|
|
24
|
+
for name, p in props.items():
|
|
25
|
+
t = types.get(p.get("type"), Any)
|
|
26
|
+
fields[name] = (t, Field(... if name in required else None, description=p.get("description", "")))
|
|
27
|
+
return create_model(f"{spec['name']}_input", __config__=ConfigDict(extra="allow"), **fields)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def socials_tools(client: SocialsForAI | None = None, *, tools: Iterable[str] | None = None, include_destructive: bool = False) -> list:
|
|
31
|
+
try:
|
|
32
|
+
from crewai.tools import BaseTool
|
|
33
|
+
except ImportError as e: # pragma: no cover
|
|
34
|
+
raise ImportError("Install CrewAI first: pip install crewai") from e
|
|
35
|
+
c = client_or_default(client)
|
|
36
|
+
out = []
|
|
37
|
+
for spec in pick(tools, include_destructive):
|
|
38
|
+
|
|
39
|
+
class SocialsTool(BaseTool):
|
|
40
|
+
name: str = spec["name"]
|
|
41
|
+
description: str = spec["description"]
|
|
42
|
+
args_schema: type = _model_for(spec)
|
|
43
|
+
|
|
44
|
+
def _run(self, **kwargs) -> str:
|
|
45
|
+
return as_text(run_tool(c, self.name, {k: v for k, v in kwargs.items() if v is not None}))
|
|
46
|
+
|
|
47
|
+
out.append(SocialsTool())
|
|
48
|
+
return out
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""LangChain tools (also usable from LangGraph).
|
|
2
|
+
|
|
3
|
+
from socialsforai.integrations.langchain import socials_tools
|
|
4
|
+
tools = socials_tools() # reads SOCIALSFORAI_API_KEY
|
|
5
|
+
agent = create_react_agent(model, tools)
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Iterable
|
|
11
|
+
|
|
12
|
+
from .._client import SocialsForAI
|
|
13
|
+
from ..tools import run_tool
|
|
14
|
+
from ._common import as_text, client_or_default, pick
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def socials_tools(client: SocialsForAI | None = None, *, tools: Iterable[str] | None = None, include_destructive: bool = False) -> list:
|
|
18
|
+
try:
|
|
19
|
+
from langchain_core.tools import StructuredTool
|
|
20
|
+
except ImportError as e: # pragma: no cover
|
|
21
|
+
raise ImportError("Install LangChain first: pip install langchain-core") from e
|
|
22
|
+
c = client_or_default(client)
|
|
23
|
+
out = []
|
|
24
|
+
for spec in pick(tools, include_destructive):
|
|
25
|
+
|
|
26
|
+
def call(_name: str = spec["name"], **kwargs) -> str:
|
|
27
|
+
return as_text(run_tool(c, _name, kwargs))
|
|
28
|
+
|
|
29
|
+
out.append(StructuredTool.from_function(func=call, name=spec["name"], description=spec["description"], args_schema=spec["input"]))
|
|
30
|
+
return out
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Tools for the OpenAI Agents SDK.
|
|
2
|
+
|
|
3
|
+
from agents import Agent
|
|
4
|
+
from socialsforai.integrations.openai_agents import socials_tools
|
|
5
|
+
agent = Agent(name="Social media", tools=socials_tools())
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from typing import Iterable
|
|
12
|
+
|
|
13
|
+
from .._client import SocialsForAI
|
|
14
|
+
from ..tools import run_tool
|
|
15
|
+
from ._common import as_text, client_or_default, pick
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def socials_tools(client: SocialsForAI | None = None, *, tools: Iterable[str] | None = None, include_destructive: bool = False) -> list:
|
|
19
|
+
try:
|
|
20
|
+
from agents import FunctionTool
|
|
21
|
+
except ImportError as e: # pragma: no cover
|
|
22
|
+
raise ImportError("Install the OpenAI Agents SDK first: pip install openai-agents") from e
|
|
23
|
+
c = client_or_default(client)
|
|
24
|
+
out = []
|
|
25
|
+
for spec in pick(tools, include_destructive):
|
|
26
|
+
|
|
27
|
+
async def invoke(_ctx, raw: str, _name: str = spec["name"]) -> str:
|
|
28
|
+
import asyncio
|
|
29
|
+
|
|
30
|
+
args = json.loads(raw or "{}")
|
|
31
|
+
return as_text(await asyncio.to_thread(run_tool, c, _name, args))
|
|
32
|
+
|
|
33
|
+
# Our schemas have optional fields, so strict mode (which requires every field) is off.
|
|
34
|
+
out.append(FunctionTool(name=spec["name"], description=spec["description"], params_json_schema=spec["input"], on_invoke_tool=invoke, strict_json_schema=False))
|
|
35
|
+
return out
|
socialsforai/py.typed
ADDED
|
File without changes
|
socialsforai/tools.json
ADDED
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"tools": [
|
|
4
|
+
{
|
|
5
|
+
"name": "list_accounts",
|
|
6
|
+
"description": "List the social accounts connected to this workspace. Use their ids in create_post. If the one you need is missing, call create_connect_link.",
|
|
7
|
+
"request": {
|
|
8
|
+
"method": "GET",
|
|
9
|
+
"path": "/v1/accounts",
|
|
10
|
+
"query": [
|
|
11
|
+
"platform"
|
|
12
|
+
]
|
|
13
|
+
},
|
|
14
|
+
"input": {
|
|
15
|
+
"type": "object",
|
|
16
|
+
"properties": {
|
|
17
|
+
"platform": {
|
|
18
|
+
"type": "string",
|
|
19
|
+
"description": "Only accounts on this network, e.g. \"x\"."
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"readOnly": true
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"name": "list_platforms",
|
|
27
|
+
"description": "Which networks are available and what each supports: character and media limits, threads, polls, delete, metrics.",
|
|
28
|
+
"request": {
|
|
29
|
+
"method": "GET",
|
|
30
|
+
"path": "/v1/platforms"
|
|
31
|
+
},
|
|
32
|
+
"input": {
|
|
33
|
+
"type": "object",
|
|
34
|
+
"properties": {}
|
|
35
|
+
},
|
|
36
|
+
"readOnly": true
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"name": "create_connect_link",
|
|
40
|
+
"description": "Create a link the account owner opens to connect social accounts (they approve on the network's own page). Give them the url, then check list_accounts.",
|
|
41
|
+
"request": {
|
|
42
|
+
"method": "POST",
|
|
43
|
+
"path": "/v1/connect-links",
|
|
44
|
+
"body": [
|
|
45
|
+
"platforms",
|
|
46
|
+
"external_ref"
|
|
47
|
+
]
|
|
48
|
+
},
|
|
49
|
+
"input": {
|
|
50
|
+
"type": "object",
|
|
51
|
+
"properties": {
|
|
52
|
+
"platforms": {
|
|
53
|
+
"type": "array",
|
|
54
|
+
"items": {
|
|
55
|
+
"type": "string"
|
|
56
|
+
},
|
|
57
|
+
"description": "Networks to offer, e.g. [\"x\", \"linkedin\"]. Omit for all."
|
|
58
|
+
},
|
|
59
|
+
"external_ref": {
|
|
60
|
+
"type": "string",
|
|
61
|
+
"description": "Your label for whose accounts these are."
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
"name": "create_post",
|
|
68
|
+
"description": "Publish or schedule a post to one or more accounts and get one result per account. Run it with dry_run: true first and show the person the text per network. Every error includes a fix: act on it. Reuse idempotency_key when retrying.",
|
|
69
|
+
"request": {
|
|
70
|
+
"method": "POST",
|
|
71
|
+
"path": "/v1/posts",
|
|
72
|
+
"body": "*",
|
|
73
|
+
"defaults": {
|
|
74
|
+
"wait": true
|
|
75
|
+
},
|
|
76
|
+
"idempotency": true
|
|
77
|
+
},
|
|
78
|
+
"input": {
|
|
79
|
+
"type": "object",
|
|
80
|
+
"properties": {
|
|
81
|
+
"accounts": {
|
|
82
|
+
"type": "array",
|
|
83
|
+
"items": {
|
|
84
|
+
"type": "string"
|
|
85
|
+
},
|
|
86
|
+
"description": "Account ids (acc_...) from list_accounts, or a network name like \"linkedin\" when there is exactly one account on it."
|
|
87
|
+
},
|
|
88
|
+
"text": {
|
|
89
|
+
"type": "string",
|
|
90
|
+
"description": "The post's text."
|
|
91
|
+
},
|
|
92
|
+
"media": {
|
|
93
|
+
"type": "array",
|
|
94
|
+
"description": "Images, videos or a document, by public URL. The URL must be the file itself, not a share page.",
|
|
95
|
+
"items": {
|
|
96
|
+
"type": "object",
|
|
97
|
+
"properties": {
|
|
98
|
+
"url": {
|
|
99
|
+
"type": "string",
|
|
100
|
+
"description": "Public URL of the file."
|
|
101
|
+
},
|
|
102
|
+
"type": {
|
|
103
|
+
"type": "string",
|
|
104
|
+
"enum": [
|
|
105
|
+
"image",
|
|
106
|
+
"video",
|
|
107
|
+
"document"
|
|
108
|
+
]
|
|
109
|
+
},
|
|
110
|
+
"alt": {
|
|
111
|
+
"type": "string",
|
|
112
|
+
"description": "Alt text."
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
"required": [
|
|
116
|
+
"url"
|
|
117
|
+
]
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
"link": {
|
|
121
|
+
"type": "object",
|
|
122
|
+
"description": "Share a URL as a preview card.",
|
|
123
|
+
"properties": {
|
|
124
|
+
"url": {
|
|
125
|
+
"type": "string",
|
|
126
|
+
"description": "The page to link to."
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
"required": [
|
|
130
|
+
"url"
|
|
131
|
+
]
|
|
132
|
+
},
|
|
133
|
+
"thread": {
|
|
134
|
+
"type": "array",
|
|
135
|
+
"description": "Follow-up posts, each a reply to the one before (X, Bluesky, Mastodon, Telegram, Nostr).",
|
|
136
|
+
"items": {
|
|
137
|
+
"type": "object",
|
|
138
|
+
"properties": {
|
|
139
|
+
"text": {
|
|
140
|
+
"type": "string"
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
"required": [
|
|
144
|
+
"text"
|
|
145
|
+
]
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
"auto_thread": {
|
|
149
|
+
"type": "boolean",
|
|
150
|
+
"description": "Split text that is over a network's limit into a thread there instead of failing."
|
|
151
|
+
},
|
|
152
|
+
"scheduled_at": {
|
|
153
|
+
"type": "string",
|
|
154
|
+
"description": "When to publish: ISO-8601 with an offset, a local time together with timezone, or \"best\" for the hour that has worked best. Omit to publish now."
|
|
155
|
+
},
|
|
156
|
+
"timezone": {
|
|
157
|
+
"type": "string",
|
|
158
|
+
"description": "IANA time zone for a local scheduled_at, e.g. \"America/New_York\"."
|
|
159
|
+
},
|
|
160
|
+
"repeat": {
|
|
161
|
+
"type": "object",
|
|
162
|
+
"description": "Repeat the post: count posts, every day, week or month, at the same local time.",
|
|
163
|
+
"properties": {
|
|
164
|
+
"every": {
|
|
165
|
+
"type": "string",
|
|
166
|
+
"enum": [
|
|
167
|
+
"day",
|
|
168
|
+
"week",
|
|
169
|
+
"month"
|
|
170
|
+
]
|
|
171
|
+
},
|
|
172
|
+
"count": {
|
|
173
|
+
"type": "integer",
|
|
174
|
+
"minimum": 2,
|
|
175
|
+
"maximum": 52
|
|
176
|
+
},
|
|
177
|
+
"interval": {
|
|
178
|
+
"type": "integer",
|
|
179
|
+
"minimum": 1
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
"required": [
|
|
183
|
+
"every",
|
|
184
|
+
"count"
|
|
185
|
+
]
|
|
186
|
+
},
|
|
187
|
+
"reply_to": {
|
|
188
|
+
"type": "string",
|
|
189
|
+
"description": "A post id (post_...) to reply to, on the same accounts."
|
|
190
|
+
},
|
|
191
|
+
"reply_to_comment": {
|
|
192
|
+
"type": "string",
|
|
193
|
+
"description": "With reply_to: the id of someone's reply under that post (from list_replies), to answer them. One account only."
|
|
194
|
+
},
|
|
195
|
+
"first_comment": {
|
|
196
|
+
"type": "string",
|
|
197
|
+
"description": "Posted as a comment right after the post (LinkedIn), e.g. to hold a link."
|
|
198
|
+
},
|
|
199
|
+
"platform_options": {
|
|
200
|
+
"type": "object",
|
|
201
|
+
"description": "Per-network settings, e.g. {\"x\": {\"text\": \"shorter version\"}, \"reddit\": {\"subreddit\": \"test\"}}. See https://socialsforai.com/llms.txt."
|
|
202
|
+
},
|
|
203
|
+
"approval": {
|
|
204
|
+
"type": "string",
|
|
205
|
+
"enum": [
|
|
206
|
+
"required"
|
|
207
|
+
],
|
|
208
|
+
"description": "Hold the post until a person approves it at approval.url."
|
|
209
|
+
},
|
|
210
|
+
"dry_run": {
|
|
211
|
+
"type": "boolean",
|
|
212
|
+
"description": "Check everything without publishing. Returns ok, issues (each with a fix) and the exact text per network."
|
|
213
|
+
},
|
|
214
|
+
"idempotency_key": {
|
|
215
|
+
"type": "string",
|
|
216
|
+
"description": "Send the same key when retrying the same post, so it is never published twice."
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
"required": [
|
|
220
|
+
"accounts"
|
|
221
|
+
]
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
"name": "create_posts",
|
|
226
|
+
"description": "Create up to 50 posts in one call, such as a week or month of scheduled content. Each is checked and created by itself; results come back per item.",
|
|
227
|
+
"request": {
|
|
228
|
+
"method": "POST",
|
|
229
|
+
"path": "/v1/posts/batch",
|
|
230
|
+
"body": "*",
|
|
231
|
+
"idempotency": true
|
|
232
|
+
},
|
|
233
|
+
"input": {
|
|
234
|
+
"type": "object",
|
|
235
|
+
"properties": {
|
|
236
|
+
"posts": {
|
|
237
|
+
"type": "array",
|
|
238
|
+
"items": {
|
|
239
|
+
"type": "object",
|
|
240
|
+
"properties": {
|
|
241
|
+
"accounts": {
|
|
242
|
+
"type": "array",
|
|
243
|
+
"items": {
|
|
244
|
+
"type": "string"
|
|
245
|
+
},
|
|
246
|
+
"description": "Account ids (acc_...) from list_accounts, or a network name like \"linkedin\" when there is exactly one account on it."
|
|
247
|
+
},
|
|
248
|
+
"text": {
|
|
249
|
+
"type": "string",
|
|
250
|
+
"description": "The post's text."
|
|
251
|
+
},
|
|
252
|
+
"media": {
|
|
253
|
+
"type": "array",
|
|
254
|
+
"description": "Images, videos or a document, by public URL. The URL must be the file itself, not a share page.",
|
|
255
|
+
"items": {
|
|
256
|
+
"type": "object",
|
|
257
|
+
"properties": {
|
|
258
|
+
"url": {
|
|
259
|
+
"type": "string",
|
|
260
|
+
"description": "Public URL of the file."
|
|
261
|
+
},
|
|
262
|
+
"type": {
|
|
263
|
+
"type": "string",
|
|
264
|
+
"enum": [
|
|
265
|
+
"image",
|
|
266
|
+
"video",
|
|
267
|
+
"document"
|
|
268
|
+
]
|
|
269
|
+
},
|
|
270
|
+
"alt": {
|
|
271
|
+
"type": "string",
|
|
272
|
+
"description": "Alt text."
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
"required": [
|
|
276
|
+
"url"
|
|
277
|
+
]
|
|
278
|
+
}
|
|
279
|
+
},
|
|
280
|
+
"link": {
|
|
281
|
+
"type": "object",
|
|
282
|
+
"description": "Share a URL as a preview card.",
|
|
283
|
+
"properties": {
|
|
284
|
+
"url": {
|
|
285
|
+
"type": "string",
|
|
286
|
+
"description": "The page to link to."
|
|
287
|
+
}
|
|
288
|
+
},
|
|
289
|
+
"required": [
|
|
290
|
+
"url"
|
|
291
|
+
]
|
|
292
|
+
},
|
|
293
|
+
"thread": {
|
|
294
|
+
"type": "array",
|
|
295
|
+
"description": "Follow-up posts, each a reply to the one before (X, Bluesky, Mastodon, Telegram, Nostr).",
|
|
296
|
+
"items": {
|
|
297
|
+
"type": "object",
|
|
298
|
+
"properties": {
|
|
299
|
+
"text": {
|
|
300
|
+
"type": "string"
|
|
301
|
+
}
|
|
302
|
+
},
|
|
303
|
+
"required": [
|
|
304
|
+
"text"
|
|
305
|
+
]
|
|
306
|
+
}
|
|
307
|
+
},
|
|
308
|
+
"auto_thread": {
|
|
309
|
+
"type": "boolean",
|
|
310
|
+
"description": "Split text that is over a network's limit into a thread there instead of failing."
|
|
311
|
+
},
|
|
312
|
+
"scheduled_at": {
|
|
313
|
+
"type": "string",
|
|
314
|
+
"description": "When to publish: ISO-8601 with an offset, a local time together with timezone, or \"best\" for the hour that has worked best. Omit to publish now."
|
|
315
|
+
},
|
|
316
|
+
"timezone": {
|
|
317
|
+
"type": "string",
|
|
318
|
+
"description": "IANA time zone for a local scheduled_at, e.g. \"America/New_York\"."
|
|
319
|
+
},
|
|
320
|
+
"repeat": {
|
|
321
|
+
"type": "object",
|
|
322
|
+
"description": "Repeat the post: count posts, every day, week or month, at the same local time.",
|
|
323
|
+
"properties": {
|
|
324
|
+
"every": {
|
|
325
|
+
"type": "string",
|
|
326
|
+
"enum": [
|
|
327
|
+
"day",
|
|
328
|
+
"week",
|
|
329
|
+
"month"
|
|
330
|
+
]
|
|
331
|
+
},
|
|
332
|
+
"count": {
|
|
333
|
+
"type": "integer",
|
|
334
|
+
"minimum": 2,
|
|
335
|
+
"maximum": 52
|
|
336
|
+
},
|
|
337
|
+
"interval": {
|
|
338
|
+
"type": "integer",
|
|
339
|
+
"minimum": 1
|
|
340
|
+
}
|
|
341
|
+
},
|
|
342
|
+
"required": [
|
|
343
|
+
"every",
|
|
344
|
+
"count"
|
|
345
|
+
]
|
|
346
|
+
},
|
|
347
|
+
"reply_to": {
|
|
348
|
+
"type": "string",
|
|
349
|
+
"description": "A post id (post_...) to reply to, on the same accounts."
|
|
350
|
+
},
|
|
351
|
+
"reply_to_comment": {
|
|
352
|
+
"type": "string",
|
|
353
|
+
"description": "With reply_to: the id of someone's reply under that post (from list_replies), to answer them. One account only."
|
|
354
|
+
},
|
|
355
|
+
"first_comment": {
|
|
356
|
+
"type": "string",
|
|
357
|
+
"description": "Posted as a comment right after the post (LinkedIn), e.g. to hold a link."
|
|
358
|
+
},
|
|
359
|
+
"platform_options": {
|
|
360
|
+
"type": "object",
|
|
361
|
+
"description": "Per-network settings, e.g. {\"x\": {\"text\": \"shorter version\"}, \"reddit\": {\"subreddit\": \"test\"}}. See https://socialsforai.com/llms.txt."
|
|
362
|
+
},
|
|
363
|
+
"approval": {
|
|
364
|
+
"type": "string",
|
|
365
|
+
"enum": [
|
|
366
|
+
"required"
|
|
367
|
+
],
|
|
368
|
+
"description": "Hold the post until a person approves it at approval.url."
|
|
369
|
+
},
|
|
370
|
+
"dry_run": {
|
|
371
|
+
"type": "boolean",
|
|
372
|
+
"description": "Check everything without publishing. Returns ok, issues (each with a fix) and the exact text per network."
|
|
373
|
+
},
|
|
374
|
+
"idempotency_key": {
|
|
375
|
+
"type": "string",
|
|
376
|
+
"description": "Send the same key when retrying the same post, so it is never published twice."
|
|
377
|
+
}
|
|
378
|
+
},
|
|
379
|
+
"required": [
|
|
380
|
+
"accounts"
|
|
381
|
+
]
|
|
382
|
+
},
|
|
383
|
+
"minItems": 1,
|
|
384
|
+
"maxItems": 50
|
|
385
|
+
},
|
|
386
|
+
"dry_run": {
|
|
387
|
+
"type": "boolean",
|
|
388
|
+
"description": "Check everything without publishing. Returns ok, issues (each with a fix) and the exact text per network."
|
|
389
|
+
}
|
|
390
|
+
},
|
|
391
|
+
"required": [
|
|
392
|
+
"posts"
|
|
393
|
+
]
|
|
394
|
+
}
|
|
395
|
+
},
|
|
396
|
+
{
|
|
397
|
+
"name": "get_post",
|
|
398
|
+
"description": "A post's status and, per account, its link or error with a fix.",
|
|
399
|
+
"request": {
|
|
400
|
+
"method": "GET",
|
|
401
|
+
"path": "/v1/posts/{id}"
|
|
402
|
+
},
|
|
403
|
+
"input": {
|
|
404
|
+
"type": "object",
|
|
405
|
+
"properties": {
|
|
406
|
+
"id": {
|
|
407
|
+
"type": "string",
|
|
408
|
+
"description": "The post id (post_...)."
|
|
409
|
+
}
|
|
410
|
+
},
|
|
411
|
+
"required": [
|
|
412
|
+
"id"
|
|
413
|
+
]
|
|
414
|
+
},
|
|
415
|
+
"readOnly": true
|
|
416
|
+
},
|
|
417
|
+
{
|
|
418
|
+
"name": "list_posts",
|
|
419
|
+
"description": "Recent posts, newest first. Filter by status, e.g. scheduled or failed.",
|
|
420
|
+
"request": {
|
|
421
|
+
"method": "GET",
|
|
422
|
+
"path": "/v1/posts",
|
|
423
|
+
"query": [
|
|
424
|
+
"status",
|
|
425
|
+
"limit"
|
|
426
|
+
]
|
|
427
|
+
},
|
|
428
|
+
"input": {
|
|
429
|
+
"type": "object",
|
|
430
|
+
"properties": {
|
|
431
|
+
"status": {
|
|
432
|
+
"type": "string",
|
|
433
|
+
"enum": [
|
|
434
|
+
"pending_approval",
|
|
435
|
+
"scheduled",
|
|
436
|
+
"publishing",
|
|
437
|
+
"published",
|
|
438
|
+
"partially_published",
|
|
439
|
+
"failed",
|
|
440
|
+
"canceled",
|
|
441
|
+
"deleted"
|
|
442
|
+
]
|
|
443
|
+
},
|
|
444
|
+
"limit": {
|
|
445
|
+
"type": "integer",
|
|
446
|
+
"minimum": 1,
|
|
447
|
+
"maximum": 100
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
},
|
|
451
|
+
"readOnly": true
|
|
452
|
+
},
|
|
453
|
+
{
|
|
454
|
+
"name": "delete_post",
|
|
455
|
+
"description": "Delete a post everywhere it went out, or cancel it if it hasn't. Permanent: confirm with the person first. series: true also cancels the rest of a repeat series.",
|
|
456
|
+
"request": {
|
|
457
|
+
"method": "DELETE",
|
|
458
|
+
"path": "/v1/posts/{id}",
|
|
459
|
+
"query": [
|
|
460
|
+
"accounts",
|
|
461
|
+
"series"
|
|
462
|
+
]
|
|
463
|
+
},
|
|
464
|
+
"input": {
|
|
465
|
+
"type": "object",
|
|
466
|
+
"properties": {
|
|
467
|
+
"id": {
|
|
468
|
+
"type": "string",
|
|
469
|
+
"description": "The post id."
|
|
470
|
+
},
|
|
471
|
+
"accounts": {
|
|
472
|
+
"type": "array",
|
|
473
|
+
"items": {
|
|
474
|
+
"type": "string"
|
|
475
|
+
},
|
|
476
|
+
"description": "Only delete it from these accounts."
|
|
477
|
+
},
|
|
478
|
+
"series": {
|
|
479
|
+
"type": "boolean"
|
|
480
|
+
}
|
|
481
|
+
},
|
|
482
|
+
"required": [
|
|
483
|
+
"id"
|
|
484
|
+
]
|
|
485
|
+
},
|
|
486
|
+
"destructive": true
|
|
487
|
+
},
|
|
488
|
+
{
|
|
489
|
+
"name": "get_post_metrics",
|
|
490
|
+
"description": "Likes, reposts, replies, views and more for each published copy of a post, plus totals. Cached 15 minutes; X bills per read, so don't poll it in a loop.",
|
|
491
|
+
"request": {
|
|
492
|
+
"method": "GET",
|
|
493
|
+
"path": "/v1/posts/{id}/metrics"
|
|
494
|
+
},
|
|
495
|
+
"input": {
|
|
496
|
+
"type": "object",
|
|
497
|
+
"properties": {
|
|
498
|
+
"id": {
|
|
499
|
+
"type": "string",
|
|
500
|
+
"description": "The post id."
|
|
501
|
+
}
|
|
502
|
+
},
|
|
503
|
+
"required": [
|
|
504
|
+
"id"
|
|
505
|
+
]
|
|
506
|
+
},
|
|
507
|
+
"readOnly": true
|
|
508
|
+
},
|
|
509
|
+
{
|
|
510
|
+
"name": "list_replies",
|
|
511
|
+
"description": "What people replied under each published copy of a post. Answer one with create_post (one account, reply_to, reply_to_comment). Show the person your answer first.",
|
|
512
|
+
"request": {
|
|
513
|
+
"method": "GET",
|
|
514
|
+
"path": "/v1/posts/{id}/replies"
|
|
515
|
+
},
|
|
516
|
+
"input": {
|
|
517
|
+
"type": "object",
|
|
518
|
+
"properties": {
|
|
519
|
+
"id": {
|
|
520
|
+
"type": "string",
|
|
521
|
+
"description": "The post id."
|
|
522
|
+
}
|
|
523
|
+
},
|
|
524
|
+
"required": [
|
|
525
|
+
"id"
|
|
526
|
+
]
|
|
527
|
+
},
|
|
528
|
+
"readOnly": true
|
|
529
|
+
},
|
|
530
|
+
{
|
|
531
|
+
"name": "list_events",
|
|
532
|
+
"description": "What happened since you last looked: posts that failed, approvals, accounts needing reconnection. Pass the last_id from the previous call as after.",
|
|
533
|
+
"request": {
|
|
534
|
+
"method": "GET",
|
|
535
|
+
"path": "/v1/events",
|
|
536
|
+
"query": [
|
|
537
|
+
"after",
|
|
538
|
+
"types",
|
|
539
|
+
"limit"
|
|
540
|
+
]
|
|
541
|
+
},
|
|
542
|
+
"input": {
|
|
543
|
+
"type": "object",
|
|
544
|
+
"properties": {
|
|
545
|
+
"after": {
|
|
546
|
+
"type": "string",
|
|
547
|
+
"description": "An event id (evt_...)."
|
|
548
|
+
},
|
|
549
|
+
"types": {
|
|
550
|
+
"type": "array",
|
|
551
|
+
"items": {
|
|
552
|
+
"type": "string"
|
|
553
|
+
},
|
|
554
|
+
"description": "Only these event types."
|
|
555
|
+
},
|
|
556
|
+
"limit": {
|
|
557
|
+
"type": "integer",
|
|
558
|
+
"minimum": 1,
|
|
559
|
+
"maximum": 200
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
},
|
|
563
|
+
"readOnly": true
|
|
564
|
+
}
|
|
565
|
+
]
|
|
566
|
+
}
|
socialsforai/tools.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Agent tools, independent of any framework: the definitions (name, description, JSON Schema) and
|
|
2
|
+
one function that runs them. The framework adapters in socialsforai.integrations wrap these.
|
|
3
|
+
|
|
4
|
+
from socialsforai import SocialsForAI
|
|
5
|
+
from socialsforai.tools import TOOLS, run_tool
|
|
6
|
+
result = run_tool(SocialsForAI(), "create_post", {"accounts": ["linkedin"], "text": "Hi", "dry_run": True})
|
|
7
|
+
|
|
8
|
+
Errors come back as {"error": {"code", "message", "fix", ...}} instead of being raised, so the model
|
|
9
|
+
can read the fix and act on it.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import uuid
|
|
16
|
+
from importlib import resources
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from ._client import SocialsError, SocialsForAI, _query, _quote
|
|
20
|
+
|
|
21
|
+
_SPEC = json.loads(resources.files(__package__).joinpath("tools.json").read_text())
|
|
22
|
+
#: Every tool: {"name", "description", "input" (JSON Schema), "request", "readOnly"?, "destructive"?}.
|
|
23
|
+
TOOLS: list[dict[str, Any]] = _SPEC["tools"]
|
|
24
|
+
_BY_NAME = {t["name"]: t for t in TOOLS}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def tool_names(read_only: bool = False, include_destructive: bool = True) -> list[str]:
|
|
28
|
+
return [t["name"] for t in TOOLS if (not read_only or t.get("readOnly")) and (include_destructive or not t.get("destructive"))]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def run_tool(client: SocialsForAI, name: str, args: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
32
|
+
"""Run one tool with the model's arguments and return the API's JSON (or its error)."""
|
|
33
|
+
spec = _BY_NAME.get(name)
|
|
34
|
+
if not spec:
|
|
35
|
+
return {"error": {"code": "unknown_tool", "message": f"No tool named {name}."}}
|
|
36
|
+
args = dict(args or {})
|
|
37
|
+
req = spec["request"]
|
|
38
|
+
path = req["path"]
|
|
39
|
+
for key in [k[1:-1] for k in path.split("/") if k.startswith("{")]:
|
|
40
|
+
if key not in args:
|
|
41
|
+
return {"error": {"code": "missing_argument", "message": f"{name} needs {key}."}}
|
|
42
|
+
path = path.replace("{" + key + "}", _quote(args.pop(key)))
|
|
43
|
+
query = {k: args.pop(k) for k in req.get("query", []) if k in args}
|
|
44
|
+
body: Any = None
|
|
45
|
+
if req.get("body") == "*":
|
|
46
|
+
body = {**req.get("defaults", {}), **args}
|
|
47
|
+
elif req.get("body"):
|
|
48
|
+
body = {k: args[k] for k in req["body"] if k in args}
|
|
49
|
+
headers = None
|
|
50
|
+
if req.get("idempotency") and not (body or {}).get("dry_run"):
|
|
51
|
+
headers = {"Idempotency-Key": (body or {}).pop("idempotency_key", None) or str(uuid.uuid4())}
|
|
52
|
+
try:
|
|
53
|
+
out = client.request(req["method"], path + _query(query), body=body, headers=headers, auth=name != "list_platforms")
|
|
54
|
+
return out if isinstance(out, dict) else {"result": out}
|
|
55
|
+
except SocialsError as e:
|
|
56
|
+
return e.to_dict()
|
|
@@ -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,14 @@
|
|
|
1
|
+
socialsforai/__init__.py,sha256=8gdX4MBfhMkMKirF86NICP7sDAqWwijVVOmgSOPV0wA,583
|
|
2
|
+
socialsforai/_client.py,sha256=n-rfPfyIXdKb5ERFiBsaXS4X6o8tl72lld2hACXxuK4,16539
|
|
3
|
+
socialsforai/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
socialsforai/tools.json,sha256=NVd4XYQMklPTz5yrz1yF2GL1cJ7hhbA79WmfR4pJ_GM,18138
|
|
5
|
+
socialsforai/tools.py,sha256=uWUZqyFsDIHryuXn1P6u0Mo0d7qY-pxBrNQznJtuigU,2589
|
|
6
|
+
socialsforai/integrations/__init__.py,sha256=wSJTGYcX7grhGLzIjNCdUNbhKR8VagWhcFECb6clMrM,168
|
|
7
|
+
socialsforai/integrations/_common.py,sha256=6A36vkajfUnADYJoGw6_3rJTCG7cGtd2J3Lb4A02NSE,1018
|
|
8
|
+
socialsforai/integrations/crewai.py,sha256=Y8lzqmmOIT_RCGJCbFtr3lsazMDojNaF_mibZOKVQz8,1814
|
|
9
|
+
socialsforai/integrations/langchain.py,sha256=BmO3n_dfII4SrkqysoWzrY-GPERubPTukAmv1PJLdn8,1128
|
|
10
|
+
socialsforai/integrations/openai_agents.py,sha256=jC-nMiktnRdTvuTAppo1SuuMMLUbp3l9IBKO1gm-8hw,1337
|
|
11
|
+
socialsforai-0.1.0.dist-info/METADATA,sha256=UbjlLLyoF88RDgwxiwqRWCYMuF8COvC7CGnzKigeUIY,5275
|
|
12
|
+
socialsforai-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
13
|
+
socialsforai-0.1.0.dist-info/licenses/LICENSE,sha256=ssGiy7YfLrCObMMi-0DFnJU4Fqn0zYU4VKN7K9xCqas,1070
|
|
14
|
+
socialsforai-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|