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
|
||||
|
||||
# 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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -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}")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue