machinaos 0.0.21 → 0.0.23

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 (60) hide show
  1. package/README.md +32 -6
  2. package/bin/cli.js +0 -0
  3. package/client/dist/assets/index-5BWZnM6b.js +703 -0
  4. package/client/dist/index.html +1 -1
  5. package/client/package.json +1 -1
  6. package/client/src/Dashboard.tsx +12 -5
  7. package/client/src/ParameterPanel.tsx +6 -5
  8. package/client/src/components/AIAgentNode.tsx +35 -16
  9. package/client/src/components/CredentialsModal.tsx +450 -5
  10. package/client/src/components/TeamMonitorNode.tsx +269 -0
  11. package/client/src/components/parameterPanel/InputSection.tsx +25 -0
  12. package/client/src/contexts/WebSocketContext.tsx +38 -0
  13. package/client/src/hooks/useApiKeys.ts +44 -0
  14. package/client/src/nodeDefinitions/specializedAgentNodes.ts +59 -3
  15. package/client/src/nodeDefinitions/twitterNodes.ts +441 -0
  16. package/client/src/nodeDefinitions/utilityNodes.ts +45 -1
  17. package/client/src/nodeDefinitions.ts +7 -1
  18. package/client/src/services/executionService.ts +4 -1
  19. package/install.sh +63 -1
  20. package/package.json +5 -2
  21. package/scripts/build.js +0 -0
  22. package/scripts/clean.js +0 -0
  23. package/scripts/daemon.js +0 -0
  24. package/scripts/docker.js +0 -0
  25. package/scripts/install.js +0 -0
  26. package/scripts/postinstall.js +29 -0
  27. package/scripts/preinstall.js +67 -0
  28. package/scripts/serve-client.js +0 -0
  29. package/scripts/start.js +0 -0
  30. package/scripts/stop.js +0 -0
  31. package/scripts/sync-version.js +0 -0
  32. package/server/Dockerfile +10 -15
  33. package/server/constants.py +20 -0
  34. package/server/core/database.py +443 -3
  35. package/server/main.py +9 -1
  36. package/server/models/database.py +112 -2
  37. package/server/pyproject.toml +3 -0
  38. package/server/requirements.txt +3 -0
  39. package/server/routers/twitter.py +390 -0
  40. package/server/routers/websocket.py +320 -0
  41. package/server/services/agent_team.py +266 -0
  42. package/server/services/ai.py +43 -0
  43. package/server/services/compaction.py +39 -4
  44. package/server/services/event_waiter.py +41 -0
  45. package/server/services/handlers/__init__.py +13 -0
  46. package/server/services/handlers/ai.py +66 -2
  47. package/server/services/handlers/tools.py +84 -0
  48. package/server/services/handlers/twitter.py +297 -0
  49. package/server/services/handlers/utility.py +91 -0
  50. package/server/services/node_executor.py +15 -1
  51. package/server/services/pricing.py +270 -0
  52. package/server/services/status_broadcaster.py +79 -0
  53. package/server/services/twitter_oauth.py +410 -0
  54. package/server/skills/social_agent/twitter-search-skill/SKILL.md +146 -0
  55. package/server/skills/social_agent/twitter-send-skill/SKILL.md +142 -0
  56. package/server/skills/social_agent/twitter-user-skill/SKILL.md +165 -0
  57. package/workflows/Zeenie_full.json +459 -0
  58. package/workflows/Zeenie_small.json +459 -0
  59. package/client/dist/assets/index-YVvAiByx.js +0 -703
  60. package/server/requirements-docker.txt +0 -86
@@ -10,6 +10,7 @@ from datetime import datetime, timezone
10
10
  from typing import Dict, Any, Optional, TYPE_CHECKING
11
11
 
12
12
  from core.logging import get_logger
13
+ from services.pricing import get_pricing_service
13
14
 
14
15
  if TYPE_CHECKING:
15
16
  from core.database import Database
