typefully-cli 0.2.1__py3-none-any.whl → 0.3.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.
- typefully_cli/__init__.py +1 -1
- typefully_cli/cli.py +301 -44
- typefully_cli/client.py +25 -4
- typefully_cli/media.py +2 -1
- typefully_cli/payloads.py +195 -0
- typefully_cli/request-schemas.json +1220 -0
- {typefully_cli-0.2.1.dist-info → typefully_cli-0.3.0.dist-info}/METADATA +41 -3
- typefully_cli-0.3.0.dist-info/RECORD +19 -0
- {typefully_cli-0.2.1.dist-info → typefully_cli-0.3.0.dist-info}/WHEEL +1 -1
- typefully_cli-0.2.1.dist-info/RECORD +0 -17
- {typefully_cli-0.2.1.dist-info → typefully_cli-0.3.0.dist-info}/entry_points.txt +0 -0
- {typefully_cli-0.2.1.dist-info → typefully_cli-0.3.0.dist-info}/licenses/LICENSE +0 -0
typefully_cli/__init__.py
CHANGED
typefully_cli/cli.py
CHANGED
|
@@ -10,6 +10,7 @@ import os
|
|
|
10
10
|
import sys
|
|
11
11
|
from functools import wraps
|
|
12
12
|
from typing import Any
|
|
13
|
+
from urllib.parse import urlsplit
|
|
13
14
|
|
|
14
15
|
import click
|
|
15
16
|
|
|
@@ -152,6 +153,136 @@ POST_PLATFORMS = {"x", "linkedin", "threads", "bluesky", "mastodon", "substack"}
|
|
|
152
153
|
LINK_PREVIEW_PLATFORMS = {"linkedin", "threads", "substack"}
|
|
153
154
|
|
|
154
155
|
|
|
156
|
+
def _quote_platform(url: str, platforms: list[str]) -> str:
|
|
157
|
+
"""Route quotes using Typefully's official agent CLI host/path rules."""
|
|
158
|
+
try:
|
|
159
|
+
parsed = urlsplit(url)
|
|
160
|
+
host = (parsed.hostname or "").lower()
|
|
161
|
+
except ValueError:
|
|
162
|
+
raise TypefullyError("Quote URL must be a full HTTP(S) post URL", code="invalid_input")
|
|
163
|
+
if not host or parsed.scheme not in {"http", "https"}:
|
|
164
|
+
raise TypefullyError("Quote URL must be a full HTTP(S) post URL", code="invalid_input")
|
|
165
|
+
known = None
|
|
166
|
+
for platform, domains in {
|
|
167
|
+
"x": ("x.com", "twitter.com"),
|
|
168
|
+
"threads": ("threads.net", "threads.com"),
|
|
169
|
+
"bluesky": ("bsky.app",),
|
|
170
|
+
"substack": ("substack.com",),
|
|
171
|
+
}.items():
|
|
172
|
+
if any(host == domain or host.endswith("." + domain) for domain in domains):
|
|
173
|
+
known = platform
|
|
174
|
+
break
|
|
175
|
+
if not known and "substack" in platforms and (
|
|
176
|
+
"/note/c-" in parsed.path or "/p/" in parsed.path
|
|
177
|
+
):
|
|
178
|
+
known = "substack"
|
|
179
|
+
if known:
|
|
180
|
+
if known not in platforms:
|
|
181
|
+
raise TypefullyError(
|
|
182
|
+
f"Quote URL requires {known} in --platform", code="invalid_input"
|
|
183
|
+
)
|
|
184
|
+
return known
|
|
185
|
+
candidates = [p for p in platforms if p in POST_PLATFORMS and p != "linkedin"]
|
|
186
|
+
if "mastodon" in candidates:
|
|
187
|
+
return "mastodon"
|
|
188
|
+
if len(candidates) == 1:
|
|
189
|
+
return candidates[0]
|
|
190
|
+
raise TypefullyError(
|
|
191
|
+
"Quote URL has no unambiguous target; select X, Threads, Bluesky, Mastodon, or Substack",
|
|
192
|
+
code="invalid_input",
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _platform_field_options(f):
|
|
197
|
+
"""Optional platform fields, omitted unless explicitly supplied."""
|
|
198
|
+
options = [
|
|
199
|
+
click.option("--reply-settings", type=click.Choice([
|
|
200
|
+
"following", "mentionedUsers", "subscribers", "verified", "null",
|
|
201
|
+
]), default=None, help="X reply audience; null restores everyone"),
|
|
202
|
+
click.option("--subscribers-only/--no-subscribers-only", default=None),
|
|
203
|
+
click.option("--share-with-followers/--no-share-with-followers", default=None,
|
|
204
|
+
help="Also share an X community post to followers"),
|
|
205
|
+
click.option("--linkedin-first-comment", default=None,
|
|
206
|
+
help="LinkedIn first comment (max 1250 characters); null clears"),
|
|
207
|
+
click.option("--linkedin-reshare-target", default=None,
|
|
208
|
+
help="LinkedIn post URL or URN (max 2048 characters); null clears"),
|
|
209
|
+
]
|
|
210
|
+
for option in options:
|
|
211
|
+
f = option(f)
|
|
212
|
+
return f
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _validate_platform_fields(platforms, fields):
|
|
216
|
+
x_fields = ("reply_settings", "subscribers_only", "share_with_followers")
|
|
217
|
+
li_fields = ("linkedin_first_comment", "linkedin_reshare_target")
|
|
218
|
+
for platform, names in (("x", x_fields), ("linkedin", li_fields)):
|
|
219
|
+
if any(fields.get(name) is not None for name in names) and platform not in platforms:
|
|
220
|
+
raise TypefullyError(
|
|
221
|
+
f"{platform}-only options require {platform} in --platform", code="invalid_input"
|
|
222
|
+
)
|
|
223
|
+
reply = fields.get("reply_settings")
|
|
224
|
+
if reply is not None and reply not in {
|
|
225
|
+
"following", "mentionedUsers", "subscribers", "verified", "null",
|
|
226
|
+
}:
|
|
227
|
+
raise TypefullyError("Invalid X reply settings", code="invalid_input")
|
|
228
|
+
for name, limit in (("linkedin_first_comment", 1250), ("linkedin_reshare_target", 2048)):
|
|
229
|
+
value = fields.get(name)
|
|
230
|
+
if value is not None and len(value) > limit:
|
|
231
|
+
raise TypefullyError(
|
|
232
|
+
f"--{name.replace('_', '-')} must be at most {limit} characters",
|
|
233
|
+
code="invalid_input",
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _apply_platform_fields(platforms, fields):
|
|
238
|
+
"""Apply explicit fields only; settings merge rather than overwrite."""
|
|
239
|
+
_validate_platform_fields(platforms, fields)
|
|
240
|
+
x = platforms.get("x", {})
|
|
241
|
+
for name in ("reply_settings", "subscribers_only"):
|
|
242
|
+
value = fields.get(name)
|
|
243
|
+
if value is not None:
|
|
244
|
+
for post in x.get("posts", []):
|
|
245
|
+
post[name] = None if name == "reply_settings" and value == "null" else value
|
|
246
|
+
share = fields.get("share_with_followers")
|
|
247
|
+
if share is not None:
|
|
248
|
+
settings = x.setdefault("settings", {})
|
|
249
|
+
if not settings.get("community_id"):
|
|
250
|
+
raise TypefullyError(
|
|
251
|
+
"--share-with-followers requires an X community (use --community)",
|
|
252
|
+
code="invalid_input",
|
|
253
|
+
)
|
|
254
|
+
settings["share_with_followers"] = share
|
|
255
|
+
linkedin = platforms.get("linkedin", {})
|
|
256
|
+
first_comment = fields.get("linkedin_first_comment")
|
|
257
|
+
if first_comment is not None:
|
|
258
|
+
linkedin.setdefault("settings", {})["first_comment"] = (
|
|
259
|
+
None if first_comment == "null" else first_comment
|
|
260
|
+
)
|
|
261
|
+
reshare = fields.get("linkedin_reshare_target")
|
|
262
|
+
if reshare is not None:
|
|
263
|
+
for post in linkedin.get("posts", [])[:1]:
|
|
264
|
+
post["linkedin_reshare_target"] = None if reshare == "null" else reshare
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _editable_posts(name, source_posts):
|
|
268
|
+
"""Copy writable fields, retaining anchors and translating response-only names."""
|
|
269
|
+
allowed = {"text", "media_ids", "hide_link_preview"}
|
|
270
|
+
if name == "x":
|
|
271
|
+
allowed |= {"quote_post_url", "paid_partnership", "made_with_ai",
|
|
272
|
+
"subscribers_only", "reply_settings"}
|
|
273
|
+
elif name == "linkedin":
|
|
274
|
+
allowed.add("linkedin_reshare_target")
|
|
275
|
+
else:
|
|
276
|
+
allowed.add("quote_post_url")
|
|
277
|
+
posts = []
|
|
278
|
+
for source in source_posts:
|
|
279
|
+
post = {k: v for k, v in source.items() if k in allowed}
|
|
280
|
+
if name == "linkedin" and "linkedin_reshare_urn" in source:
|
|
281
|
+
post["linkedin_reshare_target"] = source["linkedin_reshare_urn"]
|
|
282
|
+
posts.append(post)
|
|
283
|
+
return posts
|
|
284
|
+
|
|
285
|
+
|
|
155
286
|
def _resolve_platforms(
|
|
156
287
|
client: TypefullyClient,
|
|
157
288
|
social_set_id: int,
|
|
@@ -474,7 +605,10 @@ def recent(limit, since, until_date, output_format, api_key, account, use_text,
|
|
|
474
605
|
@click.option("--share/--no-share", default=False)
|
|
475
606
|
@click.option("--reply-to", default=None, help="URL of tweet to reply to")
|
|
476
607
|
@click.option("--scratchpad", default=None, help="Internal notes (not published)")
|
|
477
|
-
@click.option(
|
|
608
|
+
@click.option(
|
|
609
|
+
"--qrt", "--quote-post-url", "qrt", default=None,
|
|
610
|
+
help="Quote URL for X, Threads, Bluesky, Mastodon, or Substack",
|
|
611
|
+
)
|
|
478
612
|
@click.option("--threadify/--no-threadify", default=False)
|
|
479
613
|
@click.option("--auto-retweet/--no-auto-retweet", default=None)
|
|
480
614
|
@click.option("--auto-plug/--no-auto-plug", default=None)
|
|
@@ -491,6 +625,7 @@ def recent(limit, since, until_date, output_format, api_key, account, use_text,
|
|
|
491
625
|
@click.option("--paid-partnership", is_flag=True, help="Add X paid-partnership disclosure")
|
|
492
626
|
@click.option("--made-with-ai", is_flag=True, help="Add X AI disclosure")
|
|
493
627
|
@click.option("--hide-link-preview", is_flag=True, help="Hide link preview on LinkedIn, Threads, Substack")
|
|
628
|
+
@_platform_field_options
|
|
494
629
|
@shared_options
|
|
495
630
|
@error_handler
|
|
496
631
|
def draft(
|
|
@@ -498,9 +633,18 @@ def draft(
|
|
|
498
633
|
qrt, threadify, auto_retweet, auto_plug,
|
|
499
634
|
linkedin, threads, bluesky, mastodon, all_platforms, community, platform, substack,
|
|
500
635
|
content_markdown, cover_media_id, paid_partnership, made_with_ai, hide_link_preview,
|
|
636
|
+
reply_settings, subscribers_only, share_with_followers,
|
|
637
|
+
linkedin_first_comment, linkedin_reshare_target,
|
|
501
638
|
api_key, account, use_text, use_json, quiet, debug,
|
|
502
639
|
):
|
|
503
640
|
"""Create a draft post."""
|
|
641
|
+
platform_fields = dict(
|
|
642
|
+
reply_settings=reply_settings, subscribers_only=subscribers_only,
|
|
643
|
+
share_with_followers=share_with_followers,
|
|
644
|
+
linkedin_first_comment=linkedin_first_comment,
|
|
645
|
+
linkedin_reshare_target=linkedin_reshare_target,
|
|
646
|
+
)
|
|
647
|
+
|
|
504
648
|
if input_file:
|
|
505
649
|
with open(input_file, encoding="utf-8") as fh:
|
|
506
650
|
text = fh.read().strip()
|
|
@@ -538,7 +682,7 @@ def draft(
|
|
|
538
682
|
quote_post_url=qrt, community=community, platforms=target_platforms,
|
|
539
683
|
content_markdown=content_markdown, cover_media_id=cover_media_id,
|
|
540
684
|
paid_partnership=paid_partnership, made_with_ai=made_with_ai,
|
|
541
|
-
hide_link_preview=hide_link_preview,
|
|
685
|
+
hide_link_preview=hide_link_preview, **platform_fields,
|
|
542
686
|
)
|
|
543
687
|
|
|
544
688
|
console.status("Creating draft...")
|
|
@@ -558,7 +702,10 @@ def draft(
|
|
|
558
702
|
@click.option("--share/--no-share", default=False)
|
|
559
703
|
@click.option("--reply-to", default=None)
|
|
560
704
|
@click.option("--scratchpad", default=None)
|
|
561
|
-
@click.option(
|
|
705
|
+
@click.option(
|
|
706
|
+
"--qrt", "--quote-post-url", "qrt", default=None,
|
|
707
|
+
help="Quote URL for X, Threads, Bluesky, Mastodon, or Substack",
|
|
708
|
+
)
|
|
562
709
|
@click.option("--auto-retweet/--no-auto-retweet", default=None)
|
|
563
710
|
@click.option("--auto-plug/--no-auto-plug", default=None)
|
|
564
711
|
@click.option("--linkedin", is_flag=True)
|
|
@@ -572,6 +719,7 @@ def draft(
|
|
|
572
719
|
@click.option("--paid-partnership", is_flag=True, help="Add X paid-partnership disclosure")
|
|
573
720
|
@click.option("--made-with-ai", is_flag=True, help="Add X AI disclosure")
|
|
574
721
|
@click.option("--hide-link-preview", is_flag=True, help="Hide link preview on LinkedIn, Threads, Substack")
|
|
722
|
+
@_platform_field_options
|
|
575
723
|
@shared_options
|
|
576
724
|
@error_handler
|
|
577
725
|
def thread(
|
|
@@ -579,9 +727,18 @@ def thread(
|
|
|
579
727
|
qrt, auto_retweet, auto_plug,
|
|
580
728
|
linkedin, threads, bluesky, mastodon, all_platforms, community, platform, substack,
|
|
581
729
|
paid_partnership, made_with_ai, hide_link_preview,
|
|
730
|
+
reply_settings, subscribers_only, share_with_followers,
|
|
731
|
+
linkedin_first_comment, linkedin_reshare_target,
|
|
582
732
|
api_key, account, use_text, use_json, quiet, debug,
|
|
583
733
|
):
|
|
584
734
|
"""Create a multi-post thread. Provide 2+ posts as separate arguments."""
|
|
735
|
+
platform_fields = dict(
|
|
736
|
+
reply_settings=reply_settings, subscribers_only=subscribers_only,
|
|
737
|
+
share_with_followers=share_with_followers,
|
|
738
|
+
linkedin_first_comment=linkedin_first_comment,
|
|
739
|
+
linkedin_reshare_target=linkedin_reshare_target,
|
|
740
|
+
)
|
|
741
|
+
|
|
585
742
|
if len(posts) < 2:
|
|
586
743
|
Console(quiet).error_json({
|
|
587
744
|
"code": "invalid_input",
|
|
@@ -617,7 +774,7 @@ def thread(
|
|
|
617
774
|
auto_retweet=auto_retweet, auto_plug=auto_plug,
|
|
618
775
|
quote_post_url=qrt, community=community, platforms=target_platforms,
|
|
619
776
|
paid_partnership=paid_partnership, made_with_ai=made_with_ai,
|
|
620
|
-
hide_link_preview=hide_link_preview,
|
|
777
|
+
hide_link_preview=hide_link_preview, **platform_fields,
|
|
621
778
|
)
|
|
622
779
|
|
|
623
780
|
console.status(f"Creating {len(posts_list)}-post thread...")
|
|
@@ -702,19 +859,33 @@ def open_draft(draft_id, api_key, account, use_text, use_json, quiet, debug):
|
|
|
702
859
|
@click.option("--paid-partnership", is_flag=True)
|
|
703
860
|
@click.option("--made-with-ai", is_flag=True)
|
|
704
861
|
@click.option("--hide-link-preview", is_flag=True)
|
|
705
|
-
@click.option(
|
|
862
|
+
@click.option(
|
|
863
|
+
"--qrt", "--quote-post-url", "qrt", default=None,
|
|
864
|
+
help="Quote URL for X, Threads, Bluesky, Mastodon, or Substack",
|
|
865
|
+
)
|
|
866
|
+
@click.option("--community", default=None, help="X community ID")
|
|
706
867
|
@click.option("--force-overwrite-comments", is_flag=True)
|
|
707
868
|
@click.option("--exclude-comment-markers", is_flag=True)
|
|
869
|
+
@_platform_field_options
|
|
708
870
|
@shared_options
|
|
709
871
|
@error_handler
|
|
710
872
|
def update(
|
|
711
873
|
draft_id, text, input_file, schedule, plan, tags, title, share_flag, scratchpad,
|
|
712
874
|
auto_retweet, auto_plug, media, append_posts, platform, content_markdown, cover_media_id,
|
|
713
875
|
paid_partnership, made_with_ai, hide_link_preview, force_overwrite_comments,
|
|
714
|
-
exclude_comment_markers, qrt,
|
|
876
|
+
exclude_comment_markers, qrt, community,
|
|
877
|
+
reply_settings, subscribers_only, share_with_followers,
|
|
878
|
+
linkedin_first_comment, linkedin_reshare_target,
|
|
715
879
|
api_key, account, use_text, use_json, quiet, debug,
|
|
716
880
|
):
|
|
717
881
|
"""Update an existing draft. Text supports === for thread post splitting."""
|
|
882
|
+
platform_fields = dict(
|
|
883
|
+
reply_settings=reply_settings, subscribers_only=subscribers_only,
|
|
884
|
+
share_with_followers=share_with_followers,
|
|
885
|
+
linkedin_first_comment=linkedin_first_comment,
|
|
886
|
+
linkedin_reshare_target=linkedin_reshare_target,
|
|
887
|
+
)
|
|
888
|
+
|
|
718
889
|
client, console, cfg = _get_client_and_console(api_key, quiet, debug=debug)
|
|
719
890
|
use_text = _resolve_text_mode(use_text, use_json, cfg)
|
|
720
891
|
if input_file:
|
|
@@ -735,6 +906,9 @@ def update(
|
|
|
735
906
|
|
|
736
907
|
if content_markdown is not None or cover_media_id is not None:
|
|
737
908
|
target_platforms = _resolve_platforms(client, ssid, platform)
|
|
909
|
+
_validate_platform_fields(target_platforms, platform_fields)
|
|
910
|
+
if community is not None:
|
|
911
|
+
raise TypefullyError("--community requires X posts", code="invalid_input")
|
|
738
912
|
if target_platforms != ["x_article"]:
|
|
739
913
|
raise TypefullyError(
|
|
740
914
|
"X Article updates require --platform x_article", code="invalid_input"
|
|
@@ -749,14 +923,39 @@ def update(
|
|
|
749
923
|
if append_posts:
|
|
750
924
|
# Fetch existing draft to get current posts
|
|
751
925
|
existing = client.get_draft(ssid, draft_id)
|
|
752
|
-
|
|
753
|
-
platforms = existing.get("platforms", {})
|
|
754
|
-
if isinstance(platforms, dict) and "x" in platforms:
|
|
755
|
-
existing_posts = platforms["x"].get("posts", [])
|
|
926
|
+
target_platforms = _resolve_platforms(client, ssid, platform)
|
|
756
927
|
new_parts = [p.strip() for p in text.split("===") if p.strip()]
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
928
|
+
additions = _build_draft_payload(
|
|
929
|
+
posts_text=new_parts, media=media, platforms=target_platforms,
|
|
930
|
+
quote_post_url=qrt,
|
|
931
|
+
paid_partnership=paid_partnership, made_with_ai=made_with_ai,
|
|
932
|
+
hide_link_preview=hide_link_preview,
|
|
933
|
+
)["platforms"]
|
|
934
|
+
for name, config in additions.items():
|
|
935
|
+
old = existing.get("platforms", {}).get(name, {})
|
|
936
|
+
old_posts = _editable_posts(name, old.get("posts", []))
|
|
937
|
+
if name == "substack" and old_posts:
|
|
938
|
+
raise TypefullyError(
|
|
939
|
+
"Substack Notes supports one post per draft; cannot append",
|
|
940
|
+
code="invalid_input",
|
|
941
|
+
)
|
|
942
|
+
if old.get("settings"):
|
|
943
|
+
config["settings"] = dict(old["settings"])
|
|
944
|
+
if name == "x" and community is not None:
|
|
945
|
+
config.setdefault("settings", {})["community_id"] = community
|
|
946
|
+
# Apply per-post options only to newly appended posts.
|
|
947
|
+
selected_fields = {
|
|
948
|
+
key: value for key, value in platform_fields.items()
|
|
949
|
+
if (name == "x" and key in {
|
|
950
|
+
"reply_settings", "subscribers_only", "share_with_followers"
|
|
951
|
+
}) or (name == "linkedin" and key.startswith("linkedin_"))
|
|
952
|
+
}
|
|
953
|
+
_apply_platform_fields({name: config}, selected_fields)
|
|
954
|
+
config["posts"] = old_posts + config["posts"]
|
|
955
|
+
_validate_platform_fields(target_platforms, platform_fields)
|
|
956
|
+
if community is not None and "x" not in target_platforms:
|
|
957
|
+
raise TypefullyError("--community requires X", code="invalid_input")
|
|
958
|
+
payload["platforms"] = additions
|
|
760
959
|
else:
|
|
761
960
|
parts = [p.strip() for p in text.split("===") if p.strip()]
|
|
762
961
|
target_platforms = _resolve_platforms(client, ssid, platform)
|
|
@@ -764,34 +963,60 @@ def update(
|
|
|
764
963
|
posts_text=parts, media=media, platforms=target_platforms,
|
|
765
964
|
quote_post_url=qrt,
|
|
766
965
|
paid_partnership=paid_partnership, made_with_ai=made_with_ai,
|
|
767
|
-
hide_link_preview=hide_link_preview,
|
|
966
|
+
hide_link_preview=hide_link_preview, community=community, **platform_fields,
|
|
768
967
|
)["platforms"]
|
|
769
|
-
elif media
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
}
|
|
773
|
-
elif qrt or paid_partnership or made_with_ai or hide_link_preview:
|
|
968
|
+
elif media or qrt or paid_partnership or made_with_ai or hide_link_preview or (
|
|
969
|
+
community is not None or any(value is not None for value in platform_fields.values())
|
|
970
|
+
):
|
|
774
971
|
existing = client.get_draft(ssid, draft_id)
|
|
775
|
-
requested = _resolve_platforms(client, ssid, platform) if platform else None
|
|
776
972
|
existing_platforms = existing.get("platforms", {})
|
|
777
|
-
target_platforms =
|
|
973
|
+
target_platforms = _resolve_platforms(client, ssid, platform) if platform else [
|
|
778
974
|
name for name, config in existing_platforms.items()
|
|
779
975
|
if name in POST_PLATFORMS and isinstance(config, dict) and config.get("enabled")
|
|
780
976
|
]
|
|
781
|
-
if not
|
|
782
|
-
|
|
977
|
+
if media and not platform and not (
|
|
978
|
+
qrt or paid_partnership or made_with_ai or hide_link_preview
|
|
979
|
+
or community is not None
|
|
980
|
+
or any(value is not None for value in platform_fields.values())
|
|
981
|
+
):
|
|
982
|
+
# Preserve the historical default for media-only updates (X).
|
|
983
|
+
target_platforms = ["x"]
|
|
984
|
+
_validate_platform_fields(target_platforms, platform_fields)
|
|
985
|
+
x_options = community is not None or paid_partnership or made_with_ai or any(
|
|
986
|
+
platform_fields[key] is not None
|
|
987
|
+
for key in ("reply_settings", "subscribers_only", "share_with_followers")
|
|
988
|
+
)
|
|
989
|
+
li_options = any(platform_fields[key] is not None for key in (
|
|
990
|
+
"linkedin_first_comment", "linkedin_reshare_target"
|
|
991
|
+
))
|
|
992
|
+
if x_options and "x" not in target_platforms:
|
|
993
|
+
raise TypefullyError("X-only options require X", code="invalid_input")
|
|
994
|
+
if hide_link_preview and not set(target_platforms) & LINK_PREVIEW_PLATFORMS:
|
|
995
|
+
raise TypefullyError("Link-preview hiding requires a supported platform",
|
|
996
|
+
code="invalid_input")
|
|
997
|
+
quote_platform = _quote_platform(qrt, target_platforms) if qrt else None
|
|
783
998
|
payload["platforms"] = {}
|
|
784
999
|
for name in target_platforms:
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
1000
|
+
if not (
|
|
1001
|
+
name == quote_platform or (name == "x" and x_options)
|
|
1002
|
+
or (name == "linkedin" and li_options)
|
|
1003
|
+
or (name in LINK_PREVIEW_PLATFORMS and hide_link_preview)
|
|
1004
|
+
or (media and (not qrt or name == quote_platform))
|
|
1005
|
+
):
|
|
790
1006
|
continue
|
|
1007
|
+
source = existing_platforms.get(name, {})
|
|
1008
|
+
posts = _editable_posts(name, source.get("posts", []))
|
|
1009
|
+
if not posts:
|
|
1010
|
+
raise TypefullyError(
|
|
1011
|
+
f"Cannot update {name}: draft has no existing posts", code="invalid_input"
|
|
1012
|
+
)
|
|
1013
|
+
if name == quote_platform:
|
|
1014
|
+
for post in posts if name == "x" else posts[:1]:
|
|
1015
|
+
post["quote_post_url"] = qrt
|
|
1016
|
+
if media and (not qrt or name == quote_platform):
|
|
1017
|
+
posts[0]["media_ids"] = [m.strip() for m in media.split(",") if m.strip()]
|
|
791
1018
|
if name == "x":
|
|
792
1019
|
for post in posts:
|
|
793
|
-
if qrt:
|
|
794
|
-
post["quote_post_url"] = qrt
|
|
795
1020
|
if paid_partnership:
|
|
796
1021
|
post["paid_partnership"] = True
|
|
797
1022
|
if made_with_ai:
|
|
@@ -799,7 +1024,15 @@ def update(
|
|
|
799
1024
|
if name in LINK_PREVIEW_PLATFORMS and hide_link_preview:
|
|
800
1025
|
for post in posts:
|
|
801
1026
|
post["hide_link_preview"] = True
|
|
802
|
-
|
|
1027
|
+
config = {"enabled": True, "posts": posts}
|
|
1028
|
+
if source.get("settings"):
|
|
1029
|
+
config["settings"] = dict(source["settings"])
|
|
1030
|
+
if name == "x" and community is not None:
|
|
1031
|
+
config.setdefault("settings", {})["community_id"] = community
|
|
1032
|
+
payload["platforms"][name] = config
|
|
1033
|
+
_apply_platform_fields(payload["platforms"], platform_fields)
|
|
1034
|
+
if not payload["platforms"]:
|
|
1035
|
+
raise TypefullyError("Draft has no editable post platforms", code="invalid_input")
|
|
803
1036
|
|
|
804
1037
|
if share_flag is True:
|
|
805
1038
|
payload["share"] = True
|
|
@@ -883,11 +1116,11 @@ def delete(ids, api_key, account, use_text, use_json, quiet, debug):
|
|
|
883
1116
|
|
|
884
1117
|
|
|
885
1118
|
@cli.command()
|
|
886
|
-
@click.option("--status", "draft_status", default=None, help="Filter: draft|
|
|
1119
|
+
@click.option("--status", "draft_status", default=None, help="Filter: draft|planned|scheduled|publishing|published|error")
|
|
887
1120
|
@click.option("-n", "--limit", default=10, type=int)
|
|
888
|
-
@click.option("--offset", default=0, type=
|
|
1121
|
+
@click.option("--offset", default=0, type=click.IntRange(min=0))
|
|
889
1122
|
@click.option("--order", default="-updated_at")
|
|
890
|
-
@click.option("--tag",
|
|
1123
|
+
@click.option("--tag", multiple=True, help="Filter by tag slug (repeat for multiple tags)")
|
|
891
1124
|
@click.option("--content-filter", default=None, help="Filter: original|repost")
|
|
892
1125
|
@shared_options
|
|
893
1126
|
@error_handler
|
|
@@ -906,7 +1139,7 @@ def drafts(draft_status, limit, offset, order, tag, content_filter, api_key, acc
|
|
|
906
1139
|
if draft_status:
|
|
907
1140
|
params["status"] = draft_status
|
|
908
1141
|
if tag:
|
|
909
|
-
params["tag"] = tag
|
|
1142
|
+
params["tag"] = tag[0] if len(tag) == 1 else list(tag)
|
|
910
1143
|
if content_filter:
|
|
911
1144
|
params["content_filter"] = content_filter
|
|
912
1145
|
|
|
@@ -919,7 +1152,7 @@ def drafts(draft_status, limit, offset, order, tag, content_filter, api_key, acc
|
|
|
919
1152
|
|
|
920
1153
|
@cli.command()
|
|
921
1154
|
@click.option("-n", "--limit", default=50, type=int)
|
|
922
|
-
@click.option("--offset", default=0, type=
|
|
1155
|
+
@click.option("--offset", default=0, type=click.IntRange(min=0))
|
|
923
1156
|
@shared_options
|
|
924
1157
|
@error_handler
|
|
925
1158
|
def tags(limit, offset, api_key, account, use_text, use_json, quiet, debug):
|
|
@@ -965,10 +1198,14 @@ def tag_create(name, api_key, account, use_text, use_json, quiet, debug):
|
|
|
965
1198
|
@click.option("--no-wait", is_flag=True, help="Return immediately after S3 upload, skip processing poll")
|
|
966
1199
|
@click.option("--timeout", "poll_timeout", default=60, type=int,
|
|
967
1200
|
help="Media processing poll timeout in seconds")
|
|
1201
|
+
@click.option("--alt-text", default=None, help="Media alt text (max 1000 characters)")
|
|
968
1202
|
@shared_options
|
|
969
1203
|
@error_handler
|
|
970
|
-
def upload(file_path, no_wait, poll_timeout, api_key, account, use_text, use_json, quiet, debug):
|
|
1204
|
+
def upload(file_path, no_wait, poll_timeout, alt_text, api_key, account, use_text, use_json, quiet, debug):
|
|
971
1205
|
"""Upload a media file and return its media ID."""
|
|
1206
|
+
if alt_text is not None and len(alt_text) > 1000:
|
|
1207
|
+
raise TypefullyError("--alt-text must be at most 1000 characters", code="invalid_input")
|
|
1208
|
+
upload_options = {"alt_text": alt_text} if alt_text is not None else {}
|
|
972
1209
|
client, console, cfg = _get_client_and_console(api_key, quiet, debug=debug)
|
|
973
1210
|
use_text = _resolve_text_mode(use_text, use_json, cfg)
|
|
974
1211
|
with client:
|
|
@@ -977,7 +1214,7 @@ def upload(file_path, no_wait, poll_timeout, api_key, account, use_text, use_jso
|
|
|
977
1214
|
from typefully_cli.media import _sanitize_filename
|
|
978
1215
|
file_name = _sanitize_filename(os.path.basename(file_path))
|
|
979
1216
|
console.status(f"Requesting upload URL for {file_name}...")
|
|
980
|
-
upload_response = client.request_upload(ssid, file_name)
|
|
1217
|
+
upload_response = client.request_upload(ssid, file_name, **upload_options)
|
|
981
1218
|
media_id = upload_response.get("media_id", "")
|
|
982
1219
|
upload_url = upload_response.get("upload_url", "")
|
|
983
1220
|
console.status(f"Uploading {file_name}...")
|
|
@@ -985,7 +1222,7 @@ def upload(file_path, no_wait, poll_timeout, api_key, account, use_text, use_jso
|
|
|
985
1222
|
data = {"media_id": media_id, "status": "uploading"}
|
|
986
1223
|
_output(data, use_text, fmt.print_upload_result)
|
|
987
1224
|
else:
|
|
988
|
-
data = upload_media(client, ssid, file_path, console, timeout=poll_timeout)
|
|
1225
|
+
data = upload_media(client, ssid, file_path, console, timeout=poll_timeout, **upload_options)
|
|
989
1226
|
_output(data, use_text, fmt.print_upload_result)
|
|
990
1227
|
|
|
991
1228
|
|
|
@@ -1106,7 +1343,7 @@ def batch(file_path, schedule, tags, share, dry_run, output_file, api_key, accou
|
|
|
1106
1343
|
@click.option("--end-date", default="", help="End date YYYY-MM-DD")
|
|
1107
1344
|
@click.option("--include-replies", is_flag=True, help="Include reply posts")
|
|
1108
1345
|
@click.option("-n", "--limit", default=50, type=int, help="Number of posts (max: 50)")
|
|
1109
|
-
@click.option("--offset", default=0, type=
|
|
1346
|
+
@click.option("--offset", default=0, type=click.IntRange(min=0))
|
|
1110
1347
|
@shared_options
|
|
1111
1348
|
@error_handler
|
|
1112
1349
|
def analytics(platform, start_date, end_date, include_replies, limit, offset,
|
|
@@ -1236,7 +1473,7 @@ def comments():
|
|
|
1236
1473
|
@click.option("--platform", default=None)
|
|
1237
1474
|
@click.option("--status", "comment_status", default="unresolved")
|
|
1238
1475
|
@click.option("-n", "--limit", default=10, type=int)
|
|
1239
|
-
@click.option("--offset", default=0, type=
|
|
1476
|
+
@click.option("--offset", default=0, type=click.IntRange(min=0))
|
|
1240
1477
|
@shared_options
|
|
1241
1478
|
@error_handler
|
|
1242
1479
|
def comments_list(draft_id, platform, comment_status, limit, offset,
|
|
@@ -1452,9 +1689,15 @@ def _build_draft_payload(
|
|
|
1452
1689
|
paid_partnership: bool = False,
|
|
1453
1690
|
made_with_ai: bool = False,
|
|
1454
1691
|
hide_link_preview: bool = False,
|
|
1692
|
+
reply_settings: str | None = None,
|
|
1693
|
+
subscribers_only: bool | None = None,
|
|
1694
|
+
share_with_followers: bool | None = None,
|
|
1695
|
+
linkedin_first_comment: str | None = None,
|
|
1696
|
+
linkedin_reshare_target: str | None = None,
|
|
1455
1697
|
) -> dict:
|
|
1456
1698
|
"""Build a draft creation payload from CLI args."""
|
|
1457
1699
|
target_platforms = platforms or ["x"]
|
|
1700
|
+
quote_platform = _quote_platform(quote_post_url, target_platforms) if quote_post_url else None
|
|
1458
1701
|
if "x_article" in target_platforms:
|
|
1459
1702
|
if len(target_platforms) != 1 or not content_markdown or posts_text:
|
|
1460
1703
|
raise TypefullyError(
|
|
@@ -1478,7 +1721,7 @@ def _build_draft_payload(
|
|
|
1478
1721
|
"Substack Notes supports one post per draft; threads are not supported",
|
|
1479
1722
|
code="invalid_input",
|
|
1480
1723
|
)
|
|
1481
|
-
if (
|
|
1724
|
+
if (paid_partnership or made_with_ai or reply_to or community) and "x" not in target_platforms:
|
|
1482
1725
|
raise TypefullyError("X-only options require x in --platform", code="invalid_input")
|
|
1483
1726
|
if hide_link_preview and not (set(target_platforms) & LINK_PREVIEW_PLATFORMS):
|
|
1484
1727
|
raise TypefullyError(
|
|
@@ -1492,10 +1735,12 @@ def _build_draft_payload(
|
|
|
1492
1735
|
platform_payload = {}
|
|
1493
1736
|
for name in target_platforms:
|
|
1494
1737
|
posts = [dict(post) for post in base_posts]
|
|
1738
|
+
if name == quote_platform:
|
|
1739
|
+
# Keep legacy X thread behavior; new quote platforms quote the first post.
|
|
1740
|
+
for post in posts if name == "x" else posts[:1]:
|
|
1741
|
+
post["quote_post_url"] = quote_post_url
|
|
1495
1742
|
if name == "x":
|
|
1496
1743
|
for post in posts:
|
|
1497
|
-
if quote_post_url:
|
|
1498
|
-
post["quote_post_url"] = quote_post_url
|
|
1499
1744
|
if paid_partnership:
|
|
1500
1745
|
post["paid_partnership"] = True
|
|
1501
1746
|
if made_with_ai:
|
|
@@ -1512,6 +1757,12 @@ def _build_draft_payload(
|
|
|
1512
1757
|
config["settings"]["community_id"] = community
|
|
1513
1758
|
platform_payload[name] = config
|
|
1514
1759
|
|
|
1760
|
+
_apply_platform_fields(platform_payload, dict(
|
|
1761
|
+
reply_settings=reply_settings, subscribers_only=subscribers_only,
|
|
1762
|
+
share_with_followers=share_with_followers,
|
|
1763
|
+
linkedin_first_comment=linkedin_first_comment,
|
|
1764
|
+
linkedin_reshare_target=linkedin_reshare_target,
|
|
1765
|
+
))
|
|
1515
1766
|
payload: dict[str, Any] = {"platforms": platform_payload}
|
|
1516
1767
|
|
|
1517
1768
|
if threadify:
|
|
@@ -1546,3 +1797,9 @@ def _build_draft_payload(
|
|
|
1546
1797
|
payload["draft_title"] = title
|
|
1547
1798
|
|
|
1548
1799
|
return payload
|
|
1800
|
+
|
|
1801
|
+
|
|
1802
|
+
# Register advanced, explicit request-input and media metadata commands.
|
|
1803
|
+
from typefully_cli.payloads import register_commands # noqa: E402
|
|
1804
|
+
|
|
1805
|
+
register_commands(cli)
|
typefully_cli/client.py
CHANGED
|
@@ -64,8 +64,19 @@ class TypefullyClient:
|
|
|
64
64
|
def get_social_sets(self) -> list[dict]:
|
|
65
65
|
if self._social_sets_cache is not None:
|
|
66
66
|
return self._social_sets_cache
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
results = []
|
|
68
|
+
offset = 0
|
|
69
|
+
while True:
|
|
70
|
+
params = {"limit": 50}
|
|
71
|
+
if offset:
|
|
72
|
+
params["offset"] = offset
|
|
73
|
+
data = self._request("GET", "/social-sets", params=params) or {}
|
|
74
|
+
page = data.get("results", [])
|
|
75
|
+
results.extend(page)
|
|
76
|
+
if len(page) < 50:
|
|
77
|
+
break
|
|
78
|
+
offset += len(page)
|
|
79
|
+
self._social_sets_cache = results
|
|
69
80
|
return self._social_sets_cache
|
|
70
81
|
|
|
71
82
|
def resolve_account(self, name_or_id: str | None, config_default: str = "") -> int:
|
|
@@ -208,12 +219,15 @@ class TypefullyClient:
|
|
|
208
219
|
|
|
209
220
|
# --- Media ---
|
|
210
221
|
|
|
211
|
-
def request_upload(self, social_set_id: int, file_name: str) -> dict:
|
|
222
|
+
def request_upload(self, social_set_id: int, file_name: str, alt_text: str | None = None) -> dict:
|
|
223
|
+
payload = {"file_name": file_name}
|
|
224
|
+
if alt_text is not None:
|
|
225
|
+
payload["alt_text"] = alt_text
|
|
212
226
|
return (
|
|
213
227
|
self._request(
|
|
214
228
|
"POST",
|
|
215
229
|
f"/social-sets/{social_set_id}/media/upload",
|
|
216
|
-
json=
|
|
230
|
+
json=payload,
|
|
217
231
|
)
|
|
218
232
|
or {}
|
|
219
233
|
)
|
|
@@ -229,6 +243,13 @@ class TypefullyClient:
|
|
|
229
243
|
"""Get current media status."""
|
|
230
244
|
return self._request("GET", f"/social-sets/{social_set_id}/media/{media_id}") or {}
|
|
231
245
|
|
|
246
|
+
def update_media(self, social_set_id: int, media_id: str, alt_text: str | None) -> dict:
|
|
247
|
+
"""Update alt text; None explicitly clears the stored value."""
|
|
248
|
+
return self._request(
|
|
249
|
+
"PATCH", f"/social-sets/{social_set_id}/media/{media_id}",
|
|
250
|
+
json={"alt_text": alt_text},
|
|
251
|
+
) or {}
|
|
252
|
+
|
|
232
253
|
# --- Recent (with pagination) ---
|
|
233
254
|
|
|
234
255
|
def list_recent(
|
typefully_cli/media.py
CHANGED
|
@@ -34,6 +34,7 @@ def upload_media(
|
|
|
34
34
|
file_path: str,
|
|
35
35
|
console: Console,
|
|
36
36
|
timeout: int = 60,
|
|
37
|
+
alt_text: str | None = None,
|
|
37
38
|
) -> dict:
|
|
38
39
|
"""Upload a media file and poll until ready. Returns {media_id, status}."""
|
|
39
40
|
if not os.path.isfile(file_path):
|
|
@@ -43,7 +44,7 @@ def upload_media(
|
|
|
43
44
|
|
|
44
45
|
# Step 1: Get presigned upload URL
|
|
45
46
|
console.status(f"Requesting upload URL for {file_name}...")
|
|
46
|
-
upload_response = client.request_upload(social_set_id, file_name)
|
|
47
|
+
upload_response = client.request_upload(social_set_id, file_name, **({"alt_text": alt_text} if alt_text is not None else {}))
|
|
47
48
|
media_id = upload_response.get("media_id", "")
|
|
48
49
|
upload_url = upload_response.get("upload_url", "")
|
|
49
50
|
if not media_id or not upload_url:
|