arcade-mcp/toolkits/x/arcade_x/tools/tweets.py
Renato Byrro cd1fb648bd
Return media attachments metadata when retrieving X tweets (#191)
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.
2025-01-08 16:36:43 -03:00

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