@@ -92,20 +93,54 @@ class CompactionService:
92
93
  return {"context_management": {"compact_threshold": threshold or self._config.threshold}}
93
94
 
94
95
  async def track(self, session_id: str, node_id: str, provider: str, model: str, usage: Dict[str, int]) -> Dict[str, Any]:
95
- """Track token usage and return compaction status."""
96
- await self._db.save_token_metric({"session_id": session_id, "node_id": node_id, "provider": provider, "model": model, **usage})
96
+ """Track token usage and cost, return compaction status."""
97
+ # Calculate cost using PricingService
98
+ pricing_service = get_pricing_service()
99
+ cost = pricing_service.calculate_cost(
100
+ provider=provider,
101
+ model=model,
102
+ input_tokens=usage.get("input_tokens", 0),
103
+ output_tokens=usage.get("output_tokens", 0),
104
+ cache_read_tokens=usage.get("cache_read_tokens", 0),
105
+ cache_creation_tokens=usage.get("cache_creation_tokens", 0),
106
+ reasoning_tokens=usage.get("reasoning_tokens", 0)
107
+ )
108
+
109
+ # Save token metric with cost fields
110
+ await self._db.save_token_metric({
111
+ "session_id": session_id,
112
+ "node_id": node_id,
113
+ "provider": provider,
114
+ "model": model,
115
+ **usage,
116
+ "input_cost": cost["input_cost"],
117
+ "output_cost": cost["output_cost"],
118
+ "cache_cost": cost["cache_cost"],
119
+ "total_cost": cost["total_cost"]
120
+ })
97
121
 
98
122
  state = await self._db.get_or_create_session_token_state(session_id)
99
123
  new_total = state["cumulative_total"] + usage.get("total_tokens", 0)
124
+ new_total_cost = state.get("cumulative_total_cost", 0.0) + cost["total_cost"]
100
125
 
126
+ # Update cumulative state with cost
101
127
  await self._db.update_session_token_state(session_id, {
102
128
  "cumulative_input_tokens": state["cumulative_input_tokens"] + usage.get("input_tokens", 0),
103
129
  "cumulative_output_tokens": state["cumulative_output_tokens"] + usage.get("output_tokens", 0),
104
- "cumulative_total": new_total
130
+ "cumulative_total": new_total,
131
+ "cumulative_input_cost": state.get("cumulative_input_cost", 0.0) + cost["input_cost"],
132
+ "cumulative_output_cost": state.get("cumulative_output_cost", 0.0) + cost["output_cost"],
133
+ "cumulative_total_cost": new_total_cost
105
134
  })
106
135
 
107
136
  threshold = state.get("custom_threshold") or self._config.threshold
108
- return {"total": new_total, "threshold": threshold, "needs_compaction": self._config.enabled and new_total >= threshold}
137
+ return {
138
+ "total": new_total,
139
+ "total_cost": new_total_cost,
140
+ "cost": cost,
141
+ "threshold": threshold,
142
+ "needs_compaction": self._config.enabled and new_total >= threshold
143
+ }
109
144
 
110
145
  async def record(self, session_id: str, node_id: str, provider: str, model: str, tokens_before: int, tokens_after: int, summary: Optional[str] = None) -> None:
111
146
  """Record compaction event after native API handles it."""
@@ -110,6 +110,11 @@ TRIGGER_REGISTRY: Dict[str, TriggerConfig] = {
110
110
  event_type='task_completed',
111
111
  display_name='Task Completed'
112
112
  ),
113
+ 'twitterReceive': TriggerConfig(
114
+ node_type='twitterReceive',
115
+ event_type='twitter_event_received',
116
+ display_name='Twitter Event'
117
+ ),
113
118
  # Future triggers - just add to registry:
114
119
  # 'emailTrigger': TriggerConfig('emailTrigger', 'email_received', 'Email'),
115
120
  # 'mqttTrigger': TriggerConfig('mqttTrigger', 'mqtt_message', 'MQTT Message'),
