webagents 0.2.0__py3-none-any.whl → 0.2.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- webagents/__init__.py +9 -0
- webagents/agents/core/base_agent.py +865 -69
- webagents/agents/core/handoffs.py +14 -6
- webagents/agents/skills/base.py +33 -2
- webagents/agents/skills/core/llm/litellm/skill.py +906 -27
- webagents/agents/skills/core/memory/vector_memory/skill.py +8 -16
- webagents/agents/skills/ecosystem/crewai/__init__.py +3 -1
- webagents/agents/skills/ecosystem/crewai/skill.py +158 -0
- webagents/agents/skills/ecosystem/database/__init__.py +3 -1
- webagents/agents/skills/ecosystem/database/skill.py +522 -0
- webagents/agents/skills/ecosystem/mongodb/__init__.py +3 -0
- webagents/agents/skills/ecosystem/mongodb/skill.py +428 -0
- webagents/agents/skills/ecosystem/n8n/README.md +287 -0
- webagents/agents/skills/ecosystem/n8n/__init__.py +3 -0
- webagents/agents/skills/ecosystem/n8n/skill.py +341 -0
- webagents/agents/skills/ecosystem/openai/__init__.py +6 -0
- webagents/agents/skills/ecosystem/openai/skill.py +867 -0
- webagents/agents/skills/ecosystem/replicate/README.md +440 -0
- webagents/agents/skills/ecosystem/replicate/__init__.py +10 -0
- webagents/agents/skills/ecosystem/replicate/skill.py +517 -0
- webagents/agents/skills/ecosystem/x_com/README.md +401 -0
- webagents/agents/skills/ecosystem/x_com/__init__.py +3 -0
- webagents/agents/skills/ecosystem/x_com/skill.py +1048 -0
- webagents/agents/skills/ecosystem/zapier/README.md +363 -0
- webagents/agents/skills/ecosystem/zapier/__init__.py +3 -0
- webagents/agents/skills/ecosystem/zapier/skill.py +337 -0
- webagents/agents/skills/examples/__init__.py +6 -0
- webagents/agents/skills/examples/music_player.py +329 -0
- webagents/agents/skills/robutler/handoff/__init__.py +6 -0
- webagents/agents/skills/robutler/handoff/skill.py +191 -0
- webagents/agents/skills/robutler/nli/skill.py +180 -24
- webagents/agents/skills/robutler/payments/exceptions.py +27 -7
- webagents/agents/skills/robutler/payments/skill.py +64 -14
- webagents/agents/skills/robutler/storage/files/skill.py +2 -2
- webagents/agents/tools/decorators.py +243 -47
- webagents/agents/widgets/__init__.py +6 -0
- webagents/agents/widgets/renderer.py +150 -0
- webagents/server/core/app.py +130 -15
- webagents/server/core/models.py +1 -1
- webagents/utils/logging.py +13 -1
- {webagents-0.2.0.dist-info → webagents-0.2.3.dist-info}/METADATA +16 -9
- {webagents-0.2.0.dist-info → webagents-0.2.3.dist-info}/RECORD +45 -24
- webagents/agents/skills/ecosystem/openai_agents/__init__.py +0 -0
- {webagents-0.2.0.dist-info → webagents-0.2.3.dist-info}/WHEEL +0 -0
- {webagents-0.2.0.dist-info → webagents-0.2.3.dist-info}/entry_points.txt +0 -0
- {webagents-0.2.0.dist-info → webagents-0.2.3.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,1048 @@
|
|
1
|
+
import os
|
2
|
+
import json
|
3
|
+
import base64
|
4
|
+
import hashlib
|
5
|
+
import hmac
|
6
|
+
import urllib.parse
|
7
|
+
import secrets
|
8
|
+
from typing import Any, Dict, Optional, List, Union
|
9
|
+
from datetime import datetime, timezone
|
10
|
+
|
11
|
+
import httpx
|
12
|
+
from fastapi import Request, HTTPException
|
13
|
+
from fastapi.responses import JSONResponse
|
14
|
+
|
15
|
+
from webagents.agents.skills.base import Skill
|
16
|
+
from webagents.agents.tools.decorators import tool, prompt, http, hook
|
17
|
+
from webagents.server.context.context_vars import get_context
|
18
|
+
from webagents.utils.logging import get_logger, log_skill_event, log_tool_execution
|
19
|
+
|
20
|
+
|
21
|
+
class XComSkill(Skill):
|
22
|
+
"""Simplified X.com (Twitter) OAuth 1.0a integration for multitenant applications.
|
23
|
+
|
24
|
+
This skill provides:
|
25
|
+
- OAuth 1.0a User Context authentication with per-user rate limits
|
26
|
+
- User subscription monitoring (follow specific X users)
|
27
|
+
- Webhook-based post monitoring with relevance checking
|
28
|
+
- Automatic notifications via notification skill integration
|
29
|
+
- Secure credential storage via auth/KV skills
|
30
|
+
|
31
|
+
Core workflow:
|
32
|
+
1. Users authenticate via OAuth 1.0a
|
33
|
+
2. Subscribe to specific X users to monitor
|
34
|
+
3. Webhook receives posts from subscribed users
|
35
|
+
4. Agent checks post relevance against instructions
|
36
|
+
5. Relevant posts trigger notifications to owner
|
37
|
+
"""
|
38
|
+
|
39
|
+
def __init__(self, config: Optional[Dict[str, Any]] = None) -> None:
|
40
|
+
super().__init__(config or {}, scope="all")
|
41
|
+
self.logger = None
|
42
|
+
|
43
|
+
# OAuth 1.0a credentials
|
44
|
+
self.api_key = os.getenv("X_API_KEY", "")
|
45
|
+
self.api_secret = os.getenv("X_API_SECRET", "")
|
46
|
+
|
47
|
+
# OAuth endpoints
|
48
|
+
self.request_token_url = "https://api.x.com/oauth/request_token"
|
49
|
+
self.authorize_url = "https://api.x.com/oauth/authorize"
|
50
|
+
self.access_token_url = "https://api.x.com/oauth/access_token"
|
51
|
+
|
52
|
+
# API base URL
|
53
|
+
self.api_base = "https://api.x.com/2"
|
54
|
+
|
55
|
+
# OAuth callback path
|
56
|
+
self.oauth_redirect_path = "/oauth/x/callback"
|
57
|
+
|
58
|
+
# Base URL for this agent
|
59
|
+
env_agents = os.getenv("AGENTS_BASE_URL")
|
60
|
+
base_root = (env_agents or "http://localhost:2224").rstrip('/')
|
61
|
+
if base_root.endswith("/agents"):
|
62
|
+
self.agent_base_url = base_root
|
63
|
+
else:
|
64
|
+
self.agent_base_url = base_root + "/agents"
|
65
|
+
|
66
|
+
def get_dependencies(self) -> List[str]:
|
67
|
+
"""Skill dependencies"""
|
68
|
+
return ['auth', 'kv', 'notifications']
|
69
|
+
|
70
|
+
async def initialize(self, agent) -> None:
|
71
|
+
self.agent = agent
|
72
|
+
self.logger = get_logger('skill.x_com', agent.name)
|
73
|
+
log_skill_event(agent.name, 'x_com', 'initialized', {})
|
74
|
+
|
75
|
+
# ---------------- OAuth 1.0a Helpers ----------------
|
76
|
+
def _redirect_uri(self) -> str:
|
77
|
+
"""Generate the OAuth callback URI"""
|
78
|
+
base = self.agent_base_url.rstrip('/')
|
79
|
+
return f"{base}/{self.agent.name}{self.oauth_redirect_path}"
|
80
|
+
|
81
|
+
def _generate_nonce(self) -> str:
|
82
|
+
"""Generate a random nonce for OAuth"""
|
83
|
+
return secrets.token_urlsafe(32)
|
84
|
+
|
85
|
+
def _generate_timestamp(self) -> str:
|
86
|
+
"""Generate timestamp for OAuth"""
|
87
|
+
return str(int(datetime.now(timezone.utc).timestamp()))
|
88
|
+
|
89
|
+
def _percent_encode(self, string: str) -> str:
|
90
|
+
"""Percent encode string for OAuth"""
|
91
|
+
return urllib.parse.quote(str(string), safe='')
|
92
|
+
|
93
|
+
def _generate_signature_base_string(self, method: str, url: str, params: Dict[str, str]) -> str:
|
94
|
+
"""Generate OAuth signature base string"""
|
95
|
+
# Sort parameters
|
96
|
+
sorted_params = sorted(params.items())
|
97
|
+
param_string = '&'.join([f"{self._percent_encode(k)}={self._percent_encode(v)}" for k, v in sorted_params])
|
98
|
+
|
99
|
+
return f"{method.upper()}&{self._percent_encode(url)}&{self._percent_encode(param_string)}"
|
100
|
+
|
101
|
+
def _generate_signature(self, method: str, url: str, params: Dict[str, str],
|
102
|
+
token_secret: str = "") -> str:
|
103
|
+
"""Generate OAuth signature"""
|
104
|
+
base_string = self._generate_signature_base_string(method, url, params)
|
105
|
+
signing_key = f"{self._percent_encode(self.api_secret)}&{self._percent_encode(token_secret)}"
|
106
|
+
|
107
|
+
signature = hmac.new(
|
108
|
+
signing_key.encode('utf-8'),
|
109
|
+
base_string.encode('utf-8'),
|
110
|
+
hashlib.sha1
|
111
|
+
).digest()
|
112
|
+
|
113
|
+
return base64.b64encode(signature).decode('utf-8')
|
114
|
+
|
115
|
+
def _build_auth_header(self, method: str, url: str, params: Dict[str, str],
|
116
|
+
oauth_params: Dict[str, str], token_secret: str = "") -> str:
|
117
|
+
"""Build OAuth authorization header"""
|
118
|
+
# Combine all parameters for signature
|
119
|
+
all_params = {**params, **oauth_params}
|
120
|
+
signature = self._generate_signature(method, url, all_params, token_secret)
|
121
|
+
oauth_params['oauth_signature'] = signature
|
122
|
+
|
123
|
+
# Build header
|
124
|
+
header_params = []
|
125
|
+
for key, value in sorted(oauth_params.items()):
|
126
|
+
header_params.append(f'{self._percent_encode(key)}="{self._percent_encode(value)}"')
|
127
|
+
|
128
|
+
return f"OAuth {', '.join(header_params)}"
|
129
|
+
|
130
|
+
async def _get_auth_skill(self):
|
131
|
+
"""Get authentication skill"""
|
132
|
+
return self.agent.skills.get("auth")
|
133
|
+
|
134
|
+
async def _get_kv_skill(self):
|
135
|
+
"""Get key-value storage skill"""
|
136
|
+
return self.agent.skills.get("kv") or self.agent.skills.get("json_storage")
|
137
|
+
|
138
|
+
async def _get_notification_skill(self):
|
139
|
+
"""Get notification skill"""
|
140
|
+
return self.agent.skills.get("notifications")
|
141
|
+
|
142
|
+
def _token_filename(self, user_id: str) -> str:
|
143
|
+
"""Generate filename for user tokens"""
|
144
|
+
return f"x_tokens_{user_id}.json"
|
145
|
+
|
146
|
+
async def _save_user_tokens(self, user_id: str, tokens: Dict[str, Any]) -> None:
|
147
|
+
"""Save user tokens securely using KV skill"""
|
148
|
+
kv_skill = await self._get_kv_skill()
|
149
|
+
if kv_skill and hasattr(kv_skill, 'kv_set'):
|
150
|
+
try:
|
151
|
+
# Use KV skill for secure, persistent storage
|
152
|
+
result = await kv_skill.kv_set(
|
153
|
+
key=self._token_filename(user_id),
|
154
|
+
value=json.dumps(tokens),
|
155
|
+
namespace="x_com_auth"
|
156
|
+
)
|
157
|
+
if "✅" in str(result): # KV skill returns "✅ Saved" on success
|
158
|
+
return
|
159
|
+
except Exception as e:
|
160
|
+
self.logger.warning(f"Failed to save tokens to KV skill: {e}")
|
161
|
+
|
162
|
+
# Fallback: in-memory storage (not recommended for production)
|
163
|
+
setattr(self.agent, '_x_tokens', getattr(self.agent, '_x_tokens', {}))
|
164
|
+
self.agent._x_tokens[user_id] = tokens
|
165
|
+
|
166
|
+
async def _load_user_tokens(self, user_id: str) -> Optional[Dict[str, Any]]:
|
167
|
+
"""Load user tokens from KV skill"""
|
168
|
+
kv_skill = await self._get_kv_skill()
|
169
|
+
if kv_skill and hasattr(kv_skill, 'kv_get'):
|
170
|
+
try:
|
171
|
+
# Load from KV skill
|
172
|
+
stored = await kv_skill.kv_get(
|
173
|
+
key=self._token_filename(user_id),
|
174
|
+
namespace="x_com_auth"
|
175
|
+
)
|
176
|
+
if isinstance(stored, str) and stored.strip() and stored.startswith('{'):
|
177
|
+
return json.loads(stored)
|
178
|
+
except Exception as e:
|
179
|
+
self.logger.warning(f"Failed to load tokens from KV skill: {e}")
|
180
|
+
|
181
|
+
# Fallback: in-memory storage
|
182
|
+
mem = getattr(self.agent, '_x_tokens', {})
|
183
|
+
return mem.get(user_id)
|
184
|
+
|
185
|
+
async def _get_user_id_from_context(self) -> Optional[str]:
|
186
|
+
"""Get user ID from request context via auth skill"""
|
187
|
+
try:
|
188
|
+
ctx = get_context()
|
189
|
+
if not ctx:
|
190
|
+
return None
|
191
|
+
auth = getattr(ctx, 'auth', None) or (ctx and ctx.get('auth'))
|
192
|
+
return getattr(auth, 'user_id', None)
|
193
|
+
except Exception:
|
194
|
+
return None
|
195
|
+
|
196
|
+
async def _get_authenticated_user_id(self) -> Optional[str]:
|
197
|
+
"""Get authenticated user ID, ensuring proper auth"""
|
198
|
+
auth_skill = await self._get_auth_skill()
|
199
|
+
if not auth_skill:
|
200
|
+
return await self._get_user_id_from_context()
|
201
|
+
|
202
|
+
try:
|
203
|
+
ctx = get_context()
|
204
|
+
if not ctx or not ctx.auth or not ctx.auth.authenticated:
|
205
|
+
return None
|
206
|
+
return ctx.auth.user_id
|
207
|
+
except Exception:
|
208
|
+
return None
|
209
|
+
|
210
|
+
# ---------------- User Subscription Management ----------------
|
211
|
+
async def _save_subscriptions(self, owner_user_id: str, subscriptions: Dict[str, Any]) -> None:
|
212
|
+
"""Save user subscriptions for an owner"""
|
213
|
+
kv_skill = await self._get_kv_skill()
|
214
|
+
if kv_skill and hasattr(kv_skill, 'kv_set'):
|
215
|
+
try:
|
216
|
+
await kv_skill.kv_set(
|
217
|
+
key=f"x_subscriptions_{owner_user_id}",
|
218
|
+
value=json.dumps(subscriptions),
|
219
|
+
namespace="x_com_subscriptions"
|
220
|
+
)
|
221
|
+
except Exception as e:
|
222
|
+
self.logger.error(f"Failed to save subscriptions: {e}")
|
223
|
+
|
224
|
+
async def _load_subscriptions(self, owner_user_id: str) -> Dict[str, Any]:
|
225
|
+
"""Load user subscriptions for an owner"""
|
226
|
+
kv_skill = await self._get_kv_skill()
|
227
|
+
if kv_skill and hasattr(kv_skill, 'kv_get'):
|
228
|
+
try:
|
229
|
+
stored = await kv_skill.kv_get(
|
230
|
+
key=f"x_subscriptions_{owner_user_id}",
|
231
|
+
namespace="x_com_subscriptions"
|
232
|
+
)
|
233
|
+
if isinstance(stored, str) and stored.strip() and stored.startswith('{'):
|
234
|
+
return json.loads(stored)
|
235
|
+
except Exception as e:
|
236
|
+
self.logger.error(f"Failed to load subscriptions: {e}")
|
237
|
+
return {
|
238
|
+
'users': {}, # username -> {user_id, instructions, active}
|
239
|
+
'webhook_active': False,
|
240
|
+
'created_at': datetime.now(timezone.utc).isoformat()
|
241
|
+
}
|
242
|
+
|
243
|
+
async def _register_webhook_with_x(self, webhook_url: str, user_tokens: Dict[str, str]) -> Dict[str, Any]:
|
244
|
+
"""Register webhook with X.com Account Activity API"""
|
245
|
+
try:
|
246
|
+
# First, create webhook environment if not exists
|
247
|
+
env_response = await self._make_authenticated_request(
|
248
|
+
'POST', '/1.1/account_activity/all/webhooks.json',
|
249
|
+
{'url': webhook_url},
|
250
|
+
user_tokens
|
251
|
+
)
|
252
|
+
|
253
|
+
webhook_id = env_response.get('id')
|
254
|
+
|
255
|
+
# Subscribe user to webhook
|
256
|
+
subscription_response = await self._make_authenticated_request(
|
257
|
+
'POST', f'/1.1/account_activity/all/{webhook_id}/subscriptions.json',
|
258
|
+
{},
|
259
|
+
user_tokens
|
260
|
+
)
|
261
|
+
|
262
|
+
return {
|
263
|
+
'webhook_id': webhook_id,
|
264
|
+
'webhook_url': webhook_url,
|
265
|
+
'subscription_active': True,
|
266
|
+
'created_at': datetime.now(timezone.utc).isoformat()
|
267
|
+
}
|
268
|
+
|
269
|
+
except Exception as e:
|
270
|
+
self.logger.error(f"Failed to register webhook with X.com: {e}")
|
271
|
+
raise
|
272
|
+
|
273
|
+
async def _verify_webhook_signature(self, signature: str, timestamp: str, body: str) -> bool:
|
274
|
+
"""Verify X.com webhook signature"""
|
275
|
+
try:
|
276
|
+
# X.com uses HMAC-SHA256 for webhook signatures
|
277
|
+
expected_signature = hmac.new(
|
278
|
+
self.api_secret.encode('utf-8'),
|
279
|
+
f"{timestamp}.{body}".encode('utf-8'),
|
280
|
+
hashlib.sha256
|
281
|
+
).hexdigest()
|
282
|
+
|
283
|
+
# Compare signatures securely
|
284
|
+
return hmac.compare_digest(signature, expected_signature)
|
285
|
+
except Exception as e:
|
286
|
+
self.logger.error(f"Webhook signature verification failed: {e}")
|
287
|
+
return False
|
288
|
+
|
289
|
+
async def _process_webhook_event(self, event_data: Dict[str, Any], owner_user_id: str) -> None:
|
290
|
+
"""Process incoming webhook event from subscribed users"""
|
291
|
+
try:
|
292
|
+
# Focus only on tweet creation events from subscribed users
|
293
|
+
if 'tweet_create_events' in event_data:
|
294
|
+
await self._handle_subscribed_user_tweets(event_data['tweet_create_events'], owner_user_id)
|
295
|
+
|
296
|
+
except Exception as e:
|
297
|
+
self.logger.error(f"Error processing webhook event: {e}")
|
298
|
+
|
299
|
+
async def _handle_subscribed_user_tweets(self, tweet_events: List[Dict], owner_user_id: str) -> None:
|
300
|
+
"""Handle tweet creation events from subscribed users"""
|
301
|
+
subscriptions = await self._load_subscriptions(owner_user_id)
|
302
|
+
subscribed_users = subscriptions.get('users', {})
|
303
|
+
|
304
|
+
if not subscribed_users:
|
305
|
+
return
|
306
|
+
|
307
|
+
for tweet in tweet_events:
|
308
|
+
tweet_user = tweet.get('user', {}).get('screen_name', '').lower()
|
309
|
+
tweet_text = tweet.get('text', '')
|
310
|
+
tweet_id = tweet.get('id_str', '')
|
311
|
+
|
312
|
+
# Check if this tweet is from a subscribed user
|
313
|
+
if tweet_user in subscribed_users:
|
314
|
+
user_config = subscribed_users[tweet_user]
|
315
|
+
if user_config.get('active', True):
|
316
|
+
# Check relevance against instructions
|
317
|
+
is_relevant = await self._check_post_relevance(
|
318
|
+
tweet_text,
|
319
|
+
user_config.get('instructions', ''),
|
320
|
+
tweet_user,
|
321
|
+
owner_user_id
|
322
|
+
)
|
323
|
+
|
324
|
+
if is_relevant:
|
325
|
+
await self._send_post_notification(
|
326
|
+
tweet_text,
|
327
|
+
tweet_user,
|
328
|
+
tweet_id,
|
329
|
+
owner_user_id
|
330
|
+
)
|
331
|
+
|
332
|
+
async def _check_post_relevance(self, tweet_text: str, instructions: str, username: str, owner_user_id: str) -> bool:
|
333
|
+
"""Check if a post is relevant based on agent instructions"""
|
334
|
+
if not instructions.strip():
|
335
|
+
# If no specific instructions, consider all posts relevant
|
336
|
+
return True
|
337
|
+
|
338
|
+
try:
|
339
|
+
# Get the agent's LLM skill for relevance checking
|
340
|
+
llm_skill = self.agent.skills.get("llm")
|
341
|
+
|
342
|
+
prompt = f"""
|
343
|
+
You are helping monitor X.com posts for relevance.
|
344
|
+
|
345
|
+
INSTRUCTIONS: {instructions}
|
346
|
+
|
347
|
+
POST from @{username}: {tweet_text}
|
348
|
+
|
349
|
+
Based on the instructions above, is this post relevant and worth notifying the owner about?
|
350
|
+
Consider:
|
351
|
+
- Does it match the monitoring criteria?
|
352
|
+
- Is it actionable or important?
|
353
|
+
- Would the owner want to know about this?
|
354
|
+
|
355
|
+
Respond with only "YES" or "NO".
|
356
|
+
"""
|
357
|
+
|
358
|
+
if llm_skill and hasattr(llm_skill, 'generate'):
|
359
|
+
response = await llm_skill.generate(prompt)
|
360
|
+
result = response.get('text', '').strip().upper() if isinstance(response, dict) else str(response).strip().upper()
|
361
|
+
return result.startswith('YES')
|
362
|
+
else:
|
363
|
+
# Fallback: simple keyword matching if no LLM available
|
364
|
+
instructions_lower = instructions.lower()
|
365
|
+
tweet_lower = tweet_text.lower()
|
366
|
+
|
367
|
+
# Simple relevance check based on keyword overlap
|
368
|
+
instruction_words = set(instructions_lower.split())
|
369
|
+
tweet_words = set(tweet_lower.split())
|
370
|
+
|
371
|
+
# If at least 20% of instruction keywords appear in tweet, consider relevant
|
372
|
+
if instruction_words:
|
373
|
+
overlap = len(instruction_words & tweet_words)
|
374
|
+
return overlap / len(instruction_words) >= 0.2
|
375
|
+
|
376
|
+
return True # Default to relevant if no clear criteria
|
377
|
+
|
378
|
+
except Exception as e:
|
379
|
+
self.logger.error(f"Error checking post relevance: {e}")
|
380
|
+
return True # Default to relevant on error
|
381
|
+
|
382
|
+
async def _send_post_notification(self, tweet_text: str, username: str, tweet_id: str, owner_user_id: str) -> None:
|
383
|
+
"""Send notification about relevant post using notification skill"""
|
384
|
+
try:
|
385
|
+
notification_skill = await self._get_notification_skill()
|
386
|
+
if not notification_skill:
|
387
|
+
self.logger.warning("Notification skill not available")
|
388
|
+
return
|
389
|
+
|
390
|
+
# Truncate tweet text for notification
|
391
|
+
display_text = tweet_text[:100] + "..." if len(tweet_text) > 100 else tweet_text
|
392
|
+
|
393
|
+
title = f"📱 New post from @{username}"
|
394
|
+
body = f"Post: {display_text}\n\nTweet ID: {tweet_id}"
|
395
|
+
|
396
|
+
# Use the notification skill to send notification
|
397
|
+
if hasattr(notification_skill, 'send_notification'):
|
398
|
+
result = await notification_skill.send_notification(
|
399
|
+
title=title,
|
400
|
+
body=body,
|
401
|
+
tag=f"x_com_post_{username}",
|
402
|
+
type="agent_update",
|
403
|
+
priority="normal"
|
404
|
+
)
|
405
|
+
self.logger.info(f"Notification sent for @{username} post: {result}")
|
406
|
+
else:
|
407
|
+
self.logger.warning("Notification skill does not have send_notification method")
|
408
|
+
|
409
|
+
except Exception as e:
|
410
|
+
self.logger.error(f"Failed to send post notification: {e}")
|
411
|
+
|
412
|
+
|
413
|
+
|
414
|
+
async def _get_request_token(self) -> Dict[str, str]:
|
415
|
+
"""Step 1: Get OAuth request token"""
|
416
|
+
oauth_params = {
|
417
|
+
'oauth_consumer_key': self.api_key,
|
418
|
+
'oauth_nonce': self._generate_nonce(),
|
419
|
+
'oauth_signature_method': 'HMAC-SHA1',
|
420
|
+
'oauth_timestamp': self._generate_timestamp(),
|
421
|
+
'oauth_version': '1.0',
|
422
|
+
'oauth_callback': self._redirect_uri()
|
423
|
+
}
|
424
|
+
|
425
|
+
auth_header = self._build_auth_header('POST', self.request_token_url, {}, oauth_params)
|
426
|
+
|
427
|
+
async with httpx.AsyncClient(timeout=30) as client:
|
428
|
+
response = await client.post(
|
429
|
+
self.request_token_url,
|
430
|
+
headers={'Authorization': auth_header}
|
431
|
+
)
|
432
|
+
response.raise_for_status()
|
433
|
+
|
434
|
+
# Parse response
|
435
|
+
data = urllib.parse.parse_qs(response.text)
|
436
|
+
return {
|
437
|
+
'oauth_token': data['oauth_token'][0],
|
438
|
+
'oauth_token_secret': data['oauth_token_secret'][0],
|
439
|
+
'oauth_callback_confirmed': data.get('oauth_callback_confirmed', ['false'])[0]
|
440
|
+
}
|
441
|
+
|
442
|
+
def _build_authorize_url(self, oauth_token: str, user_id: str) -> str:
|
443
|
+
"""Step 2: Build authorization URL"""
|
444
|
+
params = {
|
445
|
+
'oauth_token': oauth_token,
|
446
|
+
'state': user_id # Include user_id for callback correlation
|
447
|
+
}
|
448
|
+
return f"{self.authorize_url}?{urllib.parse.urlencode(params)}"
|
449
|
+
|
450
|
+
async def _get_access_token(self, oauth_token: str, oauth_token_secret: str,
|
451
|
+
oauth_verifier: str) -> Dict[str, str]:
|
452
|
+
"""Step 3: Exchange request token for access token"""
|
453
|
+
oauth_params = {
|
454
|
+
'oauth_consumer_key': self.api_key,
|
455
|
+
'oauth_nonce': self._generate_nonce(),
|
456
|
+
'oauth_signature_method': 'HMAC-SHA1',
|
457
|
+
'oauth_timestamp': self._generate_timestamp(),
|
458
|
+
'oauth_version': '1.0',
|
459
|
+
'oauth_token': oauth_token,
|
460
|
+
'oauth_verifier': oauth_verifier
|
461
|
+
}
|
462
|
+
|
463
|
+
auth_header = self._build_auth_header(
|
464
|
+
'POST', self.access_token_url, {}, oauth_params, oauth_token_secret
|
465
|
+
)
|
466
|
+
|
467
|
+
async with httpx.AsyncClient(timeout=30) as client:
|
468
|
+
response = await client.post(
|
469
|
+
self.access_token_url,
|
470
|
+
headers={'Authorization': auth_header}
|
471
|
+
)
|
472
|
+
response.raise_for_status()
|
473
|
+
|
474
|
+
# Parse response
|
475
|
+
data = urllib.parse.parse_qs(response.text)
|
476
|
+
return {
|
477
|
+
'oauth_token': data['oauth_token'][0],
|
478
|
+
'oauth_token_secret': data['oauth_token_secret'][0],
|
479
|
+
'user_id': data.get('user_id', [''])[0],
|
480
|
+
'screen_name': data.get('screen_name', [''])[0]
|
481
|
+
}
|
482
|
+
|
483
|
+
async def _make_authenticated_request(self, method: str, endpoint: str,
|
484
|
+
params: Dict[str, Any] = None,
|
485
|
+
user_tokens: Dict[str, str] = None) -> Dict[str, Any]:
|
486
|
+
"""Make authenticated API request using user tokens"""
|
487
|
+
if not user_tokens:
|
488
|
+
raise ValueError("User tokens required for authenticated requests")
|
489
|
+
|
490
|
+
url = f"{self.api_base}{endpoint}"
|
491
|
+
params = params or {}
|
492
|
+
|
493
|
+
# Convert params to strings for OAuth signature
|
494
|
+
str_params = {k: str(v) for k, v in params.items()}
|
495
|
+
|
496
|
+
oauth_params = {
|
497
|
+
'oauth_consumer_key': self.api_key,
|
498
|
+
'oauth_nonce': self._generate_nonce(),
|
499
|
+
'oauth_signature_method': 'HMAC-SHA1',
|
500
|
+
'oauth_timestamp': self._generate_timestamp(),
|
501
|
+
'oauth_version': '1.0',
|
502
|
+
'oauth_token': user_tokens['oauth_token']
|
503
|
+
}
|
504
|
+
|
505
|
+
auth_header = self._build_auth_header(
|
506
|
+
method, url, str_params, oauth_params, user_tokens['oauth_token_secret']
|
507
|
+
)
|
508
|
+
|
509
|
+
headers = {
|
510
|
+
'Authorization': auth_header,
|
511
|
+
'Content-Type': 'application/json'
|
512
|
+
}
|
513
|
+
|
514
|
+
async with httpx.AsyncClient(timeout=30) as client:
|
515
|
+
if method.upper() == 'GET':
|
516
|
+
response = await client.get(url, params=params, headers=headers)
|
517
|
+
elif method.upper() == 'POST':
|
518
|
+
response = await client.post(url, json=params, headers=headers)
|
519
|
+
else:
|
520
|
+
response = await client.request(method, url, json=params, headers=headers)
|
521
|
+
|
522
|
+
# Check rate limits
|
523
|
+
remaining = response.headers.get('x-rate-limit-remaining')
|
524
|
+
reset = response.headers.get('x-rate-limit-reset')
|
525
|
+
|
526
|
+
if remaining:
|
527
|
+
self.logger.info(f"Rate limit remaining: {remaining}, resets at: {reset}")
|
528
|
+
|
529
|
+
response.raise_for_status()
|
530
|
+
return response.json()
|
531
|
+
|
532
|
+
# ---------------- Prompts ----------------
|
533
|
+
@prompt(priority=40, scope=["owner", "all"])
|
534
|
+
def x_com_prompt(self) -> str:
|
535
|
+
return (
|
536
|
+
"Ultra-minimal X.com integration with just 3 tools: "
|
537
|
+
"x_subscribe() to monitor users and get notifications, "
|
538
|
+
"x_post() to tweet, and x_manage() to view/manage subscriptions. "
|
539
|
+
"Authentication is handled automatically. Uses notification skill for alerts."
|
540
|
+
)
|
541
|
+
|
542
|
+
# ---------------- HTTP Callback Handler ----------------
|
543
|
+
@http(subpath="/oauth/x/callback", method="get", scope=["all"])
|
544
|
+
async def oauth_callback(self, oauth_token: str = None, oauth_verifier: str = None,
|
545
|
+
state: str = None) -> Dict[str, Any]:
|
546
|
+
"""Handle OAuth callback"""
|
547
|
+
from fastapi.responses import HTMLResponse
|
548
|
+
|
549
|
+
def html(success: bool, message: str) -> str:
|
550
|
+
from string import Template
|
551
|
+
color_ok = "#1DA1F2" # Twitter blue
|
552
|
+
color_err = "#dc2626" # red-600
|
553
|
+
accent = color_ok if success else color_err
|
554
|
+
title = "X.com connected" if success else "X.com connection failed"
|
555
|
+
safe_msg = (message or '').replace('&','&').replace('<','<').replace('>','>').replace('$','$$')
|
556
|
+
|
557
|
+
template = Template("""<!doctype html>
|
558
|
+
<html lang="en">
|
559
|
+
<head>
|
560
|
+
<meta charset="utf-8" />
|
561
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
562
|
+
<title>WebAgents – X.com</title>
|
563
|
+
<style>
|
564
|
+
:root { color-scheme: light dark; }
|
565
|
+
html, body { height: 100%; margin: 0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji"; }
|
566
|
+
body { background: var(--bg, #0b0b0c); color: var(--fg, #e5e7eb); display: grid; place-items: center; }
|
567
|
+
@media (prefers-color-scheme: light) { body { --bg: #f7f7f8; --card: #ffffff; --border: #e5e7eb; --fg: #0f172a; } }
|
568
|
+
@media (prefers-color-scheme: dark) { body { --bg: #0b0b0c; --card: #111214; --border: #23252a; --fg: #e5e7eb; } }
|
569
|
+
.card { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 24px 20px; width: min(92vw, 420px); box-shadow: 0 6px 24px rgba(0,0,0,.12); text-align: center; }
|
570
|
+
.icon { color: $accent; display:inline-flex; align-items:center; justify-content:center; margin-bottom: 12px; }
|
571
|
+
h1 { margin: 0 0 6px; font-size: 18px; font-weight: 600; color: var(--fg); }
|
572
|
+
p { margin: 0 0 16px; font-size: 13px; opacity: .78; line-height: 1.4; }
|
573
|
+
button { appearance: none; border: 1px solid var(--border); background: transparent; color: var(--fg); border-radius: 8px; padding: 8px 14px; font-size: 13px; cursor: pointer; }
|
574
|
+
button:hover { background: rgba(127,127,127,.12); }
|
575
|
+
.x-icon { width: 44px; height: 44px; }
|
576
|
+
</style>
|
577
|
+
</head>
|
578
|
+
<body>
|
579
|
+
<div class="card">
|
580
|
+
<div class="icon">
|
581
|
+
<svg class="x-icon" viewBox="0 0 24 24" fill="currentColor">
|
582
|
+
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
|
583
|
+
</svg>
|
584
|
+
</div>
|
585
|
+
<h1>$title</h1>
|
586
|
+
<p>$safe_msg</p>
|
587
|
+
<button id="ok">OK</button>
|
588
|
+
</div>
|
589
|
+
<script>
|
590
|
+
(function(){
|
591
|
+
var payload = { type: 'x-com-connected', success: $postSuccess };
|
592
|
+
try {
|
593
|
+
if (window.opener && !window.opener.closed) { window.opener.postMessage(payload, '*'); }
|
594
|
+
else if (window.parent && window.parent !== window) { window.parent.postMessage(payload, '*'); }
|
595
|
+
} catch(e){}
|
596
|
+
var ok = document.getElementById('ok');
|
597
|
+
if (ok) ok.addEventListener('click', function(){
|
598
|
+
try { window.close(); } catch(e) {}
|
599
|
+
setTimeout(function(){ location.replace('about:blank'); }, 150);
|
600
|
+
});
|
601
|
+
})();
|
602
|
+
</script>
|
603
|
+
</body>
|
604
|
+
</html>""")
|
605
|
+
|
606
|
+
return template.safe_substitute(
|
607
|
+
title=title,
|
608
|
+
accent=accent,
|
609
|
+
safe_msg=safe_msg,
|
610
|
+
postSuccess=("true" if success else "false"),
|
611
|
+
)
|
612
|
+
|
613
|
+
if not oauth_token or not oauth_verifier:
|
614
|
+
return HTMLResponse(
|
615
|
+
content=html(False, "Missing OAuth parameters. Please retry authentication."),
|
616
|
+
media_type="text/html"
|
617
|
+
)
|
618
|
+
|
619
|
+
if not self.api_key or not self.api_secret:
|
620
|
+
return HTMLResponse(
|
621
|
+
content=html(False, "Server missing X.com API credentials. Contact support."),
|
622
|
+
media_type="text/html"
|
623
|
+
)
|
624
|
+
|
625
|
+
user_id = state or await self._get_user_id_from_context() or ""
|
626
|
+
|
627
|
+
try:
|
628
|
+
# Load request token secret from temporary storage
|
629
|
+
temp_tokens = getattr(self.agent, '_temp_x_tokens', {})
|
630
|
+
token_secret = temp_tokens.get(oauth_token, {}).get('oauth_token_secret')
|
631
|
+
|
632
|
+
if not token_secret:
|
633
|
+
return HTMLResponse(
|
634
|
+
content=html(False, "Invalid OAuth state. Please restart authentication."),
|
635
|
+
media_type="text/html"
|
636
|
+
)
|
637
|
+
|
638
|
+
# Exchange for access token
|
639
|
+
access_tokens = await self._get_access_token(oauth_token, token_secret, oauth_verifier)
|
640
|
+
|
641
|
+
# Save access tokens
|
642
|
+
await self._save_user_tokens(user_id, access_tokens)
|
643
|
+
|
644
|
+
# Clean up temporary tokens
|
645
|
+
if oauth_token in temp_tokens:
|
646
|
+
del temp_tokens[oauth_token]
|
647
|
+
|
648
|
+
return HTMLResponse(
|
649
|
+
content=html(True, "Your X.com account is now connected and ready to use."),
|
650
|
+
media_type="text/html"
|
651
|
+
)
|
652
|
+
|
653
|
+
except Exception as e:
|
654
|
+
return HTMLResponse(
|
655
|
+
content=html(False, f"Authentication failed: {str(e)}"),
|
656
|
+
media_type="text/html"
|
657
|
+
)
|
658
|
+
|
659
|
+
# ---------------- Webhook HTTP Handler ----------------
|
660
|
+
@http(subpath="/webhook/x/events", method="post", scope=["all"])
|
661
|
+
async def webhook_handler(self, request: Request) -> JSONResponse:
|
662
|
+
"""Handle incoming webhook events from X.com"""
|
663
|
+
try:
|
664
|
+
# Get request headers and body
|
665
|
+
signature = request.headers.get('x-twitter-webhooks-signature')
|
666
|
+
timestamp = request.headers.get('x-twitter-webhooks-timestamp')
|
667
|
+
|
668
|
+
if not signature or not timestamp:
|
669
|
+
raise HTTPException(status_code=400, detail="Missing webhook signature headers")
|
670
|
+
|
671
|
+
# Read request body
|
672
|
+
body = await request.body()
|
673
|
+
body_text = body.decode('utf-8')
|
674
|
+
|
675
|
+
# Verify webhook signature
|
676
|
+
if not await self._verify_webhook_signature(signature, timestamp, body_text):
|
677
|
+
raise HTTPException(status_code=401, detail="Invalid webhook signature")
|
678
|
+
|
679
|
+
# Parse event data
|
680
|
+
try:
|
681
|
+
event_data = json.loads(body_text)
|
682
|
+
except json.JSONDecodeError:
|
683
|
+
raise HTTPException(status_code=400, detail="Invalid JSON payload")
|
684
|
+
|
685
|
+
# Extract owner user ID from event data
|
686
|
+
# For X.com webhooks, we need to map the webhook to the owner
|
687
|
+
# This is simplified - in production you'd have a proper mapping system
|
688
|
+
owner_user_id = event_data.get('for_user_id') # X.com provides this
|
689
|
+
|
690
|
+
if owner_user_id:
|
691
|
+
# Process the webhook event
|
692
|
+
await self._process_webhook_event(event_data, owner_user_id)
|
693
|
+
|
694
|
+
# Return success response
|
695
|
+
return JSONResponse(
|
696
|
+
content={"status": "success", "message": "Webhook processed"},
|
697
|
+
status_code=200
|
698
|
+
)
|
699
|
+
|
700
|
+
except HTTPException:
|
701
|
+
raise
|
702
|
+
except Exception as e:
|
703
|
+
self.logger.error(f"Webhook handler error: {e}")
|
704
|
+
raise HTTPException(status_code=500, detail="Internal server error")
|
705
|
+
|
706
|
+
@http(subpath="/webhook/x/challenge", method="get", scope=["all"])
|
707
|
+
async def webhook_challenge(self, crc_token: str = None) -> JSONResponse:
|
708
|
+
"""Handle X.com webhook challenge (CRC - Challenge Response Check)"""
|
709
|
+
if not crc_token:
|
710
|
+
raise HTTPException(status_code=400, detail="Missing crc_token parameter")
|
711
|
+
|
712
|
+
try:
|
713
|
+
# Generate response using HMAC-SHA256
|
714
|
+
response_token = base64.b64encode(
|
715
|
+
hmac.new(
|
716
|
+
self.api_secret.encode('utf-8'),
|
717
|
+
crc_token.encode('utf-8'),
|
718
|
+
hashlib.sha256
|
719
|
+
).digest()
|
720
|
+
).decode('utf-8')
|
721
|
+
|
722
|
+
return JSONResponse(
|
723
|
+
content={"response_token": f"sha256={response_token}"},
|
724
|
+
status_code=200
|
725
|
+
)
|
726
|
+
|
727
|
+
except Exception as e:
|
728
|
+
self.logger.error(f"Webhook challenge error: {e}")
|
729
|
+
raise HTTPException(status_code=500, detail="Challenge generation failed")
|
730
|
+
|
731
|
+
# ---------------- Minimal Tools (3 Only) ----------------
|
732
|
+
|
733
|
+
@tool(description="Subscribe to X.com users and get notified when they post relevant content. Handles authentication automatically.", scope="owner")
|
734
|
+
async def x_subscribe(self, username: str, instructions: str = "Notify me about all posts") -> str:
|
735
|
+
"""Subscribe to monitor posts from a specific X.com user with automatic authentication"""
|
736
|
+
user_id = await self._get_authenticated_user_id()
|
737
|
+
if not user_id:
|
738
|
+
return "❌ Authentication required"
|
739
|
+
|
740
|
+
# Check if authenticated, if not provide auth URL
|
741
|
+
tokens = await self._load_user_tokens(user_id)
|
742
|
+
if not tokens:
|
743
|
+
if not self.api_key or not self.api_secret:
|
744
|
+
return "❌ X.com API credentials not configured"
|
745
|
+
|
746
|
+
try:
|
747
|
+
# Auto-generate auth URL
|
748
|
+
request_tokens = await self._get_request_token()
|
749
|
+
if not hasattr(self.agent, '_temp_x_tokens'):
|
750
|
+
self.agent._temp_x_tokens = {}
|
751
|
+
self.agent._temp_x_tokens[request_tokens['oauth_token']] = request_tokens
|
752
|
+
auth_url = self._build_authorize_url(request_tokens['oauth_token'], user_id)
|
753
|
+
|
754
|
+
return f"🔗 First, authorize X.com access: {auth_url}\n\nThen run this command again to subscribe to @{username}"
|
755
|
+
except Exception as e:
|
756
|
+
return f"❌ Authentication setup failed: {str(e)}"
|
757
|
+
|
758
|
+
try:
|
759
|
+
# Clean username
|
760
|
+
username = username.lstrip('@').lower()
|
761
|
+
|
762
|
+
# Validate username exists on X.com
|
763
|
+
user_info = await self._make_authenticated_request(
|
764
|
+
'GET', '/2/users/by/username/' + username,
|
765
|
+
{'user.fields': 'id,name,username'},
|
766
|
+
tokens
|
767
|
+
)
|
768
|
+
|
769
|
+
if not user_info.get('data'):
|
770
|
+
return f"❌ User @{username} not found on X.com"
|
771
|
+
|
772
|
+
x_user_data = user_info['data']
|
773
|
+
display_name = x_user_data['name']
|
774
|
+
|
775
|
+
# Load and update subscriptions
|
776
|
+
subscriptions = await self._load_subscriptions(user_id)
|
777
|
+
subscriptions['users'][username] = {
|
778
|
+
'user_id': x_user_data['id'],
|
779
|
+
'display_name': display_name,
|
780
|
+
'instructions': instructions.strip(),
|
781
|
+
'active': True,
|
782
|
+
'subscribed_at': datetime.now(timezone.utc).isoformat()
|
783
|
+
}
|
784
|
+
|
785
|
+
# Set up webhook if needed
|
786
|
+
if not subscriptions.get('webhook_active'):
|
787
|
+
webhook_url = f"{self.agent_base_url}/{self.agent.name}/webhook/x/events"
|
788
|
+
webhook_info = await self._register_webhook_with_x(webhook_url, tokens)
|
789
|
+
subscriptions['webhook_active'] = True
|
790
|
+
subscriptions['webhook_id'] = webhook_info['webhook_id']
|
791
|
+
|
792
|
+
await self._save_subscriptions(user_id, subscriptions)
|
793
|
+
|
794
|
+
return f"✅ Subscribed to @{username} ({display_name})!\n🔔 Instructions: {instructions}\n\nYou'll get notifications when they post relevant content."
|
795
|
+
|
796
|
+
except Exception as e:
|
797
|
+
return f"❌ Failed to subscribe to @{username}: {str(e)}"
|
798
|
+
|
799
|
+
@tool(description="Post a tweet to X.com. Handles authentication automatically.")
|
800
|
+
async def x_post(self, text: str) -> str:
|
801
|
+
"""Post a tweet with automatic authentication handling"""
|
802
|
+
user_id = await self._get_authenticated_user_id()
|
803
|
+
if not user_id:
|
804
|
+
return "❌ Authentication required"
|
805
|
+
|
806
|
+
# Check authentication
|
807
|
+
tokens = await self._load_user_tokens(user_id)
|
808
|
+
if not tokens:
|
809
|
+
if not self.api_key or not self.api_secret:
|
810
|
+
return "❌ X.com API credentials not configured"
|
811
|
+
|
812
|
+
try:
|
813
|
+
# Auto-generate auth URL
|
814
|
+
request_tokens = await self._get_request_token()
|
815
|
+
if not hasattr(self.agent, '_temp_x_tokens'):
|
816
|
+
self.agent._temp_x_tokens = {}
|
817
|
+
self.agent._temp_x_tokens[request_tokens['oauth_token']] = request_tokens
|
818
|
+
auth_url = self._build_authorize_url(request_tokens['oauth_token'], user_id)
|
819
|
+
|
820
|
+
return f"🔗 First, authorize X.com access: {auth_url}\n\nThen run this command again to post your tweet."
|
821
|
+
except Exception as e:
|
822
|
+
return f"❌ Authentication setup failed: {str(e)}"
|
823
|
+
|
824
|
+
try:
|
825
|
+
response = await self._make_authenticated_request(
|
826
|
+
'POST', '/tweets',
|
827
|
+
{'text': text},
|
828
|
+
tokens
|
829
|
+
)
|
830
|
+
|
831
|
+
tweet_id = response.get('data', {}).get('id', 'unknown')
|
832
|
+
return f"✅ Tweet posted! ID: {tweet_id}"
|
833
|
+
|
834
|
+
except httpx.HTTPStatusError as e:
|
835
|
+
if e.response.status_code == 429:
|
836
|
+
return "❌ Rate limit exceeded. Please wait before posting again."
|
837
|
+
elif e.response.status_code == 401:
|
838
|
+
return "❌ Authentication expired. Please re-authorize X.com access."
|
839
|
+
else:
|
840
|
+
return f"❌ Failed to post tweet: {e.response.status_code}"
|
841
|
+
except Exception as e:
|
842
|
+
return f"❌ Error posting tweet: {str(e)}"
|
843
|
+
|
844
|
+
@tool(description="View your X.com subscriptions and manage them.", scope="owner")
|
845
|
+
async def x_manage(self, action: str = "list", username: str = None) -> str:
|
846
|
+
"""Manage X.com subscriptions: list, unsubscribe"""
|
847
|
+
user_id = await self._get_authenticated_user_id()
|
848
|
+
if not user_id:
|
849
|
+
return "❌ Authentication required"
|
850
|
+
|
851
|
+
subscriptions = await self._load_subscriptions(user_id)
|
852
|
+
users = subscriptions.get('users', {})
|
853
|
+
|
854
|
+
if action.lower() == "list":
|
855
|
+
if not users:
|
856
|
+
return "📭 No subscriptions yet.\n\nUse x_subscribe(username, instructions) to start monitoring X.com users."
|
857
|
+
|
858
|
+
result = ["📋 Your X.com Subscriptions:\n"]
|
859
|
+
for username, config in users.items():
|
860
|
+
display_name = config.get('display_name', username)
|
861
|
+
instructions = config.get('instructions', 'Monitor all posts')
|
862
|
+
result.append(f"• @{username} ({display_name})")
|
863
|
+
result.append(f" 📝 {instructions}\n")
|
864
|
+
|
865
|
+
result.append("💡 Use x_manage('unsubscribe', 'username') to remove a subscription")
|
866
|
+
return "\n".join(result)
|
867
|
+
|
868
|
+
elif action.lower() == "unsubscribe":
|
869
|
+
if not username:
|
870
|
+
return "❌ Please specify username to unsubscribe from"
|
871
|
+
|
872
|
+
username = username.lstrip('@').lower()
|
873
|
+
if username not in users:
|
874
|
+
return f"❌ Not subscribed to @{username}"
|
875
|
+
|
876
|
+
display_name = users[username].get('display_name', username)
|
877
|
+
del users[username]
|
878
|
+
await self._save_subscriptions(user_id, subscriptions)
|
879
|
+
|
880
|
+
return f"✅ Unsubscribed from @{username} ({display_name})"
|
881
|
+
|
882
|
+
else:
|
883
|
+
return "❌ Invalid action. Use 'list' or 'unsubscribe'"
|
884
|
+
async def get_webhook_config(self) -> str:
|
885
|
+
"""Get current webhook monitoring configuration"""
|
886
|
+
user_id = await self._get_authenticated_user_id()
|
887
|
+
if not user_id:
|
888
|
+
return "❌ Authentication required"
|
889
|
+
|
890
|
+
config = await self._load_webhook_config(user_id)
|
891
|
+
if not config:
|
892
|
+
return "❌ No webhook monitoring configured. Use setup_webhook_monitoring() first."
|
893
|
+
|
894
|
+
status = "🟢 Active" if config.get('active', False) else "🔴 Inactive"
|
895
|
+
keywords = config.get('keywords', [])
|
896
|
+
|
897
|
+
return f"""📡 Webhook Monitoring Configuration
|
898
|
+
|
899
|
+
Status: {status}
|
900
|
+
Webhook URL: {config.get('webhook_url', 'Not set')}
|
901
|
+
Keywords: {', '.join(keywords) if keywords else 'All posts'}
|
902
|
+
Mentions only: {'Yes' if config.get('mentions_only', False) else 'No'}
|
903
|
+
Notifications: {'Enabled' if config.get('send_notifications', True) else 'Disabled'}
|
904
|
+
Created: {config.get('created_at', 'Unknown')}"""
|
905
|
+
|
906
|
+
@tool(description="Update webhook monitoring configuration.", scope="owner")
|
907
|
+
async def update_webhook_config(self, keywords: List[str] = None, mentions_only: bool = None,
|
908
|
+
send_notifications: bool = None) -> str:
|
909
|
+
"""Update webhook monitoring configuration"""
|
910
|
+
user_id = await self._get_authenticated_user_id()
|
911
|
+
if not user_id:
|
912
|
+
return "❌ Authentication required"
|
913
|
+
|
914
|
+
config = await self._load_webhook_config(user_id)
|
915
|
+
if not config:
|
916
|
+
return "❌ No webhook configured. Use setup_webhook_monitoring() first."
|
917
|
+
|
918
|
+
# Update configuration
|
919
|
+
if keywords is not None:
|
920
|
+
config['keywords'] = keywords
|
921
|
+
if mentions_only is not None:
|
922
|
+
config['mentions_only'] = mentions_only
|
923
|
+
if send_notifications is not None:
|
924
|
+
config['send_notifications'] = send_notifications
|
925
|
+
|
926
|
+
config['updated_at'] = datetime.now(timezone.utc).isoformat()
|
927
|
+
|
928
|
+
await self._save_webhook_config(user_id, config)
|
929
|
+
|
930
|
+
return "✅ Webhook configuration updated successfully!"
|
931
|
+
|
932
|
+
@tool(description="Disable webhook monitoring.", scope="owner")
|
933
|
+
async def disable_webhook_monitoring(self) -> str:
|
934
|
+
"""Disable webhook monitoring"""
|
935
|
+
user_id = await self._get_authenticated_user_id()
|
936
|
+
if not user_id:
|
937
|
+
return "❌ Authentication required"
|
938
|
+
|
939
|
+
config = await self._load_webhook_config(user_id)
|
940
|
+
if not config:
|
941
|
+
return "❌ No webhook configured"
|
942
|
+
|
943
|
+
try:
|
944
|
+
# Deactivate subscription with X.com
|
945
|
+
tokens = await self._load_user_tokens(user_id)
|
946
|
+
if tokens and config.get('webhook_id'):
|
947
|
+
await self._make_authenticated_request(
|
948
|
+
'DELETE',
|
949
|
+
f"/1.1/account_activity/all/{config['webhook_id']}/subscriptions.json",
|
950
|
+
{},
|
951
|
+
tokens
|
952
|
+
)
|
953
|
+
|
954
|
+
# Update configuration
|
955
|
+
config['active'] = False
|
956
|
+
config['disabled_at'] = datetime.now(timezone.utc).isoformat()
|
957
|
+
await self._save_webhook_config(user_id, config)
|
958
|
+
|
959
|
+
return "✅ Webhook monitoring disabled successfully!"
|
960
|
+
|
961
|
+
except Exception as e:
|
962
|
+
return f"❌ Failed to disable webhook monitoring: {str(e)}"
|
963
|
+
|
964
|
+
@tool(description="Get recent notifications from webhook events.", scope="owner")
|
965
|
+
async def get_notifications(self, limit: int = 10) -> str:
|
966
|
+
"""Get recent notifications from webhook events"""
|
967
|
+
user_id = await self._get_authenticated_user_id()
|
968
|
+
if not user_id:
|
969
|
+
return "❌ Authentication required"
|
970
|
+
|
971
|
+
try:
|
972
|
+
kv_skill = await self._get_kv_skill()
|
973
|
+
if not kv_skill:
|
974
|
+
return "❌ KV skill not available"
|
975
|
+
|
976
|
+
notifications_data = await kv_skill.kv_get(
|
977
|
+
key=f"notifications_{user_id}",
|
978
|
+
namespace="x_com_notifications"
|
979
|
+
)
|
980
|
+
|
981
|
+
if not notifications_data or not notifications_data.strip():
|
982
|
+
return "📭 No notifications yet"
|
983
|
+
|
984
|
+
try:
|
985
|
+
notifications = json.loads(notifications_data)
|
986
|
+
except:
|
987
|
+
return "❌ Error reading notifications"
|
988
|
+
|
989
|
+
if not notifications:
|
990
|
+
return "📭 No notifications yet"
|
991
|
+
|
992
|
+
# Get recent notifications
|
993
|
+
recent = notifications[-limit:]
|
994
|
+
result = ["🔔 Recent Notifications:\n"]
|
995
|
+
|
996
|
+
for i, notif in enumerate(reversed(recent), 1):
|
997
|
+
timestamp = notif.get('timestamp', 'Unknown time')
|
998
|
+
event_type = notif.get('event_type', 'unknown')
|
999
|
+
message = notif.get('message', 'No message')
|
1000
|
+
read_status = "✅" if notif.get('read', False) else "🔴"
|
1001
|
+
|
1002
|
+
result.append(f"{i}. {read_status} [{event_type}] {timestamp}")
|
1003
|
+
result.append(f" {message}\n")
|
1004
|
+
|
1005
|
+
return "\n".join(result)
|
1006
|
+
|
1007
|
+
except Exception as e:
|
1008
|
+
return f"❌ Error getting notifications: {str(e)}"
|
1009
|
+
|
1010
|
+
@tool(description="Mark notifications as read.", scope="owner")
|
1011
|
+
async def mark_notifications_read(self) -> str:
|
1012
|
+
"""Mark all notifications as read"""
|
1013
|
+
user_id = await self._get_authenticated_user_id()
|
1014
|
+
if not user_id:
|
1015
|
+
return "❌ Authentication required"
|
1016
|
+
|
1017
|
+
try:
|
1018
|
+
kv_skill = await self._get_kv_skill()
|
1019
|
+
if not kv_skill:
|
1020
|
+
return "❌ KV skill not available"
|
1021
|
+
|
1022
|
+
notifications_data = await kv_skill.kv_get(
|
1023
|
+
key=f"notifications_{user_id}",
|
1024
|
+
namespace="x_com_notifications"
|
1025
|
+
)
|
1026
|
+
|
1027
|
+
if not notifications_data or not notifications_data.strip():
|
1028
|
+
return "📭 No notifications to mark as read"
|
1029
|
+
|
1030
|
+
try:
|
1031
|
+
notifications = json.loads(notifications_data)
|
1032
|
+
except:
|
1033
|
+
return "❌ Error reading notifications"
|
1034
|
+
|
1035
|
+
# Mark all as read
|
1036
|
+
for notif in notifications:
|
1037
|
+
notif['read'] = True
|
1038
|
+
|
1039
|
+
await kv_skill.kv_set(
|
1040
|
+
key=f"notifications_{user_id}",
|
1041
|
+
value=json.dumps(notifications),
|
1042
|
+
namespace="x_com_notifications"
|
1043
|
+
)
|
1044
|
+
|
1045
|
+
return "✅ All notifications marked as read"
|
1046
|
+
|
1047
|
+
except Exception as e:
|
1048
|
+
return f"❌ Error marking notifications as read: {str(e)}"
|