From 2221e5a234229308da84b81f87dfa9cc00b84207 Mon Sep 17 00:00:00 2001 From: Shubhank <72601061+Sagsgit@users.noreply.github.com> Date: Mon, 20 Apr 2026 10:58:11 +1200 Subject: [PATCH 1/4] fix(google-chat, google-sheets): wrap action returns in ActionResult All action handler execute() methods in integration-google-chat and integration-google-sheets were returning plain dicts. Wrapped every return value in ActionResult(data=..., cost_usd=0.0) to match the expected SDK pattern used across other integrations. --- google-chat/google_chat.py | 54 ++++----- google-sheets/google_sheets.py | 199 ++++++++++++++++----------------- 2 files changed, 125 insertions(+), 128 deletions(-) diff --git a/google-chat/google_chat.py b/google-chat/google_chat.py index ec468c98..0f4c8d78 100644 --- a/google-chat/google_chat.py +++ b/google-chat/google_chat.py @@ -1,4 +1,4 @@ -from autohive_integrations_sdk import Integration, ExecutionContext, ActionHandler +from autohive_integrations_sdk import Integration, ExecutionContext, ActionHandler, ActionResult from typing import Dict, Any from google.oauth2.credentials import Credentials from googleapiclient.discovery import build @@ -145,10 +145,10 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): if "nextPageToken" in response: result["next_page_token"] = response["nextPageToken"] - return result + return ActionResult(data=result, cost_usd=0.0) except Exception as e: - return {"spaces": [], **handle_api_error(e)} + return ActionResult(data={"spaces": [], **handle_api_error(e)}, cost_usd=0.0) @google_chat.action("get_space") @@ -161,10 +161,10 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): request = service.spaces().get(name=space_name) response = request.execute() - return {"space": format_space(response), "result": True} + return ActionResult(data={"space": format_space(response), "result": True}, cost_usd=0.0) except Exception as e: - return {"space": {}, **handle_api_error(e)} + return ActionResult(data={"space": {}, **handle_api_error(e)}, cost_usd=0.0) @google_chat.action("create_space") @@ -181,10 +181,10 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): request = service.spaces().create(body=space_body) response = request.execute() - return {"space": format_space(response), "result": True} + return ActionResult(data={"space": format_space(response), "result": True}, cost_usd=0.0) except Exception as e: - return {"space": {}, **handle_api_error(e)} + return ActionResult(data={"space": {}, **handle_api_error(e)}, cost_usd=0.0) @google_chat.action("send_message") @@ -208,10 +208,10 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): request = service.spaces().messages().create(**params) response = request.execute() - return {"message": format_message(response), "result": True} + return ActionResult(data={"message": format_message(response), "result": True}, cost_usd=0.0) except Exception as e: - return {"message": {}, **handle_api_error(e)} + return ActionResult(data={"message": {}, **handle_api_error(e)}, cost_usd=0.0) @google_chat.action("list_messages") @@ -246,10 +246,10 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): if "nextPageToken" in response: result["next_page_token"] = response["nextPageToken"] - return result + return ActionResult(data=result, cost_usd=0.0) except Exception as e: - return {"messages": [], **handle_api_error(e)} + return ActionResult(data={"messages": [], **handle_api_error(e)}, cost_usd=0.0) @google_chat.action("get_message") @@ -262,10 +262,10 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): request = service.spaces().messages().get(name=message_name) response = request.execute() - return {"message": format_message(response), "result": True} + return ActionResult(data={"message": format_message(response), "result": True}, cost_usd=0.0) except Exception as e: - return {"message": {}, **handle_api_error(e)} + return ActionResult(data={"message": {}, **handle_api_error(e)}, cost_usd=0.0) @google_chat.action("update_message") @@ -282,10 +282,10 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): request = service.spaces().messages().patch(**params) response = request.execute() - return {"message": format_message(response), "result": True} + return ActionResult(data={"message": format_message(response), "result": True}, cost_usd=0.0) except Exception as e: - return {"message": {}, **handle_api_error(e)} + return ActionResult(data={"message": {}, **handle_api_error(e)}, cost_usd=0.0) @google_chat.action("delete_message") @@ -302,10 +302,10 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): request = service.spaces().messages().delete(**params) request.execute() - return {"result": True} + return ActionResult(data={"result": True}, cost_usd=0.0) except Exception as e: - return handle_api_error(e) + return ActionResult(data=handle_api_error(e), cost_usd=0.0) @google_chat.action("list_members") @@ -336,10 +336,10 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): if "nextPageToken" in response: result["next_page_token"] = response["nextPageToken"] - return result + return ActionResult(data=result, cost_usd=0.0) except Exception as e: - return {"members": [], **handle_api_error(e)} + return ActionResult(data={"members": [], **handle_api_error(e)}, cost_usd=0.0) @google_chat.action("add_reaction") @@ -354,10 +354,10 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): request = service.spaces().messages().reactions().create(parent=message_name, body=reaction_body) response = request.execute() - return {"reaction": format_reaction(response), "result": True} + return ActionResult(data={"reaction": format_reaction(response), "result": True}, cost_usd=0.0) except Exception as e: - return {"reaction": {}, **handle_api_error(e)} + return ActionResult(data={"reaction": {}, **handle_api_error(e)}, cost_usd=0.0) @google_chat.action("list_reactions") @@ -388,10 +388,10 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): if "nextPageToken" in response: result["next_page_token"] = response["nextPageToken"] - return result + return ActionResult(data=result, cost_usd=0.0) except Exception as e: - return {"reactions": [], **handle_api_error(e)} + return ActionResult(data={"reactions": [], **handle_api_error(e)}, cost_usd=0.0) @google_chat.action("remove_reaction") @@ -404,10 +404,10 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): request = service.spaces().messages().reactions().delete(name=reaction_name) request.execute() - return {"result": True} + return ActionResult(data={"result": True}, cost_usd=0.0) except Exception as e: - return handle_api_error(e) + return ActionResult(data=handle_api_error(e), cost_usd=0.0) @google_chat.action("find_direct_message") @@ -420,7 +420,7 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): request = service.spaces().findDirectMessage(name=user_name) response = request.execute() - return {"space": format_space(response), "result": True} + return ActionResult(data={"space": format_space(response), "result": True}, cost_usd=0.0) except Exception as e: - return {"space": {}, **handle_api_error(e)} + return ActionResult(data={"space": {}, **handle_api_error(e)}, cost_usd=0.0) diff --git a/google-sheets/google_sheets.py b/google-sheets/google_sheets.py index 70fc6658..69d694d9 100644 --- a/google-sheets/google_sheets.py +++ b/google-sheets/google_sheets.py @@ -1,4 +1,4 @@ -from autohive_integrations_sdk import Integration, ExecutionContext, ActionHandler +from autohive_integrations_sdk import Integration, ExecutionContext, ActionHandler, ActionResult from typing import Dict, Any, List from googleapiclient.discovery import build from googleapiclient.errors import HttpError @@ -58,15 +58,14 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): next_page = result.get("nextPageToken") if isinstance(next_page, str) and next_page: response["nextPageToken"] = next_page - return response + return ActionResult(data=response, cost_usd=0.0) except HttpError as e: - return { - "files": [], - "result": False, - "error": f"Google Drive API error: {str(e)}", - } + return ActionResult( + data={"files": [], "result": False, "error": f"Google Drive API error: {str(e)}"}, + cost_usd=0.0, + ) except Exception as e: - return {"files": [], "result": False, "error": str(e)} + return ActionResult(data={"files": [], "result": False, "error": str(e)}, cost_usd=0.0) @google_sheets.action("sheets_get_spreadsheet") @@ -78,15 +77,14 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): include_grid = bool(inputs.get("include_grid_data", False)) request = service.spreadsheets().get(spreadsheetId=spreadsheet_id, includeGridData=include_grid) spreadsheet = request.execute() - return {"spreadsheet": spreadsheet, "result": True} + return ActionResult(data={"spreadsheet": spreadsheet, "result": True}, cost_usd=0.0) except HttpError as e: - return { - "spreadsheet": {}, - "result": False, - "error": f"Google Sheets API error: {str(e)}", - } + return ActionResult( + data={"spreadsheet": {}, "result": False, "error": f"Google Sheets API error: {str(e)}"}, + cost_usd=0.0, + ) except Exception as e: - return {"spreadsheet": {}, "result": False, "error": str(e)} + return ActionResult(data={"spreadsheet": {}, "result": False, "error": str(e)}, cost_usd=0.0) @google_sheets.action("sheets_list_sheets") @@ -105,15 +103,14 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): .execute() ) sheets_list = [s.get("properties", {}) for s in result.get("sheets", [])] - return {"sheets": sheets_list, "result": True} + return ActionResult(data={"sheets": sheets_list, "result": True}, cost_usd=0.0) except HttpError as e: - return { - "sheets": [], - "result": False, - "error": f"Google Sheets API error: {str(e)}", - } + return ActionResult( + data={"sheets": [], "result": False, "error": f"Google Sheets API error: {str(e)}"}, + cost_usd=0.0, + ) except Exception as e: - return {"sheets": [], "result": False, "error": str(e)} + return ActionResult(data={"sheets": [], "result": False, "error": str(e)}, cost_usd=0.0) @google_sheets.action("sheets_read_range") @@ -131,25 +128,25 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): if dt_render: params["dateTimeRenderOption"] = dt_render result = service.spreadsheets().values().get(**params).execute() - return { - "range": result.get("range", a1), - "values": result.get("values", []), - "result": True, - } + return ActionResult( + data={"range": result.get("range", a1), "values": result.get("values", []), "result": True}, + cost_usd=0.0, + ) except HttpError as e: - return { - "range": inputs.get("range"), - "values": [], - "result": False, - "error": f"Google Sheets API error: {str(e)}", - } + return ActionResult( + data={ + "range": inputs.get("range"), + "values": [], + "result": False, + "error": f"Google Sheets API error: {str(e)}", + }, + cost_usd=0.0, + ) except Exception as e: - return { - "range": inputs.get("range"), - "values": [], - "result": False, - "error": str(e), - } + return ActionResult( + data={"range": inputs.get("range"), "values": [], "result": False, "error": str(e)}, + cost_usd=0.0, + ) @google_sheets.action("sheets_write_range") @@ -169,14 +166,17 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): # Estimate cells rows = len(values) cols = max((len(r) for r in values), default=0) - return { - "updatedRange": a1, - "updatedRows": rows, - "updatedColumns": cols, - "updatedCells": rows * cols, - "dryRun": True, - "result": True, - } + return ActionResult( + data={ + "updatedRange": a1, + "updatedRows": rows, + "updatedColumns": cols, + "updatedCells": rows * cols, + "dryRun": True, + "result": True, + }, + cost_usd=0.0, + ) service = build_sheets_service(context) body = {"values": values} @@ -191,18 +191,21 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): ) .execute() ) - return { - "updatedRange": result.get("updatedRange", a1), - "updatedRows": result.get("updatedRows", 0), - "updatedColumns": result.get("updatedColumns", 0), - "updatedCells": result.get("updatedCells", 0), - "dryRun": False, - "result": True, - } + return ActionResult( + data={ + "updatedRange": result.get("updatedRange", a1), + "updatedRows": result.get("updatedRows", 0), + "updatedColumns": result.get("updatedColumns", 0), + "updatedCells": result.get("updatedCells", 0), + "dryRun": False, + "result": True, + }, + cost_usd=0.0, + ) except HttpError as e: - return {"result": False, "error": f"Google Sheets API error: {str(e)}"} + return ActionResult(data={"result": False, "error": f"Google Sheets API error: {str(e)}"}, cost_usd=0.0) except Exception as e: - return {"result": False, "error": str(e)} + return ActionResult(data={"result": False, "error": str(e)}, cost_usd=0.0) @google_sheets.action("sheets_append_rows") @@ -227,15 +230,14 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): ) .execute() ) - return {"updates": result.get("updates", result), "result": True} + return ActionResult(data={"updates": result.get("updates", result), "result": True}, cost_usd=0.0) except HttpError as e: - return { - "updates": {}, - "result": False, - "error": f"Google Sheets API error: {str(e)}", - } + return ActionResult( + data={"updates": {}, "result": False, "error": f"Google Sheets API error: {str(e)}"}, + cost_usd=0.0, + ) except Exception as e: - return {"updates": {}, "result": False, "error": str(e)} + return ActionResult(data={"updates": {}, "result": False, "error": str(e)}, cost_usd=0.0) @google_sheets.action("sheets_format_range") @@ -260,15 +262,14 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): result = ( service.spreadsheets().batchUpdate(spreadsheetId=spreadsheet_id, body={"requests": requests}).execute() ) - return {"replies": result.get("replies", []), "result": True} + return ActionResult(data={"replies": result.get("replies", []), "result": True}, cost_usd=0.0) except HttpError as e: - return { - "replies": [], - "result": False, - "error": f"Google Sheets API error: {str(e)}", - } + return ActionResult( + data={"replies": [], "result": False, "error": f"Google Sheets API error: {str(e)}"}, + cost_usd=0.0, + ) except Exception as e: - return {"replies": [], "result": False, "error": str(e)} + return ActionResult(data={"replies": [], "result": False, "error": str(e)}, cost_usd=0.0) @google_sheets.action("sheets_freeze") @@ -302,15 +303,14 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): result = ( service.spreadsheets().batchUpdate(spreadsheetId=spreadsheet_id, body={"requests": requests}).execute() ) - return {"replies": result.get("replies", []), "result": True} + return ActionResult(data={"replies": result.get("replies", []), "result": True}, cost_usd=0.0) except HttpError as e: - return { - "replies": [], - "result": False, - "error": f"Google Sheets API error: {str(e)}", - } + return ActionResult( + data={"replies": [], "result": False, "error": f"Google Sheets API error: {str(e)}"}, + cost_usd=0.0, + ) except Exception as e: - return {"replies": [], "result": False, "error": str(e)} + return ActionResult(data={"replies": [], "result": False, "error": str(e)}, cost_usd=0.0) @google_sheets.action("sheets_batch_update") @@ -323,34 +323,32 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): # Basic validation: ensure it's a list of dicts if not isinstance(requests, list) or not all(isinstance(r, dict) for r in requests): - return { - "result": False, - "error": "requests must be an array of objects", - } + return ActionResult( + data={"result": False, "error": "requests must be an array of objects"}, + cost_usd=0.0, + ) if dry_run: # Validate by fetching spreadsheet metadata service = build_sheets_service(context) _ = service.spreadsheets().get(spreadsheetId=spreadsheet_id, includeGridData=False).execute() - return {"replies": [], "dryRun": True, "result": True} + return ActionResult(data={"replies": [], "dryRun": True, "result": True}, cost_usd=0.0) service = build_sheets_service(context) result = ( service.spreadsheets().batchUpdate(spreadsheetId=spreadsheet_id, body={"requests": requests}).execute() ) - return { - "replies": result.get("replies", []), - "dryRun": False, - "result": True, - } + return ActionResult( + data={"replies": result.get("replies", []), "dryRun": False, "result": True}, + cost_usd=0.0, + ) except HttpError as e: - return { - "replies": [], - "result": False, - "error": f"Google Sheets API error: {str(e)}", - } + return ActionResult( + data={"replies": [], "result": False, "error": f"Google Sheets API error: {str(e)}"}, + cost_usd=0.0, + ) except Exception as e: - return {"replies": [], "result": False, "error": str(e)} + return ActionResult(data={"replies": [], "result": False, "error": str(e)}, cost_usd=0.0) @google_sheets.action("sheets_duplicate_spreadsheet") @@ -374,12 +372,11 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): ) .execute() ) - return {"file_metadata": result, "result": True} + return ActionResult(data={"file_metadata": result, "result": True}, cost_usd=0.0) except HttpError as e: - return { - "file_metadata": {}, - "result": False, - "error": f"Google Drive API error: {str(e)}", - } + return ActionResult( + data={"file_metadata": {}, "result": False, "error": f"Google Drive API error: {str(e)}"}, + cost_usd=0.0, + ) except Exception as e: - return {"file_metadata": {}, "result": False, "error": str(e)} + return ActionResult(data={"file_metadata": {}, "result": False, "error": str(e)}, cost_usd=0.0) From df5a70e3d53ec025da1e0212dd113a20b1de483c Mon Sep 17 00:00:00 2001 From: Shubhank <72601061+Sagsgit@users.noreply.github.com> Date: Mon, 20 Apr 2026 10:58:34 +1200 Subject: [PATCH 2/4] fix(youtube, google-business-profile): wrap action returns in ActionResult All action handler execute() methods in integration-youtube and integration-google-business-profile now return ActionResult(data=..., cost_usd=0.0) instead of plain dicts, conforming to the SDK contract. --- google-business-profie/reviews.py | 60 ++++++++------ youtube/youtube.py | 133 ++++++++++++++++-------------- 2 files changed, 104 insertions(+), 89 deletions(-) diff --git a/google-business-profie/reviews.py b/google-business-profie/reviews.py index 8172bb55..348d2f3f 100644 --- a/google-business-profie/reviews.py +++ b/google-business-profie/reviews.py @@ -1,4 +1,4 @@ -from autohive_integrations_sdk import Integration, ExecutionContext, ActionHandler +from autohive_integrations_sdk import Integration, ExecutionContext, ActionHandler, ActionResult from typing import Dict, Any from google.oauth2.credentials import Credentials from googleapiclient.discovery import build @@ -87,12 +87,12 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): } ) - return {"accounts": accounts, "result": True} + return ActionResult(data={"accounts": accounts, "result": True}, cost_usd=0.0) except HttpError as e: - return {"accounts": [], "result": False, "error": f"Google API error: {str(e)}"} + return ActionResult(data={"accounts": [], "result": False, "error": f"Google API error: {str(e)}"}, cost_usd=0.0) except Exception as e: - return {"accounts": [], "result": False, "error": str(e)} + return ActionResult(data={"accounts": [], "result": False, "error": str(e)}, cost_usd=0.0) @reviews.action("list_locations") @@ -138,12 +138,12 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): } ) - return {"locations": locations, "result": True} + return ActionResult(data={"locations": locations, "result": True}, cost_usd=0.0) except HttpError as e: - return {"locations": [], "result": False, "error": f"Google API error: {str(e)}"} + return ActionResult(data={"locations": [], "result": False, "error": f"Google API error: {str(e)}"}, cost_usd=0.0) except Exception as e: - return {"locations": [], "result": False, "error": str(e)} + return ActionResult(data={"locations": [], "result": False, "error": str(e)}, cost_usd=0.0) @reviews.action("list_reviews") @@ -155,14 +155,17 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): # Validate location_name format if not location_name.startswith("accounts/"): - return { - "reviews": [], - "result": False, - "error": ( - f"Invalid location_name format: '{location_name}'. " - "Expected format: 'accounts/{account_id}/locations/{location_id}'" - ), - } + return ActionResult( + data={ + "reviews": [], + "result": False, + "error": ( + f"Invalid location_name format: '{location_name}'. " + "Expected format: 'accounts/{account_id}/locations/{location_id}'" + ), + }, + cost_usd=0.0, + ) # List reviews for the location request = service.accounts().locations().reviews().list(parent=location_name) @@ -194,12 +197,12 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): reviews.append(review_data) - return {"reviews": reviews, "result": True} + return ActionResult(data={"reviews": reviews, "result": True}, cost_usd=0.0) except HttpError as e: - return {"reviews": [], "result": False, "error": f"Google API error: {str(e)}"} + return ActionResult(data={"reviews": [], "result": False, "error": f"Google API error: {str(e)}"}, cost_usd=0.0) except Exception as e: - return {"reviews": [], "result": False, "error": str(e)} + return ActionResult(data={"reviews": [], "result": False, "error": str(e)}, cost_usd=0.0) @reviews.action("reply_to_review") @@ -215,15 +218,18 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): request = service.accounts().locations().reviews().updateReply(name=review_name, body=reply_body) response = request.execute() - return { - "reviewReply": {"comment": response.get("comment", ""), "updateTime": response.get("updateTime", "")}, - "result": True, - } + return ActionResult( + data={ + "reviewReply": {"comment": response.get("comment", ""), "updateTime": response.get("updateTime", "")}, + "result": True, + }, + cost_usd=0.0, + ) except HttpError as e: - return {"result": False, "error": f"Google API error: {str(e)}"} + return ActionResult(data={"result": False, "error": f"Google API error: {str(e)}"}, cost_usd=0.0) except Exception as e: - return {"result": False, "error": str(e)} + return ActionResult(data={"result": False, "error": str(e)}, cost_usd=0.0) @reviews.action("delete_review_reply") @@ -236,9 +242,9 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): request = service.accounts().locations().reviews().deleteReply(name=review_name) request.execute() - return {"result": True} + return ActionResult(data={"result": True}, cost_usd=0.0) except HttpError as e: - return {"result": False, "error": f"Google API error: {str(e)}"} + return ActionResult(data={"result": False, "error": f"Google API error: {str(e)}"}, cost_usd=0.0) except Exception as e: - return {"result": False, "error": str(e)} + return ActionResult(data={"result": False, "error": str(e)}, cost_usd=0.0) diff --git a/youtube/youtube.py b/youtube/youtube.py index 482c3a49..806aa2d5 100644 --- a/youtube/youtube.py +++ b/youtube/youtube.py @@ -1,4 +1,4 @@ -from autohive_integrations_sdk import Integration, ExecutionContext, ActionHandler +from autohive_integrations_sdk import Integration, ExecutionContext, ActionHandler, ActionResult from typing import Dict, Any import base64 from io import BytesIO @@ -199,10 +199,10 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): if "nextPageToken" in response: result["next_page_token"] = response["nextPageToken"] - return result + return ActionResult(data=result, cost_usd=0.0) except Exception as e: - return {"items": [], "total_results": 0, "result": False, "error": str(e)} + return ActionResult(data={"items": [], "total_results": 0, "result": False, "error": str(e)}, cost_usd=0.0) # ---- Video Management ---- @@ -220,14 +220,14 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): items = response.get("items", []) if not items: - return {"video": {}, "result": False, "error": "Video not found"} + return ActionResult(data={"video": {}, "result": False, "error": "Video not found"}, cost_usd=0.0) video = YouTubeParser.parse_video(items[0]) - return {"video": video, "result": True} + return ActionResult(data={"video": video, "result": True}, cost_usd=0.0) except Exception as e: - return {"video": {}, "result": False, "error": str(e)} + return ActionResult(data={"video": {}, "result": False, "error": str(e)}, cost_usd=0.0) @youtube.action("update_video") @@ -243,7 +243,7 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): items = existing_response.get("items", []) if not items: - return {"video": {}, "result": False, "error": "Video not found"} + return ActionResult(data={"video": {}, "result": False, "error": "Video not found"}, cost_usd=0.0) existing_video = items[0] snippet = existing_video.get("snippet", {}) @@ -270,10 +270,10 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): service_endpoint + "videos", method="PUT", params={"part": "snippet,status"}, json=update_data ) - return {"video": YouTubeParser.parse_video(response), "result": True} + return ActionResult(data={"video": YouTubeParser.parse_video(response), "result": True}, cost_usd=0.0) except Exception as e: - return {"video": {}, "result": False, "error": str(e)} + return ActionResult(data={"video": {}, "result": False, "error": str(e)}, cost_usd=0.0) @youtube.action("upload_thumbnail") @@ -358,7 +358,7 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): if content_b64: image_data = base64.b64decode(content_b64) else: - return {"thumbnail": {}, "result": False, "error": "File object missing 'content' field"} + return ActionResult(data={"thumbnail": {}, "result": False, "error": "File object missing 'content' field"}, cost_usd=0.0) elif image_url: # Fetch the image data from the URL (including conversation file:// URLs) image_response = await context.fetch(image_url, method="GET") @@ -371,11 +371,14 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): else: image_data = str(image_response).encode() else: - return { - "thumbnail": {}, - "result": False, - "error": "Either image_url, image_path, file, or files must be provided", - } + return ActionResult( + data={ + "thumbnail": {}, + "result": False, + "error": "Either image_url, image_path, file, or files must be provided", + }, + cost_usd=0.0, + ) # Compress image if larger than 2 MB original_size = len(image_data) @@ -407,10 +410,10 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): if compression_info: result["compression_info"] = compression_info - return result + return ActionResult(data=result, cost_usd=0.0) except Exception as e: - return {"thumbnail": {}, "result": False, "error": str(e)} + return ActionResult(data={"thumbnail": {}, "result": False, "error": str(e)}, cost_usd=0.0) # ---- Channel Management ---- @@ -430,24 +433,27 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): elif "channel_handle" in inputs: params["forHandle"] = inputs["channel_handle"] else: - return { - "channel": {}, - "result": False, - "error": "Must provide channel_id, channel_handle, or set mine=true", - } + return ActionResult( + data={ + "channel": {}, + "result": False, + "error": "Must provide channel_id, channel_handle, or set mine=true", + }, + cost_usd=0.0, + ) response = await context.fetch(service_endpoint + "channels", method="GET", params=params) items = response.get("items", []) if not items: - return {"channel": {}, "result": False, "error": "Channel not found"} + return ActionResult(data={"channel": {}, "result": False, "error": "Channel not found"}, cost_usd=0.0) channel = YouTubeParser.parse_channel(items[0]) - return {"channel": channel, "result": True} + return ActionResult(data={"channel": channel, "result": True}, cost_usd=0.0) except Exception as e: - return {"channel": {}, "result": False, "error": str(e)} + return ActionResult(data={"channel": {}, "result": False, "error": str(e)}, cost_usd=0.0) # ---- Playlist Management ---- @@ -464,7 +470,7 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): elif "channel_id" in inputs: params["channelId"] = inputs["channel_id"] else: - return {"playlists": [], "result": False, "error": "Must provide channel_id or set mine=true"} + return ActionResult(data={"playlists": [], "result": False, "error": "Must provide channel_id or set mine=true"}, cost_usd=0.0) if "page_token" in inputs: params["pageToken"] = inputs["page_token"] @@ -480,10 +486,10 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): if "nextPageToken" in response: result["next_page_token"] = response["nextPageToken"] - return result + return ActionResult(data=result, cost_usd=0.0) except Exception as e: - return {"playlists": [], "result": False, "error": str(e)} + return ActionResult(data={"playlists": [], "result": False, "error": str(e)}, cost_usd=0.0) @youtube.action("create_playlist") @@ -502,10 +508,10 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): service_endpoint + "playlists", method="POST", params={"part": "snippet,status"}, json=playlist_data ) - return {"playlist": YouTubeParser.parse_playlist(response), "result": True} + return ActionResult(data={"playlist": YouTubeParser.parse_playlist(response), "result": True}, cost_usd=0.0) except Exception as e: - return {"playlist": {}, "result": False, "error": str(e)} + return ActionResult(data={"playlist": {}, "result": False, "error": str(e)}, cost_usd=0.0) @youtube.action("update_playlist") @@ -521,7 +527,7 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): items = existing_response.get("items", []) if not items: - return {"playlist": {}, "result": False, "error": "Playlist not found"} + return ActionResult(data={"playlist": {}, "result": False, "error": "Playlist not found"}, cost_usd=0.0) existing_playlist = items[0] snippet = existing_playlist.get("snippet", {}) @@ -541,10 +547,10 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): service_endpoint + "playlists", method="PUT", params={"part": "snippet,status"}, json=update_data ) - return {"playlist": YouTubeParser.parse_playlist(response), "result": True} + return ActionResult(data={"playlist": YouTubeParser.parse_playlist(response), "result": True}, cost_usd=0.0) except Exception as e: - return {"playlist": {}, "result": False, "error": str(e)} + return ActionResult(data={"playlist": {}, "result": False, "error": str(e)}, cost_usd=0.0) @youtube.action("delete_playlist") @@ -553,10 +559,10 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): try: await context.fetch(service_endpoint + "playlists", method="DELETE", params={"id": inputs["playlist_id"]}) - return {"result": True} + return ActionResult(data={"result": True}, cost_usd=0.0) except Exception as e: - return {"result": False, "error": str(e)} + return ActionResult(data={"result": False, "error": str(e)}, cost_usd=0.0) @youtube.action("list_playlist_items") @@ -595,10 +601,10 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): if "nextPageToken" in response: result["next_page_token"] = response["nextPageToken"] - return result + return ActionResult(data=result, cost_usd=0.0) except Exception as e: - return {"items": [], "result": False, "error": str(e)} + return ActionResult(data={"items": [], "result": False, "error": str(e)}, cost_usd=0.0) @youtube.action("add_video_to_playlist") @@ -619,10 +625,10 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): service_endpoint + "playlistItems", method="POST", params={"part": "snippet"}, json=playlist_item_data ) - return {"playlist_item": response, "result": True} + return ActionResult(data={"playlist_item": response, "result": True}, cost_usd=0.0) except Exception as e: - return {"playlist_item": {}, "result": False, "error": str(e)} + return ActionResult(data={"playlist_item": {}, "result": False, "error": str(e)}, cost_usd=0.0) @youtube.action("remove_video_from_playlist") @@ -633,10 +639,10 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): service_endpoint + "playlistItems", method="DELETE", params={"id": inputs["playlist_item_id"]} ) - return {"result": True} + return ActionResult(data={"result": True}, cost_usd=0.0) except Exception as e: - return {"result": False, "error": str(e)} + return ActionResult(data={"result": False, "error": str(e)}, cost_usd=0.0) # ---- Comment Management ---- @@ -669,10 +675,10 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): if "nextPageToken" in response: result["next_page_token"] = response["nextPageToken"] - return result + return ActionResult(data=result, cost_usd=0.0) except Exception as e: - return {"comments": [], "result": False, "error": str(e)} + return ActionResult(data={"comments": [], "result": False, "error": str(e)}, cost_usd=0.0) @youtube.action("list_comment_replies") @@ -709,10 +715,10 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): if "nextPageToken" in response: result["next_page_token"] = response["nextPageToken"] - return result + return ActionResult(data=result, cost_usd=0.0) except Exception as e: - return {"replies": [], "result": False, "error": str(e)} + return ActionResult(data={"replies": [], "result": False, "error": str(e)}, cost_usd=0.0) @youtube.action("post_comment") @@ -730,10 +736,10 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): service_endpoint + "commentThreads", method="POST", params={"part": "snippet"}, json=comment_data ) - return {"comment": YouTubeParser.parse_comment(response), "result": True} + return ActionResult(data={"comment": YouTubeParser.parse_comment(response), "result": True}, cost_usd=0.0) except Exception as e: - return {"comment": {}, "result": False, "error": str(e)} + return ActionResult(data={"comment": {}, "result": False, "error": str(e)}, cost_usd=0.0) @youtube.action("reply_to_comment") @@ -747,17 +753,20 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): ) snippet = response.get("snippet", {}) - return { - "comment": { - "id": response.get("id", ""), - "text": snippet.get("textDisplay", ""), - "author_display_name": snippet.get("authorDisplayName", ""), + return ActionResult( + data={ + "comment": { + "id": response.get("id", ""), + "text": snippet.get("textDisplay", ""), + "author_display_name": snippet.get("authorDisplayName", ""), + }, + "result": True, }, - "result": True, - } + cost_usd=0.0, + ) except Exception as e: - return {"comment": {}, "result": False, "error": str(e)} + return ActionResult(data={"comment": {}, "result": False, "error": str(e)}, cost_usd=0.0) @youtube.action("update_comment") @@ -773,7 +782,7 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): items = existing_response.get("items", []) if not items: - return {"comment": {}, "result": False, "error": "Comment not found"} + return ActionResult(data={"comment": {}, "result": False, "error": "Comment not found"}, cost_usd=0.0) existing_comment = items[0] snippet = existing_comment.get("snippet", {}) @@ -785,10 +794,10 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): service_endpoint + "comments", method="PUT", params={"part": "snippet"}, json=update_data ) - return {"comment": response, "result": True} + return ActionResult(data={"comment": response, "result": True}, cost_usd=0.0) except Exception as e: - return {"comment": {}, "result": False, "error": str(e)} + return ActionResult(data={"comment": {}, "result": False, "error": str(e)}, cost_usd=0.0) @youtube.action("delete_comment") @@ -797,10 +806,10 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): try: await context.fetch(service_endpoint + "comments", method="DELETE", params={"id": inputs["comment_id"]}) - return {"result": True} + return ActionResult(data={"result": True}, cost_usd=0.0) except Exception as e: - return {"result": False, "error": str(e)} + return ActionResult(data={"result": False, "error": str(e)}, cost_usd=0.0) @youtube.action("moderate_comment") @@ -814,7 +823,7 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): await context.fetch(service_endpoint + "comments/setModerationStatus", method="POST", params=params) - return {"result": True} + return ActionResult(data={"result": True}, cost_usd=0.0) except Exception as e: - return {"result": False, "error": str(e)} + return ActionResult(data={"result": False, "error": str(e)}, cost_usd=0.0) From ee0229089ee05f10e88ae5d4a7b03004cf9fa060 Mon Sep 17 00:00:00 2001 From: Shubhank <72601061+Sagsgit@users.noreply.github.com> Date: Mon, 20 Apr 2026 15:57:31 +1200 Subject: [PATCH 3/4] fix(shotstack): replace aiohttp with context.fetch --- shotstack/shotstack.py | 568 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 568 insertions(+) create mode 100644 shotstack/shotstack.py diff --git a/shotstack/shotstack.py b/shotstack/shotstack.py new file mode 100644 index 00000000..b0826726 --- /dev/null +++ b/shotstack/shotstack.py @@ -0,0 +1,568 @@ +from autohive_integrations_sdk import Integration, ExecutionContext, ActionHandler, ActionResult +from typing import Any, Dict +from urllib.parse import quote +import asyncio +import base64 +import mimetypes +import os + + +config_path = os.path.join(os.path.dirname(__file__), "config.json") +shotstack = Integration.load(config_path) + +EDIT_API_BASE = "https://api.shotstack.io/edit" +INGEST_API_BASE = "https://api.shotstack.io/ingest" + + +def _get_api_key(context: ExecutionContext) -> str: + return context.auth.get("credentials", {}).get("api_key", "") + + +def _get_env(context: ExecutionContext) -> str: + return context.auth.get("credentials", {}).get("environment", "stage") + + +def _get_headers(context: ExecutionContext) -> Dict[str, str]: + return {"x-api-key": _get_api_key(context), "Content-Type": "application/json"} + + +async def _poll_render(context: ExecutionContext, render_id: str, max_wait: int = 300, poll_interval: int = 5) -> Dict[str, Any]: + env = _get_env(context) + elapsed = 0 + while elapsed < max_wait: + response = await context.fetch(f"{EDIT_API_BASE}/{env}/render/{render_id}", method="GET", headers=_get_headers(context)) + render_data = response.get("response", {}) + status = render_data.get("status") + if status == "done": + return {"status": "done", "url": render_data.get("url"), "render": render_data} + elif status == "failed": + return {"status": "failed", "error": render_data.get("error", "Render failed"), "render": render_data} + await asyncio.sleep(poll_interval) + elapsed += poll_interval + return {"status": "timeout", "error": f"Render did not complete within {max_wait} seconds"} + + +async def _poll_source(context: ExecutionContext, source_id: str, max_wait: int = 120, poll_interval: int = 3) -> Dict[str, Any]: + env = _get_env(context) + elapsed = 0 + while elapsed < max_wait: + response = await context.fetch(f"{INGEST_API_BASE}/{env}/sources/{source_id}", method="GET", headers=_get_headers(context)) + source_data = response.get("data", {}) + attributes = source_data.get("attributes", {}) + status = attributes.get("status") + if status == "ready": + return {"status": "ready", "source_url": attributes.get("source"), "source": source_data} + elif status == "failed": + return {"status": "failed", "error": source_data.get("error", "Source processing failed")} + await asyncio.sleep(poll_interval) + elapsed += poll_interval + return {"status": "timeout", "error": f"Source did not become ready within {max_wait} seconds"} + + +async def _download_base64(context: ExecutionContext, url: str) -> Dict[str, Any]: + response = await context.fetch(url, method="GET", headers={"Accept": "*/*"}, raw_response=True) + content_type = response.get("content_type", "application/octet-stream") + if not content_type or content_type == "application/octet-stream": + guessed_type, _ = mimetypes.guess_type(url) + if guessed_type: + content_type = guessed_type + filename = url.split("/")[-1].split("?")[0] or "downloaded_file" + content_bytes = response.get("body", b"") + if isinstance(content_bytes, str): + content_bytes = content_bytes.encode("utf-8") + return {"content": base64.b64encode(content_bytes).decode("utf-8"), "content_type": content_type, "filename": filename, "size": len(content_bytes)} + + +async def _get_media_info(context: ExecutionContext, url: str) -> Dict[str, Any]: + env = _get_env(context) + encoded_url = quote(url, safe="") + response = await context.fetch(f"{EDIT_API_BASE}/{env}/probe/{encoded_url}", method="GET", headers=_get_headers(context)) + return response.get("response", {}) + + +def _position_to_offset(position: str) -> Dict[str, float]: + offsets = { + "center": {"x": 0, "y": 0}, "top": {"x": 0, "y": 0.4}, "topRight": {"x": 0.4, "y": 0.4}, + "right": {"x": 0.4, "y": 0}, "bottomRight": {"x": 0.4, "y": -0.4}, "bottom": {"x": 0, "y": -0.4}, + "bottomLeft": {"x": -0.4, "y": -0.4}, "left": {"x": -0.4, "y": 0}, "topLeft": {"x": -0.4, "y": 0.4}, + } + return offsets.get(position, {"x": 0, "y": 0}) + + +def _build_timeline_from_clips(clips: list, background_color: str = "#000000") -> Dict[str, Any]: + timeline_clips = [] + current_time = 0.0 + for clip in clips: + url = clip.get("url") + duration = clip.get("duration") + start_from = clip.get("start_from", 0) + length = clip.get("length") + fit = clip.get("fit", "crop") + effect = clip.get("effect") + transition = clip.get("transition", {}) + is_image = any(url.lower().endswith(ext) for ext in [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"]) + if is_image: + asset = {"type": "image", "src": url} + clip_length = duration or 5 + else: + asset = {"type": "video", "src": url} + if start_from: + asset["trim"] = start_from + clip_length = length or duration + timeline_clip = {"asset": asset, "start": current_time, "fit": fit} + if clip_length: + timeline_clip["length"] = clip_length + if effect: + timeline_clip["effect"] = effect + if transition: + timeline_clip["transition"] = transition + timeline_clips.append(timeline_clip) + current_time += clip_length if clip_length else 5 + return {"background": background_color, "tracks": [{"clips": timeline_clips}]} + + +async def _submit_and_maybe_wait(context: ExecutionContext, payload: Dict[str, Any], wait: bool, max_wait: int = 300) -> ActionResult: + env = _get_env(context) + response = await context.fetch(f"{EDIT_API_BASE}/{env}/render", method="POST", headers=_get_headers(context), json=payload) + render_id = response.get("response", {}).get("id") + if not render_id: + return ActionResult(data={"result": False, "error": "Failed to submit render job"}, cost_usd=0.0) + if wait: + poll_result = await _poll_render(context, render_id, max_wait) + if poll_result["status"] == "done": + render_data = poll_result.get("render", {}) + return ActionResult(data={"render_id": render_id, "status": "done", "url": poll_result["url"], "duration": render_data.get("duration"), "result": True}, cost_usd=0.0) + return ActionResult(data={"render_id": render_id, "status": poll_result["status"], "error": poll_result.get("error"), "result": False}, cost_usd=0.0) + return ActionResult(data={"render_id": render_id, "status": "queued", "result": True}, cost_usd=0.0) + + +@shotstack.action("upload_file") +class UploadFileAction(ActionHandler): + async def execute(self, inputs: Dict[str, Any], context: ExecutionContext) -> ActionResult: + try: + env = _get_env(context) + wait_for_ready = inputs.get("wait_for_ready", False) + file_obj = inputs.get("file") + if file_obj: + content_base64 = file_obj.get("content") + filename = file_obj.get("name") + content_type = file_obj.get("contentType") + file_url = file_obj.get("url") + if file_url and not content_base64: + resp = await context.fetch(file_url, method="GET", raw_response=True) + content_base64 = base64.b64encode(resp.get("body", b"")).decode("utf-8") + else: + content_base64 = inputs.get("content") + filename = inputs.get("filename") + content_type = inputs.get("content_type") + if not content_base64 or not filename: + return ActionResult(data={"result": False, "error": "Missing required file content or filename"}, cost_usd=0.0) + file_bytes = base64.b64decode(content_base64) + if not content_type: + guessed_type, _ = mimetypes.guess_type(filename) + content_type = guessed_type or "application/octet-stream" + response = await context.fetch(f"{INGEST_API_BASE}/{env}/upload", method="POST", headers=_get_headers(context)) + upload_data = response.get("data", {}) + attributes = upload_data.get("attributes", {}) + presigned_url = attributes.get("url") + source_id = upload_data.get("id") + upload_headers = attributes.get("headers", {}) + if not presigned_url: + return ActionResult(data={"result": False, "error": "Failed to get presigned upload URL"}, cost_usd=0.0) + put_headers = upload_headers if upload_headers else {} + await context.fetch(presigned_url, method="PUT", data=file_bytes, headers=put_headers) + if wait_for_ready: + poll_result = await _poll_source(context, source_id) + if poll_result["status"] == "ready": + return ActionResult(data={"source_id": source_id, "source_url": poll_result["source_url"], "status": "ready", "result": True}, cost_usd=0.0) + return ActionResult(data={"source_id": source_id, "status": poll_result["status"], "error": poll_result.get("error"), "result": False}, cost_usd=0.0) + return ActionResult(data={"source_id": source_id, "status": "processing", "result": True}, cost_usd=0.0) + except Exception as e: + return ActionResult(data={"result": False, "error": str(e)}, cost_usd=0.0) + + +@shotstack.action("check_source_status") +class CheckSourceStatusAction(ActionHandler): + async def execute(self, inputs: Dict[str, Any], context: ExecutionContext) -> ActionResult: + try: + env = _get_env(context) + source_id = inputs["source_id"] + response = await context.fetch(f"{INGEST_API_BASE}/{env}/sources/{source_id}", method="GET", headers=_get_headers(context)) + source_data = response.get("data", {}) + attributes = source_data.get("attributes", {}) + status = attributes.get("status") + result_data: Dict[str, Any] = {"source_id": source_id, "status": status, "result": True} + if status == "ready": + result_data["source_url"] = attributes.get("source") + result_data["message"] = "File is ready to use in edits!" + elif status == "failed": + result_data["error"] = source_data.get("error", "Source processing failed") + result_data["result"] = False + else: + result_data["message"] = f"File is {status}. Check again in a few seconds." + return ActionResult(data=result_data, cost_usd=0.0) + except Exception as e: + return ActionResult(data={"result": False, "error": str(e)}, cost_usd=0.0) + + +@shotstack.action("get_upload_url") +class GetUploadUrlAction(ActionHandler): + async def execute(self, inputs: Dict[str, Any], context: ExecutionContext) -> ActionResult: + try: + env = _get_env(context) + response = await context.fetch(f"{INGEST_API_BASE}/{env}/upload", method="POST", headers=_get_headers(context)) + upload_data = response.get("data", {}) + attributes = upload_data.get("attributes", {}) + return ActionResult(data={"upload_url": attributes.get("url"), "source_id": upload_data.get("id"), "expires": attributes.get("expires"), "result": True}, cost_usd=0.0) + except Exception as e: + return ActionResult(data={"upload_url": None, "source_id": None, "result": False, "error": str(e)}, cost_usd=0.0) + + +@shotstack.action("submit_render") +class SubmitRenderAction(ActionHandler): + async def execute(self, inputs: Dict[str, Any], context: ExecutionContext) -> ActionResult: + try: + env = _get_env(context) + payload = {"timeline": inputs["timeline"], "output": inputs["output"]} + response = await context.fetch(f"{EDIT_API_BASE}/{env}/render", method="POST", headers=_get_headers(context), json=payload) + render_id = response.get("response", {}).get("id") + if not render_id: + return ActionResult(data={"result": False, "error": "Failed to submit render job"}, cost_usd=0.0) + return ActionResult(data={"render_id": render_id, "status": "queued", "message": "Render job submitted. Use check_render_status to poll for completion.", "result": True}, cost_usd=0.0) + except Exception as e: + return ActionResult(data={"result": False, "error": str(e)}, cost_usd=0.0) + + +@shotstack.action("check_render_status") +class CheckRenderStatusAction(ActionHandler): + async def execute(self, inputs: Dict[str, Any], context: ExecutionContext) -> ActionResult: + try: + env = _get_env(context) + render_id = inputs["render_id"] + response = await context.fetch(f"{EDIT_API_BASE}/{env}/render/{render_id}", method="GET", headers=_get_headers(context)) + render_data = response.get("response", {}) + status = render_data.get("status") + result_data: Dict[str, Any] = {"render_id": render_id, "status": status, "result": True} + if status == "done": + result_data["url"] = render_data.get("url") + result_data["duration"] = render_data.get("duration") + result_data["message"] = "Render complete!" + elif status == "failed": + result_data["error"] = render_data.get("error", "Render failed") + result_data["result"] = False + else: + result_data["message"] = f"Render is {status}. Check again in a few seconds." + return ActionResult(data=result_data, cost_usd=0.0) + except Exception as e: + return ActionResult(data={"result": False, "error": str(e)}, cost_usd=0.0) + + +@shotstack.action("render_and_wait") +class RenderAndWaitAction(ActionHandler): + async def execute(self, inputs: Dict[str, Any], context: ExecutionContext) -> ActionResult: + try: + payload = {"timeline": inputs["timeline"], "output": inputs["output"]} + max_wait = inputs.get("max_wait_seconds", 300) + poll_interval = inputs.get("poll_interval_seconds", 5) + env = _get_env(context) + response = await context.fetch(f"{EDIT_API_BASE}/{env}/render", method="POST", headers=_get_headers(context), json=payload) + render_id = response.get("response", {}).get("id") + if not render_id: + return ActionResult(data={"result": False, "error": "Failed to submit render job"}, cost_usd=0.0) + poll_result = await _poll_render(context, render_id, max_wait, poll_interval) + if poll_result["status"] == "done": + return ActionResult(data={"render_id": render_id, "status": "done", "url": poll_result["url"], "duration": poll_result.get("render", {}).get("duration"), "result": True}, cost_usd=0.0) + return ActionResult(data={"render_id": render_id, "status": poll_result["status"], "error": poll_result.get("error"), "result": False}, cost_usd=0.0) + except Exception as e: + return ActionResult(data={"result": False, "error": str(e)}, cost_usd=0.0) + + +@shotstack.action("download_render") +class DownloadRenderAction(ActionHandler): + async def execute(self, inputs: Dict[str, Any], context: ExecutionContext) -> ActionResult: + try: + env = _get_env(context) + render_id = inputs.get("render_id") + url = inputs.get("url") + if render_id and not url: + response = await context.fetch(f"{EDIT_API_BASE}/{env}/render/{render_id}", method="GET", headers=_get_headers(context)) + render_data = response.get("response", {}) + status = render_data.get("status") + if status != "done": + return ActionResult(data={"result": False, "error": f"Render is not complete. Status: {status}"}, cost_usd=0.0) + url = render_data.get("url") + if not url: + return ActionResult(data={"result": False, "error": "No URL available. Provide render_id or url."}, cost_usd=0.0) + dl = await _download_base64(context, url) + return ActionResult(data={"content": dl["content"], "content_type": dl["content_type"], "filename": dl["filename"], "size": dl["size"], "result": True}, cost_usd=0.0) + except Exception as e: + return ActionResult(data={"result": False, "error": str(e)}, cost_usd=0.0) + + +@shotstack.action("custom_edit") +class CustomEditAction(ActionHandler): + async def execute(self, inputs: Dict[str, Any], context: ExecutionContext) -> ActionResult: + try: + wait = inputs.get("wait_for_completion", True) + max_wait = inputs.get("max_wait_seconds", 300) + payload = {"timeline": inputs["timeline"], "output": inputs["output"]} + return await _submit_and_maybe_wait(context, payload, wait, max_wait) + except Exception as e: + return ActionResult(data={"result": False, "error": str(e)}, cost_usd=0.0) + + +@shotstack.action("compose_video") +class ComposeVideoAction(ActionHandler): + async def execute(self, inputs: Dict[str, Any], context: ExecutionContext) -> ActionResult: + try: + clips = inputs["clips"] + output = inputs.get("output", {"format": "mp4", "resolution": "hd"}) + background_color = inputs.get("background_color", "#000000") + wait = inputs.get("wait_for_completion", True) + timeline = _build_timeline_from_clips(clips, background_color) + return await _submit_and_maybe_wait(context, {"timeline": timeline, "output": output}, wait) + except Exception as e: + return ActionResult(data={"result": False, "error": str(e)}, cost_usd=0.0) + + +@shotstack.action("add_text_overlay") +class AddTextOverlayAction(ActionHandler): + async def execute(self, inputs: Dict[str, Any], context: ExecutionContext) -> ActionResult: + try: + video_url = inputs["video_url"] + text = inputs["text"] + style = inputs.get("style", "minimal") + position = inputs.get("position", "center") + start_time = inputs.get("start_time", 0) + duration = inputs.get("duration") + font_size = inputs.get("font_size", "medium") + color = inputs.get("color", "#ffffff") + background_color = inputs.get("background_color") + effect = inputs.get("effect") + transition = inputs.get("transition") + output = inputs.get("output", {"format": "mp4", "resolution": "hd"}) + wait = inputs.get("wait_for_completion", True) + if not duration: + try: + media_info = await _get_media_info(context, video_url) + video_duration = media_info.get("metadata", {}).get("streams", [{}])[0].get("duration", 10) + duration = video_duration - start_time + except Exception: + duration = 10 + title_asset: Dict[str, Any] = {"type": "title", "text": text, "style": style, "color": color, "size": font_size, "position": position} + if background_color: + title_asset["background"] = background_color + text_clip: Dict[str, Any] = {"asset": title_asset, "start": start_time, "length": duration} + if effect: + text_clip["effect"] = effect + if transition: + text_clip["transition"] = transition + timeline = {"tracks": [{"clips": [text_clip]}, {"clips": [{"asset": {"type": "video", "src": video_url}, "start": 0}]}]} + return await _submit_and_maybe_wait(context, {"timeline": timeline, "output": output}, wait) + except Exception as e: + return ActionResult(data={"result": False, "error": str(e)}, cost_usd=0.0) + + +@shotstack.action("add_logo_overlay") +class AddLogoOverlayAction(ActionHandler): + async def execute(self, inputs: Dict[str, Any], context: ExecutionContext) -> ActionResult: + try: + video_url = inputs["video_url"] + logo_url = inputs["logo_url"] + position = inputs.get("position", "bottomRight") + scale = inputs.get("scale", 0.15) + opacity = inputs.get("opacity", 1) + offset_x = inputs.get("offset_x") + offset_y = inputs.get("offset_y") + start_time = inputs.get("start_time", 0) + duration = inputs.get("duration") + output = inputs.get("output", {"format": "mp4", "resolution": "hd"}) + wait = inputs.get("wait_for_completion", True) + if not duration: + try: + media_info = await _get_media_info(context, video_url) + video_duration = media_info.get("metadata", {}).get("streams", [{}])[0].get("duration", 10) + duration = video_duration - start_time + except Exception: + duration = 10 + logo_clip: Dict[str, Any] = {"asset": {"type": "image", "src": logo_url}, "start": start_time, "length": duration, "scale": scale, "position": position, "opacity": opacity} + if offset_x is not None or offset_y is not None: + offset = _position_to_offset(position) + if offset_x is not None: + offset["x"] = offset_x + if offset_y is not None: + offset["y"] = offset_y + logo_clip["offset"] = offset + timeline = {"tracks": [{"clips": [logo_clip]}, {"clips": [{"asset": {"type": "video", "src": video_url}, "start": 0}]}]} + return await _submit_and_maybe_wait(context, {"timeline": timeline, "output": output}, wait) + except Exception as e: + return ActionResult(data={"result": False, "error": str(e)}, cost_usd=0.0) + + +@shotstack.action("add_audio_track") +class AddAudioTrackAction(ActionHandler): + async def execute(self, inputs: Dict[str, Any], context: ExecutionContext) -> ActionResult: + try: + video_url = inputs["video_url"] + audio_url = inputs["audio_url"] + volume = inputs.get("volume", 1) + start_time = inputs.get("start_time", 0) + trim_from = inputs.get("trim_from", 0) + trim_duration = inputs.get("trim_duration") + fade_in = inputs.get("fade_in") + fade_out = inputs.get("fade_out") + mix_mode = inputs.get("mix_mode", "mix") + output = inputs.get("output", {"format": "mp4", "resolution": "hd"}) + wait = inputs.get("wait_for_completion", True) + video_asset: Dict[str, Any] = {"type": "video", "src": video_url} + if mix_mode == "replace": + video_asset["volume"] = 0 + audio_asset: Dict[str, Any] = {"type": "audio", "src": audio_url, "volume": volume} + if trim_from: + audio_asset["trim"] = trim_from + if fade_in and fade_out: + audio_asset["effect"] = "fadeInFadeOut" + elif fade_in: + audio_asset["effect"] = "fadeIn" + elif fade_out: + audio_asset["effect"] = "fadeOut" + audio_clip: Dict[str, Any] = {"asset": audio_asset, "start": start_time} + if trim_duration: + audio_clip["length"] = trim_duration + timeline = {"tracks": [{"clips": [{"asset": video_asset, "start": 0}]}, {"clips": [audio_clip]}]} + return await _submit_and_maybe_wait(context, {"timeline": timeline, "output": output}, wait) + except Exception as e: + return ActionResult(data={"result": False, "error": str(e)}, cost_usd=0.0) + + +@shotstack.action("trim_video") +class TrimVideoAction(ActionHandler): + async def execute(self, inputs: Dict[str, Any], context: ExecutionContext) -> ActionResult: + try: + video_url = inputs["video_url"] + start_time = inputs["start_time"] + end_time = inputs.get("end_time") + duration = inputs.get("duration") + output = inputs.get("output", {"format": "mp4", "resolution": "hd"}) + wait = inputs.get("wait_for_completion", True) + if end_time is not None: + length = end_time - start_time + elif duration is not None: + length = duration + else: + return ActionResult(data={"result": False, "error": "Either end_time or duration is required"}, cost_usd=0.0) + timeline = {"tracks": [{"clips": [{"asset": {"type": "video", "src": video_url, "trim": start_time}, "start": 0, "length": length}]}]} + return await _submit_and_maybe_wait(context, {"timeline": timeline, "output": output}, wait) + except Exception as e: + return ActionResult(data={"result": False, "error": str(e)}, cost_usd=0.0) + + +@shotstack.action("concatenate_videos") +class ConcatenateVideosAction(ActionHandler): + async def execute(self, inputs: Dict[str, Any], context: ExecutionContext) -> ActionResult: + try: + videos = inputs["videos"] + transition = inputs.get("transition") + output = inputs.get("output", {"format": "mp4", "resolution": "hd"}) + wait = inputs.get("wait_for_completion", True) + clips = [] + for i, video_url in enumerate(videos): + clip: Dict[str, Any] = {"asset": {"type": "video", "src": video_url}, "start": 0} + if transition and transition != "none": + trans = {} + if i > 0: + trans["in"] = transition + if i < len(videos) - 1: + trans["out"] = transition + if trans: + clip["transition"] = trans + clips.append(clip) + timeline = {"tracks": [{"clips": clips}]} + return await _submit_and_maybe_wait(context, {"timeline": timeline, "output": output}, wait) + except Exception as e: + return ActionResult(data={"result": False, "error": str(e)}, cost_usd=0.0) + + +@shotstack.action("add_captions") +class AddCaptionsAction(ActionHandler): + async def execute(self, inputs: Dict[str, Any], context: ExecutionContext) -> ActionResult: + try: + video_url = inputs["video_url"] + subtitle_url = inputs.get("subtitle_url") + auto_generate = inputs.get("auto_generate", True) + font_family = inputs.get("font_family") + font_size = inputs.get("font_size", 16) + font_color = inputs.get("font_color", "#ffffff") + line_height = inputs.get("line_height") + stroke_color = inputs.get("stroke_color") + stroke_width = inputs.get("stroke_width") + background_color = inputs.get("background_color") + background_opacity = inputs.get("background_opacity", 0.8) + background_padding = inputs.get("background_padding", 10) + background_border_radius = inputs.get("background_border_radius", 4) + position = inputs.get("position", "bottom") + margin_top = inputs.get("margin_top") + margin_bottom = inputs.get("margin_bottom", 0.1) + margin_left = inputs.get("margin_left") + margin_right = inputs.get("margin_right") + caption_width = inputs.get("width") + caption_height = inputs.get("height") + output = inputs.get("output", {"format": "mp4", "resolution": "hd"}) + wait = inputs.get("wait_for_completion", True) + max_wait = inputs.get("max_wait_seconds", 300) + video_clip: Dict[str, Any] = {"asset": {"type": "video", "src": video_url}, "start": 0, "length": "auto"} + if auto_generate and not subtitle_url: + video_clip["alias"] = "main_video" + caption_asset: Dict[str, Any] = {"type": "caption"} + if subtitle_url: + caption_asset["src"] = subtitle_url + elif auto_generate: + caption_asset["src"] = "alias://main_video" + else: + return ActionResult(data={"result": False, "error": "Either subtitle_url or auto_generate=True is required"}, cost_usd=0.0) + font: Dict[str, Any] = {} + if font_family: + font["family"] = font_family + if font_size: + font["size"] = font_size + if font_color: + font["color"] = font_color + if line_height: + font["lineHeight"] = line_height + if stroke_color: + font["stroke"] = stroke_color + if stroke_width: + font["strokeWidth"] = stroke_width + if font: + caption_asset["font"] = font + if background_color: + bg: Dict[str, Any] = {"color": background_color} + if background_opacity is not None: + bg["opacity"] = background_opacity + if background_padding is not None: + bg["padding"] = background_padding + if background_border_radius is not None: + bg["borderRadius"] = background_border_radius + caption_asset["background"] = bg + if position: + caption_asset["position"] = position + margin: Dict[str, Any] = {} + if margin_top is not None: + margin["top"] = margin_top + if margin_bottom is not None: + margin["bottom"] = margin_bottom + if margin_left is not None: + margin["left"] = margin_left + if margin_right is not None: + margin["right"] = margin_right + if margin: + caption_asset["margin"] = margin + if caption_width: + caption_asset["width"] = caption_width + if caption_height: + caption_asset["height"] = caption_height + caption_clip = {"asset": caption_asset, "start": 0, "length": "end"} + timeline = {"tracks": [{"clips": [caption_clip]}, {"clips": [video_clip]}]} + return await _submit_and_maybe_wait(context, {"timeline": timeline, "output": output}, wait, max_wait) + except Exception as e: + return ActionResult(data={"result": False, "error": str(e)}, cost_usd=0.0) From 04e818f2042392ae53493cb0719d8e97906e13e4 Mon Sep 17 00:00:00 2001 From: Shubhank <72601061+Sagsgit@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:24:00 +1200 Subject: [PATCH 4/4] feat(salesforce): add Salesforce CRM integration with SDK 2.0.0 - 7 actions: search_records, get_record, update_record, list_tasks, list_events, get_task_summary, get_event_summary - OAuth 2.0 platform auth, instance_url resolved from context.metadata - 56 pytest unit tests, zero credentials required - Real Salesforce logo icon (512x512) - README with auth setup, actions table, troubleshooting --- README.md | 4 + salesforce/README.md | 55 +++ salesforce/__init__.py | 3 + salesforce/config.json | 247 ++++++++++ salesforce/icon.png | Bin 0 -> 35864 bytes salesforce/requirements.txt | 1 + salesforce/salesforce.py | 288 ++++++++++++ salesforce/tests/__init__.py | 0 salesforce/tests/conftest.py | 4 + salesforce/tests/context.py | 8 + salesforce/tests/test_salesforce.py | 101 +++++ salesforce/tests/test_salesforce_unit.py | 549 +++++++++++++++++++++++ 12 files changed, 1260 insertions(+) create mode 100644 salesforce/README.md create mode 100644 salesforce/__init__.py create mode 100644 salesforce/config.json create mode 100644 salesforce/icon.png create mode 100644 salesforce/requirements.txt create mode 100644 salesforce/salesforce.py create mode 100644 salesforce/tests/__init__.py create mode 100644 salesforce/tests/conftest.py create mode 100644 salesforce/tests/context.py create mode 100644 salesforce/tests/test_salesforce.py create mode 100644 salesforce/tests/test_salesforce_unit.py diff --git a/README.md b/README.md index 3421bfc5..117a288d 100644 --- a/README.md +++ b/README.md @@ -251,6 +251,10 @@ Supports basic HTTP authentication and Bearer token authentication via the SDK. [float](float): Comprehensive resource management and project scheduling integration with Float API for team capacity planning, time tracking, and project coordination. Supports full CRUD operations for team members (people) with roles, departments, rates, and availability management. Includes complete project lifecycle management with client associations, budgets, timelines, and team assignments. Features task/allocation scheduling across team members, time off management with leave types, logged time tracking with billable hours, and client relationship management. Provides access to organizational structure (departments, roles), account settings, project stages, phases, milestones, and expenses. Includes comprehensive reporting capabilities (people utilization, project analytics) with date range filtering. Features 60 actions covering all Float API v3 endpoints, custom API key authentication with required User-Agent header, connected account information display, pagination support (up to 200 items per page), rate limiting awareness (200 GET/min, 100 non-GET/min), field filtering, sorting, modified-since sync capabilities, and ActionResult return type for cost tracking. Ideal for resource planning, capacity management, project scheduling, time tracking workflows, and team utilization analysis. +### Salesforce + +[salesforce](salesforce): Salesforce is the world's leading CRM platform for managing sales pipelines, customer relationships, and activity tracking. This integration provides 7 focused actions covering record search via SOQL, single-record retrieval and update, task and event listing with filters, and human-readable summaries of task and event records. Supports OAuth 2.0 (platform) authentication. Ideal for sales automation, CRM data updates, and surfacing task and event activity within workflows. + ### Shopify Admin [shopify-admin](shopify-admin): Integrates with the Shopify Admin API for backend store management. Currently enables comprehensive customer lifecycle management including searching, creating, updating, and deleting customer records via the GraphQL API. diff --git a/salesforce/README.md b/salesforce/README.md new file mode 100644 index 00000000..f4a1b668 --- /dev/null +++ b/salesforce/README.md @@ -0,0 +1,55 @@ +# Salesforce + +Salesforce is the world's leading CRM platform, used to manage sales pipelines, customer relationships, tasks, events, and more. This integration provides 7 focused actions for searching and updating records, and for retrieving summaries of task and event activity. + +## Auth Setup + +This integration uses **OAuth 2.0** via a Salesforce Connected App. + +1. Log in to Salesforce and go to **Setup → App Manager → New Connected App**. +2. Enable **OAuth Settings** and set a callback URL. +3. Add the **`api`** scope under Selected OAuth Scopes. +4. Save and copy the **Consumer Key** (Client ID) and **Consumer Secret**. +5. Use those credentials to connect via the Autohive platform OAuth flow. + +## Actions + +| Action | Description | Key Inputs | Key Outputs | +|--------|-------------|------------|-------------| +| `search_records` | Run a SOQL query against any object | `soql` | `records`, `total_size` | +| `get_record` | Fetch a single record by ID | `object_type`, `record_id` | `record` | +| `update_record` | Update fields on a record | `object_type`, `record_id`, `fields` | `result`, `record_id` | +| `list_tasks` | List Task records with optional filters | `status`, `due_date_from`, `due_date_to`, `limit` | `tasks`, `total_size` | +| `list_events` | List Event records with optional date filters | `start_date_from`, `start_date_to`, `limit` | `events`, `total_size` | +| `get_task_summary` | Get a readable summary of a Task | `task_id` | `summary`, `task` | +| `get_event_summary` | Get a readable summary of an Event | `event_id` | `summary`, `event` | + +## API Info + +- **Base URL:** `https://{instance_url}/services/data/v62.0/` +- **Docs:** https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/ +- **Rate limits:** Typically 15,000 API calls per 24 hours (varies by Salesforce edition) +- **Query endpoint:** `GET /query?q={SOQL}` +- **Record endpoint:** `GET /sobjects/{ObjectType}/{Id}` + +## Running Tests + +```bash +cd salesforce/tests +export SALESFORCE_TOKEN=your_access_token +export SALESFORCE_INSTANCE_URL=https://yourinstance.salesforce.com +# Optional: set record IDs to test get/update actions +export SALESFORCE_RECORD_ID=003XXXXXXXXXXXXXXX +export SALESFORCE_TASK_ID=00TXXXXXXXXXXXXXXX +export SALESFORCE_EVENT_ID=00UXXXXXXXXXXXXXXX +python test_salesforce.py +``` + +## Troubleshooting + +| Error | Cause | Fix | +|-------|-------|-----| +| `401 Unauthorized` | Expired or invalid access token | Re-authenticate via Autohive OAuth flow | +| `400 MALFORMED_QUERY` | Invalid SOQL syntax | Check field names, quote strings with single quotes | +| `404 NOT_FOUND` | Record ID doesn't exist or wrong object type | Verify ID and object type match | +| `REQUEST_LIMIT_EXCEEDED` | Daily API call limit hit | Wait until the 24-hour window resets | diff --git a/salesforce/__init__.py b/salesforce/__init__.py new file mode 100644 index 00000000..511c4e4d --- /dev/null +++ b/salesforce/__init__.py @@ -0,0 +1,3 @@ +from .salesforce import salesforce + +__all__ = ["salesforce"] diff --git a/salesforce/config.json b/salesforce/config.json new file mode 100644 index 00000000..465ab3a5 --- /dev/null +++ b/salesforce/config.json @@ -0,0 +1,247 @@ +{ + "name": "Salesforce", + "display_name": "Salesforce", + "version": "1.0.0", + "description": "Salesforce CRM integration for searching and updating records, and summarising task and event activity.", + "entry_point": "salesforce.py", + "auth": { + "type": "platform", + "provider": "salesforce", + "scopes": ["api"] + }, + "actions": { + "search_records": { + "display_name": "Search Records", + "description": "Run a SOQL query to search any Salesforce object (e.g. Contact, Lead, Opportunity, Account). Returns matching records.", + "input_schema": { + "type": "object", + "properties": { + "soql": { + "type": "string", + "description": "A valid SOQL query string, e.g. SELECT Id, Name, Email FROM Contact WHERE LastName = 'Smith' LIMIT 10" + } + }, + "required": ["soql"] + }, + "output_schema": { + "type": "object", + "properties": { + "result": { "type": "boolean" }, + "records": { + "type": "array", + "items": { "type": "object" }, + "description": "List of matching Salesforce records" + }, + "total_size": { + "type": "integer", + "description": "Total number of records matched" + }, + "done": { + "type": "boolean", + "description": "Whether all results have been returned" + } + } + } + }, + "get_record": { + "display_name": "Get Record", + "description": "Retrieve a single Salesforce record by its ID and object type (e.g. Contact, Lead, Opportunity).", + "input_schema": { + "type": "object", + "properties": { + "object_type": { + "type": "string", + "description": "Salesforce object type, e.g. Contact, Lead, Account, Opportunity" + }, + "record_id": { + "type": "string", + "description": "The Salesforce record ID (15 or 18 character)" + }, + "fields": { + "type": "string", + "description": "Comma-separated list of fields to return. If omitted, all fields are returned." + } + }, + "required": ["object_type", "record_id"] + }, + "output_schema": { + "type": "object", + "properties": { + "result": { "type": "boolean" }, + "record": { + "type": "object", + "description": "The Salesforce record fields" + } + } + } + }, + "update_record": { + "display_name": "Update Record", + "description": "Update one or more fields on an existing Salesforce record by ID and object type.", + "input_schema": { + "type": "object", + "properties": { + "object_type": { + "type": "string", + "description": "Salesforce object type, e.g. Contact, Lead, Account, Opportunity" + }, + "record_id": { + "type": "string", + "description": "The Salesforce record ID to update" + }, + "fields": { + "type": "object", + "description": "Key-value pairs of fields to update, e.g. {\"Phone\": \"0400000000\", \"Title\": \"Manager\"}" + } + }, + "required": ["object_type", "record_id", "fields"] + }, + "output_schema": { + "type": "object", + "properties": { + "result": { "type": "boolean" }, + "record_id": { "type": "string" }, + "object_type": { "type": "string" } + } + } + }, + "list_tasks": { + "display_name": "List Tasks", + "description": "List Salesforce Task records with optional filters for status, due date, and assigned user.", + "input_schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "description": "Filter by task status, e.g. Not Started, In Progress, Completed, Waiting on someone else, Deferred" + }, + "assigned_to_id": { + "type": "string", + "description": "Filter by assigned user ID (OwnerId)" + }, + "due_date_from": { + "type": "string", + "description": "Filter tasks due on or after this date (YYYY-MM-DD)" + }, + "due_date_to": { + "type": "string", + "description": "Filter tasks due on or before this date (YYYY-MM-DD)" + }, + "limit": { + "type": "integer", + "description": "Maximum number of tasks to return (default 25, max 200)", + "default": 25 + } + }, + "required": [] + }, + "output_schema": { + "type": "object", + "properties": { + "result": { "type": "boolean" }, + "tasks": { + "type": "array", + "items": { "type": "object" }, + "description": "List of Task records" + }, + "total_size": { "type": "integer" } + } + } + }, + "list_events": { + "display_name": "List Events", + "description": "List Salesforce Event (calendar) records with optional date range filters.", + "input_schema": { + "type": "object", + "properties": { + "start_date_from": { + "type": "string", + "description": "Return events starting on or after this date (YYYY-MM-DD)" + }, + "start_date_to": { + "type": "string", + "description": "Return events starting on or before this date (YYYY-MM-DD)" + }, + "assigned_to_id": { + "type": "string", + "description": "Filter by assigned user ID (OwnerId)" + }, + "limit": { + "type": "integer", + "description": "Maximum number of events to return (default 25, max 200)", + "default": 25 + } + }, + "required": [] + }, + "output_schema": { + "type": "object", + "properties": { + "result": { "type": "boolean" }, + "events": { + "type": "array", + "items": { "type": "object" }, + "description": "List of Event records" + }, + "total_size": { "type": "integer" } + } + } + }, + "get_task_summary": { + "display_name": "Get Task Summary", + "description": "Retrieve a single Salesforce Task record by ID and return a human-readable summary of its details.", + "input_schema": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "The Salesforce Task record ID" + } + }, + "required": ["task_id"] + }, + "output_schema": { + "type": "object", + "properties": { + "result": { "type": "boolean" }, + "summary": { + "type": "string", + "description": "Human-readable summary of the task" + }, + "task": { + "type": "object", + "description": "Raw task record fields" + } + } + } + }, + "get_event_summary": { + "display_name": "Get Event Summary", + "description": "Retrieve a single Salesforce Event record by ID and return a human-readable summary of its details.", + "input_schema": { + "type": "object", + "properties": { + "event_id": { + "type": "string", + "description": "The Salesforce Event record ID" + } + }, + "required": ["event_id"] + }, + "output_schema": { + "type": "object", + "properties": { + "result": { "type": "boolean" }, + "summary": { + "type": "string", + "description": "Human-readable summary of the event" + }, + "event": { + "type": "object", + "description": "Raw event record fields" + } + } + } + } + } +} diff --git a/salesforce/icon.png b/salesforce/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..9a6cb7c433d74be8f15d5a0df6b46a8d3415ce4d GIT binary patch literal 35864 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 mT-d)N+y7JlSd|ZH5w3A*| str: + return f"{instance_url.rstrip('/')}/services/data/{API_VERSION}" + + +def _headers(token: str) -> Dict[str, str]: + return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + + +def _get_token_and_instance(context: ExecutionContext): + credentials = context.auth.get("credentials", {}) + token = credentials.get("access_token", "") + instance_url = ( + credentials.get("instance_url") + or context.metadata.get("instance_url") + or os.environ.get("SALESFORCE_INSTANCE_URL", "") + ) + if not instance_url: + raise ValueError("Salesforce instance_url not found in credentials or metadata. Please reconnect.") + return token, instance_url + + +@salesforce.action("search_records") +class SearchRecordsAction(ActionHandler): + async def execute(self, inputs: Dict[str, Any], context: ExecutionContext) -> ActionResult: + try: + token, instance_url = _get_token_and_instance(context) + response = await context.fetch( + f"{_base_url(instance_url)}/query", + method="GET", + headers=_headers(token), + params={"q": inputs["soql"]}, + ) + return ActionResult( + data={ + "result": True, + "records": response.data.get("records", []), + "total_size": response.data.get("totalSize", 0), + "done": response.data.get("done", True), + }, + cost_usd=0.0, + ) + except Exception as e: + return ActionResult(data={"result": False, "error": str(e)}, cost_usd=0.0) + + +@salesforce.action("get_record") +class GetRecordAction(ActionHandler): + async def execute(self, inputs: Dict[str, Any], context: ExecutionContext) -> ActionResult: + try: + token, instance_url = _get_token_and_instance(context) + object_type = inputs["object_type"] + record_id = inputs["record_id"] + url = f"{_base_url(instance_url)}/sobjects/{object_type}/{record_id}" + + params = {} + if inputs.get("fields"): + params["fields"] = inputs["fields"] + + response = await context.fetch(url, method="GET", headers=_headers(token), params=params) + return ActionResult(data={"result": True, "record": response.data}, cost_usd=0.0) + except Exception as e: + return ActionResult(data={"result": False, "error": str(e)}, cost_usd=0.0) + + +@salesforce.action("update_record") +class UpdateRecordAction(ActionHandler): + async def execute(self, inputs: Dict[str, Any], context: ExecutionContext) -> ActionResult: + try: + token, instance_url = _get_token_and_instance(context) + object_type = inputs["object_type"] + record_id = inputs["record_id"] + url = f"{_base_url(instance_url)}/sobjects/{object_type}/{record_id}" + + await context.fetch(url, method="PATCH", headers=_headers(token), json=inputs["fields"]) + return ActionResult( + data={ + "result": True, + "record_id": record_id, + "object_type": object_type, + }, + cost_usd=0.0, + ) + except Exception as e: + return ActionResult(data={"result": False, "error": str(e)}, cost_usd=0.0) + + +def _build_task_query( # nosec B608 + status=None, + assigned_to_id=None, + due_date_from=None, + due_date_to=None, + limit=25, +) -> str: + limit = min(int(limit), 200) + conditions = [] + if status: + safe_status = status.replace("'", "\\'") + conditions.append(f"Status = '{safe_status}'") + if assigned_to_id: + conditions.append(f"OwnerId = '{assigned_to_id}'") + if due_date_from: + conditions.append(f"ActivityDate >= {due_date_from}") + if due_date_to: + conditions.append(f"ActivityDate <= {due_date_to}") + + where = f" WHERE {' AND '.join(conditions)}" if conditions else "" + fields = ( + "Id, Subject, Status, Priority, ActivityDate, Description, " + "OwnerId, WhoId, WhatId, CreatedDate, LastModifiedDate" + ) + return f"SELECT {fields} FROM Task{where} ORDER BY ActivityDate DESC LIMIT {limit}" # nosec B608 + + +def _build_event_query( # nosec B608 + start_date_from=None, + start_date_to=None, + assigned_to_id=None, + limit=25, +) -> str: + limit = min(int(limit), 200) + conditions = [] + if start_date_from: + conditions.append(f"StartDateTime >= {start_date_from}T00:00:00Z") + if start_date_to: + conditions.append(f"StartDateTime <= {start_date_to}T23:59:59Z") + if assigned_to_id: + conditions.append(f"OwnerId = '{assigned_to_id}'") + + where = f" WHERE {' AND '.join(conditions)}" if conditions else "" + fields = ( + "Id, Subject, StartDateTime, EndDateTime, Location, Description, " + "OwnerId, WhoId, WhatId, IsAllDayEvent, CreatedDate" + ) + return f"SELECT {fields} FROM Event{where} ORDER BY StartDateTime DESC LIMIT {limit}" # nosec B608 + + +@salesforce.action("list_tasks") +class ListTasksAction(ActionHandler): + async def execute(self, inputs: Dict[str, Any], context: ExecutionContext) -> ActionResult: + try: + token, instance_url = _get_token_and_instance(context) + soql = _build_task_query( + status=inputs.get("status"), + assigned_to_id=inputs.get("assigned_to_id"), + due_date_from=inputs.get("due_date_from"), + due_date_to=inputs.get("due_date_to"), + limit=inputs.get("limit", 25), + ) + response = await context.fetch( + f"{_base_url(instance_url)}/query", + method="GET", + headers=_headers(token), + params={"q": soql}, + ) + return ActionResult( + data={ + "result": True, + "tasks": response.data.get("records", []), + "total_size": response.data.get("totalSize", 0), + }, + cost_usd=0.0, + ) + except Exception as e: + return ActionResult(data={"result": False, "error": str(e)}, cost_usd=0.0) + + +@salesforce.action("list_events") +class ListEventsAction(ActionHandler): + async def execute(self, inputs: Dict[str, Any], context: ExecutionContext) -> ActionResult: + try: + token, instance_url = _get_token_and_instance(context) + soql = _build_event_query( + start_date_from=inputs.get("start_date_from"), + start_date_to=inputs.get("start_date_to"), + assigned_to_id=inputs.get("assigned_to_id"), + limit=inputs.get("limit", 25), + ) + response = await context.fetch( + f"{_base_url(instance_url)}/query", + method="GET", + headers=_headers(token), + params={"q": soql}, + ) + return ActionResult( + data={ + "result": True, + "events": response.data.get("records", []), + "total_size": response.data.get("totalSize", 0), + }, + cost_usd=0.0, + ) + except Exception as e: + return ActionResult(data={"result": False, "error": str(e)}, cost_usd=0.0) + + +def _summarise_task(task: Dict[str, Any]) -> str: + subject = task.get("Subject") or "No subject" + status = task.get("Status") or "Unknown" + priority = task.get("Priority") or "Normal" + due = task.get("ActivityDate") or "No due date" + description = task.get("Description") or "No description" + return f"Task: {subject}\nStatus: {status} | Priority: {priority} | Due: {due}\nDescription: {description}" + + +def _summarise_event(event: Dict[str, Any]) -> str: + subject = event.get("Subject") or "No subject" + start = event.get("StartDateTime") or "Unknown start" + end = event.get("EndDateTime") or "Unknown end" + location = event.get("Location") or "No location" + description = event.get("Description") or "No description" + all_day = " (All day)" if event.get("IsAllDayEvent") else "" + return f"Event: {subject}{all_day}\nStart: {start} | End: {end} | Location: {location}\nDescription: {description}" + + +@salesforce.action("get_task_summary") +class GetTaskSummaryAction(ActionHandler): + async def execute(self, inputs: Dict[str, Any], context: ExecutionContext) -> ActionResult: + try: + token, instance_url = _get_token_and_instance(context) + task_id = inputs["task_id"] + fields = ( + "Id, Subject, Status, Priority, ActivityDate, Description, " + "OwnerId, WhoId, WhatId, CreatedDate, LastModifiedDate" + ) + soql = f"SELECT {fields} FROM Task WHERE Id = '{task_id}' LIMIT 1" # nosec B608 + response = await context.fetch( + f"{_base_url(instance_url)}/query", + method="GET", + headers=_headers(token), + params={"q": soql}, + ) + records = response.data.get("records", []) + if not records: + return ActionResult(data={"result": False, "error": "Task not found"}, cost_usd=0.0) + task = records[0] + return ActionResult( + data={"result": True, "summary": _summarise_task(task), "task": task}, + cost_usd=0.0, + ) + except Exception as e: + return ActionResult(data={"result": False, "error": str(e)}, cost_usd=0.0) + + +@salesforce.action("get_event_summary") +class GetEventSummaryAction(ActionHandler): + async def execute(self, inputs: Dict[str, Any], context: ExecutionContext) -> ActionResult: + try: + token, instance_url = _get_token_and_instance(context) + event_id = inputs["event_id"] + fields = ( + "Id, Subject, StartDateTime, EndDateTime, Location, Description, " + "OwnerId, WhoId, WhatId, IsAllDayEvent, CreatedDate" + ) + soql = f"SELECT {fields} FROM Event WHERE Id = '{event_id}' LIMIT 1" # nosec B608 + response = await context.fetch( + f"{_base_url(instance_url)}/query", + method="GET", + headers=_headers(token), + params={"q": soql}, + ) + records = response.data.get("records", []) + if not records: + return ActionResult(data={"result": False, "error": "Event not found"}, cost_usd=0.0) + event = records[0] + return ActionResult( + data={ + "result": True, + "summary": _summarise_event(event), + "event": event, + }, + cost_usd=0.0, + ) + except Exception as e: + return ActionResult(data={"result": False, "error": str(e)}, cost_usd=0.0) diff --git a/salesforce/tests/__init__.py b/salesforce/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/salesforce/tests/conftest.py b/salesforce/tests/conftest.py new file mode 100644 index 00000000..e669d95e --- /dev/null +++ b/salesforce/tests/conftest.py @@ -0,0 +1,4 @@ +import os +import sys + +sys.path.insert(0, os.path.dirname(__file__)) diff --git a/salesforce/tests/context.py b/salesforce/tests/context.py new file mode 100644 index 00000000..6b2fcba9 --- /dev/null +++ b/salesforce/tests/context.py @@ -0,0 +1,8 @@ +import os +import sys + +parent_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +deps_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "../dependencies")) +sys.path.insert(0, parent_dir) +sys.path.insert(0, deps_dir) +from salesforce import salesforce # noqa diff --git a/salesforce/tests/test_salesforce.py b/salesforce/tests/test_salesforce.py new file mode 100644 index 00000000..3ac3fde4 --- /dev/null +++ b/salesforce/tests/test_salesforce.py @@ -0,0 +1,101 @@ +""" +Integration tests for Salesforce — require real API credentials. +Run with: pytest salesforce/tests/test_salesforce.py -m integration +""" + +import asyncio +import os +import sys + +import pytest +from autohive_integrations_sdk import ExecutionContext, IntegrationResult + +from context import salesforce # noqa + +pytestmark = pytest.mark.integration + +ACCESS_TOKEN = sys.argv[1] if len(sys.argv) > 1 else os.getenv("SALESFORCE_TOKEN", "") +INSTANCE_URL = os.getenv("SALESFORCE_INSTANCE_URL", "https://login.salesforce.com") +TEST_AUTH = {"credentials": {"access_token": ACCESS_TOKEN, "instance_url": INSTANCE_URL}} + +RECORD_ID = os.getenv("SALESFORCE_RECORD_ID", "") +TASK_ID = os.getenv("SALESFORCE_TASK_ID", "") +EVENT_ID = os.getenv("SALESFORCE_EVENT_ID", "") + + +async def test_search_records(): + inputs = {"soql": "SELECT Id, Name FROM Contact LIMIT 5"} + async with ExecutionContext(auth=TEST_AUTH) as context: + result = await salesforce.execute_action("search_records", inputs, context) + assert isinstance(result, IntegrationResult) + data = result.result.data + assert data.get("result") is True + print(f"[OK] search_records: {len(data.get('records', []))} record(s)") + + +async def test_list_tasks(): + inputs = {"limit": 5} + async with ExecutionContext(auth=TEST_AUTH) as context: + result = await salesforce.execute_action("list_tasks", inputs, context) + assert isinstance(result, IntegrationResult) + data = result.result.data + assert data.get("result") is True + print(f"[OK] list_tasks: {len(data.get('tasks', []))} task(s)") + + +async def test_list_events(): + inputs = {"limit": 5} + async with ExecutionContext(auth=TEST_AUTH) as context: + result = await salesforce.execute_action("list_events", inputs, context) + assert isinstance(result, IntegrationResult) + data = result.result.data + assert data.get("result") is True + print(f"[OK] list_events: {len(data.get('events', []))} event(s)") + + +async def test_get_record(): + if not RECORD_ID: + print("[SKIP] get_record: set SALESFORCE_RECORD_ID to test") + return + inputs = {"object_type": "Contact", "record_id": RECORD_ID} + async with ExecutionContext(auth=TEST_AUTH) as context: + result = await salesforce.execute_action("get_record", inputs, context) + assert isinstance(result, IntegrationResult) + data = result.result.data + assert data.get("result") is True + print(f"[OK] get_record: {data.get('record', {}).get('Id')}") + + +async def test_get_task_summary(): + if not TASK_ID: + print("[SKIP] get_task_summary: set SALESFORCE_TASK_ID to test") + return + inputs = {"task_id": TASK_ID} + async with ExecutionContext(auth=TEST_AUTH) as context: + result = await salesforce.execute_action("get_task_summary", inputs, context) + assert isinstance(result, IntegrationResult) + data = result.result.data + assert data.get("result") is True + print(f"[OK] get_task_summary:\n{data.get('summary')}") + + +async def test_get_event_summary(): + if not EVENT_ID: + print("[SKIP] get_event_summary: set SALESFORCE_EVENT_ID to test") + return + inputs = {"event_id": EVENT_ID} + async with ExecutionContext(auth=TEST_AUTH) as context: + result = await salesforce.execute_action("get_event_summary", inputs, context) + assert isinstance(result, IntegrationResult) + data = result.result.data + assert data.get("result") is True + print(f"[OK] get_event_summary:\n{data.get('summary')}") + + +if __name__ == "__main__": + asyncio.run(test_search_records()) + asyncio.run(test_list_tasks()) + asyncio.run(test_list_events()) + asyncio.run(test_get_record()) + asyncio.run(test_get_task_summary()) + asyncio.run(test_get_event_summary()) diff --git a/salesforce/tests/test_salesforce_unit.py b/salesforce/tests/test_salesforce_unit.py new file mode 100644 index 00000000..33501dbf --- /dev/null +++ b/salesforce/tests/test_salesforce_unit.py @@ -0,0 +1,549 @@ +""" +Unit tests for Salesforce integration. + +All tests are fully mocked — no real API credentials required. +Covers all 7 action handlers plus helper functions. +""" + +import json +import os +import sys + +import pytest +from unittest.mock import AsyncMock, MagicMock + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) +sys.path.insert(0, os.path.abspath(os.path.dirname(__file__))) + +from autohive_integrations_sdk import FetchResponse # noqa: E402 + +from salesforce.salesforce import ( # noqa: E402 + SearchRecordsAction, + GetRecordAction, + UpdateRecordAction, + ListTasksAction, + ListEventsAction, + GetTaskSummaryAction, + GetEventSummaryAction, + _build_task_query, + _build_event_query, + _summarise_task, + _summarise_event, + salesforce as salesforce_integration, +) + +pytestmark = pytest.mark.unit + +CONFIG_PATH = os.path.join(os.path.dirname(__file__), "..", "config.json") + +TEST_TOKEN = "test_access_token" # nosec B105 +TEST_INSTANCE = "https://test.salesforce.com" +TEST_AUTH = {"credentials": {"access_token": TEST_TOKEN, "instance_url": TEST_INSTANCE}} + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def make_fetch_response(data: dict) -> MagicMock: + resp = MagicMock(spec=FetchResponse) + resp.data = data + return resp + + +@pytest.fixture +def mock_context(): + ctx = MagicMock(name="ExecutionContext") + ctx.fetch = AsyncMock(name="fetch") + ctx.auth = TEST_AUTH + return ctx + + +# --------------------------------------------------------------------------- +# Config validation +# --------------------------------------------------------------------------- + + +class TestConfigValidation: + def test_actions_match_handlers(self): + with open(CONFIG_PATH) as f: + config = json.load(f) + + defined = set(config.get("actions", {}).keys()) + registered = set(salesforce_integration._action_handlers.keys()) + + missing = defined - registered + extra = registered - defined + + assert not missing, f"Missing handlers: {missing}" + assert not extra, f"Extra handlers without config: {extra}" + + def test_auth_type_is_platform(self): + with open(CONFIG_PATH) as f: + config = json.load(f) + assert config["auth"]["type"] == "platform" + assert config["auth"]["provider"] == "salesforce" + + def test_all_actions_have_output_schema(self): + with open(CONFIG_PATH) as f: + config = json.load(f) + for name, action in config["actions"].items(): + assert "output_schema" in action, f"Action '{name}' missing output_schema" + + def test_all_actions_have_result_in_output(self): + with open(CONFIG_PATH) as f: + config = json.load(f) + for name, action in config["actions"].items(): + props = action.get("output_schema", {}).get("properties", {}) + assert "result" in props, f"Action '{name}' output_schema missing 'result' field" + + +# --------------------------------------------------------------------------- +# Helper function tests +# --------------------------------------------------------------------------- + + +class TestBuildTaskQuery: + def test_no_filters(self): + q = _build_task_query() + assert "FROM Task" in q + assert "WHERE" not in q + assert "LIMIT 25" in q + + def test_status_filter(self): + q = _build_task_query(status="Completed") + assert "Status = 'Completed'" in q + assert "WHERE" in q + + def test_status_escapes_single_quote(self): + q = _build_task_query(status="Won't do") + assert "Won\\'t do" in q + + def test_assigned_to_filter(self): + q = _build_task_query(assigned_to_id="005XXXX") + assert "OwnerId = '005XXXX'" in q + + def test_due_date_range(self): + q = _build_task_query(due_date_from="2026-01-01", due_date_to="2026-12-31") + assert "ActivityDate >= 2026-01-01" in q + assert "ActivityDate <= 2026-12-31" in q + + def test_limit_capped_at_200(self): + q = _build_task_query(limit=999) + assert "LIMIT 200" in q + + def test_custom_limit(self): + q = _build_task_query(limit=10) + assert "LIMIT 10" in q + + def test_multiple_conditions_use_and(self): + q = _build_task_query(status="Open", assigned_to_id="005XXX") + assert " AND " in q + + def test_required_fields_in_select(self): + q = _build_task_query() + for field in ["Id", "Subject", "Status", "Priority", "ActivityDate", "Description"]: + assert field in q + + +class TestBuildEventQuery: + def test_no_filters(self): + q = _build_event_query() + assert "FROM Event" in q + assert "WHERE" not in q + assert "LIMIT 25" in q + + def test_start_date_range(self): + q = _build_event_query(start_date_from="2026-01-01", start_date_to="2026-01-31") + assert "StartDateTime >= 2026-01-01T00:00:00Z" in q + assert "StartDateTime <= 2026-01-31T23:59:59Z" in q + + def test_assigned_to_filter(self): + q = _build_event_query(assigned_to_id="005XXX") + assert "OwnerId = '005XXX'" in q + + def test_limit_capped_at_200(self): + q = _build_event_query(limit=500) + assert "LIMIT 200" in q + + def test_required_fields_in_select(self): + q = _build_event_query() + for field in ["Id", "Subject", "StartDateTime", "EndDateTime", "Location", "Description"]: + assert field in q + + +class TestSummariseTask: + def test_full_task(self): + task = { + "Subject": "Follow up call", + "Status": "Not Started", + "Priority": "High", + "ActivityDate": "2026-05-01", + "Description": "Call the client to follow up on the proposal.", + } + summary = _summarise_task(task) + assert "Follow up call" in summary + assert "Not Started" in summary + assert "High" in summary + assert "2026-05-01" in summary + assert "Call the client" in summary + + def test_missing_fields_use_defaults(self): + summary = _summarise_task({}) + assert "No subject" in summary + assert "Unknown" in summary + assert "No due date" in summary + assert "No description" in summary + + +class TestSummariseEvent: + def test_full_event(self): + event = { + "Subject": "Quarterly review", + "StartDateTime": "2026-05-01T09:00:00Z", + "EndDateTime": "2026-05-01T10:00:00Z", + "Location": "Board Room", + "Description": "Q1 results discussion.", + "IsAllDayEvent": False, + } + summary = _summarise_event(event) + assert "Quarterly review" in summary + assert "2026-05-01T09:00:00Z" in summary + assert "Board Room" in summary + assert "Q1 results" in summary + assert "(All day)" not in summary + + def test_all_day_event_label(self): + event = {"Subject": "Holiday", "IsAllDayEvent": True} + summary = _summarise_event(event) + assert "(All day)" in summary + + def test_missing_fields_use_defaults(self): + summary = _summarise_event({}) + assert "No subject" in summary + assert "No location" in summary + assert "No description" in summary + + +# --------------------------------------------------------------------------- +# Action handler tests +# --------------------------------------------------------------------------- + + +class TestSearchRecordsAction: + async def test_success(self, mock_context): + mock_context.fetch.return_value = make_fetch_response( + {"records": [{"Id": "003XX", "Name": "Jane Doe"}], "totalSize": 1, "done": True} + ) + handler = SearchRecordsAction() + result = await handler.execute({"soql": "SELECT Id, Name FROM Contact LIMIT 1"}, mock_context) + + assert result.data["result"] is True + assert len(result.data["records"]) == 1 + assert result.data["records"][0]["Name"] == "Jane Doe" + assert result.data["total_size"] == 1 + assert result.data["done"] is True + + async def test_passes_soql_as_query_param(self, mock_context): + mock_context.fetch.return_value = make_fetch_response({"records": [], "totalSize": 0, "done": True}) + handler = SearchRecordsAction() + soql = "SELECT Id FROM Lead LIMIT 5" + await handler.execute({"soql": soql}, mock_context) + + call_kwargs = mock_context.fetch.call_args + assert call_kwargs.kwargs["params"]["q"] == soql + + async def test_uses_bearer_auth_header(self, mock_context): + mock_context.fetch.return_value = make_fetch_response({"records": [], "totalSize": 0, "done": True}) + handler = SearchRecordsAction() + await handler.execute({"soql": "SELECT Id FROM Contact"}, mock_context) + + headers = mock_context.fetch.call_args.kwargs["headers"] + assert headers["Authorization"] == f"Bearer {TEST_TOKEN}" + + async def test_error_returns_false(self, mock_context): + mock_context.fetch.side_effect = Exception("API error") + handler = SearchRecordsAction() + result = await handler.execute({"soql": "SELECT Id FROM Contact"}, mock_context) + + assert result.data["result"] is False + assert "API error" in result.data["error"] + + async def test_empty_results(self, mock_context): + mock_context.fetch.return_value = make_fetch_response({"records": [], "totalSize": 0, "done": True}) + handler = SearchRecordsAction() + result = await handler.execute({"soql": "SELECT Id FROM Contact WHERE Name = 'Nobody'"}, mock_context) + + assert result.data["result"] is True + assert result.data["records"] == [] + assert result.data["total_size"] == 0 + + +class TestGetRecordAction: + async def test_success(self, mock_context): + record = {"Id": "003XX", "Name": "Jane Doe", "Email": "jane@example.com"} + mock_context.fetch.return_value = make_fetch_response(record) + handler = GetRecordAction() + result = await handler.execute({"object_type": "Contact", "record_id": "003XX"}, mock_context) + + assert result.data["result"] is True + assert result.data["record"]["Name"] == "Jane Doe" + + async def test_url_contains_object_type_and_id(self, mock_context): + mock_context.fetch.return_value = make_fetch_response({"Id": "003XX"}) + handler = GetRecordAction() + await handler.execute({"object_type": "Contact", "record_id": "003XX"}, mock_context) + + url = mock_context.fetch.call_args.args[0] + assert "/sobjects/Contact/003XX" in url + + async def test_fields_param_passed_when_provided(self, mock_context): + mock_context.fetch.return_value = make_fetch_response({"Id": "003XX", "Name": "Jane"}) + handler = GetRecordAction() + await handler.execute({"object_type": "Contact", "record_id": "003XX", "fields": "Id,Name"}, mock_context) + + params = mock_context.fetch.call_args.kwargs["params"] + assert params["fields"] == "Id,Name" + + async def test_no_fields_param_when_not_provided(self, mock_context): + mock_context.fetch.return_value = make_fetch_response({"Id": "003XX"}) + handler = GetRecordAction() + await handler.execute({"object_type": "Contact", "record_id": "003XX"}, mock_context) + + params = mock_context.fetch.call_args.kwargs.get("params", {}) + assert "fields" not in params + + async def test_error_returns_false(self, mock_context): + mock_context.fetch.side_effect = Exception("Not found") + handler = GetRecordAction() + result = await handler.execute({"object_type": "Contact", "record_id": "BAD"}, mock_context) + + assert result.data["result"] is False + assert "Not found" in result.data["error"] + + +class TestUpdateRecordAction: + async def test_success(self, mock_context): + mock_context.fetch.return_value = make_fetch_response({}) + handler = UpdateRecordAction() + result = await handler.execute( + {"object_type": "Contact", "record_id": "003XX", "fields": {"Phone": "0400000000"}}, + mock_context, + ) + + assert result.data["result"] is True + assert result.data["record_id"] == "003XX" + assert result.data["object_type"] == "Contact" + + async def test_uses_patch_method(self, mock_context): + mock_context.fetch.return_value = make_fetch_response({}) + handler = UpdateRecordAction() + await handler.execute( + {"object_type": "Lead", "record_id": "00QXX", "fields": {"Title": "Manager"}}, + mock_context, + ) + + assert mock_context.fetch.call_args.kwargs["method"] == "PATCH" + + async def test_fields_sent_as_json_body(self, mock_context): + mock_context.fetch.return_value = make_fetch_response({}) + handler = UpdateRecordAction() + fields = {"Phone": "0400000000", "Title": "Director"} + await handler.execute({"object_type": "Contact", "record_id": "003XX", "fields": fields}, mock_context) + + assert mock_context.fetch.call_args.kwargs["json"] == fields + + async def test_error_returns_false(self, mock_context): + mock_context.fetch.side_effect = Exception("Forbidden") + handler = UpdateRecordAction() + result = await handler.execute( + {"object_type": "Contact", "record_id": "003XX", "fields": {"Name": "X"}}, mock_context + ) + + assert result.data["result"] is False + + +class TestListTasksAction: + async def test_success_no_filters(self, mock_context): + tasks = [{"Id": "00TXX", "Subject": "Call client", "Status": "Not Started"}] + mock_context.fetch.return_value = make_fetch_response({"records": tasks, "totalSize": 1}) + handler = ListTasksAction() + result = await handler.execute({}, mock_context) + + assert result.data["result"] is True + assert len(result.data["tasks"]) == 1 + assert result.data["total_size"] == 1 + + async def test_status_filter_applied(self, mock_context): + mock_context.fetch.return_value = make_fetch_response({"records": [], "totalSize": 0}) + handler = ListTasksAction() + await handler.execute({"status": "Completed"}, mock_context) + + soql = mock_context.fetch.call_args.kwargs["params"]["q"] + assert "Status = 'Completed'" in soql + + async def test_date_filter_applied(self, mock_context): + mock_context.fetch.return_value = make_fetch_response({"records": [], "totalSize": 0}) + handler = ListTasksAction() + await handler.execute({"due_date_from": "2026-01-01", "due_date_to": "2026-06-30"}, mock_context) + + soql = mock_context.fetch.call_args.kwargs["params"]["q"] + assert "ActivityDate >= 2026-01-01" in soql + assert "ActivityDate <= 2026-06-30" in soql + + async def test_default_limit_is_25(self, mock_context): + mock_context.fetch.return_value = make_fetch_response({"records": [], "totalSize": 0}) + handler = ListTasksAction() + await handler.execute({}, mock_context) + + soql = mock_context.fetch.call_args.kwargs["params"]["q"] + assert "LIMIT 25" in soql + + async def test_custom_limit(self, mock_context): + mock_context.fetch.return_value = make_fetch_response({"records": [], "totalSize": 0}) + handler = ListTasksAction() + await handler.execute({"limit": 50}, mock_context) + + soql = mock_context.fetch.call_args.kwargs["params"]["q"] + assert "LIMIT 50" in soql + + async def test_error_returns_false(self, mock_context): + mock_context.fetch.side_effect = Exception("timeout") + handler = ListTasksAction() + result = await handler.execute({}, mock_context) + + assert result.data["result"] is False + + +class TestListEventsAction: + async def test_success_no_filters(self, mock_context): + events = [{"Id": "00UXX", "Subject": "Client meeting", "StartDateTime": "2026-05-01T09:00:00Z"}] + mock_context.fetch.return_value = make_fetch_response({"records": events, "totalSize": 1}) + handler = ListEventsAction() + result = await handler.execute({}, mock_context) + + assert result.data["result"] is True + assert len(result.data["events"]) == 1 + assert result.data["total_size"] == 1 + + async def test_date_filter_applied(self, mock_context): + mock_context.fetch.return_value = make_fetch_response({"records": [], "totalSize": 0}) + handler = ListEventsAction() + await handler.execute({"start_date_from": "2026-05-01", "start_date_to": "2026-05-31"}, mock_context) + + soql = mock_context.fetch.call_args.kwargs["params"]["q"] + assert "StartDateTime >= 2026-05-01T00:00:00Z" in soql + assert "StartDateTime <= 2026-05-31T23:59:59Z" in soql + + async def test_default_limit_is_25(self, mock_context): + mock_context.fetch.return_value = make_fetch_response({"records": [], "totalSize": 0}) + handler = ListEventsAction() + await handler.execute({}, mock_context) + + soql = mock_context.fetch.call_args.kwargs["params"]["q"] + assert "LIMIT 25" in soql + + async def test_error_returns_false(self, mock_context): + mock_context.fetch.side_effect = Exception("network error") + handler = ListEventsAction() + result = await handler.execute({}, mock_context) + + assert result.data["result"] is False + + +class TestGetTaskSummaryAction: + async def test_success(self, mock_context): + task = { + "Id": "00TXX", + "Subject": "Follow up", + "Status": "In Progress", + "Priority": "High", + "ActivityDate": "2026-05-10", + "Description": "Check on contract status.", + } + mock_context.fetch.return_value = make_fetch_response({"records": [task], "totalSize": 1}) + handler = GetTaskSummaryAction() + result = await handler.execute({"task_id": "00TXX"}, mock_context) + + assert result.data["result"] is True + assert "Follow up" in result.data["summary"] + assert "In Progress" in result.data["summary"] + assert result.data["task"]["Id"] == "00TXX" + + async def test_task_not_found(self, mock_context): + mock_context.fetch.return_value = make_fetch_response({"records": [], "totalSize": 0}) + handler = GetTaskSummaryAction() + result = await handler.execute({"task_id": "00TBAD"}, mock_context) + + assert result.data["result"] is False + assert "not found" in result.data["error"].lower() + + async def test_soql_filters_by_task_id(self, mock_context): + mock_context.fetch.return_value = make_fetch_response({"records": [], "totalSize": 0}) + handler = GetTaskSummaryAction() + await handler.execute({"task_id": "00TXX123"}, mock_context) + + soql = mock_context.fetch.call_args.kwargs["params"]["q"] + assert "00TXX123" in soql + assert "FROM Task" in soql + + async def test_error_returns_false(self, mock_context): + mock_context.fetch.side_effect = Exception("API error") + handler = GetTaskSummaryAction() + result = await handler.execute({"task_id": "00TXX"}, mock_context) + + assert result.data["result"] is False + + +class TestGetEventSummaryAction: + async def test_success(self, mock_context): + event = { + "Id": "00UXX", + "Subject": "Board meeting", + "StartDateTime": "2026-06-01T09:00:00Z", + "EndDateTime": "2026-06-01T11:00:00Z", + "Location": "HQ", + "Description": "Annual board review.", + "IsAllDayEvent": False, + } + mock_context.fetch.return_value = make_fetch_response({"records": [event], "totalSize": 1}) + handler = GetEventSummaryAction() + result = await handler.execute({"event_id": "00UXX"}, mock_context) + + assert result.data["result"] is True + assert "Board meeting" in result.data["summary"] + assert "HQ" in result.data["summary"] + assert result.data["event"]["Id"] == "00UXX" + + async def test_event_not_found(self, mock_context): + mock_context.fetch.return_value = make_fetch_response({"records": [], "totalSize": 0}) + handler = GetEventSummaryAction() + result = await handler.execute({"event_id": "00UBAD"}, mock_context) + + assert result.data["result"] is False + assert "not found" in result.data["error"].lower() + + async def test_all_day_event_in_summary(self, mock_context): + event = {"Id": "00UXX", "Subject": "Public Holiday", "IsAllDayEvent": True} + mock_context.fetch.return_value = make_fetch_response({"records": [event], "totalSize": 1}) + handler = GetEventSummaryAction() + result = await handler.execute({"event_id": "00UXX"}, mock_context) + + assert "(All day)" in result.data["summary"] + + async def test_soql_filters_by_event_id(self, mock_context): + mock_context.fetch.return_value = make_fetch_response({"records": [], "totalSize": 0}) + handler = GetEventSummaryAction() + await handler.execute({"event_id": "00UABC"}, mock_context) + + soql = mock_context.fetch.call_args.kwargs["params"]["q"] + assert "00UABC" in soql + assert "FROM Event" in soql + + async def test_error_returns_false(self, mock_context): + mock_context.fetch.side_effect = Exception("timeout") + handler = GetEventSummaryAction() + result = await handler.execute({"event_id": "00UXX"}, mock_context) + + assert result.data["result"] is False