@@ -336,12 +341,48 @@ def build_task_completed_filter(params: Dict) -> Callable[[Dict], bool]:
336
341
  return matches
337
342
 
338
343
 
344
+ def build_twitter_filter(params: Dict) -> Callable[[Dict], bool]:
345
+ """Build filter function for Twitter events.
346
+
347
+ Filters by:
348
+ - trigger_type: 'mentions', 'search', 'user_timeline'
349
+ - search_query: Search query for 'search' trigger type
350
+ - user_id: User ID for 'user_timeline' trigger type
351
+
352
+ Args:
353
+ params: Node parameters
354
+
355
+ Returns:
356
+ Filter function that checks if event matches criteria
357
+ """
358
+ trigger_type = params.get('trigger_type', 'mentions')
359
+ search_query = params.get('search_query', '')
360
+ user_id = params.get('user_id', '')
361
+
362
+ def matches(data: Dict) -> bool:
363
+ event_type = data.get('trigger_type', '')
364
+ if trigger_type != 'all' and event_type != trigger_type:
365
+ return False
366
+ if trigger_type == 'search' and search_query:
367
+ # Check if search query matches
368
+ event_query = data.get('query', '')
369
+ if search_query.lower() not in event_query.lower():
370
+ return False
371
+ if trigger_type == 'user_timeline' and user_id:
372
+ if data.get('user_id') != user_id:
373
+ return False
374
+ return True
375
+
376
+ return matches
377
+
378
+
339
379
  # Registry of filter builders per trigger type
340
380
  FILTER_BUILDERS: Dict[str, Callable[[Dict], Callable[[Dict], bool]]] = {
341
381
  'whatsappReceive': build_whatsapp_filter,
342
382
  'webhookTrigger': build_webhook_filter,
343
383
  'chatTrigger': build_chat_filter,
344
384
  'taskTrigger': build_task_completed_filter,
385
+ 'twitterReceive': build_twitter_filter,
345
386
  }
346
387
 
347
388
 
@@ -57,6 +57,7 @@ from .utility import (
57
57
  handle_cron_scheduler,
58
58
  handle_timer,
59
59
  handle_console,
60
+ handle_team_monitor,
60
61
  )
61
62
 
62
63
  # WhatsApp handlers
@@ -65,6 +66,13 @@ from .whatsapp import (
65
66
  handle_whatsapp_db,
66
67
  )
67
68
 
69
+ # Twitter handlers
70
+ from .twitter import (
71
+ handle_twitter_send,
72
+ handle_twitter_search,
73
+ handle_twitter_user,
74
+ )
75
+
68
76
  # Social handlers (unified messaging)
