feat(threads): add token exchange and refresh commands for long-lived tokens

This commit is contained in:
Kristjan Kruus 2026-03-24 19:55:25 +02:00
parent ec562af936
commit da24695b28
3 changed files with 95 additions and 13 deletions

View file

@ -19,7 +19,8 @@ from atproto import Client, client_utils, models
# URL regex for detecting links in post text
_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
# ---------------------------------------------------------------------------
@ -201,15 +202,28 @@ def cmd_post(args) -> None:
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()
last_end = 0
# Collect all matches (urls and tags) with their positions
spans = []
for m in _URL_RE.finditer(text):
if m.start() > last_end:
tb.text(text[last_end:m.start()])
url = m.group(0)
tb.link(url, url)
last_end = m.end()
spans.append((m.start(), m.end(), 'link', m.group(0)))
for m in _TAG_RE.finditer(text):
# m.start() points to '#' (or the space before it if lookbehind matched)
tag_start = m.start() if text[m.start()] == '#' else m.start()
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):
tb.text(text[last_end:])
return tb
@ -244,14 +258,16 @@ def cmd_create_thread(args) -> 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}")
root_ref = models.create_strong_ref(parent)
parent_ref = root_ref
for i, text in enumerate(texts[1:], 2):
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}")
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:
root_ref = post.record.reply.root
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}")
@ -279,7 +296,8 @@ def cmd_quote(args) -> None:
post = thread.thread.post
ref = models.create_strong_ref(post)
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}")

View file

@ -44,6 +44,7 @@ services:
- BSKY_HANDLE=${BSKY_HANDLE}
- BSKY_APP_PASSWORD=${BSKY_APP_PASSWORD}
- THREADS_ACCESS_TOKEN=${THREADS_ACCESS_TOKEN}
- THREADS_APP_SECRET=${THREADS_APP_SECRET}
- INSTAGRAM_ACCESS_TOKEN=${INSTAGRAM_ACCESS_TOKEN}
- INSTAGRAM_USER_ID=${INSTAGRAM_USER_ID}
ports:

View file

@ -307,6 +307,60 @@ def _wait_for_container(token: str, container_id: str, max_wait: int = 30) -> No
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
# ---------------------------------------------------------------------------
@ -357,6 +411,13 @@ def main() -> None:
p = sub.add_parser("post-insights", help="Get insights for a post")
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()
if not args.command:
parser.print_help()
@ -372,6 +433,8 @@ def main() -> None:
"insights": cmd_insights,
"posts": cmd_posts,
"post-insights": cmd_post_insights,
"token-exchange": cmd_token_exchange,
"token-refresh": cmd_token_refresh,
}
try: