Modifies X tweet tools to return metadata about media attachments (photo, GIF or video) when retrieving a tweet by ID, username or keywords. The tool will always return media attachments by default. Since it's only metadata, it shouldn't add significant network overhead to existing implementations of the tool. My guess is more often than not people will want this info included. When not needed, it doesn't hurt to include by default. It'd be annoying to have to ask the LLM to include it every time they need.
217 lines
7.3 KiB
Python
217 lines
7.3 KiB
Python
from typing import Annotated, Any, Optional
|
|
|
|
import httpx
|
|
from arcade.sdk import ToolContext, tool
|
|
from arcade.sdk.auth import X
|
|
from arcade.sdk.errors import RetryableToolError
|
|
|
|
from arcade_x.tools.utils import (
|
|
expand_attached_media,
|
|
expand_long_tweet,
|
|
expand_urls_in_tweets,
|
|
get_headers_with_token,
|
|
get_tweet_url,
|
|
parse_search_recent_tweets_response,
|
|
remove_none_values,
|
|
)
|
|
|
|
TWEETS_URL = "https://api.x.com/2/tweets"
|
|
|
|
|
|
# Manage Tweets Tools. See developer docs for additional available parameters:
|
|
# https://developer.x.com/en/docs/x-api/tweets/manage-tweets/api-reference
|
|
|
|
|
|
@tool(
|
|
requires_auth=X(
|
|
scopes=["tweet.read", "tweet.write", "users.read"],
|
|
)
|
|
)
|
|
async def post_tweet(
|
|
context: ToolContext,
|
|
tweet_text: Annotated[str, "The text content of the tweet you want to post"],
|
|
) -> Annotated[str, "Success string and the URL of the tweet"]:
|
|
"""Post a tweet to X (Twitter)."""
|
|
|
|
headers = get_headers_with_token(context)
|
|
payload = {"text": tweet_text}
|
|
|
|
async with httpx.AsyncClient() as client:
|
|
response = await client.post(TWEETS_URL, headers=headers, json=payload, timeout=10)
|
|
response.raise_for_status()
|
|
|
|
tweet_id = response.json()["data"]["id"]
|
|
return f"Tweet with id {tweet_id} posted successfully. URL: {get_tweet_url(tweet_id)}"
|
|
|
|
|
|
@tool(requires_auth=X(scopes=["tweet.read", "tweet.write", "users.read"]))
|
|
async def delete_tweet_by_id(
|
|
context: ToolContext,
|
|
tweet_id: Annotated[str, "The ID of the tweet you want to delete"],
|
|
) -> Annotated[str, "Success string confirming the tweet deletion"]:
|
|
"""Delete a tweet on X (Twitter)."""
|
|
|
|
headers = get_headers_with_token(context)
|
|
url = f"{TWEETS_URL}/{tweet_id}"
|
|
|
|
async with httpx.AsyncClient() as client:
|
|
response = await client.delete(url, headers=headers, timeout=10)
|
|
response.raise_for_status()
|
|
|
|
return f"Tweet with id {tweet_id} deleted successfully."
|
|
|
|
|
|
@tool(requires_auth=X(scopes=["tweet.read", "users.read"]))
|
|
async def search_recent_tweets_by_username(
|
|
context: ToolContext,
|
|
username: Annotated[str, "The username of the X (Twitter) user to look up"],
|
|
max_results: Annotated[
|
|
int, "The maximum number of results to return. Must be in range [1, 100] inclusive"
|
|
] = 10,
|
|
next_token: Annotated[
|
|
Optional[str], "The pagination token starting from which to return results"
|
|
] = None,
|
|
) -> Annotated[dict[str, Any], "Dictionary containing the search results"]:
|
|
"""Search for recent tweets (last 7 days) on X (Twitter) by username.
|
|
Includes replies and reposts."""
|
|
|
|
headers = get_headers_with_token(context)
|
|
params: dict[str, Any] = {
|
|
"query": f"from:{username}",
|
|
"max_results": min(
|
|
max(max_results, 10), 100
|
|
), # X API does not allow 'max_results' less than 10 or greater than 100
|
|
"next_token": next_token,
|
|
"expansions": "author_id",
|
|
"user.fields": "id,name,username,entities",
|
|
"tweet.fields": "entities,note_tweet",
|
|
}
|
|
params = expand_attached_media(remove_none_values(params))
|
|
|
|
url = f"{TWEETS_URL}/search/recent"
|
|
|
|
async with httpx.AsyncClient() as client:
|
|
response = await client.get(url, headers=headers, params=params, timeout=10)
|
|
response.raise_for_status()
|
|
|
|
response_data: dict[str, Any] = response.json()
|
|
|
|
for tweet in response_data["data"]:
|
|
expand_long_tweet(tweet)
|
|
|
|
# Expand the URLs that are in the tweets
|
|
response_data["data"] = expand_urls_in_tweets(
|
|
response_data.get("data", []), delete_entities=True
|
|
)
|
|
|
|
# Parse the response data
|
|
response_data = parse_search_recent_tweets_response(response_data)
|
|
|
|
return response_data
|
|
|
|
|
|
@tool(requires_auth=X(scopes=["tweet.read", "users.read"]))
|
|
async def search_recent_tweets_by_keywords(
|
|
context: ToolContext,
|
|
keywords: Annotated[
|
|
list[str] | None, "List of keywords that must be present in the tweet"
|
|
] = None,
|
|
phrases: Annotated[
|
|
list[str] | None, "List of phrases that must be present in the tweet"
|
|
] = None,
|
|
max_results: Annotated[
|
|
int, "The maximum number of results to return. Must be in range [1, 100] inclusive"
|
|
] = 10,
|
|
next_token: Annotated[
|
|
Optional[str], "The pagination token starting from which to return results"
|
|
] = None,
|
|
) -> Annotated[dict[str, Any], "Dictionary containing the search results"]:
|
|
"""
|
|
Search for recent tweets (last 7 days) on X (Twitter) by required keywords and phrases.
|
|
Includes replies and reposts.
|
|
One of the following input parameters MUST be provided: keywords, phrases
|
|
"""
|
|
|
|
if not any([keywords, phrases]):
|
|
raise RetryableToolError(
|
|
"No keywords or phrases provided",
|
|
developer_message="Predicted inputs didn't contain any keywords or phrases",
|
|
additional_prompt_content="Please provide at least one keyword or phrase for search",
|
|
retry_after_ms=500, # Play nice with X API rate limits
|
|
)
|
|
|
|
headers = get_headers_with_token(context)
|
|
|
|
query = "".join([f'"{phrase}" ' for phrase in (phrases or [])])
|
|
if keywords:
|
|
query += " ".join(keywords or [])
|
|
|
|
params: dict[str, Any] = {
|
|
"query": query.strip(),
|
|
"max_results": min(
|
|
max(max_results, 10), 100
|
|
), # X API does not allow 'max_results' less than 10 or greater than 100
|
|
"next_token": next_token,
|
|
"expansions": "author_id",
|
|
"user.fields": "id,name,username,entities",
|
|
"tweet.fields": "entities,note_tweet",
|
|
}
|
|
params = expand_attached_media(remove_none_values(params))
|
|
|
|
url = f"{TWEETS_URL}/search/recent"
|
|
|
|
async with httpx.AsyncClient() as client:
|
|
response = await client.get(url, headers=headers, params=params, timeout=10)
|
|
response.raise_for_status()
|
|
|
|
response_data: dict[str, Any] = response.json()
|
|
|
|
for tweet in response_data["data"]:
|
|
expand_long_tweet(tweet)
|
|
|
|
# Expand the URLs that are in the tweets
|
|
response_data["data"] = expand_urls_in_tweets(
|
|
response_data.get("data", []), delete_entities=True
|
|
)
|
|
|
|
# Parse the response data
|
|
response_data = parse_search_recent_tweets_response(response_data)
|
|
|
|
return response_data
|
|
|
|
|
|
@tool(requires_auth=X(scopes=["tweet.read", "users.read"]))
|
|
async def lookup_tweet_by_id(
|
|
context: ToolContext,
|
|
tweet_id: Annotated[str, "The ID of the tweet you want to look up"],
|
|
) -> Annotated[dict[str, Any], "Dictionary containing the tweet data"]:
|
|
"""Look up a tweet on X (Twitter) by tweet ID."""
|
|
|
|
headers = get_headers_with_token(context)
|
|
params = {
|
|
"expansions": "author_id",
|
|
"user.fields": "id,name,username,entities",
|
|
"tweet.fields": "entities,note_tweet",
|
|
}
|
|
params = expand_attached_media(params)
|
|
|
|
url = f"{TWEETS_URL}/{tweet_id}"
|
|
|
|
async with httpx.AsyncClient() as client:
|
|
response = await client.get(url, headers=headers, params=params, timeout=10)
|
|
response.raise_for_status()
|
|
|
|
response_data: dict[str, Any] = response.json()
|
|
|
|
# Get the tweet data
|
|
tweet_data = response_data.get("data")
|
|
if tweet_data:
|
|
expand_long_tweet(tweet_data)
|
|
|
|
# Expand the URLs that are in the tweet
|
|
expanded_tweet_list = expand_urls_in_tweets([tweet_data], delete_entities=True)
|
|
response_data["data"] = expanded_tweet_list[0]
|
|
else:
|
|
response_data["data"] = {}
|
|
|
|
return response_data
|