feat(threads): add token exchange and refresh commands for long-lived tokens
This commit is contained in:
parent
ec562af936
commit
da24695b28
3 changed files with 95 additions and 13 deletions
|
|
@ -18,8 +18,9 @@ from pathlib import Path
|
||||||
from atproto import Client, client_utils, models
|
from atproto import Client, client_utils, models
|
||||||
|
|
||||||
# URL regex for detecting links in post text
|
# URL regex for detecting links in post text
|
||||||
_URL_RE = re.compile(r'https?://[^\s)>"]+')
|
_URL_RE = re.compile(r'https?://[^\s)>"]+')
|
||||||
|
# Hashtag regex: #word (must be preceded by start-of-string or whitespace)
|
||||||
|
_TAG_RE = re.compile(r'(?:^|(?<=\s))#([A-Za-z][A-Za-z0-9_]*)', re.UNICODE)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Config helpers
|
# Config helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -201,15 +202,28 @@ def cmd_post(args) -> None:
|
||||||
|
|
||||||
|
|
||||||
def _build_rich_text(text: str) -> client_utils.TextBuilder:
|
def _build_rich_text(text: str) -> client_utils.TextBuilder:
|
||||||
"""Parse URLs in text and return a TextBuilder with link facets."""
|
"""Parse URLs and hashtags in text and return a TextBuilder with facets."""
|
||||||
tb = client_utils.TextBuilder()
|
tb = client_utils.TextBuilder()
|
||||||
last_end = 0
|
# Collect all matches (urls and tags) with their positions
|
||||||
|
spans = []
|
||||||
for m in _URL_RE.finditer(text):
|
for m in _URL_RE.finditer(text):
|
||||||
if m.start() > last_end:
|
spans.append((m.start(), m.end(), 'link', m.group(0)))
|
||||||
tb.text(text[last_end:m.start()])
|
for m in _TAG_RE.finditer(text):
|
||||||
url = m.group(0)
|
# m.start() points to '#' (or the space before it if lookbehind matched)
|
||||||
tb.link(url, url)
|
tag_start = m.start() if text[m.start()] == '#' else m.start()
|
||||||
last_end = m.end()
|
spans.append((tag_start, m.end(), 'tag', m.group(1)))
|
||||||
|
spans.sort(key=lambda s: s[0])
|
||||||
|
last_end = 0
|
||||||
|
for start, end, kind, value in spans:
|
||||||
|
if start < last_end:
|
||||||
|
continue # overlapping, skip
|
||||||
|
if start > last_end:
|
||||||
|
tb.text(text[last_end:start])
|
||||||
|
if kind == 'link':
|
||||||
|
tb.link(text[start:end], value)
|
||||||
|
elif kind == 'tag':
|
||||||
|
tb.tag(text[start:end], value)
|
||||||
|
last_end = end
|
||||||
if last_end < len(text):
|
if last_end < len(text):
|
||||||
tb.text(text[last_end:])
|
tb.text(text[last_end:])
|
||||||
return tb
|
return tb
|
||||||
|
|
@ -244,14 +258,16 @@ def cmd_create_thread(args) -> None:
|
||||||
))
|
))
|
||||||
|
|
||||||
embed = models.AppBskyEmbedImages.Main(images=images) if images else None
|
embed = models.AppBskyEmbedImages.Main(images=images) if images else None
|
||||||
parent = client.send_post(text=texts[0], embed=embed)
|
tb = _build_rich_text(texts[0])
|
||||||
|
parent = client.send_post(text=tb, embed=embed)
|
||||||
print(f"[1/{len(texts)}] {parent.uri}")
|
print(f"[1/{len(texts)}] {parent.uri}")
|
||||||
root_ref = models.create_strong_ref(parent)
|
root_ref = models.create_strong_ref(parent)
|
||||||
parent_ref = root_ref
|
parent_ref = root_ref
|
||||||
|
|
||||||
for i, text in enumerate(texts[1:], 2):
|
for i, text in enumerate(texts[1:], 2):
|
||||||
reply_to = models.AppBskyFeedPost.ReplyRef(root=root_ref, parent=parent_ref)
|
reply_to = models.AppBskyFeedPost.ReplyRef(root=root_ref, parent=parent_ref)
|
||||||
resp = client.send_post(text=text, reply_to=reply_to)
|
tb = _build_rich_text(text)
|
||||||
|
resp = client.send_post(text=tb, reply_to=reply_to)
|
||||||
print(f"[{i}/{len(texts)}] {resp.uri}")
|
print(f"[{i}/{len(texts)}] {resp.uri}")
|
||||||
parent_ref = models.create_strong_ref(resp)
|
parent_ref = models.create_strong_ref(resp)
|
||||||
|
|
||||||
|
|
@ -268,7 +284,8 @@ def cmd_reply(args) -> None:
|
||||||
if hasattr(post, "record") and hasattr(post.record, "reply") and post.record.reply:
|
if hasattr(post, "record") and hasattr(post.record, "reply") and post.record.reply:
|
||||||
root_ref = post.record.reply.root
|
root_ref = post.record.reply.root
|
||||||
reply_to = models.AppBskyFeedPost.ReplyRef(root=root_ref, parent=post_ref)
|
reply_to = models.AppBskyFeedPost.ReplyRef(root=root_ref, parent=post_ref)
|
||||||
resp = client.send_post(text=args.text, reply_to=reply_to)
|
tb = _build_rich_text(args.text)
|
||||||
|
resp = client.send_post(text=tb, reply_to=reply_to)
|
||||||
print(f"Replied: {resp.uri}")
|
print(f"Replied: {resp.uri}")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -279,7 +296,8 @@ def cmd_quote(args) -> None:
|
||||||
post = thread.thread.post
|
post = thread.thread.post
|
||||||
ref = models.create_strong_ref(post)
|
ref = models.create_strong_ref(post)
|
||||||
embed = models.AppBskyEmbedRecord.Main(record=ref)
|
embed = models.AppBskyEmbedRecord.Main(record=ref)
|
||||||
resp = client.send_post(text=args.text, embed=embed)
|
tb = _build_rich_text(args.text)
|
||||||
|
resp = client.send_post(text=tb, embed=embed)
|
||||||
print(f"Quoted: {resp.uri}")
|
print(f"Quoted: {resp.uri}")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ services:
|
||||||
- BSKY_HANDLE=${BSKY_HANDLE}
|
- BSKY_HANDLE=${BSKY_HANDLE}
|
||||||
- BSKY_APP_PASSWORD=${BSKY_APP_PASSWORD}
|
- BSKY_APP_PASSWORD=${BSKY_APP_PASSWORD}
|
||||||
- THREADS_ACCESS_TOKEN=${THREADS_ACCESS_TOKEN}
|
- THREADS_ACCESS_TOKEN=${THREADS_ACCESS_TOKEN}
|
||||||
|
- THREADS_APP_SECRET=${THREADS_APP_SECRET}
|
||||||
- INSTAGRAM_ACCESS_TOKEN=${INSTAGRAM_ACCESS_TOKEN}
|
- INSTAGRAM_ACCESS_TOKEN=${INSTAGRAM_ACCESS_TOKEN}
|
||||||
- INSTAGRAM_USER_ID=${INSTAGRAM_USER_ID}
|
- INSTAGRAM_USER_ID=${INSTAGRAM_USER_ID}
|
||||||
ports:
|
ports:
|
||||||
|
|
|
||||||
|
|
@ -307,6 +307,60 @@ def _wait_for_container(token: str, container_id: str, max_wait: int = 30) -> No
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_token_exchange(args) -> None:
|
||||||
|
token = _get_token(args)
|
||||||
|
app_secret = args.app_secret or os.environ.get("THREADS_APP_SECRET", "")
|
||||||
|
params = {
|
||||||
|
"grant_type": "th_exchange_token",
|
||||||
|
"client_secret": app_secret,
|
||||||
|
"access_token": token,
|
||||||
|
}
|
||||||
|
qs = urllib.parse.urlencode(params)
|
||||||
|
url = f"https://graph.threads.net/access_token?{qs}"
|
||||||
|
req = urllib.request.Request(url, method="GET")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req) as resp:
|
||||||
|
data = json.loads(resp.read())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
body = e.read().decode()
|
||||||
|
print(f"Token exchange failed ({e.code}): {body}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
new_token = data.get("access_token", "")
|
||||||
|
expires_in = data.get("expires_in", 0)
|
||||||
|
days = expires_in // 86400
|
||||||
|
if args.json_output:
|
||||||
|
print(json.dumps(data, indent=2))
|
||||||
|
else:
|
||||||
|
print(f"Long-lived token: {new_token}")
|
||||||
|
print(f"Expires in: {days} days ({expires_in} seconds)")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_token_refresh(args) -> None:
|
||||||
|
token = _get_token(args)
|
||||||
|
params = {
|
||||||
|
"grant_type": "th_refresh_token",
|
||||||
|
"access_token": token,
|
||||||
|
}
|
||||||
|
qs = urllib.parse.urlencode(params)
|
||||||
|
url = f"https://graph.threads.net/refresh_access_token?{qs}"
|
||||||
|
req = urllib.request.Request(url, method="GET")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req) as resp:
|
||||||
|
data = json.loads(resp.read())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
body = e.read().decode()
|
||||||
|
print(f"Token refresh failed ({e.code}): {body}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
new_token = data.get("access_token", "")
|
||||||
|
expires_in = data.get("expires_in", 0)
|
||||||
|
days = expires_in // 86400
|
||||||
|
if args.json_output:
|
||||||
|
print(json.dumps(data, indent=2))
|
||||||
|
else:
|
||||||
|
print(f"Refreshed token: {new_token}")
|
||||||
|
print(f"Expires in: {days} days ({expires_in} seconds)")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Argument parser
|
# Argument parser
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -357,6 +411,13 @@ def main() -> None:
|
||||||
p = sub.add_parser("post-insights", help="Get insights for a post")
|
p = sub.add_parser("post-insights", help="Get insights for a post")
|
||||||
p.add_argument("post_id", help="Post ID")
|
p.add_argument("post_id", help="Post ID")
|
||||||
|
|
||||||
|
# token-exchange
|
||||||
|
p = sub.add_parser("token-exchange", help="Exchange short-lived token for a long-lived one (60 days)")
|
||||||
|
p.add_argument("--app-secret", dest="app_secret", help="Threads app secret (or set THREADS_APP_SECRET env var)")
|
||||||
|
|
||||||
|
# token-refresh
|
||||||
|
p = sub.add_parser("token-refresh", help="Refresh a long-lived token before it expires")
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
if not args.command:
|
if not args.command:
|
||||||
parser.print_help()
|
parser.print_help()
|
||||||
|
|
@ -372,6 +433,8 @@ def main() -> None:
|
||||||
"insights": cmd_insights,
|
"insights": cmd_insights,
|
||||||
"posts": cmd_posts,
|
"posts": cmd_posts,
|
||||||
"post-insights": cmd_post_insights,
|
"post-insights": cmd_post_insights,
|
||||||
|
"token-exchange": cmd_token_exchange,
|
||||||
|
"token-refresh": cmd_token_refresh,
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue