webagents 0.1.13__py3-none-any.whl → 0.2.2__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.
Files changed (77) hide show
  1. webagents/__init__.py +1 -1
  2. webagents/__main__.py +55 -0
  3. webagents/agents/__init__.py +1 -1
  4. webagents/agents/core/__init__.py +1 -1
  5. webagents/agents/core/base_agent.py +15 -15
  6. webagents/agents/core/handoffs.py +1 -1
  7. webagents/agents/skills/__init__.py +11 -11
  8. webagents/agents/skills/base.py +1 -1
  9. webagents/agents/skills/core/llm/litellm/__init__.py +1 -1
  10. webagents/agents/skills/core/llm/litellm/skill.py +1 -1
  11. webagents/agents/skills/core/mcp/README.md +2 -2
  12. webagents/agents/skills/core/mcp/skill.py +2 -2
  13. webagents/agents/skills/core/memory/long_term_memory/memory_skill.py +14 -14
  14. webagents/agents/skills/core/memory/short_term_memory/__init__.py +1 -1
  15. webagents/agents/skills/core/memory/short_term_memory/skill.py +1 -1
  16. webagents/agents/skills/core/memory/vector_memory/skill.py +6 -6
  17. webagents/agents/skills/core/planning/__init__.py +1 -1
  18. webagents/agents/skills/ecosystem/crewai/__init__.py +3 -1
  19. webagents/agents/skills/ecosystem/crewai/skill.py +158 -0
  20. webagents/agents/skills/ecosystem/database/__init__.py +3 -1
  21. webagents/agents/skills/ecosystem/database/skill.py +522 -0
  22. webagents/agents/skills/ecosystem/google/calendar/skill.py +1 -1
  23. webagents/agents/skills/ecosystem/mongodb/__init__.py +3 -0
  24. webagents/agents/skills/ecosystem/mongodb/skill.py +428 -0
  25. webagents/agents/skills/ecosystem/n8n/README.md +287 -0
  26. webagents/agents/skills/ecosystem/n8n/__init__.py +3 -0
  27. webagents/agents/skills/ecosystem/n8n/skill.py +341 -0
  28. webagents/agents/skills/ecosystem/x_com/README.md +401 -0
  29. webagents/agents/skills/ecosystem/x_com/__init__.py +3 -0
  30. webagents/agents/skills/ecosystem/x_com/skill.py +1048 -0
  31. webagents/agents/skills/ecosystem/zapier/README.md +363 -0
  32. webagents/agents/skills/ecosystem/zapier/__init__.py +3 -0
  33. webagents/agents/skills/ecosystem/zapier/skill.py +337 -0
  34. webagents/agents/skills/robutler/__init__.py +2 -2
  35. webagents/agents/skills/robutler/auth/__init__.py +3 -3
  36. webagents/agents/skills/robutler/auth/skill.py +16 -16
  37. webagents/agents/skills/robutler/crm/__init__.py +2 -2
  38. webagents/agents/skills/robutler/crm/skill.py +5 -5
  39. webagents/agents/skills/robutler/discovery/README.md +5 -5
  40. webagents/agents/skills/robutler/discovery/__init__.py +2 -2
  41. webagents/agents/skills/robutler/discovery/skill.py +21 -21
  42. webagents/agents/skills/robutler/message_history/__init__.py +2 -2
  43. webagents/agents/skills/robutler/message_history/skill.py +5 -5
  44. webagents/agents/skills/robutler/nli/__init__.py +1 -1
  45. webagents/agents/skills/robutler/nli/skill.py +9 -9
  46. webagents/agents/skills/robutler/payments/__init__.py +3 -3
  47. webagents/agents/skills/robutler/payments/exceptions.py +1 -1
  48. webagents/agents/skills/robutler/payments/skill.py +23 -23
  49. webagents/agents/skills/robutler/storage/__init__.py +2 -2
  50. webagents/agents/skills/robutler/storage/files/__init__.py +2 -2
  51. webagents/agents/skills/robutler/storage/files/skill.py +4 -4
  52. webagents/agents/skills/robutler/storage/json/__init__.py +1 -1
  53. webagents/agents/skills/robutler/storage/json/skill.py +3 -3
  54. webagents/agents/skills/robutler/storage/kv/skill.py +3 -3
  55. webagents/agents/skills/robutler/storage.py +6 -6
  56. webagents/agents/tools/decorators.py +12 -12
  57. webagents/server/__init__.py +3 -3
  58. webagents/server/context/context_vars.py +2 -2
  59. webagents/server/core/app.py +13 -13
  60. webagents/server/core/middleware.py +3 -3
  61. webagents/server/core/models.py +1 -1
  62. webagents/server/core/monitoring.py +2 -2
  63. webagents/server/middleware.py +1 -1
  64. webagents/server/models.py +2 -2
  65. webagents/server/monitoring.py +15 -15
  66. webagents/utils/logging.py +20 -20
  67. webagents-0.2.2.dist-info/METADATA +266 -0
  68. webagents-0.2.2.dist-info/RECORD +105 -0
  69. webagents-0.2.2.dist-info/licenses/LICENSE +20 -0
  70. webagents/api/__init__.py +0 -17
  71. webagents/api/client.py +0 -1207
  72. webagents/api/types.py +0 -253
  73. webagents-0.1.13.dist-info/METADATA +0 -32
  74. webagents-0.1.13.dist-info/RECORD +0 -96
  75. webagents-0.1.13.dist-info/licenses/LICENSE +0 -1
  76. {webagents-0.1.13.dist-info → webagents-0.2.2.dist-info}/WHEEL +0 -0
  77. {webagents-0.1.13.dist-info → webagents-0.2.2.dist-info}/entry_points.txt +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('&','&amp;').replace('<','&lt;').replace('>','&gt;').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)}"