Skip to content

Commit 8b1ac3c

Browse files
authored
Merge pull request #1 from strawberry-graphql/add-typefully-plugin
2 parents 1d4fac3 + 1b2e315 commit 8b1ac3c

File tree

3 files changed

+494
-1
lines changed

3 files changed

+494
-1
lines changed
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""AutoPub plugins for Strawberry GraphQL."""
22

33
from strawberry_autopub_plugins.invite_contributors import InviteContributorsPlugin
4+
from strawberry_autopub_plugins.typefully import TypefullyPlugin
45

5-
__all__ = ["InviteContributorsPlugin"]
6+
__all__ = ["InviteContributorsPlugin", "TypefullyPlugin"]
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
from __future__ import annotations
2+
3+
import json
4+
import os
5+
from typing import Literal
6+
from urllib.error import HTTPError
7+
from urllib.request import Request, urlopen
8+
9+
from pydantic import BaseModel, Field
10+
11+
from autopub.exceptions import AutopubException
12+
from autopub.plugins import AutopubPlugin
13+
from autopub.types import ReleaseInfo
14+
15+
16+
class TypefullyConfig(BaseModel):
17+
social_set_id: str = Field(validation_alias="social-set-id")
18+
platforms: list[Literal["x", "linkedin", "threads", "bluesky", "mastodon"]] = Field(
19+
default_factory=lambda: ["x"],
20+
)
21+
message_template: str = Field(
22+
default="{project_name} {version} has been released!\n\n{release_notes}",
23+
validation_alias="message-template",
24+
)
25+
platform_templates: dict[str, str] = Field(
26+
default_factory=dict,
27+
validation_alias="platform-templates",
28+
)
29+
project_name: str = Field(default="", validation_alias="project-name")
30+
publish_mode: Literal["draft", "now", "next-free-slot", "scheduled"] = Field(
31+
default="draft",
32+
validation_alias="publish-mode",
33+
)
34+
publish_at: str | None = Field(default=None, validation_alias="publish-at")
35+
tags: list[str] = Field(default_factory=list)
36+
max_length: int = Field(default=280, validation_alias="max-length")
37+
truncation_suffix: str = Field(default="...", validation_alias="truncation-suffix")
38+
dry_run: bool = Field(default=False, validation_alias="dry-run")
39+
40+
41+
def _autopub_error(message: str) -> AutopubException:
42+
"""Create an AutopubException with .message set for CLI compatibility."""
43+
exc = AutopubException(message)
44+
exc.message = message # type: ignore[attr-defined]
45+
return exc
46+
47+
48+
class TypefullyPlugin(AutopubPlugin):
49+
"""Announce releases on social media via Typefully."""
50+
51+
id = "typefully"
52+
Config = TypefullyConfig
53+
BASE_URL = "https://api.typefully.com"
54+
55+
def __init__(self) -> None:
56+
self.api_key = os.environ.get("TYPEFULLY_API_KEY")
57+
58+
if not self.api_key:
59+
raise _autopub_error("TYPEFULLY_API_KEY environment variable is required")
60+
61+
def _format_message(
62+
self,
63+
template: str,
64+
release_info: ReleaseInfo,
65+
) -> str:
66+
variables = {
67+
"version": release_info.version or "",
68+
"release_type": release_info.release_type,
69+
"release_notes": release_info.release_notes,
70+
"previous_version": release_info.previous_version or "",
71+
"project_name": self.config.project_name,
72+
}
73+
74+
message = template.format_map(variables)
75+
76+
max_length = self.config.max_length
77+
if len(message) > max_length:
78+
suffix = self.config.truncation_suffix
79+
message = message[: max_length - len(suffix)] + suffix
80+
81+
return message
82+
83+
def _build_platforms_payload(
84+
self,
85+
release_info: ReleaseInfo,
86+
) -> dict[str, object]:
87+
platforms: dict[str, object] = {}
88+
89+
for platform in self.config.platforms:
90+
template = self.config.platform_templates.get(
91+
platform,
92+
self.config.message_template,
93+
)
94+
message = self._format_message(template, release_info)
95+
platforms[platform] = {
96+
"enabled": True,
97+
"posts": [{"text": message}],
98+
}
99+
100+
return platforms
101+
102+
def _resolve_publish_at(self) -> str | None:
103+
mode = self.config.publish_mode
104+
105+
if mode == "draft":
106+
return None
107+
108+
if mode == "now":
109+
return "now"
110+
111+
if mode == "next-free-slot":
112+
return "next-free-slot"
113+
114+
if mode == "scheduled":
115+
if not self.config.publish_at:
116+
raise _autopub_error(
117+
"publish-at is required when publish-mode is 'scheduled'"
118+
)
119+
return self.config.publish_at
120+
121+
return None # pragma: no cover
122+
123+
def _build_request_body(
124+
self,
125+
release_info: ReleaseInfo,
126+
) -> dict[str, object]:
127+
body: dict[str, object] = {
128+
"platforms": self._build_platforms_payload(release_info),
129+
}
130+
131+
publish_at = self._resolve_publish_at()
132+
if publish_at is not None:
133+
body["publish_at"] = publish_at
134+
135+
if self.config.tags:
136+
body["tags"] = self.config.tags
137+
138+
return body
139+
140+
def _create_draft(self, body: dict[str, object]) -> None:
141+
social_set_id = self.config.social_set_id
142+
url = f"{self.BASE_URL}/v2/social-sets/{social_set_id}/drafts"
143+
144+
data = json.dumps(body).encode()
145+
request = Request(
146+
url,
147+
data=data,
148+
headers={
149+
"Authorization": f"Bearer {self.api_key}",
150+
"Content-Type": "application/json",
151+
},
152+
method="POST",
153+
)
154+
155+
try:
156+
urlopen(request) # noqa: S310
157+
except HTTPError as exc:
158+
if exc.code == 401:
159+
raise _autopub_error(
160+
"Typefully authentication failed: invalid API key"
161+
) from exc
162+
163+
if exc.code == 429:
164+
raise _autopub_error(
165+
"Typefully rate limit exceeded, try again later"
166+
) from exc
167+
168+
try:
169+
error_body = json.loads(exc.read())
170+
detail = error_body.get("detail", str(exc))
171+
except Exception:
172+
detail = str(exc)
173+
174+
raise _autopub_error(
175+
f"Typefully API error ({exc.code}): {detail}"
176+
) from exc
177+
178+
def post_publish(self, release_info: ReleaseInfo) -> None:
179+
body = self._build_request_body(release_info)
180+
181+
if self.config.dry_run:
182+
print(f"[typefully] dry run — request body: {body}")
183+
return
184+
185+
self._create_draft(body)
186+
187+
188+
__all__ = ["TypefullyPlugin"]

0 commit comments

Comments
 (0)