69
77
  from .social import (
70
78
  handle_social_receive,
@@ -115,9 +123,14 @@ __all__ = [
115
123
  'handle_cron_scheduler',
116
124
  'handle_timer',
117
125
  'handle_console',
126
+ 'handle_team_monitor',
118
127
  # WhatsApp
119
128
  'handle_whatsapp_send',
120
129
  'handle_whatsapp_db',
130
+ # Twitter
131
+ 'handle_twitter_send',
132
+ 'handle_twitter_search',
133
+ 'handle_twitter_user',
121
134
  # Social
122
135
  'handle_social_receive',
123
136
  'handle_social_send',
@@ -10,6 +10,9 @@ if TYPE_CHECKING:
10
10
 
11
11
  logger = get_logger(__name__)
12
12
 
13
+ # Team lead types that can create teams
14
+ TEAM_LEAD_TYPES = {'orchestrator_agent', 'ai_employee'}
15
+
13
16
 
14
17
  async def _collect_agent_connections(
15
18
  node_id: str,
@@ -302,6 +305,50 @@ async def _collect_agent_connections(
302
305
  return memory_data, skill_data, tool_data, input_data, task_data
303
306
 
304
307
 
308
+ async def _collect_teammate_connections(
309
+ node_id: str,
310
+ context: Dict[str, Any],
311
+ database: "Database"
312
+ ) -> List[Dict[str, Any]]:
313
+ """Collect agents connected via input-teammates handle for team mode.
314
+
315
+ Args:
316
+ node_id: The team lead node ID
317
+ context: Execution context with nodes, edges
318
+ database: Database instance
319
+
320
+ Returns:
321
+ List of teammate node info dicts
322
+ """
323
+ nodes = context.get('nodes', [])
324
+ edges = context.get('edges', [])
325
+ teammates = []
326
+
327
+ for edge in edges:
328
+ if edge.get('target') != node_id or edge.get('targetHandle') != 'input-teammates':
329
+ continue
330
+
331
+ source_id = edge.get('source')
332
+ source_node = next((n for n in nodes if n.get('id') == source_id), None)
333
+ if not source_node:
334
+ continue
335
+
336
+ node_type = source_node.get('type', '')
337
+ if node_type not in AI_AGENT_TYPES:
338
+ continue
339
+
340
+ params = await database.get_node_parameters(source_id) or {}
341
+ teammates.append({
342
+ 'node_id': source_id,
343
+ 'node_type': node_type,
344
+ 'label': source_node.get('data', {}).get('label', node_type),
345
+ 'parameters': params
346
+ })
347
+ logger.debug(f"[Teams] Found teammate: {node_type} ({source_id})")
348
+
349
+ return teammates
350
+
351
+
305
352
  def _format_task_context(task_data: Dict[str, Any]) -> str:
306
353
  """Format task completion data as context for the agent.
307
354
 
@@ -485,7 +532,24 @@ async def handle_chat_agent(
485
532
  from services.status_broadcaster import get_status_broadcaster
486
533
  broadcaster = get_status_broadcaster()
487
534
 
488
- # Execute Chat Agent with memory, skills and tools
535
+ # Team mode detection for orchestrator_agent and ai_employee nodes
536
+ # Teammates connected via input-teammates become delegation tools
537
+ if node_type in TEAM_LEAD_TYPES:
538
+ teammates = await _collect_teammate_connections(node_id, context, database)
539
+
540
+ if teammates:
541
+ # Add teammates as delegation tools (they become delegate_to_* tools)
542
+ tool_data = tool_data or []
543
+ for tm in teammates:
544
+ tool_data.append({
545
+ 'node_id': tm['node_id'],
546
+ 'node_type': tm['node_type'],
547
+ 'label': tm['label'],
548
+ 'parameters': tm.get('parameters', {}),
549
+ })
550
+ logger.info(f"[Teams] Added {len(teammates)} teammates as delegation tools")
551
+
552
+ # Standard execution (no team mode)
489
553
  return await ai_service.execute_chat_agent(
490
554
  node_id,
491
555
  parameters,
@@ -494,7 +558,7 @@ async def handle_chat_agent(
494
558
  tool_data=tool_data if tool_data else None,
495
559
  broadcaster=broadcaster,
496
560
  workflow_id=workflow_id,
497
- context=context # Pass context for nested agent delegation
561
+ context=context
498
562
  )
499
563
 
500
564
 
@@ -107,6 +107,18 @@ async def execute_tool(tool_name: str, tool_args: Dict[str, Any],
107
107
  if node_type == 'whatsappDb':
108
108
  return await _execute_whatsapp_db(tool_args, config.get('parameters', {}))
109
109
 
110
+ # Twitter Send (dual-purpose: workflow node + AI tool)
111
+ if node_type == 'twitterSend':
112
+ return await _execute_twitter_send(tool_args, config.get('parameters', {}))
113
+
114
+ # Twitter Search (dual-purpose: workflow node + AI tool)
115
+ if node_type == 'twitterSearch':
116
+ return await _execute_twitter_search(tool_args, config.get('parameters', {}))
117
+
118
+ # Twitter User (dual-purpose: workflow node + AI tool)
119
+ if node_type == 'twitterUser':
120
+ return await _execute_twitter_user(tool_args, config.get('parameters', {}))
121
+
110
122
  # Android toolkit - routes to connected service nodes
111
123
  if node_type == 'androidTool':
112
124
  return await _execute_android_toolkit(tool_args, config)
@@ -1118,6 +1130,78 @@ async def _execute_nearby_places(args: Dict[str, Any],
1118
1130
  return {"error": f"Nearby places search failed: {str(e)}"}
1119
1131
 
1120
1132
 
1133
+ async def _execute_twitter_send(args: Dict[str, Any],
1134
+ node_params: Dict[str, Any]) -> Dict[str, Any]:
1135
+ """Execute Twitter send action via XDK SDK.
1136
+
1137
+ Args:
1138
+ args: LLM-provided arguments (action, text, tweet_id, reply_to_id)
1139
+ node_params: Node parameters
1140
+
1141
+ Returns:
1142
+ Twitter API result
1143
+ """
1144
+ from services.handlers.twitter import handle_twitter_send
1145
+
1146
+ parameters = {**node_params, **args}
1147
+ # Handle reply_to_id alias
1148
+ if args.get('reply_to_id') and not args.get('tweet_id'):
1149
+ parameters['tweet_id'] = args['reply_to_id']
1150
+
1151
+ return await handle_twitter_send(
1152
+ node_id="tool_twitter_send",
1153
+ node_type="twitterSend",
1154
+ parameters=parameters,
1155
+ context={}
1156
+ )
1157
+
1158
+
1159
+ async def _execute_twitter_search(args: Dict[str, Any],
1160
+ node_params: Dict[str, Any]) -> Dict[str, Any]:
1161
+ """Execute Twitter search via XDK SDK.
1162
+
1163
+ Args:
1164
+ args: LLM-provided arguments (query, max_results)
1165
+ node_params: Node parameters
1166
+
1167
+ Returns:
1168
+ Search results
1169
+ """
1170
+ from services.handlers.twitter import handle_twitter_search
1171
+
1172
+ parameters = {**node_params, **args}
1173
+
1174
+ return await handle_twitter_search(
1175
+ node_id="tool_twitter_search",
1176
+ node_type="twitterSearch",
1177
+ parameters=parameters,
1178
+ context={}
1179
+ )
1180
+
1181
+
1182
+ async def _execute_twitter_user(args: Dict[str, Any],
1183
+ node_params: Dict[str, Any]) -> Dict[str, Any]:
1184
+ """Execute Twitter user lookup via XDK SDK.
1185
+
1186
+ Args:
1187
+ args: LLM-provided arguments (operation, username, user_id, max_results)
1188
+ node_params: Node parameters
1189
+
1190
+ Returns:
1191
+ User data
1192
+ """
1193
+ from services.handlers.twitter import handle_twitter_user
1194
+
1195
+ parameters = {**node_params, **args}
1196
+
1197
+ return await handle_twitter_user(
1198
+ node_id="tool_twitter_user",
1199
+ node_type="twitterUser",
1200
+ parameters=parameters,
1201
+ context={}
1202
+ )
1203
+
1204
+
1121
1205
  async def _execute_generic(args: Dict[str, Any],
1122
1206
  config: Dict[str, Any]) -> Dict[str, Any]:
1123
1207
  """Execute a generic tool (fallback handler).
@@ -0,0 +1,297 @@
1
+ """Twitter/X node handlers using official XDK SDK."""
2
+
3
+ import time
4
+ from typing import Dict, Any
5
+ from xdk import Client
6
+
7
+ from core.logging import get_logger
8
+
9
+ logger = get_logger(__name__)
10
+
11
+
12
+ async def _get_twitter_client() -> Client:
13
+ """Get authenticated Twitter client from stored OAuth 2.0 credentials."""
14
+ # Import inside function to avoid circular import
15
+ from core.container import container
16
+ auth_service = container.auth_service()
17
+ access_token = await auth_service.get_api_key("twitter_access_token")
18
+ if not access_token:
19
+ raise ValueError("Twitter not connected. Please authenticate via Credentials.")
20
+ # Use access_token for OAuth 2.0 user token (not bearer_token which is app-only)
21
+ return Client(access_token=access_token)
22
+
23
+
24
+ async def _get_my_user_id(client: Client) -> str:
25
+ """Get the authenticated user's ID."""
26
+ response = client.users.get_me()
27
+ # response.data is a dict with 'id', 'username', 'name' keys
28
+ return response.data["id"]
29
+
30
+
31
+ async def handle_twitter_send(
32
+ node_id: str,
33
+ node_type: str,
34
+ parameters: Dict[str, Any],
35
+ context: Dict[str, Any]
36
+ ) -> Dict[str, Any]:
37
+ """Handle Twitter send actions: tweet, reply, retweet, like, unlike, delete."""
38
+ start_time = time.time()
39
+ action = parameters.get('action', 'tweet')
40
+
41
+ try:
42
+ client = await _get_twitter_client()
43
+
44
+ match action:
45
+ case 'tweet':
46
+ text = parameters.get('text', '')
47
+ if not text:
48
+ raise ValueError("Tweet text is required")
49
+ # XDK API: client.posts.create(body={"text": "..."})
50
+ payload = {"text": text[:280]}
51
+ result = client.posts.create(body=payload)
52
+ return _success(_format_response(result), "tweet_sent", start_time)
53
+
54
+ case 'reply':
55
+ text = parameters.get('text', '')
56
+ reply_to = parameters.get('reply_to_id')
57
+ if not text or not reply_to:
58
+ raise ValueError("Text and reply_to_id are required")
59
+ # XDK API: reply uses nested reply object
60
+ payload = {
61
+ "text": text[:280],
62
+ "reply": {"in_reply_to_tweet_id": reply_to}
63
+ }
64
+ result = client.posts.create(body=payload)
65
+ return _success(_format_response(result), "reply_sent", start_time)
66
+
67
+ case 'retweet':
68
+ tweet_id = parameters.get('tweet_id')
69
+ if not tweet_id:
70
+ raise ValueError("tweet_id is required")
71
+ user_id = await _get_my_user_id(client)
72
+ # XDK API: client.users.repost_post(user_id, body={"tweet_id": "..."})
73
+ payload = {"tweet_id": tweet_id}
74
+ result = client.users.repost_post(user_id, body=payload)
75
+ return _success(_format_response(result), "retweeted", start_time)
76
+
77
+ case 'like':
78
+ tweet_id = parameters.get('tweet_id')
79
+ if not tweet_id:
80
+ raise ValueError("tweet_id is required")
81
+ user_id = await _get_my_user_id(client)
82
+ # XDK API: client.users.like_post(user_id, body={"tweet_id": "..."})
83
+ payload = {"tweet_id": tweet_id}
84
+ result = client.users.like_post(user_id, body=payload)
85
+ return _success(_format_response(result), "liked", start_time)
86
+
87
+ case 'unlike':
88
+ tweet_id = parameters.get('tweet_id')
89
+ if not tweet_id:
90
+ raise ValueError("tweet_id is required")
91
+ user_id = await _get_my_user_id(client)
92
+ # XDK API: client.users.unlike_post(user_id, tweet_id=post_id)
93
+ result = client.users.unlike_post(user_id, tweet_id=tweet_id)
94
+ return _success(_format_response(result), "unliked", start_time)
95
+
96
+ case 'delete':
97
+ tweet_id = parameters.get('tweet_id')
98
+ if not tweet_id:
99
+ raise ValueError("tweet_id is required")
100
+ # XDK API: client.posts.delete(post_id)
101
+ result = client.posts.delete(tweet_id)
102
+ return _success(_format_response(result), "deleted", start_time)
103
+
104
+ case _:
105
+ raise ValueError(f"Unknown action: {action}")
106
+
107
+ except Exception as e:
108
+ logger.error(f"Twitter send error: {e}")
109
+ return {"success": False, "error": str(e), "execution_time": time.time() - start_time}
110
+
111
+
112
+ async def handle_twitter_search(
113
+ node_id: str,
114
+ node_type: str,
115
+ parameters: Dict[str, Any],
116
+ context: Dict[str, Any]
117
+ ) -> Dict[str, Any]:
118
+ """Handle Twitter search operations."""
119
+ start_time = time.time()
120
+
121
+ try:
122
+ client = await _get_twitter_client()
123
+ query = parameters.get('query', '')
124
+ max_results = min(parameters.get('max_results', 10), 100)
125
+
126
+ if not query:
127
+ raise ValueError("Search query is required")
128
+
129
+ tweets = []
130
+ # XDK API: client.posts.search_recent(query=..., max_results=..., tweet_fields=[...])
131
+ for page in client.posts.search_recent(
132
+ query=query,
133
+ max_results=max_results,
134
+ tweet_fields=["author_id", "created_at"]
135
+ ):
136
+ page_data = getattr(page, 'data', []) or []
137
+ tweets.extend([_format_tweet(t) for t in page_data])
138
+ break # Only first page
139
+
140
+ return {
141
+ "success": True,
142
+ "result": {"tweets": tweets, "count": len(tweets), "query": query},
143
+ "execution_time": time.time() - start_time
144
+ }
145
+
146
+ except Exception as e:
147
+ logger.error(f"Twitter search error: {e}")
148
+ return {"success": False, "error": str(e), "execution_time": time.time() - start_time}
149
+
150
+
151
+ async def handle_twitter_user(
152
+ node_id: str,
153
+ node_type: str,
154
+ parameters: Dict[str, Any],
155
+ context: Dict[str, Any]
156
+ ) -> Dict[str, Any]:
157
+ """Handle Twitter user lookup operations."""
158
+ start_time = time.time()
159
+ operation = parameters.get('operation', 'me')
160
+
161
+ try:
162
+ client = await _get_twitter_client()
163
+
164
+ match operation:
165
+ case 'me':
166
+ # XDK API: client.users.get_me(user_fields=[...])
167
+ result = client.users.get_me(user_fields=["created_at", "description"])
168
+ return _success(_format_user_data(result.data), "user", start_time)
169
+
170
+ case 'by_username':
171
+ username = parameters.get('username')
172
+ if not username:
173
+ raise ValueError("Username is required")
174
+ # XDK API: client.users.get_by_usernames(usernames=[...], user_fields=[...])
175
+ result = client.users.get_by_usernames(
176
+ usernames=[username],
177
+ user_fields=["description", "created_at"]
178
+ )
179
+ users = getattr(result, 'data', []) or []
180
+ if not users:
181
+ raise ValueError(f"User @{username} not found")
182
+ return _success(_format_user_data(users[0]), "user", start_time)
183
+
184
+ case 'by_id':
185
+ user_id = parameters.get('user_id')
186
+ if not user_id:
187
+ raise ValueError("User ID is required")
188
+ # XDK API: client.users.get_by_ids works similar
189
+ result = client.users.get_by_ids(
190
+ ids=[user_id],
191
+ user_fields=["description", "created_at"]
192
+ )
193
+ users = getattr(result, 'data', []) or []
194
+ if not users:
195
+ raise ValueError(f"User ID {user_id} not found")
196
+ return _success(_format_user_data(users[0]), "user", start_time)
197
+
198
+ case 'followers':
199
+ user_id = parameters.get('user_id')
200
+ if not user_id:
201
+ user_id = await _get_my_user_id(client)
202
+ max_results = min(parameters.get('max_results', 100), 1000)
203
+ users = []
204
+ # XDK API: client.users.get_followers(user_id, max_results=..., user_fields=[...])
205
+ for page in client.users.get_followers(
206
+ user_id,
207
+ max_results=max_results,
208
+ user_fields=["created_at"]
209
+ ):
210
+ page_data = getattr(page, 'data', []) or []
211
+ users.extend([_format_user_data(u) for u in page_data])
212
+ break # Only first page
213
+ return _success({"users": users, "count": len(users)}, "followers", start_time)
214
+
215
+ case 'following':
216
+ user_id = parameters.get('user_id')
217
+ if not user_id:
218
+ user_id = await _get_my_user_id(client)
219
+ max_results = min(parameters.get('max_results', 100), 1000)
220
+ users = []
221
+ # XDK API: client.users.get_following(user_id, max_results=..., user_fields=[...])
222
+ for page in client.users.get_following(
223
+ user_id,
224
+ max_results=max_results,
225
+ user_fields=["created_at"]
226
+ ):
227
+ page_data = getattr(page, 'data', []) or []
228
+ users.extend([_format_user_data(u) for u in page_data])
229
+ break # Only first page
230
+ return _success({"users": users, "count": len(users)}, "following", start_time)
231
+
232
+ case _:
233
+ raise ValueError(f"Unknown operation: {operation}")
234
+
235
+ except Exception as e:
236
+ logger.error(f"Twitter user error: {e}")
237
+ return {"success": False, "error": str(e), "execution_time": time.time() - start_time}
238
+
239
+
240
+ def _success(data: Any, action: str, start_time: float) -> Dict[str, Any]:
241
+ """Build success response."""
242
+ return {
243
+ "success": True,
244
+ "result": data if isinstance(data, dict) else {"data": data, "action": action},
245
+ "execution_time": time.time() - start_time
246
+ }
247
+
248
+
249
+ def _format_response(response) -> Dict[str, Any]:
250
+ """Format XDK response object to dict."""
251
+ if hasattr(response, 'data'):
252
+ data = response.data
253
+ if isinstance(data, dict):
254
+ return data
255
+ # Convert object attributes to dict
256
+ return {k: v for k, v in data.__dict__.items() if not k.startswith('_')} if hasattr(data, '__dict__') else {"data": str(data)}
257
+ return {"response": str(response)}
258
+
259
+
260
+ def _format_tweet(tweet) -> Dict[str, Any]:
261
+ """Format tweet object/dict to dict."""
262
+ if isinstance(tweet, dict):
263
+ return {
264
+ "id": tweet.get("id"),
265
+ "text": tweet.get("text"),
266
+ "author_id": tweet.get("author_id"),
267
+ "created_at": str(tweet.get("created_at", "")),
268
+ }
269
+ return {
270
+ "id": getattr(tweet, 'id', None),
271
+ "text": getattr(tweet, 'text', None),
272
+ "author_id": getattr(tweet, 'author_id', None),
273
+ "created_at": str(getattr(tweet, 'created_at', '')),
274
+ }
275
+
276
+
277
+ def _format_user_data(user) -> Dict[str, Any]:
278
+ """Format user object/dict to dict."""
279
+ if isinstance(user, dict):
280
+ return {
281
+ "id": user.get("id"),
282
+ "username": user.get("username"),
283
+ "name": user.get("name"),
284
+ "profile_image_url": user.get("profile_image_url"),
285
+ "verified": user.get("verified", False),
286
+ "description": user.get("description"),
287
+ "created_at": str(user.get("created_at", "")),
288
+ }
289
+ return {
290
+ "id": getattr(user, 'id', None),
291
+ "username": getattr(user, 'username', None),
292
+ "name": getattr(user, 'name', None),
293
+ "profile_image_url": getattr(user, 'profile_image_url', None),
294
+ "verified": getattr(user, 'verified', False),
295
+ "description": getattr(user, 'description', None),
296
+ "created_at": str(getattr(user, 'created_at', '')),
297
+ }