## PR Description Search ([see API docs here](https://docs.x.com/x-api/posts/recent-search)) returns a 'data' field that maps to a list of tweets returned. We've observed that the 'data' field is not present if no tweets match the search. This PR handles that case safely.
215 lines
7.3 KiB
Python
215 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.constants import TWEETS_URL
|
|
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,
|
|
)
|
|
|
|
# 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.get("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.get("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
|