Skip to content

Commit f71a5fd

Browse files
kriptoburakclaude
andcommitted
[feat] add XquikToolkit for read-only X/Twitter search
Adds XquikToolkit with search_tweets, get_tweet, get_user_info, and get_trends. Complements TwitterToolkit (write-only) with the missing read capabilities — especially tweet search. Requires only XQUIK_API_KEY (1 env var) vs 4 OAuth credentials. No external dependencies (stdlib urllib). Fixes #3997 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 1821e4c commit f71a5fd

2 files changed

Lines changed: 260 additions & 0 deletions

File tree

camel/toolkits/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
from .dingtalk import DingtalkToolkit
4747
from .lark_toolkit import LarkToolkit
4848
from .twitter_toolkit import TwitterToolkit
49+
from .xquik_toolkit import XquikToolkit
4950
from .open_api_toolkit import OpenAPIToolkit
5051
from .retrieval_toolkit import RetrievalToolkit
5152
from .notion_toolkit import NotionToolkit
@@ -128,6 +129,7 @@
128129
'LarkToolkit',
129130
'ImageGenToolkit',
130131
'TwitterToolkit',
132+
'XquikToolkit',
131133
'WeatherToolkit',
132134
'RetrievalToolkit',
133135
'OpenAPIToolkit',

camel/toolkits/xquik_toolkit.py

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
# ========= Copyright 2023-2026 @ CAMEL-AI.org. All Rights Reserved. =========
2+
# Licensed under the Apache License, Version 2.0 (the "License");
3+
# you may not use this file except in compliance with the License.
4+
# You may obtain a copy of the License at
5+
#
6+
# http://www.apache.org/licenses/LICENSE-2.0
7+
#
8+
# Unless required by applicable law or agreed to in writing, software
9+
# distributed under the License is distributed on an "AS IS" BASIS,
10+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
# See the License for the specific language governing permissions and
12+
# limitations under the License.
13+
# ========= Copyright 2023-2026 @ CAMEL-AI.org. All Rights Reserved. =========
14+
import json
15+
import os
16+
import urllib.parse
17+
import urllib.request
18+
from typing import Any, Dict, List, Optional
19+
20+
from camel.logger import get_logger
21+
from camel.toolkits import FunctionTool
22+
from camel.toolkits.base import BaseToolkit
23+
from camel.utils import MCPServer, api_keys_required
24+
25+
logger = get_logger(__name__)
26+
27+
_BASE = "https://xquik.com/api/v1"
28+
29+
30+
def _xquik_get(
31+
path: str, params: Optional[Dict[str, Any]] = None
32+
) -> Dict[str, Any]:
33+
r"""Make a GET request to the Xquik API.
34+
35+
Args:
36+
path (str): API endpoint path.
37+
params (Optional[Dict[str, Any]]): Query parameters.
38+
39+
Returns:
40+
Dict[str, Any]: Parsed JSON response.
41+
42+
Raises:
43+
RuntimeError: If the API request fails.
44+
"""
45+
api_key = os.environ.get("XQUIK_API_KEY", "")
46+
url = f"{_BASE}{path}"
47+
if params:
48+
qs = urllib.parse.urlencode(
49+
{k: v for k, v in params.items() if v is not None}
50+
)
51+
url = f"{url}?{qs}"
52+
53+
req = urllib.request.Request(
54+
url,
55+
headers={
56+
"X-API-Key": api_key,
57+
"Accept": "application/json",
58+
"User-Agent": "camel-ai/1.0",
59+
},
60+
)
61+
with urllib.request.urlopen(req, timeout=15) as resp:
62+
return json.loads(resp.read().decode("utf-8"))
63+
64+
65+
@api_keys_required([(None, "XQUIK_API_KEY")])
66+
def search_tweets(
67+
query: str,
68+
max_results: int = 10,
69+
sort_order: str = "Top",
70+
) -> str:
71+
r"""Search for tweets on X (Twitter).
72+
73+
Supports X search operators: ``from:user``, ``#hashtag``,
74+
``"exact phrase"``, ``since:YYYY-MM-DD``, ``until:YYYY-MM-DD``,
75+
``-is:retweet``, ``-is:reply``, ``has:media``.
76+
77+
Args:
78+
query (str): The search query.
79+
max_results (int): Maximum number of tweets to return (10-200).
80+
(default: :obj:`10`)
81+
sort_order (str): Sort order — "Top" (engagement) or "Latest"
82+
(chronological). (default: :obj:`"Top"`)
83+
84+
Returns:
85+
str: A JSON-formatted string containing the search results with
86+
tweet text, author info, engagement metrics, and URLs.
87+
"""
88+
try:
89+
max_results = max(10, min(max_results, 200))
90+
data = _xquik_get(
91+
"/x/tweets/search",
92+
{"q": query, "limit": max_results, "queryType": sort_order},
93+
)
94+
tweets = data.get("tweets", [])
95+
results = []
96+
for tweet in tweets:
97+
author = tweet.get("author", {})
98+
results.append(
99+
{
100+
"id": tweet.get("id", ""),
101+
"text": tweet.get("text", ""),
102+
"created_at": tweet.get("createdAt", ""),
103+
"author": {
104+
"username": author.get("username", ""),
105+
"name": author.get("name", ""),
106+
"verified": author.get("verified", False),
107+
},
108+
"metrics": {
109+
"likes": tweet.get("likeCount", 0),
110+
"retweets": tweet.get("retweetCount", 0),
111+
"replies": tweet.get("replyCount", 0),
112+
"quotes": tweet.get("quoteCount", 0),
113+
"views": tweet.get("viewCount", 0),
114+
"bookmarks": tweet.get("bookmarkCount", 0),
115+
},
116+
"url": (
117+
f"https://x.com/{author.get('username', 'unknown')}"
118+
f"/status/{tweet.get('id', '')}"
119+
),
120+
}
121+
)
122+
return json.dumps(
123+
{"query": query, "count": len(results), "tweets": results},
124+
indent=2,
125+
)
126+
except Exception as e:
127+
logger.exception("Error searching tweets via Xquik")
128+
return json.dumps({"error": str(e), "query": query})
129+
130+
131+
@api_keys_required([(None, "XQUIK_API_KEY")])
132+
def get_tweet(tweet_id: str) -> str:
133+
r"""Retrieve a single tweet by ID with full engagement metrics.
134+
135+
Args:
136+
tweet_id (str): The tweet ID to look up.
137+
138+
Returns:
139+
str: A JSON-formatted string containing the tweet text, author
140+
info, and engagement metrics.
141+
"""
142+
try:
143+
data = _xquik_get(f"/x/tweets/{tweet_id}")
144+
author = data.get("author", {})
145+
result = {
146+
"id": data.get("id", ""),
147+
"text": data.get("text", ""),
148+
"created_at": data.get("createdAt", ""),
149+
"author": {
150+
"username": author.get("username", ""),
151+
"name": author.get("name", ""),
152+
"verified": author.get("verified", False),
153+
},
154+
"metrics": {
155+
"likes": data.get("likeCount", 0),
156+
"retweets": data.get("retweetCount", 0),
157+
"replies": data.get("replyCount", 0),
158+
"quotes": data.get("quoteCount", 0),
159+
"views": data.get("viewCount", 0),
160+
"bookmarks": data.get("bookmarkCount", 0),
161+
},
162+
"url": (
163+
f"https://x.com/{author.get('username', 'unknown')}"
164+
f"/status/{data.get('id', '')}"
165+
),
166+
}
167+
return json.dumps(result, indent=2)
168+
except Exception as e:
169+
logger.exception("Error fetching tweet via Xquik")
170+
return json.dumps({"error": str(e)})
171+
172+
173+
@api_keys_required([(None, "XQUIK_API_KEY")])
174+
def get_user_info(username: str) -> str:
175+
r"""Retrieve information about a specific X (Twitter) user.
176+
177+
Args:
178+
username (str): The username (without @) to look up.
179+
180+
Returns:
181+
str: A JSON-formatted string with the user's profile information.
182+
"""
183+
try:
184+
data = _xquik_get(f"/x/users/{username.lstrip('@')}")
185+
result = {
186+
"id": data.get("id", ""),
187+
"name": data.get("name", ""),
188+
"username": data.get("username", ""),
189+
"description": data.get("description", ""),
190+
"followers": data.get("followers", 0),
191+
"following": data.get("following", 0),
192+
"tweet_count": data.get("statusesCount", 0),
193+
"verified": data.get("verified", False),
194+
"url": f"https://x.com/{data.get('username', username)}",
195+
}
196+
return json.dumps(result, indent=2)
197+
except Exception as e:
198+
logger.exception("Error fetching user info via Xquik")
199+
return json.dumps({"error": str(e)})
200+
201+
202+
@api_keys_required([(None, "XQUIK_API_KEY")])
203+
def get_trends(woeid: int = 1, count: int = 20) -> str:
204+
r"""Get trending topics on X (Twitter).
205+
206+
Args:
207+
woeid (int): WOEID for region. 1=Global, 23424977=US,
208+
23424975=UK. (default: :obj:`1`)
209+
count (int): Number of trends to return (1-50).
210+
(default: :obj:`20`)
211+
212+
Returns:
213+
str: A JSON-formatted string with a list of trending topics.
214+
"""
215+
try:
216+
data = _xquik_get(
217+
"/trends", {"woeid": woeid, "count": min(count, 50)}
218+
)
219+
return json.dumps(
220+
{"trends": data.get("trends", [])}, indent=2
221+
)
222+
except Exception as e:
223+
logger.exception("Error fetching trends via Xquik")
224+
return json.dumps({"error": str(e)})
225+
226+
227+
@MCPServer()
228+
class XquikToolkit(BaseToolkit):
229+
r"""A toolkit for read-only X (Twitter) operations via the Xquik API.
230+
231+
Provides tweet search, tweet lookup, user profile lookup, and trending
232+
topics. Requires only ``XQUIK_API_KEY`` (1 env var) — no OAuth setup.
233+
234+
For write operations (posting, deleting tweets), use
235+
:class:`TwitterToolkit` instead.
236+
237+
References:
238+
https://xquik.com
239+
240+
Notes:
241+
To use this toolkit, set the ``XQUIK_API_KEY`` environment variable.
242+
Get a key at https://xquik.com
243+
"""
244+
245+
def get_tools(self) -> List[FunctionTool]:
246+
r"""Returns a list of FunctionTool objects representing the
247+
functions in the toolkit.
248+
249+
Returns:
250+
List[FunctionTool]: A list of FunctionTool objects
251+
representing the functions in the toolkit.
252+
"""
253+
return [
254+
FunctionTool(search_tweets),
255+
FunctionTool(get_tweet),
256+
FunctionTool(get_user_info),
257+
FunctionTool(get_trends),
258+
]

0 commit comments

Comments
 (0)