diff --git a/app/cli/onboarding.py b/app/cli/onboarding.py index 7c8405be..4eca83ce 100644 --- a/app/cli/onboarding.py +++ b/app/cli/onboarding.py @@ -13,7 +13,7 @@ ApiKeyStep, AgentNameStep, UserProfileStep, - MCPStep, + IntegrationStep, SkillsStep, ) from app.onboarding import onboarding_manager @@ -32,7 +32,7 @@ class CLIHardOnboarding(OnboardingInterface): 1. LLM Provider selection 2. API Key input 3. Agent name (optional) - 4. MCP server selection (optional) + 4. External app integration selection (optional) 5. Skills selection (optional) Note: User name is collected during soft onboarding (conversational interview). @@ -294,24 +294,6 @@ async def run_hard_onboarding(self) -> Dict[str, Any]: else: self._collected_data["user_profile"] = {} - # Step 5: MCP servers (optional) - mcp_step = MCPStep() - mcp_options = mcp_step.get_options() - if mcp_options: - print("\nWould you like to configure MCP servers? (y/N)") - try: - configure_mcp = await self._async_input("> ") - except (EOFError, KeyboardInterrupt): - configure_mcp = "n" - - if configure_mcp.lower().startswith("y"): - mcp_servers = await self._select_multiple(mcp_step) - self._collected_data["mcp_servers"] = mcp_servers - else: - self._collected_data["mcp_servers"] = [] - else: - self._collected_data["mcp_servers"] = [] - # Step 5: Skills (optional) skills_step = SkillsStep() skills_options = skills_step.get_options() @@ -330,6 +312,13 @@ async def run_hard_onboarding(self) -> Dict[str, Any]: else: self._collected_data["skills"] = [] + # Step 6: External app integrations (optional, web-only panel) + print( + "\nExternal app integrations (Gmail, Slack, GitHub, Notion, etc.)" + " are set up in the browser interface under Settings → Integrations." + ) + self._collected_data["integrations"] = "" + self._collected_data["completed"] = True self.on_complete() diff --git a/app/config/settings.json b/app/config/settings.json index 65e95ada..9be5089a 100644 --- a/app/config/settings.json +++ b/app/config/settings.json @@ -1,5 +1,5 @@ { - "version": "1.3.2", + "version": "1.3.1", "general": { "agent_name": "CraftBot", "os_language": "en" @@ -14,10 +14,10 @@ "item_word_limit": 150 }, "model": { - "llm_provider": "byteplus", - "vlm_provider": "byteplus", - "llm_model": "seed-2-0-pro-260328", - "vlm_model": "seed-2-0-pro-260328", + "llm_provider": "anthropic", + "vlm_provider": "anthropic", + "llm_model": "claude-sonnet-4-5-20250929", + "vlm_model": "claude-sonnet-4-5-20250929", "slow_mode": true, "slow_mode_tpm_limit": 25000 }, diff --git a/app/data/action/integrations/discord/discord_actions.py b/app/data/action/integrations/discord/discord_actions.py index cecfa07a..c4d5ada8 100644 --- a/app/data/action/integrations/discord/discord_actions.py +++ b/app/data/action/integrations/discord/discord_actions.py @@ -2,192 +2,1394 @@ # ═══════════════════════════════════════════════════════════════════════════════ -# Bot actions (sync REST methods) +# Messages — send / edit / delete / reply / bulk-delete / pins / reactions # ═══════════════════════════════════════════════════════════════════════════════ +@action( + name="send_discord_message", + description="Send a message to a Discord channel.", + action_sets=["discord_messages", "discord"], + input_schema={ + "channel_id": {"type": "string", "description": "Discord channel ID.", "example": "123456789012345678"}, + "content": {"type": "string", "description": "Message content.", "example": "Hello!"}, + "reply_to": {"type": "string", "description": "Message ID to reply to (optional).", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def send_discord_message(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "discord", "bot_send_message", + channel_id=input_data["channel_id"], content=input_data["content"], + reply_to=input_data.get("reply_to") or None, + ) + + +@action( + name="edit_discord_message", + description="Edit a previously-sent Discord message (bot can only edit its own messages).", + action_sets=["discord_messages", "discord"], + input_schema={ + "channel_id": {"type": "string", "description": "Channel ID.", "example": ""}, + "message_id": {"type": "string", "description": "Message ID.", "example": ""}, + "content": {"type": "string", "description": "New message content.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def edit_discord_message(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "discord", "edit_message", + channel_id=input_data["channel_id"], + message_id=input_data["message_id"], + content=input_data["content"], + ) + + +@action( + name="delete_discord_message", + description="Delete a Discord message.", + action_sets=["discord_messages", "discord"], + input_schema={ + "channel_id": {"type": "string", "description": "Channel ID.", "example": ""}, + "message_id": {"type": "string", "description": "Message ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def delete_discord_message(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "discord", "delete_message", + channel_id=input_data["channel_id"], + message_id=input_data["message_id"], + ) + + +@action( + name="bulk_delete_discord_messages", + description="Delete 2-100 messages at once. All must be less than 14 days old.", + action_sets=["discord_messages", "discord"], + input_schema={ + "channel_id": {"type": "string", "description": "Channel ID.", "example": ""}, + "message_ids": {"type": "array", "description": "Array of message IDs (2-100).", "example": []}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def bulk_delete_discord_messages(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "discord", "bulk_delete_messages", + channel_id=input_data["channel_id"], + message_ids=input_data["message_ids"], + ) + + +@action( + name="crosspost_discord_message", + description="Publish a message from an announcement channel to following servers.", + action_sets=["discord_messages"], + input_schema={ + "channel_id": {"type": "string", "description": "Announcement channel ID.", "example": ""}, + "message_id": {"type": "string", "description": "Message ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def crosspost_discord_message(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "discord", "crosspost_message", + channel_id=input_data["channel_id"], + message_id=input_data["message_id"], + ) + + +@action( + name="get_discord_messages", + description="Get messages from a Discord channel.", + action_sets=["discord_messages", "discord"], + input_schema={ + "channel_id": {"type": "string", "description": "Discord channel ID.", "example": "123456789012345678"}, + "limit": {"type": "integer", "description": "Max messages to return (1-100).", "example": 50}, + "before": {"type": "string", "description": "Message ID to get messages before (optional).", "example": ""}, + "after": {"type": "string", "description": "Message ID to get messages after (optional).", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_discord_messages(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "discord", "get_messages", + channel_id=input_data["channel_id"], limit=input_data.get("limit", 50), + before=input_data.get("before") or None, + after=input_data.get("after") or None, + ) + + +@action( + name="pin_discord_message", + description="Pin a message in a Discord channel.", + action_sets=["discord_messages", "discord"], + input_schema={ + "channel_id": {"type": "string", "description": "Channel ID.", "example": ""}, + "message_id": {"type": "string", "description": "Message ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def pin_discord_message(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "discord", "pin_message", + channel_id=input_data["channel_id"], + message_id=input_data["message_id"], + ) + + +@action( + name="unpin_discord_message", + description="Unpin a message from a Discord channel.", + action_sets=["discord_messages"], + input_schema={ + "channel_id": {"type": "string", "description": "Channel ID.", "example": ""}, + "message_id": {"type": "string", "description": "Message ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def unpin_discord_message(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "discord", "unpin_message", + channel_id=input_data["channel_id"], + message_id=input_data["message_id"], + ) + + +@action( + name="list_discord_pinned_messages", + description="List pinned messages in a Discord channel.", + action_sets=["discord_messages", "discord"], + input_schema={ + "channel_id": {"type": "string", "description": "Channel ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def list_discord_pinned_messages(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("discord", "list_pinned_messages", channel_id=input_data["channel_id"]) + + +@action( + name="add_discord_reaction", + description="Add a reaction emoji to a message.", + action_sets=["discord_messages", "discord"], + input_schema={ + "channel_id": {"type": "string", "description": "Channel ID.", "example": "123"}, + "message_id": {"type": "string", "description": "Message ID.", "example": "456"}, + "emoji": {"type": "string", "description": "Unicode emoji or 'name:id' for custom.", "example": "👍"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def add_discord_reaction(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "discord", "add_reaction", + channel_id=input_data["channel_id"], + message_id=input_data["message_id"], + emoji=input_data["emoji"], + ) + + +@action( + name="remove_discord_own_reaction", + description="Remove the bot's own reaction from a message.", + action_sets=["discord_messages", "discord"], + input_schema={ + "channel_id": {"type": "string", "description": "Channel ID.", "example": ""}, + "message_id": {"type": "string", "description": "Message ID.", "example": ""}, + "emoji": {"type": "string", "description": "Emoji.", "example": "👍"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def remove_discord_own_reaction(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "discord", "remove_own_reaction", + channel_id=input_data["channel_id"], + message_id=input_data["message_id"], + emoji=input_data["emoji"], + ) + + +@action( + name="remove_discord_user_reaction", + description="Remove a specific user's reaction from a message (mod action).", + action_sets=["discord_messages"], + input_schema={ + "channel_id": {"type": "string", "description": "Channel ID.", "example": ""}, + "message_id": {"type": "string", "description": "Message ID.", "example": ""}, + "emoji": {"type": "string", "description": "Emoji.", "example": ""}, + "user_id": {"type": "string", "description": "User ID whose reaction to remove.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def remove_discord_user_reaction(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "discord", "remove_user_reaction", + channel_id=input_data["channel_id"], + message_id=input_data["message_id"], + emoji=input_data["emoji"], + user_id=input_data["user_id"], + ) + + +@action( + name="list_discord_reaction_users", + description="List users who reacted with a specific emoji.", + action_sets=["discord_messages"], + input_schema={ + "channel_id": {"type": "string", "description": "Channel ID.", "example": ""}, + "message_id": {"type": "string", "description": "Message ID.", "example": ""}, + "emoji": {"type": "string", "description": "Emoji.", "example": ""}, + "limit": {"type": "integer", "description": "Max users.", "example": 100}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def list_discord_reaction_users(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "discord", "list_reaction_users", + channel_id=input_data["channel_id"], + message_id=input_data["message_id"], + emoji=input_data["emoji"], + limit=input_data.get("limit", 100), + ) + + +@action( + name="clear_discord_reactions", + description="Clear all reactions on a message, or just one emoji's reactions.", + action_sets=["discord_messages"], + input_schema={ + "channel_id": {"type": "string", "description": "Channel ID.", "example": ""}, + "message_id": {"type": "string", "description": "Message ID.", "example": ""}, + "emoji": {"type": "string", "description": "Specific emoji (optional — omit to clear ALL reactions).", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def clear_discord_reactions(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "discord", "clear_reactions", + channel_id=input_data["channel_id"], + message_id=input_data["message_id"], + emoji=input_data.get("emoji") or None, + ) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Threads +# ═══════════════════════════════════════════════════════════════════════════════ + +@action( + name="create_discord_thread_from_message", + description="Create a thread anchored to an existing message.", + action_sets=["discord_threads", "discord"], + input_schema={ + "channel_id": {"type": "string", "description": "Channel ID.", "example": ""}, + "message_id": {"type": "string", "description": "Message to thread from.", "example": ""}, + "name": {"type": "string", "description": "Thread name (1-100 chars).", "example": "Discussion"}, + "auto_archive_duration": {"type": "integer", "description": "Minutes: 60, 1440, 4320, 10080.", "example": 1440}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def create_discord_thread_from_message(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "discord", "create_thread_from_message", + channel_id=input_data["channel_id"], + message_id=input_data["message_id"], + name=input_data["name"], + auto_archive_duration=input_data.get("auto_archive_duration", 1440), + ) + + +@action( + name="create_discord_thread", + description="Create a thread (no starter message). thread_type: 10=announcement, 11=public, 12=private.", + action_sets=["discord_threads", "discord"], + input_schema={ + "channel_id": {"type": "string", "description": "Parent channel ID.", "example": ""}, + "name": {"type": "string", "description": "Thread name.", "example": ""}, + "thread_type": {"type": "integer", "description": "10/11/12.", "example": 11}, + "auto_archive_duration": {"type": "integer", "description": "Minutes.", "example": 1440}, + "invitable": {"type": "boolean", "description": "Allow non-mods to add others (private threads).", "example": True}, + "rate_limit_per_user": {"type": "integer", "description": "Slowmode seconds (optional).", "example": 0}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def create_discord_thread(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + rl = input_data.get("rate_limit_per_user") + return run_client_sync( + "discord", "create_thread", + channel_id=input_data["channel_id"], + name=input_data["name"], + thread_type=input_data.get("thread_type", 11), + auto_archive_duration=input_data.get("auto_archive_duration", 1440), + invitable=bool(input_data.get("invitable", True)), + rate_limit_per_user=rl if rl is not None else None, + ) + + +@action( + name="join_discord_thread", + description="Join a Discord thread as the bot.", + action_sets=["discord_threads", "discord"], + input_schema={ + "thread_id": {"type": "string", "description": "Thread (channel) ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def join_discord_thread(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("discord", "join_thread", thread_id=input_data["thread_id"]) + + +@action( + name="leave_discord_thread", + description="Leave a Discord thread.", + action_sets=["discord_threads"], + input_schema={ + "thread_id": {"type": "string", "description": "Thread ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def leave_discord_thread(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("discord", "leave_thread", thread_id=input_data["thread_id"]) + + +@action( + name="add_discord_thread_member", + description="Add a user to a thread.", + action_sets=["discord_threads"], + input_schema={ + "thread_id": {"type": "string", "description": "Thread ID.", "example": ""}, + "user_id": {"type": "string", "description": "User ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def add_discord_thread_member(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "discord", "add_thread_member", + thread_id=input_data["thread_id"], user_id=input_data["user_id"], + ) + + +@action( + name="remove_discord_thread_member", + description="Remove a user from a thread.", + action_sets=["discord_threads"], + input_schema={ + "thread_id": {"type": "string", "description": "Thread ID.", "example": ""}, + "user_id": {"type": "string", "description": "User ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def remove_discord_thread_member(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "discord", "remove_thread_member", + thread_id=input_data["thread_id"], user_id=input_data["user_id"], + ) + + +@action( + name="list_discord_thread_members", + description="List members of a thread.", + action_sets=["discord_threads"], + input_schema={ + "thread_id": {"type": "string", "description": "Thread ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def list_discord_thread_members(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("discord", "list_thread_members", thread_id=input_data["thread_id"]) + + +@action( + name="list_discord_active_threads", + description="List active (non-archived) threads in a guild the bot can access.", + action_sets=["discord_threads", "discord"], + input_schema={ + "guild_id": {"type": "string", "description": "Guild ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def list_discord_active_threads(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("discord", "list_active_threads", guild_id=input_data["guild_id"]) + + +@action( + name="archive_discord_thread", + description="Archive a thread (closes for new messages).", + action_sets=["discord_threads"], + input_schema={ + "thread_id": {"type": "string", "description": "Thread ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def archive_discord_thread(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("discord", "archive_thread", thread_id=input_data["thread_id"]) + + +@action( + name="unarchive_discord_thread", + description="Unarchive a previously-archived thread.", + action_sets=["discord_threads"], + input_schema={ + "thread_id": {"type": "string", "description": "Thread ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def unarchive_discord_thread(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("discord", "unarchive_thread", thread_id=input_data["thread_id"]) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Channels — list / info / CRUD / permissions / invites +# ═══════════════════════════════════════════════════════════════════════════════ + +@action( + name="get_discord_channels", + description="Get all channels in a Discord guild.", + action_sets=["discord_channels", "discord"], + input_schema={ + "guild_id": {"type": "string", "description": "Discord guild (server) ID.", "example": "123456789012345678"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_discord_channels(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("discord", "get_guild_channels", guild_id=input_data["guild_id"]) + + +@action( + name="get_discord_channel", + description="Get info about a single Discord channel.", + action_sets=["discord_channels", "discord"], + input_schema={ + "channel_id": {"type": "string", "description": "Channel ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_discord_channel(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("discord", "get_channel", channel_id=input_data["channel_id"]) + + +@action( + name="create_discord_channel", + description="Create a channel in a guild. channel_type: 0=text, 2=voice, 4=category, 5=announcement, 13=stage, 15=forum.", + action_sets=["discord_channels", "discord"], + input_schema={ + "guild_id": {"type": "string", "description": "Guild ID.", "example": ""}, + "name": {"type": "string", "description": "Channel name.", "example": "general"}, + "channel_type": {"type": "integer", "description": "0/2/4/5/13/15.", "example": 0}, + "topic": {"type": "string", "description": "Topic (text channels only).", "example": ""}, + "parent_id": {"type": "string", "description": "Category ID (optional).", "example": ""}, + "nsfw": {"type": "boolean", "description": "NSFW flag.", "example": False}, + "rate_limit_per_user": {"type": "integer", "description": "Slowmode seconds.", "example": 0}, + "position": {"type": "integer", "description": "Channel position.", "example": 0}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def create_discord_channel(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "discord", "create_guild_channel", + guild_id=input_data["guild_id"], name=input_data["name"], + channel_type=input_data.get("channel_type", 0), + topic=input_data.get("topic") or None, + parent_id=input_data.get("parent_id") or None, + nsfw=bool(input_data.get("nsfw", False)), + rate_limit_per_user=input_data.get("rate_limit_per_user") if "rate_limit_per_user" in input_data else None, + position=input_data.get("position") if "position" in input_data else None, + ) + + +@action( + name="modify_discord_channel", + description="Edit channel name/topic/slowmode/category/NSFW.", + action_sets=["discord_channels", "discord"], + input_schema={ + "channel_id": {"type": "string", "description": "Channel ID.", "example": ""}, + "name": {"type": "string", "description": "New name (optional).", "example": ""}, + "topic": {"type": "string", "description": "New topic (optional).", "example": ""}, + "nsfw": {"type": "boolean", "description": "NSFW flag (optional).", "example": False}, + "rate_limit_per_user": {"type": "integer", "description": "Slowmode seconds (optional).", "example": 0}, + "parent_id": {"type": "string", "description": "New category ID (optional).", "example": ""}, + "position": {"type": "integer", "description": "New position (optional).", "example": 0}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def modify_discord_channel(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "discord", "modify_channel", + channel_id=input_data["channel_id"], + name=input_data.get("name") or None, + topic=input_data["topic"] if "topic" in input_data else None, + nsfw=input_data["nsfw"] if "nsfw" in input_data else None, + rate_limit_per_user=input_data["rate_limit_per_user"] if "rate_limit_per_user" in input_data else None, + parent_id=input_data.get("parent_id") or None, + position=input_data["position"] if "position" in input_data else None, + ) + + +@action( + name="delete_discord_channel", + description="Delete a Discord channel.", + action_sets=["discord_channels"], + input_schema={ + "channel_id": {"type": "string", "description": "Channel ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def delete_discord_channel(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("discord", "delete_channel", channel_id=input_data["channel_id"]) + + +@action( + name="set_discord_channel_permissions", + description="Set permission overwrites for a role/member on a channel. allow/deny are decimal-string bitfields. type: 0=role, 1=member.", + action_sets=["discord_channels"], + input_schema={ + "channel_id": {"type": "string", "description": "Channel ID.", "example": ""}, + "overwrite_id": {"type": "string", "description": "Role ID or member ID.", "example": ""}, + "allow": {"type": "string", "description": "Allow bitfield as decimal string.", "example": "0"}, + "deny": {"type": "string", "description": "Deny bitfield as decimal string.", "example": "0"}, + "type": {"type": "integer", "description": "0=role, 1=member.", "example": 0}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def set_discord_channel_permissions(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "discord", "edit_channel_permissions", + channel_id=input_data["channel_id"], + overwrite_id=input_data["overwrite_id"], + allow=input_data.get("allow", "0"), + deny=input_data.get("deny", "0"), + type=input_data.get("type", 0), + ) + + +@action( + name="delete_discord_channel_permission", + description="Remove a permission overwrite from a channel.", + action_sets=["discord_channels"], + input_schema={ + "channel_id": {"type": "string", "description": "Channel ID.", "example": ""}, + "overwrite_id": {"type": "string", "description": "Role/member ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def delete_discord_channel_permission(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "discord", "delete_channel_permission", + channel_id=input_data["channel_id"], + overwrite_id=input_data["overwrite_id"], + ) + + +@action( + name="list_discord_channel_invites", + description="List invite codes for a channel.", + action_sets=["discord_channels", "discord"], + input_schema={ + "channel_id": {"type": "string", "description": "Channel ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def list_discord_channel_invites(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("discord", "list_channel_invites", channel_id=input_data["channel_id"]) + + +@action( + name="create_discord_invite", + description="Create an invite for a channel.", + action_sets=["discord_channels", "discord"], + input_schema={ + "channel_id": {"type": "string", "description": "Channel ID.", "example": ""}, + "max_age": {"type": "integer", "description": "Seconds until expiry (0=never).", "example": 86400}, + "max_uses": {"type": "integer", "description": "0=unlimited.", "example": 0}, + "temporary": {"type": "boolean", "description": "Members are kicked after disconnect.", "example": False}, + "unique": {"type": "boolean", "description": "Don't reuse existing invite.", "example": False}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def create_discord_invite(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "discord", "create_channel_invite", + channel_id=input_data["channel_id"], + max_age=input_data.get("max_age", 86400), + max_uses=input_data.get("max_uses", 0), + temporary=bool(input_data.get("temporary", False)), + unique=bool(input_data.get("unique", False)), + ) + + +@action( + name="delete_discord_invite", + description="Delete (revoke) a Discord invite code.", + action_sets=["discord_channels"], + input_schema={ + "invite_code": {"type": "string", "description": "Invite code.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def delete_discord_invite(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("discord", "delete_invite", invite_code=input_data["invite_code"]) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Webhooks (channel-scoped) +# ═══════════════════════════════════════════════════════════════════════════════ + +@action( + name="list_discord_webhooks", + description="List webhooks in a channel.", + action_sets=["discord_channels", "discord"], + input_schema={ + "channel_id": {"type": "string", "description": "Channel ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def list_discord_webhooks(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("discord", "list_channel_webhooks", channel_id=input_data["channel_id"]) + + +@action( + name="create_discord_webhook", + description="Create a webhook on a channel. Returns id + token (the token gives webhook-only posting auth).", + action_sets=["discord_channels", "discord"], + input_schema={ + "channel_id": {"type": "string", "description": "Channel ID.", "example": ""}, + "name": {"type": "string", "description": "Webhook name.", "example": "Notifier"}, + "avatar": {"type": "string", "description": "Data-URI avatar (optional).", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def create_discord_webhook(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "discord", "create_webhook", + channel_id=input_data["channel_id"], name=input_data["name"], + avatar=input_data.get("avatar") or None, + ) + + +@action( + name="get_discord_webhook", + description="Get a webhook by ID.", + action_sets=["discord_channels"], + input_schema={ + "webhook_id": {"type": "string", "description": "Webhook ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_discord_webhook(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("discord", "get_webhook", webhook_id=input_data["webhook_id"]) + + +@action( + name="modify_discord_webhook", + description="Edit a webhook's name/avatar/channel.", + action_sets=["discord_channels"], + input_schema={ + "webhook_id": {"type": "string", "description": "Webhook ID.", "example": ""}, + "name": {"type": "string", "description": "New name (optional).", "example": ""}, + "avatar": {"type": "string", "description": "New avatar data-URI (optional).", "example": ""}, + "channel_id": {"type": "string", "description": "Move to channel (optional).", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def modify_discord_webhook(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "discord", "modify_webhook", + webhook_id=input_data["webhook_id"], + name=input_data["name"] if "name" in input_data else None, + avatar=input_data["avatar"] if "avatar" in input_data else None, + channel_id=input_data["channel_id"] if "channel_id" in input_data else None, + ) + + +@action( + name="delete_discord_webhook", + description="Delete a Discord webhook.", + action_sets=["discord_channels"], + input_schema={ + "webhook_id": {"type": "string", "description": "Webhook ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def delete_discord_webhook(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("discord", "delete_webhook", webhook_id=input_data["webhook_id"]) + + +@action( + name="execute_discord_webhook", + description="Post a message via a webhook (auth via webhook_token, not bot token).", + action_sets=["discord_channels", "discord"], + input_schema={ + "webhook_id": {"type": "string", "description": "Webhook ID.", "example": ""}, + "webhook_token": {"type": "string", "description": "Webhook token (from creation).", "example": ""}, + "content": {"type": "string", "description": "Message content.", "example": ""}, + "username": {"type": "string", "description": "Override sender username (optional).", "example": ""}, + "avatar_url": {"type": "string", "description": "Override sender avatar (optional).", "example": ""}, + "embeds": {"type": "array", "description": "Embed objects (optional).", "example": []}, + "wait": {"type": "boolean", "description": "Wait for server confirmation (returns message).", "example": False}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def execute_discord_webhook(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "discord", "execute_webhook", + webhook_id=input_data["webhook_id"], + webhook_token=input_data["webhook_token"], + content=input_data.get("content") or None, + username=input_data.get("username") or None, + avatar_url=input_data.get("avatar_url") or None, + embeds=input_data.get("embeds") or None, + wait=bool(input_data.get("wait", False)), + ) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Members — list / get / search / modify (nick/roles/timeout/voice) / kick / ban +# ═══════════════════════════════════════════════════════════════════════════════ + +@action( + name="list_discord_guild_members", + description="List members of a guild.", + action_sets=["discord_members", "discord"], + input_schema={ + "guild_id": {"type": "string", "description": "Guild ID.", "example": "123456789012345678"}, + "limit": {"type": "integer", "description": "Limit.", "example": 100}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def list_discord_guild_members(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "discord", "list_guild_members", + guild_id=input_data["guild_id"], limit=input_data.get("limit", 100), + ) + + +@action( + name="get_discord_guild_member", + description="Get a single guild member (incl. roles, joined_at, nick).", + action_sets=["discord_members", "discord"], + input_schema={ + "guild_id": {"type": "string", "description": "Guild ID.", "example": ""}, + "user_id": {"type": "string", "description": "User ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_discord_guild_member(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "discord", "get_guild_member", + guild_id=input_data["guild_id"], user_id=input_data["user_id"], + ) + + +@action( + name="search_discord_guild_members", + description="Search for members by username/nickname prefix.", + action_sets=["discord_members", "discord"], + input_schema={ + "guild_id": {"type": "string", "description": "Guild ID.", "example": ""}, + "query": {"type": "string", "description": "Name prefix.", "example": "alice"}, + "limit": {"type": "integer", "description": "Max results (max 1000).", "example": 10}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def search_discord_guild_members(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "discord", "search_guild_members", + guild_id=input_data["guild_id"], + query=input_data["query"], + limit=input_data.get("limit", 10), + ) + @action( - name="send_discord_message", - description="Send a message to a Discord channel.", - action_sets=["discord"], + name="modify_discord_guild_member", + description="Modify a guild member: nick / roles (full replace) / mute/deaf / move voice channel / timeout. communication_disabled_until is an ISO 8601 timestamp (max 28 days in future) — null/omit to clear.", + action_sets=["discord_members", "discord"], input_schema={ - "channel_id": { - "type": "string", - "description": "Discord channel ID.", - "example": "123456789012345678", - }, - "content": { - "type": "string", - "description": "Message content.", - "example": "Hello!", - }, + "guild_id": {"type": "string", "description": "Guild ID.", "example": ""}, + "user_id": {"type": "string", "description": "User ID.", "example": ""}, + "nick": {"type": "string", "description": "New nickname (optional, '' to clear).", "example": ""}, + "roles": {"type": "array", "description": "Full list of role IDs (replaces existing).", "example": []}, + "mute": {"type": "boolean", "description": "Voice mute.", "example": False}, + "deaf": {"type": "boolean", "description": "Voice deafen.", "example": False}, + "channel_id": {"type": "string", "description": "Move to this voice channel.", "example": ""}, + "communication_disabled_until": {"type": "string", "description": "Timeout end (ISO 8601).", "example": ""}, }, output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, ) -def send_discord_message(input_data: dict) -> dict: +def modify_discord_guild_member(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "discord", "modify_guild_member", + guild_id=input_data["guild_id"], user_id=input_data["user_id"], + nick=input_data["nick"] if "nick" in input_data else None, + roles=input_data["roles"] if "roles" in input_data else None, + mute=input_data["mute"] if "mute" in input_data else None, + deaf=input_data["deaf"] if "deaf" in input_data else None, + channel_id=input_data["channel_id"] if "channel_id" in input_data else None, + communication_disabled_until=input_data["communication_disabled_until"] if "communication_disabled_until" in input_data else None, + ) + +@action( + name="set_discord_bot_nickname", + description="Set the bot's nickname in a guild.", + action_sets=["discord_members"], + input_schema={ + "guild_id": {"type": "string", "description": "Guild ID.", "example": ""}, + "nick": {"type": "string", "description": "New nickname (empty to clear).", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def set_discord_bot_nickname(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync return run_client_sync( - "discord", - "bot_send_message", - channel_id=input_data["channel_id"], - content=input_data["content"], + "discord", "modify_current_member_nick", + guild_id=input_data["guild_id"], nick=input_data.get("nick") or None, ) @action( - name="get_discord_messages", - description="Get messages from a Discord channel.", - action_sets=["discord"], + name="add_discord_member_role", + description="Assign a role to a guild member.", + action_sets=["discord_members", "discord"], input_schema={ - "channel_id": { - "type": "string", - "description": "Discord channel ID.", - "example": "123456789012345678", - }, - "limit": { - "type": "integer", - "description": "Max messages to return (1-100).", - "example": 50, - }, + "guild_id": {"type": "string", "description": "Guild ID.", "example": ""}, + "user_id": {"type": "string", "description": "User ID.", "example": ""}, + "role_id": {"type": "string", "description": "Role ID.", "example": ""}, }, output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, ) -def get_discord_messages(input_data: dict) -> dict: +def add_discord_member_role(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "discord", "add_guild_member_role", + guild_id=input_data["guild_id"], + user_id=input_data["user_id"], + role_id=input_data["role_id"], + ) + +@action( + name="remove_discord_member_role", + description="Remove a role from a guild member.", + action_sets=["discord_members", "discord"], + input_schema={ + "guild_id": {"type": "string", "description": "Guild ID.", "example": ""}, + "user_id": {"type": "string", "description": "User ID.", "example": ""}, + "role_id": {"type": "string", "description": "Role ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def remove_discord_member_role(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync return run_client_sync( - "discord", - "get_messages", - channel_id=input_data["channel_id"], - limit=input_data.get("limit", 50), + "discord", "remove_guild_member_role", + guild_id=input_data["guild_id"], + user_id=input_data["user_id"], + role_id=input_data["role_id"], + ) + + +@action( + name="kick_discord_member", + description="Kick a user from a guild (they can rejoin via invite).", + action_sets=["discord_members", "discord"], + input_schema={ + "guild_id": {"type": "string", "description": "Guild ID.", "example": ""}, + "user_id": {"type": "string", "description": "User ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def kick_discord_member(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "discord", "kick_guild_member", + guild_id=input_data["guild_id"], user_id=input_data["user_id"], + ) + + +@action( + name="ban_discord_member", + description="Ban a user from a guild. delete_message_seconds (0..604800) wipes their recent messages.", + action_sets=["discord_members", "discord"], + input_schema={ + "guild_id": {"type": "string", "description": "Guild ID.", "example": ""}, + "user_id": {"type": "string", "description": "User ID.", "example": ""}, + "delete_message_seconds": {"type": "integer", "description": "0..604800 (7d).", "example": 0}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def ban_discord_member(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "discord", "ban_guild_member", + guild_id=input_data["guild_id"], user_id=input_data["user_id"], + delete_message_seconds=input_data.get("delete_message_seconds", 0), + ) + + +@action( + name="unban_discord_member", + description="Lift a ban on a user.", + action_sets=["discord_members", "discord"], + input_schema={ + "guild_id": {"type": "string", "description": "Guild ID.", "example": ""}, + "user_id": {"type": "string", "description": "User ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def unban_discord_member(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "discord", "unban_guild_member", + guild_id=input_data["guild_id"], user_id=input_data["user_id"], ) +@action( + name="list_discord_bans", + description="List bans in a guild.", + action_sets=["discord_members"], + input_schema={ + "guild_id": {"type": "string", "description": "Guild ID.", "example": ""}, + "limit": {"type": "integer", "description": "Max results.", "example": 100}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def list_discord_bans(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "discord", "list_guild_bans", + guild_id=input_data["guild_id"], limit=input_data.get("limit", 100), + ) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Guild — list/info + roles + emojis/stickers + scheduled events + audit log + invites +# ═══════════════════════════════════════════════════════════════════════════════ + @action( name="list_discord_guilds", description="List Discord guilds (servers) the bot is in.", - action_sets=["discord"], + action_sets=["discord_guild", "discord"], input_schema={ - "limit": { - "type": "integer", - "description": "Max guilds to return.", - "example": 100, - }, + "limit": {"type": "integer", "description": "Max guilds to return.", "example": 100}, }, output_schema={"status": {"type": "string", "example": "success"}}, ) def list_discord_guilds(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("discord", "get_bot_guilds", limit=input_data.get("limit", 100)) + + +@action( + name="get_discord_guild", + description="Get info about a Discord guild.", + action_sets=["discord_guild", "discord"], + input_schema={ + "guild_id": {"type": "string", "description": "Guild ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_discord_guild(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("discord", "get_guild", guild_id=input_data["guild_id"]) + + +@action( + name="list_discord_guild_roles", + description="List roles in a guild.", + action_sets=["discord_guild", "discord"], + input_schema={ + "guild_id": {"type": "string", "description": "Guild ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def list_discord_guild_roles(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("discord", "get_guild_roles", guild_id=input_data["guild_id"]) + +@action( + name="create_discord_role", + description="Create a new role in a guild. permissions is a decimal-string bitfield. color is an integer (0xRRGGBB).", + action_sets=["discord_guild", "discord"], + input_schema={ + "guild_id": {"type": "string", "description": "Guild ID.", "example": ""}, + "name": {"type": "string", "description": "Role name.", "example": ""}, + "permissions": {"type": "string", "description": "Permissions bitfield (optional).", "example": "0"}, + "color": {"type": "integer", "description": "Color int (optional).", "example": 0}, + "hoist": {"type": "boolean", "description": "Display separately in member list.", "example": False}, + "mentionable": {"type": "boolean", "description": "Can be @-mentioned.", "example": False}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def create_discord_role(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync return run_client_sync( - "discord", "get_bot_guilds", limit=input_data.get("limit", 100) + "discord", "create_guild_role", + guild_id=input_data["guild_id"], name=input_data["name"], + permissions=input_data.get("permissions") or None, + color=input_data["color"] if "color" in input_data else None, + hoist=bool(input_data.get("hoist", False)), + mentionable=bool(input_data.get("mentionable", False)), ) @action( - name="get_discord_channels", - description="Get all channels in a Discord guild.", - action_sets=["discord"], + name="modify_discord_role", + description="Edit a role's name/permissions/color/hoist/mentionable.", + action_sets=["discord_guild"], input_schema={ - "guild_id": { - "type": "string", - "description": "Discord guild (server) ID.", - "example": "123456789012345678", - }, + "guild_id": {"type": "string", "description": "Guild ID.", "example": ""}, + "role_id": {"type": "string", "description": "Role ID.", "example": ""}, + "name": {"type": "string", "description": "New name (optional).", "example": ""}, + "permissions": {"type": "string", "description": "New permissions (optional).", "example": ""}, + "color": {"type": "integer", "description": "New color (optional).", "example": 0}, + "hoist": {"type": "boolean", "description": "Hoist (optional).", "example": False}, + "mentionable": {"type": "boolean", "description": "Mentionable (optional).", "example": False}, }, output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, ) -def get_discord_channels(input_data: dict) -> dict: +def modify_discord_role(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "discord", "modify_guild_role", + guild_id=input_data["guild_id"], role_id=input_data["role_id"], + name=input_data.get("name") or None, + permissions=input_data.get("permissions") or None, + color=input_data["color"] if "color" in input_data else None, + hoist=input_data["hoist"] if "hoist" in input_data else None, + mentionable=input_data["mentionable"] if "mentionable" in input_data else None, + ) + +@action( + name="delete_discord_role", + description="Delete a role from a guild.", + action_sets=["discord_guild"], + input_schema={ + "guild_id": {"type": "string", "description": "Guild ID.", "example": ""}, + "role_id": {"type": "string", "description": "Role ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def delete_discord_role(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync return run_client_sync( - "discord", "get_guild_channels", guild_id=input_data["guild_id"] + "discord", "delete_guild_role", + guild_id=input_data["guild_id"], role_id=input_data["role_id"], ) @action( - name="send_discord_dm", - description="Send a direct message to a Discord user.", - action_sets=["discord"], + name="list_discord_emojis", + description="List custom emojis in a guild.", + action_sets=["discord_guild"], input_schema={ - "recipient_id": { - "type": "string", - "description": "Discord user ID to DM.", - "example": "123456789012345678", - }, - "content": { - "type": "string", - "description": "Message content.", - "example": "Hey there!", - }, + "guild_id": {"type": "string", "description": "Guild ID.", "example": ""}, }, output_schema={"status": {"type": "string", "example": "success"}}, ) -def send_discord_dm(input_data: dict) -> dict: +def list_discord_emojis(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("discord", "list_guild_emojis", guild_id=input_data["guild_id"]) + +@action( + name="create_discord_emoji", + description="Create a custom emoji. image is a data-URI: 'data:image/png;base64,'.", + action_sets=["discord_guild"], + input_schema={ + "guild_id": {"type": "string", "description": "Guild ID.", "example": ""}, + "name": {"type": "string", "description": "Emoji name (alphanumeric+underscore).", "example": ""}, + "image": {"type": "string", "description": "Data-URI string.", "example": ""}, + "roles": {"type": "array", "description": "Role IDs restricted to use (optional).", "example": []}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def create_discord_emoji(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync return run_client_sync( - "discord", - "send_dm", - recipient_id=input_data["recipient_id"], - content=input_data["content"], + "discord", "create_guild_emoji", + guild_id=input_data["guild_id"], + name=input_data["name"], + image=input_data["image"], + roles=input_data.get("roles") or None, ) @action( - name="list_discord_guild_members", - description="List guild members.", - action_sets=["discord"], - input_schema={ - "guild_id": { - "type": "string", - "description": "Guild ID.", - "example": "123456789012345678", - }, - "limit": {"type": "integer", "description": "Limit.", "example": 100}, + name="delete_discord_emoji", + description="Delete a custom emoji.", + action_sets=["discord_guild"], + input_schema={ + "guild_id": {"type": "string", "description": "Guild ID.", "example": ""}, + "emoji_id": {"type": "string", "description": "Emoji ID.", "example": ""}, }, output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, ) -def list_discord_guild_members(input_data: dict) -> dict: +def delete_discord_emoji(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "discord", "delete_guild_emoji", + guild_id=input_data["guild_id"], emoji_id=input_data["emoji_id"], + ) + + +@action( + name="list_discord_stickers", + description="List custom stickers in a guild.", + action_sets=["discord_guild"], + input_schema={ + "guild_id": {"type": "string", "description": "Guild ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def list_discord_stickers(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("discord", "list_guild_stickers", guild_id=input_data["guild_id"]) + +@action( + name="list_discord_scheduled_events", + description="List scheduled events in a guild.", + action_sets=["discord_guild", "discord"], + input_schema={ + "guild_id": {"type": "string", "description": "Guild ID.", "example": ""}, + "with_user_count": {"type": "boolean", "description": "Include RSVP counts.", "example": False}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def list_discord_scheduled_events(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync return run_client_sync( - "discord", - "list_guild_members", + "discord", "list_scheduled_events", guild_id=input_data["guild_id"], - limit=input_data.get("limit", 100), + with_user_count=bool(input_data.get("with_user_count", False)), ) @action( - name="add_discord_reaction", - description="Add reaction.", - action_sets=["discord"], - input_schema={ - "channel_id": { - "type": "string", - "description": "Channel ID.", - "example": "123", - }, - "message_id": { - "type": "string", - "description": "Message ID.", - "example": "456", - }, - "emoji": {"type": "string", "description": "Emoji.", "example": "👍"}, + name="create_discord_scheduled_event", + description="Create a scheduled event. entity_type: 1=stage, 2=voice, 3=external. For external, provide entity_metadata={'location':'...'} and scheduled_end_time.", + action_sets=["discord_guild", "discord"], + input_schema={ + "guild_id": {"type": "string", "description": "Guild ID.", "example": ""}, + "name": {"type": "string", "description": "Event name.", "example": ""}, + "scheduled_start_time": {"type": "string", "description": "ISO 8601 start time.", "example": ""}, + "entity_type": {"type": "integer", "description": "1=stage, 2=voice, 3=external.", "example": 3}, + "scheduled_end_time": {"type": "string", "description": "ISO 8601 end (required for external).", "example": ""}, + "channel_id": {"type": "string", "description": "Voice/stage channel ID (required for 1/2).", "example": ""}, + "entity_metadata": {"type": "object", "description": "{'location': '...'} for external events.", "example": {}}, + "description": {"type": "string", "description": "Event description (optional).", "example": ""}, }, output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, ) -def add_discord_reaction(input_data: dict) -> dict: +def create_discord_scheduled_event(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "discord", "create_scheduled_event", + guild_id=input_data["guild_id"], name=input_data["name"], + scheduled_start_time=input_data["scheduled_start_time"], + entity_type=input_data["entity_type"], + scheduled_end_time=input_data.get("scheduled_end_time") or None, + channel_id=input_data.get("channel_id") or None, + entity_metadata=input_data.get("entity_metadata") or None, + description=input_data.get("description") or None, + ) + +@action( + name="delete_discord_scheduled_event", + description="Delete a scheduled event.", + action_sets=["discord_guild"], + input_schema={ + "guild_id": {"type": "string", "description": "Guild ID.", "example": ""}, + "event_id": {"type": "string", "description": "Event ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def delete_discord_scheduled_event(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync return run_client_sync( - "discord", - "add_reaction", - channel_id=input_data["channel_id"], - message_id=input_data["message_id"], - emoji=input_data["emoji"], + "discord", "delete_scheduled_event", + guild_id=input_data["guild_id"], event_id=input_data["event_id"], + ) + + +@action( + name="get_discord_audit_log", + description="Get the guild audit log (mod actions). action_type filters: 1=guild_update, 10=channel_create, 11=channel_update, 12=channel_delete, 20=member_kick, 22=member_ban_add, 23=member_ban_remove, 25=member_update, 30=role_create, 72=message_delete (see Discord docs).", + action_sets=["discord_guild", "discord"], + input_schema={ + "guild_id": {"type": "string", "description": "Guild ID.", "example": ""}, + "user_id": {"type": "string", "description": "Filter by user who triggered (optional).", "example": ""}, + "action_type": {"type": "integer", "description": "Filter by action type code (optional).", "example": 0}, + "before": {"type": "string", "description": "Pagination: entry ID.", "example": ""}, + "limit": {"type": "integer", "description": "1-100.", "example": 50}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_discord_audit_log(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + at = input_data.get("action_type") + return run_client_sync( + "discord", "get_audit_log", + guild_id=input_data["guild_id"], + user_id=input_data.get("user_id") or None, + action_type=at if at else None, + before=input_data.get("before") or None, + limit=input_data.get("limit", 50), + ) + + +@action( + name="list_discord_guild_invites", + description="List all invites for a guild.", + action_sets=["discord_guild"], + input_schema={ + "guild_id": {"type": "string", "description": "Guild ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def list_discord_guild_invites(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("discord", "list_guild_invites", guild_id=input_data["guild_id"]) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Users — bot user, user lookup, DMs +# ═══════════════════════════════════════════════════════════════════════════════ + +@action( + name="get_discord_user", + description="Get info about any Discord user by ID.", + action_sets=["discord_members", "discord"], + input_schema={ + "user_id": {"type": "string", "description": "User ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_discord_user(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("discord", "get_user", user_id=input_data["user_id"]) + + +@action( + name="get_discord_bot_user", + description="Get info about the authenticated Discord bot.", + action_sets=["discord_guild", "discord"], + input_schema={}, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_discord_bot_user(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("discord", "get_bot_user") + + +@action( + name="send_discord_dm", + description="Send a direct message to a Discord user.", + action_sets=["discord_messages", "discord"], + input_schema={ + "recipient_id": {"type": "string", "description": "Discord user ID to DM.", "example": "123456789012345678"}, + "content": {"type": "string", "description": "Message content.", "example": "Hey there!"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def send_discord_dm(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "discord", "send_dm", + recipient_id=input_data["recipient_id"], content=input_data["content"], ) @@ -195,161 +1397,199 @@ def add_discord_reaction(input_data: dict) -> dict: # User-account actions (self-bot / personal automation) # ═══════════════════════════════════════════════════════════════════════════════ +@action( + name="get_discord_user_account", + description="Get info about the authenticated user account (selfbot/user token).", + action_sets=["discord_user"], + input_schema={}, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_discord_user_account(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("discord", "user_get_current_user") + @action( name="send_discord_user_message", description="Send user message (self-bot).", - action_sets=["discord"], + action_sets=["discord_user"], input_schema={ - "channel_id": { - "type": "string", - "description": "Channel ID.", - "example": "123", - }, + "channel_id": {"type": "string", "description": "Channel ID.", "example": "123"}, "content": {"type": "string", "description": "Content.", "example": "Hi"}, }, output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, ) def send_discord_user_message(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync( - "discord", - "user_send_message", - channel_id=input_data["channel_id"], - content=input_data["content"], + "discord", "user_send_message", + channel_id=input_data["channel_id"], content=input_data["content"], ) @action( name="get_discord_user_guilds", description="Get user guilds.", - action_sets=["discord"], + action_sets=["discord_user"], input_schema={}, output_schema={"status": {"type": "string", "example": "success"}}, ) def get_discord_user_guilds(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync("discord", "user_get_guilds") @action( name="get_discord_user_dm_channels", description="Get user DMs.", - action_sets=["discord"], + action_sets=["discord_user"], input_schema={}, output_schema={"status": {"type": "string", "example": "success"}}, ) def get_discord_user_dm_channels(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync("discord", "user_get_dm_channels") @action( name="send_discord_user_dm", description="Send user DM.", - action_sets=["discord"], + action_sets=["discord_user"], input_schema={ - "recipient_id": { - "type": "string", - "description": "Recipient ID.", - "example": "123", - }, + "recipient_id": {"type": "string", "description": "Recipient ID.", "example": "123"}, "content": {"type": "string", "description": "Content.", "example": "Hi"}, }, output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, ) def send_discord_user_dm(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "discord", "user_send_dm", + recipient_id=input_data["recipient_id"], content=input_data["content"], + ) + + +@action( + name="get_discord_user_relationships", + description="Get the user account's friends/blocked/pending invitations (selfbot only).", + action_sets=["discord_user"], + input_schema={}, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_discord_user_relationships(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("discord", "user_get_relationships") + +@action( + name="search_discord_guild_messages_as_user", + description="Search messages in a guild (selfbot — uses user token's search permission).", + action_sets=["discord_user"], + input_schema={ + "guild_id": {"type": "string", "description": "Guild ID.", "example": ""}, + "query": {"type": "string", "description": "Search content.", "example": ""}, + "limit": {"type": "integer", "description": "Max results.", "example": 25}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def search_discord_guild_messages_as_user(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync return run_client_sync( - "discord", - "user_send_dm", - recipient_id=input_data["recipient_id"], - content=input_data["content"], + "discord", "user_search_guild_messages", + guild_id=input_data["guild_id"], + query=input_data["query"], + limit=input_data.get("limit", 25), ) # ═══════════════════════════════════════════════════════════════════════════════ -# Voice actions (async — lazy-loads discord.py voice helpers) +# Voice (async — lazy-loads discord.py voice helpers) # ═══════════════════════════════════════════════════════════════════════════════ - @action( name="join_discord_voice_channel", description="Join voice channel.", - action_sets=["discord"], + action_sets=["discord_voice", "discord"], input_schema={ "guild_id": {"type": "string", "description": "Guild ID.", "example": "123"}, - "channel_id": { - "type": "string", - "description": "Channel ID.", - "example": "456", - }, + "channel_id": {"type": "string", "description": "Channel ID.", "example": "456"}, }, output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, ) async def join_discord_voice_channel(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( - "discord", - "join_voice", - guild_id=input_data["guild_id"], - channel_id=input_data["channel_id"], + "discord", "join_voice", + guild_id=input_data["guild_id"], channel_id=input_data["channel_id"], ) @action( name="leave_discord_voice_channel", description="Leave voice channel.", - action_sets=["discord"], - input_schema={ - "guild_id": {"type": "string", "description": "Guild ID.", "example": "123"} - }, + action_sets=["discord_voice", "discord"], + input_schema={"guild_id": {"type": "string", "description": "Guild ID.", "example": "123"}}, output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, ) async def leave_discord_voice_channel(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client("discord", "leave_voice", guild_id=input_data["guild_id"]) @action( name="speak_discord_voice_tts", description="Speak TTS in voice.", - action_sets=["discord"], + action_sets=["discord_voice", "discord"], input_schema={ "guild_id": {"type": "string", "description": "Guild ID.", "example": "123"}, "text": {"type": "string", "description": "Text.", "example": "Hello"}, }, output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, ) async def speak_discord_voice_tts(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( - "discord", - "speak_tts", - guild_id=input_data["guild_id"], - text=input_data["text"], + "discord", "speak_tts", + guild_id=input_data["guild_id"], text=input_data["text"], ) @action( name="get_discord_voice_status", description="Get voice status.", - action_sets=["discord"], - input_schema={ - "guild_id": {"type": "string", "description": "Guild ID.", "example": "123"} - }, + action_sets=["discord_voice", "discord"], + input_schema={"guild_id": {"type": "string", "description": "Guild ID.", "example": "123"}}, output_schema={"status": {"type": "string", "example": "success"}}, ) def get_discord_voice_status(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("discord", "get_voice_status", guild_id=input_data["guild_id"]) - return run_client_sync( - "discord", "get_voice_status", guild_id=input_data["guild_id"] - ) + +# ================================================================== +# Intentionally NOT exposed as actions (and why) +# ================================================================== +# - Application commands (slash commands) / interactions / components +# Requires a paired Events API / Gateway interaction handler to receive +# button clicks and command invocations. Not actionable from a one-shot +# agent loop without persistent event subscription plumbing. +# - Gateway events (MESSAGE_REACTION_ADD, TYPING_START, PRESENCE_UPDATE, etc.) +# Handled by the listener internally. +# - Voice receive / recording / per-user voice state queries +# Heavy WebSocket-bound work; the voice manager exposes only the +# play/stop surface that fits a request-response model. +# - Stage instances (live stage management) +# Niche; create_discord_scheduled_event covers the "schedule a stage" path. +# - OAuth2 application authorization endpoints, application/team admin +# Developer-portal admin, not personal-agent work. +# - Polls (create/end), Soundboard +# Newer features in flux; add when stable. +# - Guild widget / vanity URL / preview / discovery +# Public-facing server-discovery configuration; niche. +# - Auto-moderation rules +# Server-admin-level configuration; out of scope for a generalist agent. diff --git a/app/data/action/integrations/google_workspace/gmail_actions.py b/app/data/action/integrations/google_workspace/gmail_actions.py index 9c47dc87..92558950 100644 --- a/app/data/action/integrations/google_workspace/gmail_actions.py +++ b/app/data/action/integrations/google_workspace/gmail_actions.py @@ -1,10 +1,14 @@ from agent_core import action +# ------------------------------------------------------------------ +# Mail — send / list / get / search / reply / forward / lifecycle +# ------------------------------------------------------------------ + @action( name="send_gmail", description="Send an email via Gmail.", - action_sets=["gmail"], + action_sets=["gmail_mail", "gmail"], input_schema={ "to": { "type": "string", @@ -28,6 +32,7 @@ }, }, output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, ) def send_gmail(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync @@ -48,7 +53,7 @@ def send_gmail(input_data: dict) -> dict: @action( name="list_gmail", description="List recent emails from Gmail inbox.", - action_sets=["gmail"], + action_sets=["gmail_mail", "gmail"], input_schema={ "count": { "type": "integer", @@ -73,7 +78,7 @@ def list_gmail(input_data: dict) -> dict: @action( name="get_gmail", description="Get details of a specific Gmail message by ID.", - action_sets=["gmail"], + action_sets=["gmail_mail", "gmail"], input_schema={ "message_id": { "type": "string", @@ -104,7 +109,7 @@ def get_gmail(input_data: dict) -> dict: @action( name="read_top_emails", description="Read the top N recent emails with details.", - action_sets=["gmail"], + action_sets=["gmail_mail", "gmail"], input_schema={ "count": { "type": "integer", @@ -132,10 +137,630 @@ def read_top_emails(input_data: dict) -> dict: ) +@action( + name="search_gmail", + description="Search Gmail using Gmail's q syntax (e.g. 'from:alice subject:invoice newer_than:7d has:attachment').", + action_sets=["gmail_mail", "gmail"], + input_schema={ + "query": {"type": "string", "description": "Gmail q query.", "example": "from:alice@example.com is:unread"}, + "max_results": {"type": "integer", "description": "Max results.", "example": 25}, + "include_spam_trash": {"type": "boolean", "description": "Include Spam/Trash.", "example": False}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def search_gmail(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "gmail", "search_messages", + unwrap_envelope=True, fail_message="Failed to search.", + query=input_data["query"], + max_results=input_data.get("max_results", 25), + include_spam_trash=bool(input_data.get("include_spam_trash", False)), + ) + + +@action( + name="reply_gmail", + description="Reply to a Gmail message. Preserves thread + In-Reply-To/References headers. Set reply_all=true to also CC the original To/Cc.", + action_sets=["gmail_mail", "gmail"], + input_schema={ + "message_id": {"type": "string", "description": "Original message ID.", "example": ""}, + "body": {"type": "string", "description": "Reply text.", "example": ""}, + "reply_all": {"type": "boolean", "description": "Reply-all (CC original recipients).", "example": False}, + "attachments": {"type": "array", "description": "Optional attachment file paths.", "example": []}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def reply_gmail(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "gmail", "reply_to_message", + unwrap_envelope=True, fail_message="Failed to reply.", + message_id=input_data["message_id"], + body=input_data["body"], + reply_all=bool(input_data.get("reply_all", False)), + attachments=input_data.get("attachments"), + ) + + +@action( + name="forward_gmail", + description="Forward a Gmail message to another address.", + action_sets=["gmail_mail", "gmail"], + input_schema={ + "message_id": {"type": "string", "description": "Original message ID.", "example": ""}, + "to": {"type": "string", "description": "Recipient email.", "example": "bob@example.com"}, + "body": {"type": "string", "description": "Optional intro text.", "example": ""}, + "attachments": {"type": "array", "description": "Optional attachment file paths.", "example": []}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def forward_gmail(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "gmail", "forward_message", + unwrap_envelope=True, fail_message="Failed to forward.", + message_id=input_data["message_id"], + to=input_data["to"], + body=input_data.get("body", ""), + attachments=input_data.get("attachments"), + ) + + +@action( + name="modify_gmail_labels", + description="Add/remove labels on a Gmail message. Common label IDs: INBOX, UNREAD, STARRED, IMPORTANT, TRASH, SPAM, CATEGORY_PERSONAL.", + action_sets=["gmail_mail", "gmail"], + input_schema={ + "message_id": {"type": "string", "description": "Message ID.", "example": ""}, + "add_label_ids": {"type": "array", "description": "Label IDs to add.", "example": ["STARRED"]}, + "remove_label_ids": {"type": "array", "description": "Label IDs to remove.", "example": ["UNREAD"]}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def modify_gmail_labels(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "gmail", "modify_message_labels", + unwrap_envelope=True, fail_message="Failed to modify labels.", + message_id=input_data["message_id"], + add_label_ids=input_data.get("add_label_ids"), + remove_label_ids=input_data.get("remove_label_ids"), + ) + + +@action( + name="trash_gmail", + description="Move a Gmail message to Trash (soft delete; recoverable for 30 days).", + action_sets=["gmail_mail", "gmail"], + input_schema={ + "message_id": {"type": "string", "description": "Message ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def trash_gmail(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "gmail", "trash_message", + unwrap_envelope=True, fail_message="Failed to trash.", + message_id=input_data["message_id"], + ) + + +@action( + name="untrash_gmail", + description="Recover a Gmail message from Trash.", + action_sets=["gmail_mail"], + input_schema={ + "message_id": {"type": "string", "description": "Message ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def untrash_gmail(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "gmail", "untrash_message", + unwrap_envelope=True, fail_message="Failed to untrash.", + message_id=input_data["message_id"], + ) + + +@action( + name="delete_gmail", + description="Permanently delete a Gmail message. Irreversible. Prefer trash_gmail for soft delete.", + action_sets=["gmail_mail"], + input_schema={ + "message_id": {"type": "string", "description": "Message ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def delete_gmail(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "gmail", "delete_message", + unwrap_envelope=True, fail_message="Failed to delete.", + message_id=input_data["message_id"], + ) + + +@action( + name="batch_modify_gmail", + description="Bulk add/remove labels across multiple messages in one call.", + action_sets=["gmail_mail"], + input_schema={ + "message_ids": {"type": "array", "description": "List of message IDs.", "example": []}, + "add_label_ids": {"type": "array", "description": "Label IDs to add.", "example": []}, + "remove_label_ids": {"type": "array", "description": "Label IDs to remove.", "example": []}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def batch_modify_gmail(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "gmail", "batch_modify_messages", + unwrap_envelope=True, fail_message="Failed to batch modify.", + message_ids=input_data["message_ids"], + add_label_ids=input_data.get("add_label_ids"), + remove_label_ids=input_data.get("remove_label_ids"), + ) + + +@action( + name="batch_delete_gmail", + description="Permanently delete multiple messages. Irreversible.", + action_sets=["gmail_mail"], + input_schema={ + "message_ids": {"type": "array", "description": "List of message IDs.", "example": []}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def batch_delete_gmail(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "gmail", "batch_delete_messages", + unwrap_envelope=True, fail_message="Failed to batch delete.", + message_ids=input_data["message_ids"], + ) + + +# ------------------------------------------------------------------ +# Threads +# ------------------------------------------------------------------ + +@action( + name="list_gmail_threads", + description="List Gmail conversation threads.", + action_sets=["gmail_threads", "gmail"], + input_schema={ + "query": {"type": "string", "description": "Optional Gmail q query.", "example": ""}, + "label_ids": {"type": "array", "description": "Optional label filter.", "example": ["INBOX"]}, + "max_results": {"type": "integer", "description": "Max threads.", "example": 25}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def list_gmail_threads(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "gmail", "list_threads", + unwrap_envelope=True, fail_message="Failed to list threads.", + query=input_data.get("query") or None, + label_ids=input_data.get("label_ids"), + max_results=input_data.get("max_results", 25), + ) + + +@action( + name="get_gmail_thread", + description="Get a thread (conversation) and its messages.", + action_sets=["gmail_threads", "gmail"], + input_schema={ + "thread_id": {"type": "string", "description": "Thread ID.", "example": ""}, + "fmt": {"type": "string", "description": "metadata | full | minimal.", "example": "metadata"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_gmail_thread(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "gmail", "get_thread", + unwrap_envelope=True, fail_message="Failed to get thread.", + thread_id=input_data["thread_id"], + fmt=input_data.get("fmt", "metadata"), + ) + + +@action( + name="modify_gmail_thread_labels", + description="Add/remove labels on every message in a thread.", + action_sets=["gmail_threads"], + input_schema={ + "thread_id": {"type": "string", "description": "Thread ID.", "example": ""}, + "add_label_ids": {"type": "array", "description": "Labels to add.", "example": []}, + "remove_label_ids": {"type": "array", "description": "Labels to remove.", "example": []}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def modify_gmail_thread_labels(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "gmail", "modify_thread_labels", + unwrap_envelope=True, fail_message="Failed to modify thread labels.", + thread_id=input_data["thread_id"], + add_label_ids=input_data.get("add_label_ids"), + remove_label_ids=input_data.get("remove_label_ids"), + ) + + +@action( + name="trash_gmail_thread", + description="Move an entire Gmail thread to Trash.", + action_sets=["gmail_threads"], + input_schema={ + "thread_id": {"type": "string", "description": "Thread ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def trash_gmail_thread(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "gmail", "trash_thread", + unwrap_envelope=True, fail_message="Failed to trash thread.", + thread_id=input_data["thread_id"], + ) + + +@action( + name="untrash_gmail_thread", + description="Recover a Gmail thread from Trash.", + action_sets=["gmail_threads"], + input_schema={ + "thread_id": {"type": "string", "description": "Thread ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def untrash_gmail_thread(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "gmail", "untrash_thread", + unwrap_envelope=True, fail_message="Failed to untrash thread.", + thread_id=input_data["thread_id"], + ) + + +@action( + name="delete_gmail_thread", + description="Permanently delete a Gmail thread (all messages). Irreversible.", + action_sets=["gmail_threads"], + input_schema={ + "thread_id": {"type": "string", "description": "Thread ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def delete_gmail_thread(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "gmail", "delete_thread", + unwrap_envelope=True, fail_message="Failed to delete thread.", + thread_id=input_data["thread_id"], + ) + + +# ------------------------------------------------------------------ +# Drafts +# ------------------------------------------------------------------ + +@action( + name="list_gmail_drafts", + description="List Gmail drafts.", + action_sets=["gmail_drafts", "gmail"], + input_schema={ + "max_results": {"type": "integer", "description": "Max drafts.", "example": 25}, + "query": {"type": "string", "description": "Optional q query.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def list_gmail_drafts(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "gmail", "list_drafts", + unwrap_envelope=True, fail_message="Failed to list drafts.", + max_results=input_data.get("max_results", 25), + query=input_data.get("query") or None, + ) + + +@action( + name="get_gmail_draft", + description="Get a Gmail draft by ID.", + action_sets=["gmail_drafts"], + input_schema={ + "draft_id": {"type": "string", "description": "Draft ID.", "example": ""}, + "fmt": {"type": "string", "description": "metadata | full | minimal.", "example": "metadata"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_gmail_draft(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "gmail", "get_draft", + unwrap_envelope=True, fail_message="Failed to get draft.", + draft_id=input_data["draft_id"], + fmt=input_data.get("fmt", "metadata"), + ) + + +@action( + name="create_gmail_draft", + description="Create a Gmail draft (not sent). Returns the draft ID for later edit/send.", + action_sets=["gmail_drafts", "gmail"], + input_schema={ + "to": {"type": "string", "description": "Recipient.", "example": ""}, + "subject": {"type": "string", "description": "Subject.", "example": ""}, + "body": {"type": "string", "description": "Body text.", "example": ""}, + "cc": {"type": "string", "description": "Optional CC.", "example": ""}, + "bcc": {"type": "string", "description": "Optional BCC.", "example": ""}, + "attachments": {"type": "array", "description": "Local file paths.", "example": []}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def create_gmail_draft(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "gmail", "create_draft", + unwrap_envelope=True, fail_message="Failed to create draft.", + to=input_data["to"], + subject=input_data["subject"], + body=input_data["body"], + cc=input_data.get("cc") or None, + bcc=input_data.get("bcc") or None, + attachments=input_data.get("attachments"), + ) + + +@action( + name="update_gmail_draft", + description="Replace a Gmail draft's content. All fields are required (PUT semantics).", + action_sets=["gmail_drafts"], + input_schema={ + "draft_id": {"type": "string", "description": "Draft ID.", "example": ""}, + "to": {"type": "string", "description": "Recipient.", "example": ""}, + "subject": {"type": "string", "description": "Subject.", "example": ""}, + "body": {"type": "string", "description": "Body text.", "example": ""}, + "cc": {"type": "string", "description": "Optional CC.", "example": ""}, + "bcc": {"type": "string", "description": "Optional BCC.", "example": ""}, + "attachments": {"type": "array", "description": "Local file paths.", "example": []}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def update_gmail_draft(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "gmail", "update_draft", + unwrap_envelope=True, fail_message="Failed to update draft.", + draft_id=input_data["draft_id"], + to=input_data["to"], + subject=input_data["subject"], + body=input_data["body"], + cc=input_data.get("cc") or None, + bcc=input_data.get("bcc") or None, + attachments=input_data.get("attachments"), + ) + + +@action( + name="send_gmail_draft", + description="Send a previously-created Gmail draft.", + action_sets=["gmail_drafts", "gmail"], + input_schema={ + "draft_id": {"type": "string", "description": "Draft ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def send_gmail_draft(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "gmail", "send_draft", + unwrap_envelope=True, fail_message="Failed to send draft.", + draft_id=input_data["draft_id"], + ) + + +@action( + name="delete_gmail_draft", + description="Permanently delete a Gmail draft.", + action_sets=["gmail_drafts"], + input_schema={ + "draft_id": {"type": "string", "description": "Draft ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def delete_gmail_draft(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "gmail", "delete_draft", + unwrap_envelope=True, fail_message="Failed to delete draft.", + draft_id=input_data["draft_id"], + ) + + +# ------------------------------------------------------------------ +# Labels +# ------------------------------------------------------------------ + +@action( + name="list_gmail_labels", + description="List all Gmail labels (system + user).", + action_sets=["gmail_labels", "gmail"], + input_schema={}, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def list_gmail_labels(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "gmail", "list_labels", + unwrap_envelope=True, fail_message="Failed to list labels.", + ) + + +@action( + name="get_gmail_label", + description="Get a single Gmail label by ID.", + action_sets=["gmail_labels"], + input_schema={ + "label_id": {"type": "string", "description": "Label ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_gmail_label(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "gmail", "get_label", + unwrap_envelope=True, fail_message="Failed to get label.", + label_id=input_data["label_id"], + ) + + +@action( + name="create_gmail_label", + description="Create a new user label. label_list_visibility: labelShow|labelShowIfUnread|labelHide. message_list_visibility: show|hide.", + action_sets=["gmail_labels", "gmail"], + input_schema={ + "name": {"type": "string", "description": "Label name (use '/' for nesting, e.g. 'Work/Clients').", "example": "Receipts"}, + "label_list_visibility": {"type": "string", "description": "labelShow / labelShowIfUnread / labelHide.", "example": "labelShow"}, + "message_list_visibility": {"type": "string", "description": "show / hide.", "example": "show"}, + "background_color": {"type": "string", "description": "Hex color (optional, requires text_color).", "example": ""}, + "text_color": {"type": "string", "description": "Hex color (optional, requires background_color).", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def create_gmail_label(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "gmail", "create_label", + unwrap_envelope=True, fail_message="Failed to create label.", + name=input_data["name"], + label_list_visibility=input_data.get("label_list_visibility", "labelShow"), + message_list_visibility=input_data.get("message_list_visibility", "show"), + background_color=input_data.get("background_color") or None, + text_color=input_data.get("text_color") or None, + ) + + +@action( + name="update_gmail_label", + description="Update (rename / recolor) a Gmail label.", + action_sets=["gmail_labels"], + input_schema={ + "label_id": {"type": "string", "description": "Label ID.", "example": ""}, + "name": {"type": "string", "description": "New name (optional).", "example": ""}, + "label_list_visibility": {"type": "string", "description": "labelShow / labelShowIfUnread / labelHide.", "example": ""}, + "message_list_visibility": {"type": "string", "description": "show / hide.", "example": ""}, + "background_color": {"type": "string", "description": "Hex color (optional).", "example": ""}, + "text_color": {"type": "string", "description": "Hex color (optional).", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def update_gmail_label(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "gmail", "update_label", + unwrap_envelope=True, fail_message="Failed to update label.", + label_id=input_data["label_id"], + name=input_data.get("name") or None, + label_list_visibility=input_data.get("label_list_visibility") or None, + message_list_visibility=input_data.get("message_list_visibility") or None, + background_color=input_data.get("background_color") or None, + text_color=input_data.get("text_color") or None, + ) + + +@action( + name="delete_gmail_label", + description="Delete a Gmail label (also removes it from all messages/threads).", + action_sets=["gmail_labels"], + input_schema={ + "label_id": {"type": "string", "description": "Label ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def delete_gmail_label(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "gmail", "delete_label", + unwrap_envelope=True, fail_message="Failed to delete label.", + label_id=input_data["label_id"], + ) + + +# ------------------------------------------------------------------ +# Attachments + profile +# ------------------------------------------------------------------ + +@action( + name="download_gmail_attachment", + description="Download a Gmail attachment to a local path. Get the attachment_id from get_gmail with full_body=true (payload.parts[].body.attachmentId).", + action_sets=["gmail_attachments", "gmail"], + input_schema={ + "message_id": {"type": "string", "description": "Message ID.", "example": ""}, + "attachment_id": {"type": "string", "description": "Attachment ID from the message payload.", "example": ""}, + "save_to": {"type": "string", "description": "Local path to save to.", "example": "C:/Users/me/downloads/file.pdf"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def download_gmail_attachment(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "gmail", "download_attachment", + unwrap_envelope=True, fail_message="Failed to download attachment.", + message_id=input_data["message_id"], + attachment_id=input_data["attachment_id"], + save_to=input_data["save_to"], + ) + + +@action( + name="get_gmail_profile", + description="Get the authenticated user's Gmail profile: email address, message/thread totals, historyId.", + action_sets=["gmail_mail", "gmail"], + input_schema={}, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_gmail_profile(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "gmail", "get_profile", + unwrap_envelope=True, fail_message="Failed to get profile.", + ) + + +# ------------------------------------------------------------------ +# Backwards-compat aliases (legacy action names — kept for skills/memory) +# ------------------------------------------------------------------ + @action( name="send_google_workspace_email", description="Send email via Google Workspace.", - action_sets=["gmail"], + action_sets=["gmail_mail"], input_schema={ "to_email": { "type": "string", @@ -152,6 +777,7 @@ def read_top_emails(input_data: dict) -> dict: "attachments": {"type": "array", "description": "Attachments.", "example": []}, }, output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, ) def send_google_workspace_email(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync @@ -173,7 +799,7 @@ def send_google_workspace_email(input_data: dict) -> dict: @action( name="read_recent_google_workspace_emails", description="Read recent emails.", - action_sets=["gmail"], + action_sets=["gmail_mail"], input_schema={ "n": {"type": "integer", "description": "Count.", "example": 5}, "full_body": {"type": "boolean", "description": "Full body.", "example": False}, @@ -196,3 +822,20 @@ def read_recent_google_workspace_emails(input_data: dict) -> dict: n=input_data.get("n", 5), full_body=input_data.get("full_body", False), ) + + +# ================================================================== +# Intentionally NOT exposed as actions (and why) +# ================================================================== +# - History API (users.history.list) +# Incremental sync plumbing. The listener uses it internally. +# - Watch / push notifications (users.watch, users.stop) +# Cloud Pub/Sub webhook setup; server-side infrastructure. +# - Settings (users.settings.*): vacation, filters, forwarding, sendAs, smimeInfo, cse +# Each is a separate admin-style sub-resource. Could be added as +# gmail_settings if needed. For an assistant, ad-hoc rules are +# usually managed in the Gmail UI rather than via API. +# - Drafts.list with format=full +# The metadata format works for the common "list and resume" case. +# - Messages.import / messages.insert (raw upload of an existing email) +# Migration tooling, not interactive use. diff --git a/app/data/action/integrations/google_workspace/google_calendar_actions.py b/app/data/action/integrations/google_workspace/google_calendar_actions.py index 94b44f36..bdd65ed0 100644 --- a/app/data/action/integrations/google_workspace/google_calendar_actions.py +++ b/app/data/action/integrations/google_workspace/google_calendar_actions.py @@ -1,32 +1,25 @@ from agent_core import action +# ------------------------------------------------------------------ +# Convenience helpers (kept as-is for backwards-compat) +# ------------------------------------------------------------------ + @action( name="create_google_meet", description="Create a Google Calendar event with a Google Meet link.", - action_sets=["google_calendar"], + action_sets=["google_calendar_events", "google_calendar"], input_schema={ - "event_data": { - "type": "object", - "description": "Calendar event data with summary, start, end, conferenceData.", - "example": {}, - }, - "calendar_id": { - "type": "string", - "description": "Calendar ID (default: primary).", - "example": "primary", - }, + "event_data": {"type": "object", "description": "Calendar event data with summary, start, end, conferenceData.", "example": {}}, + "calendar_id": {"type": "string", "description": "Calendar ID (default: primary).", "example": "primary"}, }, output_schema={"status": {"type": "string", "example": "success"}}, ) def create_google_meet(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync( - "google_calendar", - "create_meet_event", - unwrap_envelope=True, - fail_message="Failed to create event.", + "google_calendar", "create_meet_event", + unwrap_envelope=True, fail_message="Failed to create event.", calendar_id=input_data.get("calendar_id", "primary"), event_data=input_data.get("event_data"), ) @@ -35,34 +28,19 @@ def create_google_meet(input_data: dict) -> dict: @action( name="check_calendar_availability", description="Check Google Calendar free/busy availability.", - action_sets=["google_calendar"], + action_sets=["google_calendar_events", "google_calendar"], input_schema={ - "time_min": { - "type": "string", - "description": "Start time in ISO 8601 format.", - "example": "2024-01-15T09:00:00Z", - }, - "time_max": { - "type": "string", - "description": "End time in ISO 8601 format.", - "example": "2024-01-15T17:00:00Z", - }, - "calendar_id": { - "type": "string", - "description": "Calendar ID (default: primary).", - "example": "primary", - }, + "time_min": {"type": "string", "description": "Start time in ISO 8601 format.", "example": "2024-01-15T09:00:00Z"}, + "time_max": {"type": "string", "description": "End time in ISO 8601 format.", "example": "2024-01-15T17:00:00Z"}, + "calendar_id": {"type": "string", "description": "Calendar ID (default: primary).", "example": "primary"}, }, output_schema={"status": {"type": "string", "example": "success"}}, ) def check_calendar_availability(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync( - "google_calendar", - "check_availability", - unwrap_envelope=True, - fail_message="Failed to check availability.", + "google_calendar", "check_availability", + unwrap_envelope=True, fail_message="Failed to check availability.", calendar_id=input_data.get("calendar_id", "primary"), time_min=input_data.get("time_min"), time_max=input_data.get("time_max"), @@ -72,41 +50,19 @@ def check_calendar_availability(input_data: dict) -> dict: @action( name="check_availability_and_schedule", description="Schedule meeting if free.", - action_sets=["google_calendar"], + action_sets=["google_calendar_events", "google_calendar"], input_schema={ - "start_time": { - "type": "string", - "description": "Start time.", - "example": "2024-01-01T10:00:00", - }, - "end_time": { - "type": "string", - "description": "End time.", - "example": "2024-01-01T11:00:00", - }, + "start_time": {"type": "string", "description": "Start time.", "example": "2024-01-01T10:00:00"}, + "end_time": {"type": "string", "description": "End time.", "example": "2024-01-01T11:00:00"}, "summary": {"type": "string", "description": "Summary.", "example": "Meeting"}, - "description": { - "type": "string", - "description": "Description.", - "example": "Details", - }, - "attendees": { - "type": "array", - "description": "Attendees.", - "example": ["a@b.com"], - }, - "from_email": { - "type": "string", - "description": "Sender.", - "example": "me@example.com", - }, + "description": {"type": "string", "description": "Description.", "example": "Details"}, + "attendees": {"type": "array", "description": "Attendees.", "example": ["a@b.com"]}, + "from_email": {"type": "string", "description": "Sender.", "example": "me@example.com"}, }, output_schema={"status": {"type": "string", "example": "success"}}, ) def check_availability_and_schedule(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - - """Two client calls + branching ("busy" early-exit) + custom result shape.""" import uuid from datetime import datetime @@ -117,30 +73,18 @@ def check_availability_and_schedule(input_data: dict) -> dict: return {"status": "error", "message": str(e)} avail = run_client_sync( - "google_calendar", - "check_availability", - unwrap_envelope=True, - fail_message="Google Calendar FreeBusy API error", + "google_calendar", "check_availability", + unwrap_envelope=True, fail_message="Google Calendar FreeBusy API error", calendar_id="primary", time_min=start_time.isoformat() + "Z", time_max=end_time.isoformat() + "Z", ) if avail["status"] == "error": - return { - "status": "error", - "reason": "Google Calendar FreeBusy API error", - "details": avail, - } + return {"status": "error", "reason": "Google Calendar FreeBusy API error", "details": avail} - busy_slots = ( - avail.get("result", {}).get("calendars", {}).get("primary", {}).get("busy", []) - ) + busy_slots = avail.get("result", {}).get("calendars", {}).get("primary", {}).get("busy", []) if busy_slots: - return { - "status": "busy", - "reason": "Time slot is already occupied", - "conflicting_events": busy_slots, - } + return {"status": "busy", "reason": "Time slot is already occupied", "conflicting_events": busy_slots} attendees = input_data.get("attendees") or [] event_payload = { @@ -157,21 +101,688 @@ def check_availability_and_schedule(input_data: dict) -> dict: }, } result = run_client_sync( - "google_calendar", - "create_meet_event", - unwrap_envelope=True, - fail_message="Google Calendar API error", + "google_calendar", "create_meet_event", + unwrap_envelope=True, fail_message="Google Calendar API error", calendar_id="primary", event_data=event_payload, ) if result["status"] == "error": - return { - "status": "error", - "reason": "Google Calendar API error", - "details": result, - } + return {"status": "error", "reason": "Google Calendar API error", "details": result} return { "status": "success", "reason": "Meeting scheduled successfully.", "event": result.get("result", result), } + + +# ------------------------------------------------------------------ +# Events — daily-driver event operations +# ------------------------------------------------------------------ + +@action( + name="list_google_calendar_events", + description="List events on a calendar between time_min and time_max. Returns expanded single events sorted by start time.", + action_sets=["google_calendar_events", "google_calendar"], + input_schema={ + "calendar_id": {"type": "string", "description": "Calendar ID (default: primary).", "example": "primary"}, + "time_min": {"type": "string", "description": "ISO 8601 lower bound (optional).", "example": "2026-05-20T00:00:00Z"}, + "time_max": {"type": "string", "description": "ISO 8601 upper bound (optional).", "example": "2026-05-27T00:00:00Z"}, + "max_results": {"type": "integer", "description": "Max events to return.", "example": 50}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def list_google_calendar_events(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_calendar", "list_events", + unwrap_envelope=True, fail_message="Failed to list events.", + calendar_id=input_data.get("calendar_id", "primary"), + time_min=input_data.get("time_min"), + time_max=input_data.get("time_max"), + max_results=input_data.get("max_results", 50), + ) + + +@action( + name="get_google_calendar_event", + description="Get a single event by ID.", + action_sets=["google_calendar_events", "google_calendar"], + input_schema={ + "event_id": {"type": "string", "description": "Event ID.", "example": ""}, + "calendar_id": {"type": "string", "description": "Calendar ID (default: primary).", "example": "primary"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_google_calendar_event(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_calendar", "get_event", + unwrap_envelope=True, fail_message="Failed to get event.", + event_id=input_data["event_id"], + calendar_id=input_data.get("calendar_id", "primary"), + ) + + +@action( + name="create_google_calendar_event", + description="Create a calendar event. event_data is the full Event resource (summary, start, end, attendees, etc.). Use create_google_meet for events with a Meet link.", + action_sets=["google_calendar_events", "google_calendar"], + input_schema={ + "event_data": {"type": "object", "description": "Event resource: summary, description, start, end, attendees, recurrence, etc.", "example": {}}, + "calendar_id": {"type": "string", "description": "Calendar ID (default: primary).", "example": "primary"}, + "send_updates": {"type": "string", "description": "none, all, or externalOnly — who gets notified.", "example": "none"}, + "supports_attachments": {"type": "boolean", "description": "Set true if event_data includes attachments.", "example": False}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def create_google_calendar_event(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_calendar", "insert_event", + unwrap_envelope=True, fail_message="Failed to create event.", + calendar_id=input_data.get("calendar_id", "primary"), + event_data=input_data["event_data"], + send_updates=input_data.get("send_updates", "none"), + supports_attachments=bool(input_data.get("supports_attachments", False)), + ) + + +@action( + name="update_google_calendar_event", + description="Replace an event entirely (PUT). For partial updates use patch_google_calendar_event.", + action_sets=["google_calendar_events", "google_calendar"], + input_schema={ + "event_id": {"type": "string", "description": "Event ID.", "example": ""}, + "event_data": {"type": "object", "description": "Full Event resource — replaces existing.", "example": {}}, + "calendar_id": {"type": "string", "description": "Calendar ID (default: primary).", "example": "primary"}, + "send_updates": {"type": "string", "description": "none, all, externalOnly.", "example": "none"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def update_google_calendar_event(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_calendar", "update_event", + unwrap_envelope=True, fail_message="Failed to update event.", + calendar_id=input_data.get("calendar_id", "primary"), + event_id=input_data["event_id"], + event_data=input_data["event_data"], + send_updates=input_data.get("send_updates", "none"), + ) + + +@action( + name="patch_google_calendar_event", + description="Patch (partial update) an event. event_data contains ONLY the fields to change.", + action_sets=["google_calendar_events", "google_calendar"], + input_schema={ + "event_id": {"type": "string", "description": "Event ID.", "example": ""}, + "event_data": {"type": "object", "description": "Partial event fields to update.", "example": {"summary": "New title"}}, + "calendar_id": {"type": "string", "description": "Calendar ID (default: primary).", "example": "primary"}, + "send_updates": {"type": "string", "description": "none, all, externalOnly.", "example": "none"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def patch_google_calendar_event(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_calendar", "patch_event", + unwrap_envelope=True, fail_message="Failed to patch event.", + calendar_id=input_data.get("calendar_id", "primary"), + event_id=input_data["event_id"], + event_data=input_data["event_data"], + send_updates=input_data.get("send_updates", "none"), + ) + + +@action( + name="delete_google_calendar_event", + description="Delete a calendar event.", + action_sets=["google_calendar_events", "google_calendar"], + input_schema={ + "event_id": {"type": "string", "description": "Event ID.", "example": ""}, + "calendar_id": {"type": "string", "description": "Calendar ID (default: primary).", "example": "primary"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def delete_google_calendar_event(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_calendar", "delete_event", + unwrap_envelope=True, fail_message="Failed to delete event.", + event_id=input_data["event_id"], + calendar_id=input_data.get("calendar_id", "primary"), + ) + + +@action( + name="move_google_calendar_event", + description="Move an event from one calendar to another.", + action_sets=["google_calendar_events"], + input_schema={ + "event_id": {"type": "string", "description": "Event ID.", "example": ""}, + "calendar_id": {"type": "string", "description": "Current calendar ID.", "example": "primary"}, + "destination_calendar_id": {"type": "string", "description": "Target calendar ID.", "example": ""}, + "send_updates": {"type": "string", "description": "none, all, externalOnly.", "example": "none"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def move_google_calendar_event(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_calendar", "move_event", + unwrap_envelope=True, fail_message="Failed to move event.", + event_id=input_data["event_id"], + calendar_id=input_data.get("calendar_id", "primary"), + destination_calendar_id=input_data["destination_calendar_id"], + send_updates=input_data.get("send_updates", "none"), + ) + + +@action( + name="quick_add_google_calendar_event", + description="Create an event from a natural-language string (e.g. 'Lunch with Alice tomorrow at noon').", + action_sets=["google_calendar_events", "google_calendar"], + input_schema={ + "text": {"type": "string", "description": "Natural-language event description.", "example": "Lunch with Alice tomorrow at noon"}, + "calendar_id": {"type": "string", "description": "Calendar ID (default: primary).", "example": "primary"}, + "send_updates": {"type": "string", "description": "none, all, externalOnly.", "example": "none"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def quick_add_google_calendar_event(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_calendar", "quick_add_event", + unwrap_envelope=True, fail_message="Failed to quick-add event.", + calendar_id=input_data.get("calendar_id", "primary"), + text=input_data["text"], + send_updates=input_data.get("send_updates", "none"), + ) + + +@action( + name="list_google_calendar_event_instances", + description="Expand a recurring event into its individual instances.", + action_sets=["google_calendar_events"], + input_schema={ + "event_id": {"type": "string", "description": "Recurring event ID.", "example": ""}, + "calendar_id": {"type": "string", "description": "Calendar ID (default: primary).", "example": "primary"}, + "time_min": {"type": "string", "description": "ISO 8601 lower bound (optional).", "example": ""}, + "time_max": {"type": "string", "description": "ISO 8601 upper bound (optional).", "example": ""}, + "max_results": {"type": "integer", "description": "Max instances.", "example": 50}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def list_google_calendar_event_instances(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_calendar", "list_event_instances", + unwrap_envelope=True, fail_message="Failed to list instances.", + calendar_id=input_data.get("calendar_id", "primary"), + event_id=input_data["event_id"], + time_min=input_data.get("time_min"), + time_max=input_data.get("time_max"), + max_results=input_data.get("max_results", 50), + ) + + +@action( + name="import_google_calendar_event", + description="Import a pre-existing event (with its own iCal UID) into a calendar — preserves identity across calendars. Distinct from create.", + action_sets=["google_calendar_events"], + input_schema={ + "event_data": {"type": "object", "description": "Event resource including iCalUID.", "example": {}}, + "calendar_id": {"type": "string", "description": "Target calendar ID.", "example": "primary"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def import_google_calendar_event(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_calendar", "import_event", + unwrap_envelope=True, fail_message="Failed to import event.", + calendar_id=input_data.get("calendar_id", "primary"), + event_data=input_data["event_data"], + ) + + +# ------------------------------------------------------------------ +# Calendars (the calendar resources themselves) +# ------------------------------------------------------------------ + +@action( + name="list_google_calendars", + description="List calendars the user has access to (from their calendarList).", + action_sets=["google_calendar_admin", "google_calendar"], + input_schema={}, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def list_google_calendars(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_calendar", "list_calendars", + unwrap_envelope=True, fail_message="Failed to list calendars.", + ) + + +@action( + name="get_google_calendar", + description="Get metadata for a single calendar (summary, timezone, description).", + action_sets=["google_calendar_admin", "google_calendar"], + input_schema={ + "calendar_id": {"type": "string", "description": "Calendar ID (default: primary).", "example": "primary"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_google_calendar(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_calendar", "get_calendar", + unwrap_envelope=True, fail_message="Failed to get calendar.", + calendar_id=input_data.get("calendar_id", "primary"), + ) + + +@action( + name="create_google_calendar", + description="Create a new (secondary) calendar owned by the authenticated user.", + action_sets=["google_calendar_admin"], + input_schema={ + "summary": {"type": "string", "description": "Calendar name.", "example": "Team events"}, + "description": {"type": "string", "description": "Description (optional).", "example": ""}, + "time_zone": {"type": "string", "description": "IANA tz (optional, e.g. Asia/Tokyo).", "example": "UTC"}, + "location": {"type": "string", "description": "Default location (optional).", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def create_google_calendar(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_calendar", "create_calendar", + unwrap_envelope=True, fail_message="Failed to create calendar.", + summary=input_data["summary"], + description=input_data.get("description") or None, + time_zone=input_data.get("time_zone") or None, + location=input_data.get("location") or None, + ) + + +@action( + name="update_google_calendar", + description="Replace a calendar's metadata (PUT). For partial updates use patch_google_calendar.", + action_sets=["google_calendar_admin"], + input_schema={ + "calendar_id": {"type": "string", "description": "Calendar ID.", "example": ""}, + "summary": {"type": "string", "description": "New name (optional).", "example": ""}, + "description": {"type": "string", "description": "New description (optional).", "example": ""}, + "time_zone": {"type": "string", "description": "New IANA tz (optional).", "example": ""}, + "location": {"type": "string", "description": "New location (optional).", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def update_google_calendar(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_calendar", "update_calendar", + unwrap_envelope=True, fail_message="Failed to update calendar.", + calendar_id=input_data["calendar_id"], + summary=input_data.get("summary") or None, + description=input_data["description"] if "description" in input_data else None, + time_zone=input_data.get("time_zone") or None, + location=input_data["location"] if "location" in input_data else None, + ) + + +@action( + name="patch_google_calendar", + description="Patch (partial update) a calendar's metadata.", + action_sets=["google_calendar_admin"], + input_schema={ + "calendar_id": {"type": "string", "description": "Calendar ID.", "example": ""}, + "summary": {"type": "string", "description": "New name (optional).", "example": ""}, + "description": {"type": "string", "description": "New description (optional).", "example": ""}, + "time_zone": {"type": "string", "description": "New IANA tz (optional).", "example": ""}, + "location": {"type": "string", "description": "New location (optional).", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def patch_google_calendar(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_calendar", "patch_calendar", + unwrap_envelope=True, fail_message="Failed to patch calendar.", + calendar_id=input_data["calendar_id"], + summary=input_data.get("summary") or None, + description=input_data["description"] if "description" in input_data else None, + time_zone=input_data.get("time_zone") or None, + location=input_data["location"] if "location" in input_data else None, + ) + + +@action( + name="delete_google_calendar", + description="DELETE a secondary calendar. Cannot be used on the primary calendar.", + action_sets=["google_calendar_admin"], + input_schema={ + "calendar_id": {"type": "string", "description": "Calendar ID to delete.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def delete_google_calendar(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_calendar", "delete_calendar", + unwrap_envelope=True, fail_message="Failed to delete calendar.", + calendar_id=input_data["calendar_id"], + ) + + +@action( + name="clear_google_calendar", + description="Delete ALL events on the user's PRIMARY calendar. Irreversible. No-op on secondary calendars.", + action_sets=["google_calendar_admin"], + input_schema={ + "calendar_id": {"type": "string", "description": "Must be 'primary'.", "example": "primary"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def clear_google_calendar(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_calendar", "clear_calendar", + unwrap_envelope=True, fail_message="Failed to clear calendar.", + calendar_id=input_data.get("calendar_id", "primary"), + ) + + +# ------------------------------------------------------------------ +# CalendarList (the user's view of calendars: subscriptions, colors, visibility) +# ------------------------------------------------------------------ + +@action( + name="get_google_calendar_list_entry", + description="Get the user's per-calendar settings (color, visibility, summary override).", + action_sets=["google_calendar_admin"], + input_schema={ + "calendar_id": {"type": "string", "description": "Calendar ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_google_calendar_list_entry(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_calendar", "get_calendar_list_entry", + unwrap_envelope=True, fail_message="Failed to get calendar list entry.", + calendar_id=input_data["calendar_id"], + ) + + +@action( + name="subscribe_google_calendar", + description="Subscribe to (add to the user's calendar list) an existing calendar by ID.", + action_sets=["google_calendar_admin"], + input_schema={ + "calendar_id": {"type": "string", "description": "Calendar ID to subscribe to.", "example": ""}, + "color_id": {"type": "string", "description": "Color ID from get_google_calendar_colors (optional).", "example": ""}, + "summary_override": {"type": "string", "description": "User-side display name (optional).", "example": ""}, + "selected": {"type": "boolean", "description": "Show in UI (optional).", "example": True}, + "hidden": {"type": "boolean", "description": "Hide from UI (optional).", "example": False}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def subscribe_google_calendar(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_calendar", "subscribe_calendar", + unwrap_envelope=True, fail_message="Failed to subscribe to calendar.", + calendar_id=input_data["calendar_id"], + color_id=input_data.get("color_id") or None, + summary_override=input_data.get("summary_override") or None, + selected=input_data["selected"] if "selected" in input_data else None, + hidden=input_data["hidden"] if "hidden" in input_data else None, + ) + + +@action( + name="update_google_calendar_list_entry", + description="Update the user's per-calendar settings (color, visibility, display name).", + action_sets=["google_calendar_admin"], + input_schema={ + "calendar_id": {"type": "string", "description": "Calendar ID.", "example": ""}, + "color_id": {"type": "string", "description": "Color ID (optional).", "example": ""}, + "summary_override": {"type": "string", "description": "Display name (optional).", "example": ""}, + "selected": {"type": "boolean", "description": "Show in UI (optional).", "example": True}, + "hidden": {"type": "boolean", "description": "Hide from UI (optional).", "example": False}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def update_google_calendar_list_entry(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_calendar", "update_calendar_list_entry", + unwrap_envelope=True, fail_message="Failed to update calendar list entry.", + calendar_id=input_data["calendar_id"], + color_id=input_data.get("color_id") or None, + summary_override=input_data["summary_override"] if "summary_override" in input_data else None, + selected=input_data["selected"] if "selected" in input_data else None, + hidden=input_data["hidden"] if "hidden" in input_data else None, + ) + + +@action( + name="unsubscribe_google_calendar", + description="Remove a calendar from the user's calendar list. Does NOT delete the calendar itself.", + action_sets=["google_calendar_admin"], + input_schema={ + "calendar_id": {"type": "string", "description": "Calendar ID to unsubscribe from.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def unsubscribe_google_calendar(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_calendar", "unsubscribe_calendar", + unwrap_envelope=True, fail_message="Failed to unsubscribe.", + calendar_id=input_data["calendar_id"], + ) + + +# ------------------------------------------------------------------ +# ACL (per-calendar sharing) +# ------------------------------------------------------------------ + +@action( + name="list_google_calendar_acl", + description="List ACL rules (who has what access) on a calendar.", + action_sets=["google_calendar_admin"], + input_schema={ + "calendar_id": {"type": "string", "description": "Calendar ID (default: primary).", "example": "primary"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def list_google_calendar_acl(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_calendar", "list_calendar_acl", + unwrap_envelope=True, fail_message="Failed to list ACL.", + calendar_id=input_data.get("calendar_id", "primary"), + ) + + +@action( + name="get_google_calendar_acl_rule", + description="Get a single ACL rule by ID.", + action_sets=["google_calendar_admin"], + input_schema={ + "calendar_id": {"type": "string", "description": "Calendar ID.", "example": "primary"}, + "rule_id": {"type": "string", "description": "ACL rule ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_google_calendar_acl_rule(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_calendar", "get_calendar_acl_rule", + unwrap_envelope=True, fail_message="Failed to get ACL rule.", + calendar_id=input_data.get("calendar_id", "primary"), + rule_id=input_data["rule_id"], + ) + + +@action( + name="add_google_calendar_acl_rule", + description="Grant calendar access. scope_type: user/group/domain/default. role: none/freeBusyReader/reader/writer/owner.", + action_sets=["google_calendar_admin"], + input_schema={ + "calendar_id": {"type": "string", "description": "Calendar ID (default: primary).", "example": "primary"}, + "scope_type": {"type": "string", "description": "user, group, domain, or default.", "example": "user"}, + "scope_value": {"type": "string", "description": "Email, group address, or domain (empty for 'default').", "example": "alice@example.com"}, + "role": {"type": "string", "description": "none, freeBusyReader, reader, writer, or owner.", "example": "reader"}, + "send_notifications": {"type": "boolean", "description": "Email the grantee.", "example": True}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def add_google_calendar_acl_rule(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_calendar", "add_calendar_acl_rule", + unwrap_envelope=True, fail_message="Failed to add ACL rule.", + calendar_id=input_data.get("calendar_id", "primary"), + scope_type=input_data["scope_type"], + scope_value=input_data.get("scope_value", ""), + role=input_data["role"], + send_notifications=bool(input_data.get("send_notifications", True)), + ) + + +@action( + name="update_google_calendar_acl_rule", + description="Change the role of an existing ACL rule.", + action_sets=["google_calendar_admin"], + input_schema={ + "calendar_id": {"type": "string", "description": "Calendar ID.", "example": "primary"}, + "rule_id": {"type": "string", "description": "ACL rule ID.", "example": ""}, + "role": {"type": "string", "description": "New role.", "example": "writer"}, + "scope_type": {"type": "string", "description": "New scope type (optional).", "example": ""}, + "scope_value": {"type": "string", "description": "New scope value (optional).", "example": ""}, + "send_notifications": {"type": "boolean", "description": "Email the grantee.", "example": True}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def update_google_calendar_acl_rule(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_calendar", "update_calendar_acl_rule", + unwrap_envelope=True, fail_message="Failed to update ACL rule.", + calendar_id=input_data.get("calendar_id", "primary"), + rule_id=input_data["rule_id"], + role=input_data["role"], + scope_type=input_data.get("scope_type") or None, + scope_value=input_data.get("scope_value") or None, + send_notifications=bool(input_data.get("send_notifications", True)), + ) + + +@action( + name="delete_google_calendar_acl_rule", + description="Revoke access by deleting an ACL rule.", + action_sets=["google_calendar_admin"], + input_schema={ + "calendar_id": {"type": "string", "description": "Calendar ID.", "example": "primary"}, + "rule_id": {"type": "string", "description": "ACL rule ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def delete_google_calendar_acl_rule(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_calendar", "delete_calendar_acl_rule", + unwrap_envelope=True, fail_message="Failed to delete ACL rule.", + calendar_id=input_data.get("calendar_id", "primary"), + rule_id=input_data["rule_id"], + ) + + +# ------------------------------------------------------------------ +# Settings & colors +# ------------------------------------------------------------------ + +@action( + name="list_google_calendar_settings", + description="List the authenticated user's Calendar settings (timezone, locale, weekStart, etc.) as a dict.", + action_sets=["google_calendar_admin"], + input_schema={}, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def list_google_calendar_settings(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_calendar", "list_calendar_settings", + unwrap_envelope=True, fail_message="Failed to list settings.", + ) + + +@action( + name="get_google_calendar_setting", + description="Get a single user setting by ID. Common IDs: timezone, locale, autoAddHangouts, weekStart.", + action_sets=["google_calendar_admin"], + input_schema={ + "setting_id": {"type": "string", "description": "Setting ID.", "example": "timezone"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_google_calendar_setting(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_calendar", "get_calendar_setting", + unwrap_envelope=True, fail_message="Failed to get setting.", + setting_id=input_data["setting_id"], + ) + + +@action( + name="get_google_calendar_colors", + description="Get the color palette available for calendars and events (color_id → hex map).", + action_sets=["google_calendar_admin"], + input_schema={}, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_google_calendar_colors(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_calendar", "get_calendar_colors", + unwrap_envelope=True, fail_message="Failed to get colors.", + ) + + +# ================================================================== +# Intentionally NOT exposed as actions (and why) +# ================================================================== +# - Push notifications / watch endpoints (events.watch, calendarList.watch, ...) +# Server-side webhook setup for incremental sync. Not a per-interaction action; +# the host environment would own webhook plumbing if needed. +# - Conference data providers beyond hangoutsMeet +# Add-on/3rd-party conference data (Zoom/Webex via add-ons) is configured in +# the event_data payload by the agent — no separate endpoint needed. +# - Events.instances pagination tokens +# Single-call instances() with maxResults covers the realistic agent use +# case; full pagination can be added if/when needed. diff --git a/app/data/action/integrations/google_workspace/google_docs_actions.py b/app/data/action/integrations/google_workspace/google_docs_actions.py index 00b60970..31011715 100644 --- a/app/data/action/integrations/google_workspace/google_docs_actions.py +++ b/app/data/action/integrations/google_workspace/google_docs_actions.py @@ -1,27 +1,25 @@ from agent_core import action +# ------------------------------------------------------------------ +# File-level: create / get / list / search / delete / copy / export +# Sub-set: google_docs_files +# ------------------------------------------------------------------ + @action( name="create_google_doc", description="Create a new blank Google Doc with the given title. Returns the document ID and editable URL.", - action_sets=["google_docs"], + action_sets=["google_docs_files", "google_docs"], input_schema={ - "title": { - "type": "string", - "description": "Title for the new document.", - "example": "Meeting Notes", - }, + "title": {"type": "string", "description": "Title for the new document.", "example": "Meeting Notes"}, }, output_schema={"status": {"type": "string", "example": "success"}}, ) def create_google_doc(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync( - "google_docs", - "create_document", - unwrap_envelope=True, - fail_message="Failed to create Google Doc.", + "google_docs", "create_document", + unwrap_envelope=True, fail_message="Failed to create Google Doc.", title=input_data["title"], ) @@ -29,24 +27,17 @@ def create_google_doc(input_data: dict) -> dict: @action( name="get_google_doc", description="Fetch the full structured content of a Google Doc.", - action_sets=["google_docs"], + action_sets=["google_docs_files", "google_docs"], input_schema={ - "document_id": { - "type": "string", - "description": "The Google Doc's document ID.", - "example": "1abcDEF...", - }, + "document_id": {"type": "string", "description": "The Google Doc's document ID.", "example": "1abcDEF..."}, }, output_schema={"status": {"type": "string", "example": "success"}}, ) def get_google_doc(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync( - "google_docs", - "get_document", - unwrap_envelope=True, - fail_message="Failed to fetch document.", + "google_docs", "get_document", + unwrap_envelope=True, fail_message="Failed to fetch document.", document_id=input_data["document_id"], ) @@ -54,92 +45,211 @@ def get_google_doc(input_data: dict) -> dict: @action( name="get_google_doc_text", description="Get a Google Doc as plain text. Returns title and the doc body flattened to a string.", - action_sets=["google_docs"], + action_sets=["google_docs_files", "google_docs"], input_schema={ - "document_id": { - "type": "string", - "description": "The Google Doc's document ID.", - "example": "1abcDEF...", - }, + "document_id": {"type": "string", "description": "The Google Doc's document ID.", "example": "1abcDEF..."}, }, output_schema={"status": {"type": "string", "example": "success"}}, ) def get_google_doc_text(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_docs", "get_document_text", + unwrap_envelope=True, fail_message="Failed to read document.", + document_id=input_data["document_id"], + ) + +@action( + name="list_google_docs", + description="List Google Docs the user owns or has access to, most recent first.", + action_sets=["google_docs_files", "google_docs"], + input_schema={ + "max_results": {"type": "integer", "description": "Max number of docs to return.", "example": 50}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def list_google_docs(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync return run_client_sync( - "google_docs", - "get_document_text", - unwrap_envelope=True, - fail_message="Failed to read document.", + "google_docs", "list_documents", + unwrap_envelope=True, fail_message="Failed to list docs.", + max_results=input_data.get("max_results", 50), + ) + + +@action( + name="search_google_docs", + description="Search for Google Docs by title fragment.", + action_sets=["google_docs_files", "google_docs"], + input_schema={ + "query": {"type": "string", "description": "Title fragment to search for.", "example": "Meeting"}, + "max_results": {"type": "integer", "description": "Max number of docs to return.", "example": 50}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def search_google_docs(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_docs", "search_documents", + unwrap_envelope=True, fail_message="Failed to search docs.", + query=input_data["query"], + max_results=input_data.get("max_results", 50), + ) + + +@action( + name="delete_google_doc", + description="Move a Google Doc to the Drive trash.", + action_sets=["google_docs_files", "google_docs"], + input_schema={ + "document_id": {"type": "string", "description": "The Google Doc's document ID.", "example": "1abcDEF..."}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def delete_google_doc(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_docs", "delete_document", + unwrap_envelope=True, success_message="Document deleted.", fail_message="Failed to delete document.", + document_id=input_data["document_id"], + ) + + +@action( + name="copy_google_doc", + description="Copy an existing Google Doc to a new file with a new title.", + action_sets=["google_docs_files"], + input_schema={ + "document_id": {"type": "string", "description": "Source document ID.", "example": "1abcDEF..."}, + "new_title": {"type": "string", "description": "Title for the copy.", "example": "Meeting Notes (copy)"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def copy_google_doc(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_docs", "copy_document", + unwrap_envelope=True, fail_message="Failed to copy document.", + document_id=input_data["document_id"], + new_title=input_data["new_title"], + ) + + +@action( + name="export_google_doc", + description="Export a Google Doc to PDF, DOCX, ODT, plain text, or HTML and save to a local file path.", + action_sets=["google_docs_files"], + input_schema={ + "document_id": {"type": "string", "description": "Source document ID.", "example": "1abcDEF..."}, + "mime_type": {"type": "string", "description": "Export MIME type. application/pdf | application/vnd.openxmlformats-officedocument.wordprocessingml.document | application/vnd.oasis.opendocument.text | text/plain | text/html.", "example": "application/pdf"}, + "dest_path": {"type": "string", "description": "Local file path to write to.", "example": "/tmp/doc.pdf"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def export_google_doc(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_docs", "export_document", + unwrap_envelope=True, fail_message="Failed to export document.", document_id=input_data["document_id"], + mime_type=input_data["mime_type"], + dest_path=input_data["dest_path"], ) +# ------------------------------------------------------------------ +# Content: insert / delete text, append, replace +# Sub-set: google_docs_content +# ------------------------------------------------------------------ + @action( name="append_to_google_doc", description="Append text to the end of a Google Doc.", - action_sets=["google_docs"], + action_sets=["google_docs_content", "google_docs"], input_schema={ - "document_id": { - "type": "string", - "description": "The Google Doc's document ID.", - "example": "1abcDEF...", - }, - "text": { - "type": "string", - "description": "Text to append.", - "example": "\\n\\nFollow-up: ...", - }, + "document_id": {"type": "string", "description": "The Google Doc's document ID.", "example": "1abcDEF..."}, + "text": {"type": "string", "description": "Text to append.", "example": "\\n\\nFollow-up: ..."}, }, output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, ) def append_to_google_doc(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_docs", "append_text", + unwrap_envelope=True, success_message="Text appended.", fail_message="Failed to append text.", + document_id=input_data["document_id"], + text=input_data["text"], + ) + +@action( + name="insert_text_into_google_doc", + description="Insert text at a specific UTF-16 index in the document. Index 1 is the start of the body.", + action_sets=["google_docs_content", "google_docs"], + input_schema={ + "document_id": {"type": "string", "description": "Document ID.", "example": "1abcDEF..."}, + "text": {"type": "string", "description": "Text to insert.", "example": "Introduction\\n"}, + "index": {"type": "integer", "description": "Position (UTF-16 index). Index 1 = start of body.", "example": 1}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def insert_text_into_google_doc(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync return run_client_sync( - "google_docs", - "append_text", - unwrap_envelope=True, - success_message="Text appended.", - fail_message="Failed to append text.", + "google_docs", "insert_text", + unwrap_envelope=True, success_message="Text inserted.", fail_message="Failed to insert text.", document_id=input_data["document_id"], text=input_data["text"], + index=input_data["index"], + ) + + +@action( + name="delete_google_doc_range", + description="Delete content in a range (between startIndex and endIndex).", + action_sets=["google_docs_content", "google_docs"], + input_schema={ + "document_id": {"type": "string", "description": "Document ID.", "example": "1abcDEF..."}, + "start_index": {"type": "integer", "description": "Start UTF-16 index (inclusive).", "example": 10}, + "end_index": {"type": "integer", "description": "End UTF-16 index (exclusive).", "example": 30}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def delete_google_doc_range(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_docs", "delete_content_range", + unwrap_envelope=True, success_message="Range deleted.", fail_message="Failed to delete range.", + document_id=input_data["document_id"], + start_index=input_data["start_index"], + end_index=input_data["end_index"], ) @action( name="replace_google_doc_text", description="Find-and-replace across the entire Google Doc body. Returns the number of occurrences changed.", - action_sets=["google_docs"], + action_sets=["google_docs_content", "google_docs"], input_schema={ - "document_id": { - "type": "string", - "description": "The Google Doc's document ID.", - "example": "1abcDEF...", - }, + "document_id": {"type": "string", "description": "The Google Doc's document ID.", "example": "1abcDEF..."}, "find": {"type": "string", "description": "Text to find.", "example": "TODO"}, - "replace": { - "type": "string", - "description": "Replacement text.", - "example": "DONE", - }, - "match_case": { - "type": "boolean", - "description": "Whether the search is case-sensitive.", - "example": False, - }, + "replace": {"type": "string", "description": "Replacement text.", "example": "DONE"}, + "match_case": {"type": "boolean", "description": "Whether the search is case-sensitive.", "example": False}, }, output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, ) def replace_google_doc_text(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync( - "google_docs", - "replace_text", - unwrap_envelope=True, - fail_message="Failed to replace text.", + "google_docs", "replace_text", + unwrap_envelope=True, fail_message="Failed to replace text.", document_id=input_data["document_id"], find=input_data["find"], replace=input_data["replace"], @@ -147,83 +257,559 @@ def replace_google_doc_text(input_data: dict) -> dict: ) +# ------------------------------------------------------------------ +# Styling: text + paragraph +# Sub-set: google_docs_styling +# ------------------------------------------------------------------ + @action( - name="list_google_docs", - description="List Google Docs the user owns or has access to, most recent first.", - action_sets=["google_docs"], + name="style_google_doc_text", + description="Apply text-level styling (bold, italic, font size, color, link) to a range. Only supplied fields change; others stay untouched.", + action_sets=["google_docs_styling", "google_docs"], input_schema={ - "max_results": { - "type": "integer", - "description": "Max number of docs to return.", - "example": 50, - }, + "document_id": {"type": "string", "description": "Document ID.", "example": "1abcDEF..."}, + "start_index": {"type": "integer", "description": "Start UTF-16 index.", "example": 10}, + "end_index": {"type": "integer", "description": "End UTF-16 index (exclusive).", "example": 30}, + "bold": {"type": "boolean", "description": "Toggle bold.", "example": True}, + "italic": {"type": "boolean", "description": "Toggle italic.", "example": False}, + "underline": {"type": "boolean", "description": "Toggle underline.", "example": False}, + "strikethrough": {"type": "boolean", "description": "Toggle strikethrough.", "example": False}, + "font_size_pt": {"type": "number", "description": "Font size in points.", "example": 14}, + "font_family": {"type": "string", "description": "Font family name.", "example": "Arial"}, + "foreground_color_hex": {"type": "string", "description": "Foreground color (#RRGGBB).", "example": "#FF0000"}, + "background_color_hex": {"type": "string", "description": "Background color (#RRGGBB).", "example": "#FFFF00"}, + "link_url": {"type": "string", "description": "Turn range into a hyperlink to this URL.", "example": "https://example.com"}, }, output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, ) -def list_google_docs(input_data: dict) -> dict: +def style_google_doc_text(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_docs", "update_text_style", + unwrap_envelope=True, success_message="Text styled.", fail_message="Failed to style text.", + document_id=input_data["document_id"], + start_index=input_data["start_index"], + end_index=input_data["end_index"], + bold=input_data.get("bold"), + italic=input_data.get("italic"), + underline=input_data.get("underline"), + strikethrough=input_data.get("strikethrough"), + font_size_pt=input_data.get("font_size_pt"), + font_family=input_data.get("font_family") or None, + foreground_color_hex=input_data.get("foreground_color_hex") or None, + background_color_hex=input_data.get("background_color_hex") or None, + link_url=input_data.get("link_url") or None, + ) + +@action( + name="style_google_doc_paragraph", + description="Apply paragraph-level styling (heading, alignment, line spacing) to a range.", + action_sets=["google_docs_styling", "google_docs"], + input_schema={ + "document_id": {"type": "string", "description": "Document ID.", "example": "1abcDEF..."}, + "start_index": {"type": "integer", "description": "Start UTF-16 index.", "example": 1}, + "end_index": {"type": "integer", "description": "End UTF-16 index (exclusive).", "example": 20}, + "named_style_type": {"type": "string", "description": "NORMAL_TEXT | TITLE | SUBTITLE | HEADING_1..HEADING_6.", "example": "HEADING_1"}, + "alignment": {"type": "string", "description": "START | CENTER | END | JUSTIFIED.", "example": "CENTER"}, + "line_spacing": {"type": "number", "description": "Percentage (100 = single).", "example": 150}, + "keep_with_next": {"type": "boolean", "description": "Keep with following paragraph.", "example": True}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def style_google_doc_paragraph(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync return run_client_sync( - "google_docs", - "list_documents", - unwrap_envelope=True, - fail_message="Failed to list docs.", - max_results=input_data.get("max_results", 50), + "google_docs", "update_paragraph_style", + unwrap_envelope=True, success_message="Paragraph styled.", fail_message="Failed to style paragraph.", + document_id=input_data["document_id"], + start_index=input_data["start_index"], + end_index=input_data["end_index"], + named_style_type=input_data.get("named_style_type") or None, + alignment=input_data.get("alignment") or None, + line_spacing=input_data.get("line_spacing"), + keep_with_next=input_data.get("keep_with_next"), ) +# ------------------------------------------------------------------ +# Lists +# Sub-set: google_docs_lists +# ------------------------------------------------------------------ + @action( - name="search_google_docs", - description="Search for Google Docs by title fragment.", - action_sets=["google_docs"], + name="create_google_doc_bullets", + description="Turn paragraphs in a range into a bulleted or numbered list.", + action_sets=["google_docs_lists"], input_schema={ - "query": { - "type": "string", - "description": "Title fragment to search for.", - "example": "Meeting", - }, - "max_results": { - "type": "integer", - "description": "Max number of docs to return.", - "example": 50, - }, + "document_id": {"type": "string", "description": "Document ID.", "example": "1abcDEF..."}, + "start_index": {"type": "integer", "description": "Start UTF-16 index.", "example": 10}, + "end_index": {"type": "integer", "description": "End UTF-16 index.", "example": 60}, + "bullet_preset": {"type": "string", "description": "BULLET_DISC_CIRCLE_SQUARE | NUMBERED_DECIMAL_NESTED | BULLET_CHECKBOX | NUMBERED_DECIMAL_ALPHA_ROMAN | BULLET_ARROW_DIAMOND_DISC.", "example": "BULLET_DISC_CIRCLE_SQUARE"}, }, output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, ) -def search_google_docs(input_data: dict) -> dict: +def create_google_doc_bullets(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_docs", "create_paragraph_bullets", + unwrap_envelope=True, success_message="Bullets created.", fail_message="Failed to create bullets.", + document_id=input_data["document_id"], + start_index=input_data["start_index"], + end_index=input_data["end_index"], + bullet_preset=input_data.get("bullet_preset", "BULLET_DISC_CIRCLE_SQUARE"), + ) + + +@action( + name="delete_google_doc_bullets", + description="Remove bullet/numbered list formatting from a range.", + action_sets=["google_docs_lists"], + input_schema={ + "document_id": {"type": "string", "description": "Document ID.", "example": "1abcDEF..."}, + "start_index": {"type": "integer", "description": "Start UTF-16 index.", "example": 10}, + "end_index": {"type": "integer", "description": "End UTF-16 index.", "example": 60}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def delete_google_doc_bullets(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_docs", "delete_paragraph_bullets", + unwrap_envelope=True, success_message="Bullets removed.", fail_message="Failed to remove bullets.", + document_id=input_data["document_id"], + start_index=input_data["start_index"], + end_index=input_data["end_index"], + ) + +# ------------------------------------------------------------------ +# Tables +# Sub-set: google_docs_tables +# ------------------------------------------------------------------ + +@action( + name="insert_google_doc_table", + description="Insert a new empty table at a specific document index.", + action_sets=["google_docs_tables", "google_docs"], + input_schema={ + "document_id": {"type": "string", "description": "Document ID.", "example": "1abcDEF..."}, + "rows": {"type": "integer", "description": "Number of rows.", "example": 3}, + "columns": {"type": "integer", "description": "Number of columns.", "example": 3}, + "index": {"type": "integer", "description": "Position to insert at.", "example": 1}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def insert_google_doc_table(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync return run_client_sync( - "google_docs", - "search_documents", - unwrap_envelope=True, - fail_message="Failed to search docs.", - query=input_data["query"], - max_results=input_data.get("max_results", 50), + "google_docs", "insert_table", + unwrap_envelope=True, success_message="Table inserted.", fail_message="Failed to insert table.", + document_id=input_data["document_id"], + rows=input_data["rows"], + columns=input_data["columns"], + index=input_data["index"], ) @action( - name="delete_google_doc", - description="Move a Google Doc to the Drive trash.", - action_sets=["google_docs"], + name="insert_google_doc_table_row", + description="Insert a row above or below a table cell.", + action_sets=["google_docs_tables"], input_schema={ - "document_id": { - "type": "string", - "description": "The Google Doc's document ID.", - "example": "1abcDEF...", - }, + "document_id": {"type": "string", "description": "Document ID.", "example": "1abcDEF..."}, + "table_start_index": {"type": "integer", "description": "The table's start index in the document.", "example": 5}, + "row_index": {"type": "integer", "description": "Reference cell row (0-based).", "example": 0}, + "column_index": {"type": "integer", "description": "Reference cell column (0-based).", "example": 0}, + "insert_below": {"type": "boolean", "description": "True = below, False = above.", "example": True}, }, output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, ) -def delete_google_doc(input_data: dict) -> dict: +def insert_google_doc_table_row(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_docs", "insert_table_row", + unwrap_envelope=True, fail_message="Failed to insert row.", + document_id=input_data["document_id"], + table_start_index=input_data["table_start_index"], + row_index=input_data["row_index"], + column_index=input_data["column_index"], + insert_below=input_data.get("insert_below", True), + ) + + +@action( + name="insert_google_doc_table_column", + description="Insert a column left or right of a table cell.", + action_sets=["google_docs_tables"], + input_schema={ + "document_id": {"type": "string", "description": "Document ID.", "example": "1abcDEF..."}, + "table_start_index": {"type": "integer", "description": "Table start index.", "example": 5}, + "row_index": {"type": "integer", "description": "Reference cell row.", "example": 0}, + "column_index": {"type": "integer", "description": "Reference cell column.", "example": 0}, + "insert_right": {"type": "boolean", "description": "True = right, False = left.", "example": True}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def insert_google_doc_table_column(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_docs", "insert_table_column", + unwrap_envelope=True, fail_message="Failed to insert column.", + document_id=input_data["document_id"], + table_start_index=input_data["table_start_index"], + row_index=input_data["row_index"], + column_index=input_data["column_index"], + insert_right=input_data.get("insert_right", True), + ) + +@action( + name="delete_google_doc_table_row", + description="Delete a row at the specified cell location.", + action_sets=["google_docs_tables"], + input_schema={ + "document_id": {"type": "string", "description": "Document ID.", "example": "1abcDEF..."}, + "table_start_index": {"type": "integer", "description": "Table start index.", "example": 5}, + "row_index": {"type": "integer", "description": "Row to delete.", "example": 1}, + "column_index": {"type": "integer", "description": "Any column index in the row.", "example": 0}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def delete_google_doc_table_row(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_docs", "delete_table_row", + unwrap_envelope=True, fail_message="Failed to delete row.", + document_id=input_data["document_id"], + table_start_index=input_data["table_start_index"], + row_index=input_data["row_index"], + column_index=input_data["column_index"], + ) + + +@action( + name="delete_google_doc_table_column", + description="Delete a column at the specified cell location.", + action_sets=["google_docs_tables"], + input_schema={ + "document_id": {"type": "string", "description": "Document ID.", "example": "1abcDEF..."}, + "table_start_index": {"type": "integer", "description": "Table start index.", "example": 5}, + "row_index": {"type": "integer", "description": "Any row index in the column.", "example": 0}, + "column_index": {"type": "integer", "description": "Column to delete.", "example": 1}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def delete_google_doc_table_column(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_docs", "delete_table_column", + unwrap_envelope=True, fail_message="Failed to delete column.", + document_id=input_data["document_id"], + table_start_index=input_data["table_start_index"], + row_index=input_data["row_index"], + column_index=input_data["column_index"], + ) + + +@action( + name="merge_google_doc_table_cells", + description="Merge a rectangular range of table cells into one.", + action_sets=["google_docs_tables"], + input_schema={ + "document_id": {"type": "string", "description": "Document ID.", "example": "1abcDEF..."}, + "table_start_index": {"type": "integer", "description": "Table start index.", "example": 5}, + "row_index": {"type": "integer", "description": "Top-left cell row.", "example": 0}, + "column_index": {"type": "integer", "description": "Top-left cell column.", "example": 0}, + "row_span": {"type": "integer", "description": "Rows to span.", "example": 2}, + "column_span": {"type": "integer", "description": "Columns to span.", "example": 2}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def merge_google_doc_table_cells(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_docs", "merge_table_cells", + unwrap_envelope=True, fail_message="Failed to merge cells.", + document_id=input_data["document_id"], + table_start_index=input_data["table_start_index"], + row_index=input_data["row_index"], + column_index=input_data["column_index"], + row_span=input_data["row_span"], + column_span=input_data["column_span"], + ) + + +@action( + name="unmerge_google_doc_table_cells", + description="Reverse a cell merge in a table range.", + action_sets=["google_docs_tables"], + input_schema={ + "document_id": {"type": "string", "description": "Document ID.", "example": "1abcDEF..."}, + "table_start_index": {"type": "integer", "description": "Table start index.", "example": 5}, + "row_index": {"type": "integer", "description": "Top-left cell row.", "example": 0}, + "column_index": {"type": "integer", "description": "Top-left cell column.", "example": 0}, + "row_span": {"type": "integer", "description": "Rows in merged region.", "example": 2}, + "column_span": {"type": "integer", "description": "Columns in merged region.", "example": 2}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def unmerge_google_doc_table_cells(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_docs", "unmerge_table_cells", + unwrap_envelope=True, fail_message="Failed to unmerge cells.", + document_id=input_data["document_id"], + table_start_index=input_data["table_start_index"], + row_index=input_data["row_index"], + column_index=input_data["column_index"], + row_span=input_data["row_span"], + column_span=input_data["column_span"], + ) + + +# ------------------------------------------------------------------ +# Images +# Sub-set: google_docs_images +# ------------------------------------------------------------------ + +@action( + name="insert_google_doc_image", + description="Insert an inline image (referenced by public URI) at a document index.", + action_sets=["google_docs_images", "google_docs"], + input_schema={ + "document_id": {"type": "string", "description": "Document ID.", "example": "1abcDEF..."}, + "image_uri": {"type": "string", "description": "Publicly accessible image URL.", "example": "https://example.com/logo.png"}, + "index": {"type": "integer", "description": "Insertion index.", "example": 1}, + "width_pt": {"type": "number", "description": "Optional width in points.", "example": 200}, + "height_pt": {"type": "number", "description": "Optional height in points.", "example": 150}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def insert_google_doc_image(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_docs", "insert_inline_image", + unwrap_envelope=True, success_message="Image inserted.", fail_message="Failed to insert image.", + document_id=input_data["document_id"], + image_uri=input_data["image_uri"], + index=input_data["index"], + width_pt=input_data.get("width_pt"), + height_pt=input_data.get("height_pt"), + ) + + +@action( + name="replace_google_doc_image", + description="Replace an existing inline image with a new URI (keeps position and size).", + action_sets=["google_docs_images"], + input_schema={ + "document_id": {"type": "string", "description": "Document ID.", "example": "1abcDEF..."}, + "image_object_id": {"type": "string", "description": "Inline image object ID.", "example": "kix.xxxx"}, + "image_uri": {"type": "string", "description": "New image URI.", "example": "https://example.com/new.png"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def replace_google_doc_image(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_docs", "replace_image", + unwrap_envelope=True, success_message="Image replaced.", fail_message="Failed to replace image.", + document_id=input_data["document_id"], + image_object_id=input_data["image_object_id"], + image_uri=input_data["image_uri"], + ) + + +# ------------------------------------------------------------------ +# Structure: page/section breaks, headers/footers, named ranges +# Sub-set: google_docs_structure +# ------------------------------------------------------------------ + +@action( + name="insert_google_doc_page_break", + description="Insert a page break at a document index.", + action_sets=["google_docs_structure"], + input_schema={ + "document_id": {"type": "string", "description": "Document ID.", "example": "1abcDEF..."}, + "index": {"type": "integer", "description": "Insertion index.", "example": 1}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def insert_google_doc_page_break(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_docs", "insert_page_break", + unwrap_envelope=True, success_message="Page break inserted.", fail_message="Failed to insert page break.", + document_id=input_data["document_id"], + index=input_data["index"], + ) + + +@action( + name="insert_google_doc_section_break", + description="Insert a section break (NEXT_PAGE or CONTINUOUS) at a document index.", + action_sets=["google_docs_structure"], + input_schema={ + "document_id": {"type": "string", "description": "Document ID.", "example": "1abcDEF..."}, + "index": {"type": "integer", "description": "Insertion index.", "example": 1}, + "section_type": {"type": "string", "description": "NEXT_PAGE | CONTINUOUS.", "example": "NEXT_PAGE"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def insert_google_doc_section_break(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_docs", "insert_section_break", + unwrap_envelope=True, success_message="Section break inserted.", fail_message="Failed to insert section break.", + document_id=input_data["document_id"], + index=input_data["index"], + section_type=input_data.get("section_type", "NEXT_PAGE"), + ) + + +@action( + name="create_google_doc_header", + description="Create a document header. Returns the header ID for further edits.", + action_sets=["google_docs_structure"], + input_schema={ + "document_id": {"type": "string", "description": "Document ID.", "example": "1abcDEF..."}, + "header_type": {"type": "string", "description": "DEFAULT | FIRST_PAGE_HEADER.", "example": "DEFAULT"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def create_google_doc_header(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_docs", "create_header", + unwrap_envelope=True, success_message="Header created.", fail_message="Failed to create header.", + document_id=input_data["document_id"], + header_type=input_data.get("header_type", "DEFAULT"), + ) + + +@action( + name="create_google_doc_footer", + description="Create a document footer. Returns the footer ID for further edits.", + action_sets=["google_docs_structure"], + input_schema={ + "document_id": {"type": "string", "description": "Document ID.", "example": "1abcDEF..."}, + "footer_type": {"type": "string", "description": "DEFAULT | FIRST_PAGE_FOOTER.", "example": "DEFAULT"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def create_google_doc_footer(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_docs", "create_footer", + unwrap_envelope=True, success_message="Footer created.", fail_message="Failed to create footer.", + document_id=input_data["document_id"], + footer_type=input_data.get("footer_type", "DEFAULT"), + ) + + +@action( + name="delete_google_doc_header", + description="Delete a header by its ID.", + action_sets=["google_docs_structure"], + input_schema={ + "document_id": {"type": "string", "description": "Document ID.", "example": "1abcDEF..."}, + "header_id": {"type": "string", "description": "Header ID.", "example": "kix.xxxx"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def delete_google_doc_header(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_docs", "delete_header", + unwrap_envelope=True, success_message="Header deleted.", fail_message="Failed to delete header.", + document_id=input_data["document_id"], + header_id=input_data["header_id"], + ) + + +@action( + name="delete_google_doc_footer", + description="Delete a footer by its ID.", + action_sets=["google_docs_structure"], + input_schema={ + "document_id": {"type": "string", "description": "Document ID.", "example": "1abcDEF..."}, + "footer_id": {"type": "string", "description": "Footer ID.", "example": "kix.xxxx"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def delete_google_doc_footer(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_docs", "delete_footer", + unwrap_envelope=True, success_message="Footer deleted.", fail_message="Failed to delete footer.", + document_id=input_data["document_id"], + footer_id=input_data["footer_id"], + ) + + +@action( + name="create_google_doc_named_range", + description="Create a named range over a document range so it can be referenced later.", + action_sets=["google_docs_structure"], + input_schema={ + "document_id": {"type": "string", "description": "Document ID.", "example": "1abcDEF..."}, + "name": {"type": "string", "description": "Range name.", "example": "intro_section"}, + "start_index": {"type": "integer", "description": "Start UTF-16 index.", "example": 1}, + "end_index": {"type": "integer", "description": "End UTF-16 index.", "example": 50}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def create_google_doc_named_range(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_docs", "create_named_range", + unwrap_envelope=True, success_message="Named range created.", fail_message="Failed to create named range.", + document_id=input_data["document_id"], + name=input_data["name"], + start_index=input_data["start_index"], + end_index=input_data["end_index"], + ) + + +@action( + name="delete_google_doc_named_range", + description="Delete a named range by name or by ID.", + action_sets=["google_docs_structure"], + input_schema={ + "document_id": {"type": "string", "description": "Document ID.", "example": "1abcDEF..."}, + "name": {"type": "string", "description": "Range name to delete (one of name or id required).", "example": "intro_section"}, + "named_range_id": {"type": "string", "description": "Named range ID (alternative to name).", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def delete_google_doc_named_range(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync return run_client_sync( - "google_docs", - "delete_document", - unwrap_envelope=True, - success_message="Document deleted.", - fail_message="Failed to delete document.", + "google_docs", "delete_named_range", + unwrap_envelope=True, success_message="Named range deleted.", fail_message="Failed to delete named range.", document_id=input_data["document_id"], + name=input_data.get("name") or None, + named_range_id=input_data.get("named_range_id") or None, ) diff --git a/app/data/action/integrations/google_workspace/google_drive_actions.py b/app/data/action/integrations/google_workspace/google_drive_actions.py index 32c36663..2d8eed54 100644 --- a/app/data/action/integrations/google_workspace/google_drive_actions.py +++ b/app/data/action/integrations/google_workspace/google_drive_actions.py @@ -1,126 +1,320 @@ from agent_core import action +# ------------------------------------------------------------------ +# Files — list / search / get / folder / upload / download / export / copy / move / delete +# ------------------------------------------------------------------ + @action( name="list_drive_files", - description="List files in a Google Drive folder.", - action_sets=["google_drive"], + description="List files in a specific Google Drive folder.", + action_sets=["google_drive_files", "google_drive"], input_schema={ - "folder_id": { - "type": "string", - "description": "Google Drive folder ID.", - "example": "root", - }, + "folder_id": {"type": "string", "description": "Google Drive folder ID. Use 'root' for the user's My Drive.", "example": "root"}, }, output_schema={"status": {"type": "string", "example": "success"}}, ) def list_drive_files(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync( - "google_drive", - "list_drive_files", - unwrap_envelope=True, - fail_message="Failed to list files.", + "google_drive", "list_drive_files", + unwrap_envelope=True, fail_message="Failed to list files.", folder_id=input_data["folder_id"], ) +@action( + name="search_drive_files", + description="Free-form search across all of Drive using Drive's q-query syntax (e.g. \"name contains 'report' and mimeType = 'application/pdf'\").", + action_sets=["google_drive_files", "google_drive"], + input_schema={ + "query": {"type": "string", "description": "Drive q-query.", "example": "name contains 'budget' and trashed = false"}, + "max_results": {"type": "integer", "description": "Max results.", "example": 50}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def search_drive_files(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_drive", "search_drive", + unwrap_envelope=True, fail_message="Failed to search files.", + query=input_data["query"], + max_results=input_data.get("max_results", 50), + ) + + +@action( + name="get_drive_file", + description="Get metadata for a single Drive file or folder.", + action_sets=["google_drive_files", "google_drive"], + input_schema={ + "file_id": {"type": "string", "description": "File ID.", "example": ""}, + "fields": {"type": "string", "description": "Comma-separated field list (optional).", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_drive_file(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_drive", "get_drive_file", + unwrap_envelope=True, fail_message="Failed to get file.", + file_id=input_data["file_id"], + fields=input_data.get("fields") or None, + ) + + @action( name="create_drive_folder", description="Create a new folder in Google Drive.", - action_sets=["google_drive"], + action_sets=["google_drive_files", "google_drive"], input_schema={ - "name": { - "type": "string", - "description": "Folder name.", - "example": "Project Files", - }, - "parent_folder_id": { - "type": "string", - "description": "Optional parent folder ID.", - "example": "", - }, + "name": {"type": "string", "description": "Folder name.", "example": "Project Files"}, + "parent_folder_id": {"type": "string", "description": "Optional parent folder ID.", "example": ""}, }, output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, ) def create_drive_folder(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync( - "google_drive", - "create_drive_folder", - unwrap_envelope=True, - fail_message="Failed to create folder.", + "google_drive", "create_drive_folder", + unwrap_envelope=True, fail_message="Failed to create folder.", name=input_data["name"], parent_folder_id=input_data.get("parent_folder_id"), ) +@action( + name="upload_drive_file", + description="Upload a local file to Google Drive. Reads from file_path on the agent host. MIME type is auto-detected if omitted.", + action_sets=["google_drive_files", "google_drive"], + input_schema={ + "file_path": {"type": "string", "description": "Absolute path to the local file.", "example": "C:/Users/me/report.pdf"}, + "name": {"type": "string", "description": "Drive filename (defaults to local filename).", "example": ""}, + "mime_type": {"type": "string", "description": "MIME type (defaults to autodetect).", "example": ""}, + "parent_folder_id": {"type": "string", "description": "Target folder ID (defaults to root).", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def upload_drive_file(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_drive", "upload_drive_file", + unwrap_envelope=True, fail_message="Failed to upload file.", + file_path=input_data["file_path"], + name=input_data.get("name") or None, + mime_type=input_data.get("mime_type") or None, + parent_folder_id=input_data.get("parent_folder_id") or None, + ) + + +@action( + name="update_drive_file_content", + description="Replace an existing Drive file's binary content with a local file. Does NOT change metadata.", + action_sets=["google_drive_files"], + input_schema={ + "file_id": {"type": "string", "description": "Drive file ID to overwrite.", "example": ""}, + "file_path": {"type": "string", "description": "Absolute path to the new local content.", "example": "C:/Users/me/report_v2.pdf"}, + "mime_type": {"type": "string", "description": "MIME type (defaults to autodetect).", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def update_drive_file_content(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_drive", "update_drive_file_content", + unwrap_envelope=True, fail_message="Failed to update file content.", + file_id=input_data["file_id"], + file_path=input_data["file_path"], + mime_type=input_data.get("mime_type") or None, + ) + + +@action( + name="download_drive_file", + description="Download a regular (non-Google-native) Drive file to a local path. For Google Docs/Sheets/Slides use export_drive_file instead.", + action_sets=["google_drive_files", "google_drive"], + input_schema={ + "file_id": {"type": "string", "description": "File ID.", "example": ""}, + "save_to": {"type": "string", "description": "Local path to save to. Parent directories will be created.", "example": "C:/Users/me/downloads/report.pdf"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def download_drive_file(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_drive", "download_drive_file", + unwrap_envelope=True, fail_message="Failed to download file.", + file_id=input_data["file_id"], + save_to=input_data["save_to"], + ) + + +@action( + name="export_drive_file", + description="Export a Google-native file (Doc/Sheet/Slide/Drawing) to a local path in another format. Common mime_type values: application/pdf, application/vnd.openxmlformats-officedocument.wordprocessingml.document (.docx), application/vnd.openxmlformats-officedocument.spreadsheetml.sheet (.xlsx), text/plain, text/csv. Limit: 10 MB.", + action_sets=["google_drive_files", "google_drive"], + input_schema={ + "file_id": {"type": "string", "description": "Google-native file ID.", "example": ""}, + "save_to": {"type": "string", "description": "Local path to save to.", "example": "C:/Users/me/report.pdf"}, + "mime_type": {"type": "string", "description": "Target export MIME type.", "example": "application/pdf"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def export_drive_file(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_drive", "export_drive_file", + unwrap_envelope=True, fail_message="Failed to export file.", + file_id=input_data["file_id"], + save_to=input_data["save_to"], + mime_type=input_data["mime_type"], + ) + + +@action( + name="copy_drive_file", + description="Duplicate a Drive file. Optionally rename and/or place in a different folder.", + action_sets=["google_drive_files", "google_drive"], + input_schema={ + "file_id": {"type": "string", "description": "File ID to copy.", "example": ""}, + "name": {"type": "string", "description": "Name for the copy (optional).", "example": ""}, + "parent_folder_id": {"type": "string", "description": "Target folder ID (optional).", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def copy_drive_file(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_drive", "copy_drive_file", + unwrap_envelope=True, fail_message="Failed to copy file.", + file_id=input_data["file_id"], + name=input_data.get("name") or None, + parent_folder_id=input_data.get("parent_folder_id") or None, + ) + + @action( name="move_drive_file", description="Move a file to a different Google Drive folder.", - action_sets=["google_drive"], - input_schema={ - "file_id": { - "type": "string", - "description": "File ID to move.", - "example": "abc123", - }, - "destination_folder_id": { - "type": "string", - "description": "Destination folder ID.", - "example": "def456", - }, - "source_folder_id": { - "type": "string", - "description": "Current parent folder ID.", - "example": "root", - }, + action_sets=["google_drive_files", "google_drive"], + input_schema={ + "file_id": {"type": "string", "description": "File ID to move.", "example": "abc123"}, + "destination_folder_id": {"type": "string", "description": "Destination folder ID.", "example": "def456"}, + "source_folder_id": {"type": "string", "description": "Current parent folder ID.", "example": "root"}, }, output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, ) def move_drive_file(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync( - "google_drive", - "move_drive_file", - unwrap_envelope=True, - fail_message="Failed to move file.", + "google_drive", "move_drive_file", + unwrap_envelope=True, fail_message="Failed to move file.", file_id=input_data["file_id"], add_parents=input_data["destination_folder_id"], remove_parents=input_data.get("source_folder_id", ""), ) +@action( + name="update_drive_file_metadata", + description="Rename / re-describe / star / trash a Drive file. Use trashed=true to send to trash without permanent delete.", + action_sets=["google_drive_files", "google_drive"], + input_schema={ + "file_id": {"type": "string", "description": "File ID.", "example": ""}, + "name": {"type": "string", "description": "New name (optional).", "example": ""}, + "description": {"type": "string", "description": "New description (optional).", "example": ""}, + "starred": {"type": "boolean", "description": "Star/unstar (optional).", "example": False}, + "trashed": {"type": "boolean", "description": "Send to trash without deleting (optional).", "example": False}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def update_drive_file_metadata(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_drive", "update_drive_file_metadata", + unwrap_envelope=True, fail_message="Failed to update file.", + file_id=input_data["file_id"], + name=input_data.get("name") or None, + description=input_data["description"] if "description" in input_data else None, + starred=input_data["starred"] if "starred" in input_data else None, + trashed=input_data["trashed"] if "trashed" in input_data else None, + ) + + +@action( + name="delete_drive_file", + description="Permanently delete a Drive file. Irreversible. To send to trash instead, use update_drive_file_metadata with trashed=true.", + action_sets=["google_drive_files", "google_drive"], + input_schema={ + "file_id": {"type": "string", "description": "File ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def delete_drive_file(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_drive", "delete_drive_file", + unwrap_envelope=True, fail_message="Failed to delete file.", + file_id=input_data["file_id"], + ) + + +@action( + name="empty_drive_trash", + description="Permanently delete EVERYTHING in the user's Drive trash. Irreversible.", + action_sets=["google_drive_files"], + input_schema={}, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def empty_drive_trash(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_drive", "empty_drive_trash", + unwrap_envelope=True, fail_message="Failed to empty trash.", + ) + + +@action( + name="get_drive_about", + description="Get Drive account info: storage quota, max upload size, supported export/import formats, root folder ID.", + action_sets=["google_drive_files", "google_drive"], + input_schema={}, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_drive_about(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_drive", "get_drive_about", + unwrap_envelope=True, fail_message="Failed to get Drive info.", + ) + + @action( name="find_drive_folder_by_name", description="Find folder by name.", - action_sets=["google_drive"], + action_sets=["google_drive_files", "google_drive"], input_schema={ "name": {"type": "string", "description": "Name.", "example": "Folder"}, - "parent_folder_id": { - "type": "string", - "description": "Parent.", - "example": "root", - }, - "from_email": { - "type": "string", - "description": "Email.", - "example": "me@example.com", - }, + "parent_folder_id": {"type": "string", "description": "Parent.", "example": "root"}, + "from_email": {"type": "string", "description": "Email.", "example": "me@example.com"}, }, output_schema={"status": {"type": "string", "example": "success"}}, ) def find_drive_folder_by_name(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync( - "google_drive", - "find_drive_folder_by_name", - unwrap_envelope=True, - fail_message="Failed to find folder.", + "google_drive", "find_drive_folder_by_name", + unwrap_envelope=True, fail_message="Failed to find folder.", name=input_data["name"], parent_folder_id=input_data.get("parent_folder_id"), ) @@ -129,20 +323,15 @@ def find_drive_folder_by_name(input_data: dict) -> dict: @action( name="resolve_drive_folder_path", description="Resolve folder path.", - action_sets=["google_drive"], + action_sets=["google_drive_files"], input_schema={ "path": {"type": "string", "description": "Path.", "example": "Root/Folder"}, - "from_email": { - "type": "string", - "description": "Email.", - "example": "me@example.com", - }, + "from_email": {"type": "string", "description": "Email.", "example": "me@example.com"}, }, output_schema={"status": {"type": "string", "example": "success"}}, ) def resolve_drive_folder_path(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - """Walks the path one segment at a time — custom 'not_found' shape.""" parts = [p for p in input_data["path"].split("/") if p] if parts and parts[0].lower() == "root": @@ -151,22 +340,549 @@ def resolve_drive_folder_path(input_data: dict) -> dict: for part in parts: result = run_client_sync( - "google_drive", - "find_drive_folder_by_name", - unwrap_envelope=True, - fail_message=f"Failed to look up '{part}'", - name=part, - parent_folder_id=current_folder_id, + "google_drive", "find_drive_folder_by_name", + unwrap_envelope=True, fail_message=f"Failed to look up '{part}'", + name=part, parent_folder_id=current_folder_id, ) if result["status"] == "error": return {"status": "error", "reason": result.get("message", "API error")} folder = result.get("result") if not folder: - return { - "status": "not_found", - "reason": f"Folder '{part}' not found", - "folder_id": None, - } + return {"status": "not_found", "reason": f"Folder '{part}' not found", "folder_id": None} current_folder_id = folder["id"] return {"status": "success", "folder_id": current_folder_id} + + +# ------------------------------------------------------------------ +# Permissions (sharing) +# ------------------------------------------------------------------ + +@action( + name="list_drive_permissions", + description="List who has access to a Drive file or folder, with their role.", + action_sets=["google_drive_permissions", "google_drive"], + input_schema={ + "file_id": {"type": "string", "description": "File or folder ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def list_drive_permissions(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_drive", "list_drive_permissions", + unwrap_envelope=True, fail_message="Failed to list permissions.", + file_id=input_data["file_id"], + ) + + +@action( + name="get_drive_permission", + description="Get one specific permission by ID.", + action_sets=["google_drive_permissions"], + input_schema={ + "file_id": {"type": "string", "description": "File ID.", "example": ""}, + "permission_id": {"type": "string", "description": "Permission ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_drive_permission(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_drive", "get_drive_permission", + unwrap_envelope=True, fail_message="Failed to get permission.", + file_id=input_data["file_id"], + permission_id=input_data["permission_id"], + ) + + +@action( + name="add_drive_permission", + description="Share a Drive file/folder. perm_type: user|group|domain|anyone. role: reader|commenter|writer|owner.", + action_sets=["google_drive_permissions", "google_drive"], + input_schema={ + "file_id": {"type": "string", "description": "File or folder ID.", "example": ""}, + "role": {"type": "string", "description": "reader, commenter, writer, or owner.", "example": "reader"}, + "perm_type": {"type": "string", "description": "user, group, domain, or anyone.", "example": "user"}, + "email_address": {"type": "string", "description": "Email (for user/group types).", "example": "alice@example.com"}, + "domain": {"type": "string", "description": "Domain (for domain type).", "example": ""}, + "send_notification": {"type": "boolean", "description": "Email the grantee.", "example": True}, + "email_message": {"type": "string", "description": "Custom notification message (optional).", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def add_drive_permission(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_drive", "create_drive_permission", + unwrap_envelope=True, fail_message="Failed to add permission.", + file_id=input_data["file_id"], + role=input_data["role"], + perm_type=input_data.get("perm_type", "user"), + email_address=input_data.get("email_address") or None, + domain=input_data.get("domain") or None, + send_notification=bool(input_data.get("send_notification", True)), + email_message=input_data.get("email_message") or None, + ) + + +@action( + name="update_drive_permission", + description="Change a permission's role.", + action_sets=["google_drive_permissions"], + input_schema={ + "file_id": {"type": "string", "description": "File ID.", "example": ""}, + "permission_id": {"type": "string", "description": "Permission ID.", "example": ""}, + "role": {"type": "string", "description": "New role.", "example": "writer"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def update_drive_permission(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_drive", "update_drive_permission", + unwrap_envelope=True, fail_message="Failed to update permission.", + file_id=input_data["file_id"], + permission_id=input_data["permission_id"], + role=input_data["role"], + ) + + +@action( + name="remove_drive_permission", + description="Revoke access by deleting a permission.", + action_sets=["google_drive_permissions"], + input_schema={ + "file_id": {"type": "string", "description": "File ID.", "example": ""}, + "permission_id": {"type": "string", "description": "Permission ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def remove_drive_permission(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_drive", "delete_drive_permission", + unwrap_envelope=True, fail_message="Failed to remove permission.", + file_id=input_data["file_id"], + permission_id=input_data["permission_id"], + ) + + +# ------------------------------------------------------------------ +# Comments + replies +# ------------------------------------------------------------------ + +@action( + name="list_drive_comments", + description="List comments on a Drive file.", + action_sets=["google_drive_comments"], + input_schema={ + "file_id": {"type": "string", "description": "File ID.", "example": ""}, + "include_deleted": {"type": "boolean", "description": "Include soft-deleted comments.", "example": False}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def list_drive_comments(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_drive", "list_drive_comments", + unwrap_envelope=True, fail_message="Failed to list comments.", + file_id=input_data["file_id"], + include_deleted=bool(input_data.get("include_deleted", False)), + ) + + +@action( + name="get_drive_comment", + description="Get a single comment with its replies.", + action_sets=["google_drive_comments"], + input_schema={ + "file_id": {"type": "string", "description": "File ID.", "example": ""}, + "comment_id": {"type": "string", "description": "Comment ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_drive_comment(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_drive", "get_drive_comment", + unwrap_envelope=True, fail_message="Failed to get comment.", + file_id=input_data["file_id"], + comment_id=input_data["comment_id"], + ) + + +@action( + name="create_drive_comment", + description="Post a top-level comment on a Drive file. anchor is an optional region anchor (Google's structured anchor format).", + action_sets=["google_drive_comments"], + input_schema={ + "file_id": {"type": "string", "description": "File ID.", "example": ""}, + "content": {"type": "string", "description": "Comment text.", "example": "Please review."}, + "anchor": {"type": "string", "description": "Optional anchor (structured format).", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def create_drive_comment(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_drive", "create_drive_comment", + unwrap_envelope=True, fail_message="Failed to create comment.", + file_id=input_data["file_id"], + content=input_data["content"], + anchor=input_data.get("anchor") or None, + ) + + +@action( + name="update_drive_comment", + description="Edit a comment's content or mark it resolved.", + action_sets=["google_drive_comments"], + input_schema={ + "file_id": {"type": "string", "description": "File ID.", "example": ""}, + "comment_id": {"type": "string", "description": "Comment ID.", "example": ""}, + "content": {"type": "string", "description": "New content (optional).", "example": ""}, + "resolved": {"type": "boolean", "description": "Mark as resolved (optional).", "example": True}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def update_drive_comment(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_drive", "update_drive_comment", + unwrap_envelope=True, fail_message="Failed to update comment.", + file_id=input_data["file_id"], + comment_id=input_data["comment_id"], + content=input_data["content"] if "content" in input_data else None, + resolved=input_data["resolved"] if "resolved" in input_data else None, + ) + + +@action( + name="delete_drive_comment", + description="Delete a comment.", + action_sets=["google_drive_comments"], + input_schema={ + "file_id": {"type": "string", "description": "File ID.", "example": ""}, + "comment_id": {"type": "string", "description": "Comment ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def delete_drive_comment(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_drive", "delete_drive_comment", + unwrap_envelope=True, fail_message="Failed to delete comment.", + file_id=input_data["file_id"], + comment_id=input_data["comment_id"], + ) + + +@action( + name="list_drive_comment_replies", + description="List replies on a comment.", + action_sets=["google_drive_comments"], + input_schema={ + "file_id": {"type": "string", "description": "File ID.", "example": ""}, + "comment_id": {"type": "string", "description": "Comment ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def list_drive_comment_replies(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_drive", "list_drive_comment_replies", + unwrap_envelope=True, fail_message="Failed to list replies.", + file_id=input_data["file_id"], + comment_id=input_data["comment_id"], + ) + + +@action( + name="create_drive_comment_reply", + description="Reply to a comment.", + action_sets=["google_drive_comments"], + input_schema={ + "file_id": {"type": "string", "description": "File ID.", "example": ""}, + "comment_id": {"type": "string", "description": "Comment ID.", "example": ""}, + "content": {"type": "string", "description": "Reply text.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def create_drive_comment_reply(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_drive", "create_drive_comment_reply", + unwrap_envelope=True, fail_message="Failed to create reply.", + file_id=input_data["file_id"], + comment_id=input_data["comment_id"], + content=input_data["content"], + ) + + +@action( + name="update_drive_comment_reply", + description="Edit a reply.", + action_sets=["google_drive_comments"], + input_schema={ + "file_id": {"type": "string", "description": "File ID.", "example": ""}, + "comment_id": {"type": "string", "description": "Comment ID.", "example": ""}, + "reply_id": {"type": "string", "description": "Reply ID.", "example": ""}, + "content": {"type": "string", "description": "New content.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def update_drive_comment_reply(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_drive", "update_drive_comment_reply", + unwrap_envelope=True, fail_message="Failed to update reply.", + file_id=input_data["file_id"], + comment_id=input_data["comment_id"], + reply_id=input_data["reply_id"], + content=input_data["content"], + ) + + +@action( + name="delete_drive_comment_reply", + description="Delete a reply.", + action_sets=["google_drive_comments"], + input_schema={ + "file_id": {"type": "string", "description": "File ID.", "example": ""}, + "comment_id": {"type": "string", "description": "Comment ID.", "example": ""}, + "reply_id": {"type": "string", "description": "Reply ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def delete_drive_comment_reply(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_drive", "delete_drive_comment_reply", + unwrap_envelope=True, fail_message="Failed to delete reply.", + file_id=input_data["file_id"], + comment_id=input_data["comment_id"], + reply_id=input_data["reply_id"], + ) + + +# ------------------------------------------------------------------ +# Revisions (version history) +# ------------------------------------------------------------------ + +@action( + name="list_drive_revisions", + description="List revisions (version history) of a Drive file.", + action_sets=["google_drive_revisions"], + input_schema={ + "file_id": {"type": "string", "description": "File ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def list_drive_revisions(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_drive", "list_drive_revisions", + unwrap_envelope=True, fail_message="Failed to list revisions.", + file_id=input_data["file_id"], + ) + + +@action( + name="get_drive_revision", + description="Get details of a specific revision.", + action_sets=["google_drive_revisions"], + input_schema={ + "file_id": {"type": "string", "description": "File ID.", "example": ""}, + "revision_id": {"type": "string", "description": "Revision ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_drive_revision(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_drive", "get_drive_revision", + unwrap_envelope=True, fail_message="Failed to get revision.", + file_id=input_data["file_id"], + revision_id=input_data["revision_id"], + ) + + +@action( + name="update_drive_revision", + description="Mark a revision keep-forever (pin) or set publish state for Google-native files.", + action_sets=["google_drive_revisions"], + input_schema={ + "file_id": {"type": "string", "description": "File ID.", "example": ""}, + "revision_id": {"type": "string", "description": "Revision ID.", "example": ""}, + "keep_forever": {"type": "boolean", "description": "Pin this revision (otherwise Drive auto-prunes after 100 or 30 days, whichever first).", "example": True}, + "published": {"type": "boolean", "description": "Publish state (Google-native files only).", "example": False}, + "publish_auto": {"type": "boolean", "description": "Auto-publish subsequent revisions.", "example": False}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def update_drive_revision(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_drive", "update_drive_revision", + unwrap_envelope=True, fail_message="Failed to update revision.", + file_id=input_data["file_id"], + revision_id=input_data["revision_id"], + keep_forever=input_data["keep_forever"] if "keep_forever" in input_data else None, + published=input_data["published"] if "published" in input_data else None, + publish_auto=input_data["publish_auto"] if "publish_auto" in input_data else None, + ) + + +@action( + name="delete_drive_revision", + description="Delete a revision.", + action_sets=["google_drive_revisions"], + input_schema={ + "file_id": {"type": "string", "description": "File ID.", "example": ""}, + "revision_id": {"type": "string", "description": "Revision ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def delete_drive_revision(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_drive", "delete_drive_revision", + unwrap_envelope=True, fail_message="Failed to delete revision.", + file_id=input_data["file_id"], + revision_id=input_data["revision_id"], + ) + + +# ------------------------------------------------------------------ +# Shared drives (formerly Team Drives) +# ------------------------------------------------------------------ + +@action( + name="list_shared_drives", + description="List shared drives the user has access to.", + action_sets=["google_drive_shared_drives"], + input_schema={ + "page_size": {"type": "integer", "description": "Max results.", "example": 50}, + "q": {"type": "string", "description": "Drive search query (optional).", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def list_shared_drives(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_drive", "list_shared_drives", + unwrap_envelope=True, fail_message="Failed to list shared drives.", + page_size=input_data.get("page_size", 50), + q=input_data.get("q") or None, + ) + + +@action( + name="get_shared_drive", + description="Get metadata for a shared drive.", + action_sets=["google_drive_shared_drives"], + input_schema={ + "drive_id": {"type": "string", "description": "Shared drive ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_shared_drive(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_drive", "get_shared_drive", + unwrap_envelope=True, fail_message="Failed to get shared drive.", + drive_id=input_data["drive_id"], + ) + + +@action( + name="create_shared_drive", + description="Create a new shared drive. The user must have permission to create shared drives in their org.", + action_sets=["google_drive_shared_drives"], + input_schema={ + "name": {"type": "string", "description": "Shared drive name.", "example": "Team project"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def create_shared_drive(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_drive", "create_shared_drive", + unwrap_envelope=True, fail_message="Failed to create shared drive.", + name=input_data["name"], + ) + + +@action( + name="update_shared_drive", + description="Rename or hide/unhide a shared drive.", + action_sets=["google_drive_shared_drives"], + input_schema={ + "drive_id": {"type": "string", "description": "Shared drive ID.", "example": ""}, + "name": {"type": "string", "description": "New name (optional).", "example": ""}, + "hidden": {"type": "boolean", "description": "Hide from UI (optional).", "example": False}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def update_shared_drive(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_drive", "update_shared_drive", + unwrap_envelope=True, fail_message="Failed to update shared drive.", + drive_id=input_data["drive_id"], + name=input_data.get("name") or None, + hidden=input_data["hidden"] if "hidden" in input_data else None, + ) + + +@action( + name="delete_shared_drive", + description="Delete a shared drive. The drive must be empty.", + action_sets=["google_drive_shared_drives"], + input_schema={ + "drive_id": {"type": "string", "description": "Shared drive ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def delete_shared_drive(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "google_drive", "delete_shared_drive", + unwrap_envelope=True, fail_message="Failed to delete shared drive.", + drive_id=input_data["drive_id"], + ) + + +# ================================================================== +# Intentionally NOT exposed as actions (and why) +# ================================================================== +# - Changes / watch endpoints (changes.list, changes.watch, channels.stop, etc.) +# Push notifications / incremental sync — server-side webhook plumbing, +# not per-interaction actions. +# - generateIds +# Pre-allocating IDs before insert. Niche; most agents just let Drive +# mint IDs on POST. +# - Resumable upload (uploadType=resumable) +# Used for very large uploads (>5MB) with progress tracking. The simple +# 2-step upload (metadata + uploadType=media PATCH) handles realistic +# file sizes; resumable can be added later if needed. +# - DriveAccess proposals / members management on shared drives +# Org-admin-level concerns, not personal-agent work. +# - Multipart/related upload (uploadType=multipart) +# The 2-step pattern in upload_drive_file gives equivalent semantics +# without the multipart-body construction. diff --git a/app/data/action/integrations/jira/jira_actions.py b/app/data/action/integrations/jira/jira_actions.py index d1e108d6..54fad891 100644 --- a/app/data/action/integrations/jira/jira_actions.py +++ b/app/data/action/integrations/jira/jira_actions.py @@ -6,40 +6,26 @@ # ------------------------------------------------------------------ -# Issues +# Issues — search, get, create, update, delete, transition, assign +# Sub-set: jira_issues # ------------------------------------------------------------------ - @action( name="search_jira_issues", description="Search for Jira issues using JQL (Jira Query Language).", - action_sets=["jira"], - input_schema={ - "jql": { - "type": "string", - "description": "JQL query string.", - "example": 'project = PROJ AND status = "In Progress"', - }, - "max_results": { - "type": "integer", - "description": "Max issues to return (max 100).", - "example": 20, - }, - "fields": { - "type": "string", - "description": "Comma-separated fields to return. Leave empty for defaults.", - "example": "summary,status,assignee,priority", - }, + action_sets=["jira_issues", "jira"], + input_schema={ + "jql": {"type": "string", "description": "JQL query string.", "example": 'project = PROJ AND status = "In Progress"'}, + "max_results": {"type": "integer", "description": "Max issues to return (max 100).", "example": 20}, + "fields": {"type": "string", "description": "Comma-separated fields to return. Leave empty for defaults.", "example": "summary,status,assignee,priority"}, }, output_schema={"status": {"type": "string", "example": "success"}}, ) async def search_jira_issues(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - fields_list = csv_list(input_data.get("fields", ""), default=None) return await run_client( - "jira", - "search_issues", + "jira", "search_issues", jql=input_data["jql"], max_results=input_data.get("max_results", 20), fields_list=fields_list, @@ -49,24 +35,15 @@ async def search_jira_issues(input_data: dict) -> dict: @action( name="get_jira_issue", description="Get details of a specific Jira issue by its key (e.g. PROJ-123).", - action_sets=["jira"], + action_sets=["jira_issues", "jira"], input_schema={ - "issue_key": { - "type": "string", - "description": "Issue key.", - "example": "PROJ-123", - }, - "fields": { - "type": "string", - "description": "Comma-separated fields to return. Leave empty for all.", - "example": "summary,status,assignee,description", - }, + "issue_key": {"type": "string", "description": "Issue key.", "example": "PROJ-123"}, + "fields": {"type": "string", "description": "Comma-separated fields to return. Leave empty for all.", "example": "summary,status,assignee,description"}, }, output_schema={"status": {"type": "string", "example": "success"}}, ) async def get_jira_issue(input_data: dict) -> dict: from app.data.action.integrations._helpers import with_client - fields_list = csv_list(input_data.get("fields", ""), default=None) return await with_client( "jira", @@ -77,54 +54,24 @@ async def get_jira_issue(input_data: dict) -> dict: @action( name="create_jira_issue", description="Create a new Jira issue in a project.", - action_sets=["jira"], - input_schema={ - "project_key": { - "type": "string", - "description": "Project key.", - "example": "PROJ", - }, - "summary": { - "type": "string", - "description": "Issue title/summary.", - "example": "Fix login bug", - }, - "issue_type": { - "type": "string", - "description": "Issue type name.", - "example": "Task", - }, - "description": { - "type": "string", - "description": "Issue description (plain text).", - "example": "", - }, - "assignee_id": { - "type": "string", - "description": "Atlassian account ID of the assignee. Leave empty for unassigned.", - "example": "", - }, - "labels": { - "type": "string", - "description": "Comma-separated labels.", - "example": "bug,urgent", - }, - "priority": { - "type": "string", - "description": "Priority name (e.g. High, Medium, Low).", - "example": "Medium", - }, + action_sets=["jira_issues", "jira"], + input_schema={ + "project_key": {"type": "string", "description": "Project key.", "example": "PROJ"}, + "summary": {"type": "string", "description": "Issue title/summary.", "example": "Fix login bug"}, + "issue_type": {"type": "string", "description": "Issue type name.", "example": "Task"}, + "description": {"type": "string", "description": "Issue description (plain text).", "example": ""}, + "assignee_id": {"type": "string", "description": "Atlassian account ID of the assignee. Leave empty for unassigned.", "example": ""}, + "labels": {"type": "string", "description": "Comma-separated labels.", "example": "bug,urgent"}, + "priority": {"type": "string", "description": "Priority name (e.g. High, Medium, Low).", "example": "Medium"}, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, ) async def create_jira_issue(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - labels = csv_list(input_data.get("labels", ""), default=None) return await run_client( - "jira", - "create_issue", + "jira", "create_issue", project_key=input_data["project_key"], summary=input_data["summary"], issue_type=input_data.get("issue_type", "Task"), @@ -138,35 +85,18 @@ async def create_jira_issue(input_data: dict) -> dict: @action( name="update_jira_issue", description="Update fields on an existing Jira issue.", - action_sets=["jira"], - input_schema={ - "issue_key": { - "type": "string", - "description": "Issue key.", - "example": "PROJ-123", - }, - "summary": { - "type": "string", - "description": "New summary. Leave empty to keep current.", - "example": "", - }, - "priority": { - "type": "string", - "description": "New priority name. Leave empty to keep current.", - "example": "", - }, - "labels": { - "type": "string", - "description": "Comma-separated labels to SET (replaces all). Leave empty to keep current.", - "example": "", - }, + action_sets=["jira_issues", "jira"], + input_schema={ + "issue_key": {"type": "string", "description": "Issue key.", "example": "PROJ-123"}, + "summary": {"type": "string", "description": "New summary. Leave empty to keep current.", "example": ""}, + "priority": {"type": "string", "description": "New priority name. Leave empty to keep current.", "example": ""}, + "labels": {"type": "string", "description": "Comma-separated labels to SET (replaces all). Leave empty to keep current.", "example": ""}, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, ) async def update_jira_issue(input_data: dict) -> dict: from app.data.action.integrations._helpers import with_client - fields_update = {} if input_data.get("summary"): fields_update["summary"] = input_data["summary"] @@ -182,122 +112,54 @@ async def update_jira_issue(input_data: dict) -> dict: ) -# ------------------------------------------------------------------ -# Comments -# ------------------------------------------------------------------ - - @action( - name="add_jira_comment", - description="Add a comment to a Jira issue.", - action_sets=["jira"], + name="delete_jira_issue", + description="Delete a Jira issue. Can optionally cascade-delete subtasks.", + action_sets=["jira_issues"], input_schema={ - "issue_key": { - "type": "string", - "description": "Issue key.", - "example": "PROJ-123", - }, - "body": { - "type": "string", - "description": "Comment text.", - "example": "Fixed in latest commit.", - }, + "issue_key": {"type": "string", "description": "Issue key.", "example": "PROJ-123"}, + "delete_subtasks": {"type": "boolean", "description": "Also delete subtasks.", "example": False}, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, ) -async def add_jira_comment(input_data: dict) -> dict: - from app.data.action.integrations._helpers import with_client - - return await with_client( - "jira", - lambda c: c.add_comment(input_data["issue_key"], input_data["body"]), - ) - - -@action( - name="get_jira_comments", - description="Get comments on a Jira issue.", - action_sets=["jira"], - input_schema={ - "issue_key": { - "type": "string", - "description": "Issue key.", - "example": "PROJ-123", - }, - "max_results": { - "type": "integer", - "description": "Max comments to return.", - "example": 20, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -async def get_jira_comments(input_data: dict) -> dict: - from app.data.action.integrations._helpers import with_client - - return await with_client( - "jira", - lambda c: c.get_issue_comments( - input_data["issue_key"], - max_results=input_data.get("max_results", 20), - ), +async def delete_jira_issue(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "jira", "delete_issue", + issue_key=input_data["issue_key"], + delete_subtasks=input_data.get("delete_subtasks", False), ) -# ------------------------------------------------------------------ -# Transitions -# ------------------------------------------------------------------ - - @action( name="get_jira_transitions", description="Get available status transitions for a Jira issue (to know which statuses you can move it to).", - action_sets=["jira"], + action_sets=["jira_issues", "jira"], input_schema={ - "issue_key": { - "type": "string", - "description": "Issue key.", - "example": "PROJ-123", - }, + "issue_key": {"type": "string", "description": "Issue key.", "example": "PROJ-123"}, }, output_schema={"status": {"type": "string", "example": "success"}}, ) async def get_jira_transitions(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - - return await run_client( - "jira", "get_transitions", issue_key=input_data["issue_key"] - ) + return await run_client("jira", "get_transitions", issue_key=input_data["issue_key"]) @action( name="transition_jira_issue", description="Move a Jira issue to a new status. Use get_jira_transitions first to find the transition ID.", - action_sets=["jira"], + action_sets=["jira_issues", "jira"], input_schema={ - "issue_key": { - "type": "string", - "description": "Issue key.", - "example": "PROJ-123", - }, - "transition_id": { - "type": "string", - "description": "Transition ID from get_jira_transitions.", - "example": "31", - }, - "comment": { - "type": "string", - "description": "Optional comment to add with the transition.", - "example": "", - }, + "issue_key": {"type": "string", "description": "Issue key.", "example": "PROJ-123"}, + "transition_id": {"type": "string", "description": "Transition ID from get_jira_transitions.", "example": "31"}, + "comment": {"type": "string", "description": "Optional comment to add with the transition.", "example": ""}, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, ) async def transition_jira_issue(input_data: dict) -> dict: from app.data.action.integrations._helpers import with_client - return await with_client( "jira", lambda c: c.transition_issue( @@ -308,33 +170,19 @@ async def transition_jira_issue(input_data: dict) -> dict: ) -# ------------------------------------------------------------------ -# Assignment -# ------------------------------------------------------------------ - - @action( name="assign_jira_issue", description="Assign a Jira issue to a user. Use search_jira_users to find the account ID.", - action_sets=["jira"], + action_sets=["jira_issues", "jira"], input_schema={ - "issue_key": { - "type": "string", - "description": "Issue key.", - "example": "PROJ-123", - }, - "account_id": { - "type": "string", - "description": "Atlassian account ID. Leave empty to unassign.", - "example": "", - }, + "issue_key": {"type": "string", "description": "Issue key.", "example": "PROJ-123"}, + "account_id": {"type": "string", "description": "Atlassian account ID. Leave empty to unassign.", "example": ""}, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, ) async def assign_jira_issue(input_data: dict) -> dict: from app.data.action.integrations._helpers import with_client - return await with_client( "jira", lambda c: c.assign_issue( @@ -344,33 +192,19 @@ async def assign_jira_issue(input_data: dict) -> dict: ) -# ------------------------------------------------------------------ -# Labels -# ------------------------------------------------------------------ - - @action( name="add_jira_labels", description="Add labels to a Jira issue without removing existing ones.", - action_sets=["jira"], + action_sets=["jira_issues"], input_schema={ - "issue_key": { - "type": "string", - "description": "Issue key.", - "example": "PROJ-123", - }, - "labels": { - "type": "string", - "description": "Comma-separated labels to add.", - "example": "urgent,backend", - }, + "issue_key": {"type": "string", "description": "Issue key.", "example": "PROJ-123"}, + "labels": {"type": "string", "description": "Comma-separated labels to add.", "example": "urgent,backend"}, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, ) async def add_jira_labels(input_data: dict) -> dict: from app.data.action.integrations._helpers import with_client - labels = csv_list(input_data["labels"]) if not labels: return {"status": "error", "message": "No labels provided."} @@ -383,25 +217,16 @@ async def add_jira_labels(input_data: dict) -> dict: @action( name="remove_jira_labels", description="Remove labels from a Jira issue.", - action_sets=["jira"], + action_sets=["jira_issues"], input_schema={ - "issue_key": { - "type": "string", - "description": "Issue key.", - "example": "PROJ-123", - }, - "labels": { - "type": "string", - "description": "Comma-separated labels to remove.", - "example": "urgent", - }, + "issue_key": {"type": "string", "description": "Issue key.", "example": "PROJ-123"}, + "labels": {"type": "string", "description": "Comma-separated labels to remove.", "example": "urgent"}, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, ) async def remove_jira_labels(input_data: dict) -> dict: from app.data.action.integrations._helpers import with_client - labels = csv_list(input_data["labels"]) if not labels: return {"status": "error", "message": "No labels provided."} @@ -411,195 +236,1007 @@ async def remove_jira_labels(input_data: dict) -> dict: ) -# ------------------------------------------------------------------ -# Projects & Users -# ------------------------------------------------------------------ +@action( + name="get_jira_issue_watchers", + description="Get the list of watchers on a Jira issue.", + action_sets=["jira_issues"], + input_schema={ + "issue_key": {"type": "string", "description": "Issue key.", "example": "PROJ-123"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def get_jira_issue_watchers(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("jira", "get_watchers", issue_key=input_data["issue_key"]) @action( - name="list_jira_projects", - description="List accessible Jira projects.", - action_sets=["jira"], + name="add_jira_issue_watcher", + description="Add a user as a watcher on a Jira issue.", + action_sets=["jira_issues"], input_schema={ - "max_results": { - "type": "integer", - "description": "Max projects to return.", - "example": 50, - }, + "issue_key": {"type": "string", "description": "Issue key.", "example": "PROJ-123"}, + "account_id": {"type": "string", "description": "Atlassian account ID of user to add.", "example": "557058:..."}, }, output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, ) -async def list_jira_projects(input_data: dict) -> dict: +async def add_jira_issue_watcher(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client + return await run_client( + "jira", "add_watcher", + issue_key=input_data["issue_key"], + account_id=input_data["account_id"], + ) + +@action( + name="remove_jira_issue_watcher", + description="Remove a watcher from a Jira issue.", + action_sets=["jira_issues"], + input_schema={ + "issue_key": {"type": "string", "description": "Issue key.", "example": "PROJ-123"}, + "account_id": {"type": "string", "description": "Atlassian account ID of user to remove.", "example": "557058:..."}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def remove_jira_issue_watcher(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client return await run_client( - "jira", - "get_projects", - max_results=input_data.get("max_results", 50), + "jira", "remove_watcher", + issue_key=input_data["issue_key"], + account_id=input_data["account_id"], ) +# ------------------------------------------------------------------ +# Comments — add, get, edit, delete +# Sub-set: jira_comments +# ------------------------------------------------------------------ + @action( - name="search_jira_users", - description="Search for Jira users by name or email.", - action_sets=["jira"], + name="add_jira_comment", + description="Add a comment to a Jira issue.", + action_sets=["jira_comments", "jira"], input_schema={ - "query": { - "type": "string", - "description": "Search string (name or email).", - "example": "john", - }, - "max_results": { - "type": "integer", - "description": "Max results.", - "example": 10, - }, + "issue_key": {"type": "string", "description": "Issue key.", "example": "PROJ-123"}, + "body": {"type": "string", "description": "Comment text.", "example": "Fixed in latest commit."}, }, output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, ) -async def search_jira_users(input_data: dict) -> dict: +async def add_jira_comment(input_data: dict) -> dict: from app.data.action.integrations._helpers import with_client + return await with_client( + "jira", + lambda c: c.add_comment(input_data["issue_key"], input_data["body"]), + ) + +@action( + name="get_jira_comments", + description="Get comments on a Jira issue.", + action_sets=["jira_comments", "jira"], + input_schema={ + "issue_key": {"type": "string", "description": "Issue key.", "example": "PROJ-123"}, + "max_results": {"type": "integer", "description": "Max comments to return.", "example": 20}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def get_jira_comments(input_data: dict) -> dict: + from app.data.action.integrations._helpers import with_client return await with_client( "jira", - lambda c: c.search_users( - input_data["query"], max_results=input_data.get("max_results", 10) + lambda c: c.get_issue_comments( + input_data["issue_key"], max_results=input_data.get("max_results", 20), ), ) +@action( + name="update_jira_comment", + description="Edit the body of an existing comment.", + action_sets=["jira_comments"], + input_schema={ + "issue_key": {"type": "string", "description": "Issue key.", "example": "PROJ-123"}, + "comment_id": {"type": "string", "description": "Comment ID.", "example": "10001"}, + "body": {"type": "string", "description": "New comment text.", "example": "Edited comment."}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def update_jira_comment(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "jira", "update_comment", + issue_key=input_data["issue_key"], + comment_id=input_data["comment_id"], + body=input_data["body"], + ) + + +@action( + name="delete_jira_comment", + description="Delete a comment from a Jira issue.", + action_sets=["jira_comments"], + input_schema={ + "issue_key": {"type": "string", "description": "Issue key.", "example": "PROJ-123"}, + "comment_id": {"type": "string", "description": "Comment ID.", "example": "10001"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def delete_jira_comment(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "jira", "delete_comment", + issue_key=input_data["issue_key"], + comment_id=input_data["comment_id"], + ) + + # ------------------------------------------------------------------ -# Watch Tag (custom: bespoke success messages, sync) +# Attachments — upload, get, download, delete +# Sub-set: jira_attachments # ------------------------------------------------------------------ +@action( + name="add_jira_attachment", + description="Upload a local file as an attachment on a Jira issue.", + action_sets=["jira_attachments"], + input_schema={ + "issue_key": {"type": "string", "description": "Issue key.", "example": "PROJ-123"}, + "file_path": {"type": "string", "description": "Local file path to upload.", "example": "/tmp/screenshot.png"}, + "filename": {"type": "string", "description": "Optional override filename.", "example": "screenshot.png"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def add_jira_attachment(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "jira", "add_attachment", + issue_key=input_data["issue_key"], + file_path=input_data["file_path"], + filename=input_data.get("filename") or None, + ) + @action( - name="set_jira_watch_tag", - description="Set a mention tag to watch for in Jira comments. Only comments containing this tag (e.g. '@craftbot') will trigger events. Pass empty string to disable and receive all updates.", - action_sets=["jira"], + name="get_jira_attachment", + description="Get metadata for a specific attachment by ID.", + action_sets=["jira_attachments"], input_schema={ - "tag": { - "type": "string", - "description": "The mention tag to watch for in comments. e.g. '@craftbot'. Empty = disabled.", - "example": "@craftbot", - }, + "attachment_id": {"type": "string", "description": "Attachment ID.", "example": "10001"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def get_jira_attachment(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("jira", "get_attachment", attachment_id=input_data["attachment_id"]) + + +@action( + name="delete_jira_attachment", + description="Delete an attachment by ID.", + action_sets=["jira_attachments"], + input_schema={ + "attachment_id": {"type": "string", "description": "Attachment ID.", "example": "10001"}, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, ) -def set_jira_watch_tag(input_data: dict) -> dict: - try: - from craftos_integrations import get_client +async def delete_jira_attachment(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("jira", "delete_attachment", attachment_id=input_data["attachment_id"]) + + +@action( + name="download_jira_attachment", + description="Download an attachment's bytes to a local file path.", + action_sets=["jira_attachments"], + input_schema={ + "attachment_id": {"type": "string", "description": "Attachment ID.", "example": "10001"}, + "dest_path": {"type": "string", "description": "Local destination path.", "example": "/tmp/saved.png"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def download_jira_attachment(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "jira", "download_attachment", + attachment_id=input_data["attachment_id"], + dest_path=input_data["dest_path"], + ) - client = get_client("jira") - if not client or not client.has_credentials(): - return {"status": "error", "message": _NO_CRED_MSG} - tag = input_data.get("tag", "").strip() - client.set_watch_tag(tag) - if tag: - return { - "status": "success", - "message": f"Now only triggering on comments containing '{tag}'.", - } - return { - "status": "success", - "message": "Watch tag disabled. Triggering on all issue updates.", - } - except Exception as e: - return {"status": "error", "message": str(e)} +# ------------------------------------------------------------------ +# Worklogs — add, list, update, delete +# Sub-set: jira_worklogs +# ------------------------------------------------------------------ @action( - name="get_jira_watch_tag", - description="Get the current mention tag the Jira listener watches for in comments.", - action_sets=["jira"], - input_schema={}, + name="add_jira_worklog", + description="Log time spent on a Jira issue.", + action_sets=["jira_worklogs"], + input_schema={ + "issue_key": {"type": "string", "description": "Issue key.", "example": "PROJ-123"}, + "time_spent": {"type": "string", "description": "Jira-style duration (e.g. '2h 30m', '1d').", "example": "2h 30m"}, + "time_spent_seconds": {"type": "integer", "description": "Alternative to time_spent: total seconds.", "example": 9000}, + "comment": {"type": "string", "description": "Optional worklog comment.", "example": "Implemented feature"}, + "started": {"type": "string", "description": "Optional ISO start time, e.g. '2026-05-21T09:00:00.000+0000'.", "example": ""}, + }, output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, ) -def get_jira_watch_tag(input_data: dict) -> dict: - try: - from craftos_integrations import get_client +async def add_jira_worklog(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "jira", "add_worklog", + issue_key=input_data["issue_key"], + time_spent=input_data.get("time_spent") or None, + time_spent_seconds=input_data.get("time_spent_seconds"), + comment=input_data.get("comment") or None, + started=input_data.get("started") or None, + ) - client = get_client("jira") - if not client or not client.has_credentials(): - return {"status": "error", "message": _NO_CRED_MSG} - tag = client.get_watch_tag() - if tag: - return { - "status": "success", - "tag": tag, - "message": f"Watching for: '{tag}' in comments.", - } - return { - "status": "success", - "tag": "", - "message": "No watch tag set. Triggering on all issue updates.", - } - except Exception as e: - return {"status": "error", "message": str(e)} + +@action( + name="get_jira_worklogs", + description="Get worklog entries for an issue.", + action_sets=["jira_worklogs"], + input_schema={ + "issue_key": {"type": "string", "description": "Issue key.", "example": "PROJ-123"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def get_jira_worklogs(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("jira", "get_worklogs", issue_key=input_data["issue_key"]) @action( - name="set_jira_watch_labels", - description="Set which labels the Jira listener watches for. Only issues with these labels will trigger events. Pass empty to watch all issues.", - action_sets=["jira"], + name="update_jira_worklog", + description="Edit an existing worklog entry.", + action_sets=["jira_worklogs"], input_schema={ - "labels": { - "type": "string", - "description": "Comma-separated labels to watch. Empty string = watch all issues.", - "example": "craftos,agent-task", - }, + "issue_key": {"type": "string", "description": "Issue key.", "example": "PROJ-123"}, + "worklog_id": {"type": "string", "description": "Worklog ID.", "example": "10010"}, + "time_spent": {"type": "string", "description": "Jira-style duration.", "example": "3h"}, + "time_spent_seconds": {"type": "integer", "description": "Total seconds.", "example": 10800}, + "comment": {"type": "string", "description": "New comment.", "example": ""}, + "started": {"type": "string", "description": "ISO start time.", "example": ""}, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, ) -def set_jira_watch_labels(input_data: dict) -> dict: - try: - from craftos_integrations import get_client +async def update_jira_worklog(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "jira", "update_worklog", + issue_key=input_data["issue_key"], + worklog_id=input_data["worklog_id"], + time_spent=input_data.get("time_spent") or None, + time_spent_seconds=input_data.get("time_spent_seconds"), + comment=input_data.get("comment") or None, + started=input_data.get("started") or None, + ) - client = get_client("jira") - if not client or not client.has_credentials(): - return {"status": "error", "message": _NO_CRED_MSG} - labels = csv_list(input_data.get("labels", "")) - client.set_watch_labels(labels) - if labels: - return { - "status": "success", - "message": f"Now watching issues with labels: {', '.join(labels)}", - } - return { - "status": "success", - "message": "Now watching all issues (no label filter).", - } - except Exception as e: - return {"status": "error", "message": str(e)} + +@action( + name="delete_jira_worklog", + description="Delete a worklog entry.", + action_sets=["jira_worklogs"], + input_schema={ + "issue_key": {"type": "string", "description": "Issue key.", "example": "PROJ-123"}, + "worklog_id": {"type": "string", "description": "Worklog ID.", "example": "10010"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def delete_jira_worklog(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "jira", "delete_worklog", + issue_key=input_data["issue_key"], + worklog_id=input_data["worklog_id"], + ) +# ------------------------------------------------------------------ +# Issue links — create, get, delete, list types +# Sub-set: jira_links +# ------------------------------------------------------------------ + @action( - name="get_jira_watch_labels", - description="Get the current label filter for the Jira listener.", - action_sets=["jira"], - input_schema={}, + name="create_jira_issue_link", + description="Link two issues together (e.g. 'blocks', 'relates to'). Use list_jira_issue_link_types to discover names.", + action_sets=["jira_links"], + input_schema={ + "link_type": {"type": "string", "description": "Link type name (e.g. 'Blocks', 'Relates').", "example": "Blocks"}, + "inward_issue_key": {"type": "string", "description": "Issue on the inward side.", "example": "PROJ-1"}, + "outward_issue_key": {"type": "string", "description": "Issue on the outward side.", "example": "PROJ-2"}, + "comment": {"type": "string", "description": "Optional comment on the source.", "example": ""}, + }, output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, ) -def get_jira_watch_labels(input_data: dict) -> dict: - try: - from craftos_integrations import get_client +async def create_jira_issue_link(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "jira", "create_issue_link", + link_type=input_data["link_type"], + inward_issue_key=input_data["inward_issue_key"], + outward_issue_key=input_data["outward_issue_key"], + comment=input_data.get("comment") or None, + ) + + +@action( + name="get_jira_issue_link", + description="Get a specific issue link by ID.", + action_sets=["jira_links"], + input_schema={ + "link_id": {"type": "string", "description": "Issue link ID.", "example": "10000"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def get_jira_issue_link(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("jira", "get_issue_link", link_id=input_data["link_id"]) + +@action( + name="delete_jira_issue_link", + description="Delete a specific issue link.", + action_sets=["jira_links"], + input_schema={ + "link_id": {"type": "string", "description": "Issue link ID.", "example": "10000"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def delete_jira_issue_link(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("jira", "delete_issue_link", link_id=input_data["link_id"]) + + +@action( + name="list_jira_issue_link_types", + description="List the available issue link types (Blocks, Relates, Duplicate, etc.).", + action_sets=["jira_links"], + input_schema={}, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def list_jira_issue_link_types(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("jira", "list_issue_link_types") + + +# ------------------------------------------------------------------ +# Projects / Versions / Components / Users / Metadata +# Sub-set: jira_projects +# ------------------------------------------------------------------ + +@action( + name="list_jira_projects", + description="List accessible Jira projects.", + action_sets=["jira_projects", "jira"], + input_schema={ + "max_results": {"type": "integer", "description": "Max projects to return.", "example": 50}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def list_jira_projects(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "jira", "get_projects", max_results=input_data.get("max_results", 50), + ) + + +@action( + name="get_jira_project", + description="Get information about a single Jira project.", + action_sets=["jira_projects"], + input_schema={ + "project_key": {"type": "string", "description": "Project key.", "example": "PROJ"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def get_jira_project(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("jira", "get_project", project_key=input_data["project_key"]) + + +@action( + name="search_jira_users", + description="Search for Jira users by name or email.", + action_sets=["jira_projects", "jira"], + input_schema={ + "query": {"type": "string", "description": "Search string (name or email).", "example": "john"}, + "max_results": {"type": "integer", "description": "Max results.", "example": 10}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def search_jira_users(input_data: dict) -> dict: + from app.data.action.integrations._helpers import with_client + return await with_client( + "jira", + lambda c: c.search_users(input_data["query"], max_results=input_data.get("max_results", 10)), + ) + + +@action( + name="list_jira_priorities", + description="List available issue priorities (e.g. High, Medium, Low).", + action_sets=["jira_projects"], + input_schema={}, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def list_jira_priorities(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("jira", "list_priorities") + + +@action( + name="list_jira_issue_types", + description="List available issue types (Task, Bug, Story, etc.).", + action_sets=["jira_projects"], + input_schema={}, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def list_jira_issue_types(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("jira", "list_issue_types") + + +@action( + name="list_jira_versions", + description="List versions for a project (releases/fix versions).", + action_sets=["jira_projects"], + input_schema={ + "project_key": {"type": "string", "description": "Project key.", "example": "PROJ"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def list_jira_versions(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("jira", "list_versions", project_key=input_data["project_key"]) + + +@action( + name="create_jira_version", + description="Create a new version for a project.", + action_sets=["jira_projects"], + input_schema={ + "project_key": {"type": "string", "description": "Project key.", "example": "PROJ"}, + "name": {"type": "string", "description": "Version name.", "example": "v1.0"}, + "description": {"type": "string", "description": "Optional description.", "example": ""}, + "release_date": {"type": "string", "description": "Optional release date (YYYY-MM-DD).", "example": "2026-06-30"}, + "start_date": {"type": "string", "description": "Optional start date (YYYY-MM-DD).", "example": ""}, + "released": {"type": "boolean", "description": "Mark as released.", "example": False}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def create_jira_version(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "jira", "create_version", + project_key=input_data["project_key"], + name=input_data["name"], + description=input_data.get("description") or None, + release_date=input_data.get("release_date") or None, + start_date=input_data.get("start_date") or None, + released=input_data.get("released", False), + ) + + +@action( + name="update_jira_version", + description="Update a Jira version (e.g. mark as released, archived).", + action_sets=["jira_projects"], + input_schema={ + "version_id": {"type": "string", "description": "Version ID.", "example": "10001"}, + "name": {"type": "string", "description": "New name.", "example": ""}, + "description": {"type": "string", "description": "New description.", "example": ""}, + "release_date": {"type": "string", "description": "New release date.", "example": ""}, + "released": {"type": "boolean", "description": "Set released flag.", "example": True}, + "archived": {"type": "boolean", "description": "Set archived flag.", "example": False}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def update_jira_version(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "jira", "update_version", + version_id=input_data["version_id"], + name=input_data.get("name") or None, + description=input_data.get("description") or None, + release_date=input_data.get("release_date") or None, + released=input_data.get("released"), + archived=input_data.get("archived"), + ) + + +@action( + name="delete_jira_version", + description="Delete a Jira version.", + action_sets=["jira_projects"], + input_schema={ + "version_id": {"type": "string", "description": "Version ID.", "example": "10001"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def delete_jira_version(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("jira", "delete_version", version_id=input_data["version_id"]) + + +@action( + name="list_jira_components", + description="List components for a project.", + action_sets=["jira_projects"], + input_schema={ + "project_key": {"type": "string", "description": "Project key.", "example": "PROJ"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def list_jira_components(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("jira", "list_components", project_key=input_data["project_key"]) + + +@action( + name="create_jira_component", + description="Create a new component within a project.", + action_sets=["jira_projects"], + input_schema={ + "project_key": {"type": "string", "description": "Project key.", "example": "PROJ"}, + "name": {"type": "string", "description": "Component name.", "example": "Backend"}, + "description": {"type": "string", "description": "Optional description.", "example": ""}, + "lead_account_id": {"type": "string", "description": "Optional component lead account ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def create_jira_component(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "jira", "create_component", + project_key=input_data["project_key"], + name=input_data["name"], + description=input_data.get("description") or None, + lead_account_id=input_data.get("lead_account_id") or None, + ) + + +@action( + name="delete_jira_component", + description="Delete a project component.", + action_sets=["jira_projects"], + input_schema={ + "component_id": {"type": "string", "description": "Component ID.", "example": "10100"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def delete_jira_component(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("jira", "delete_component", component_id=input_data["component_id"]) + + +@action( + name="list_jira_project_statuses", + description="List the status workflow for a project (issue statuses grouped by issue type).", + action_sets=["jira_projects"], + input_schema={ + "project_key": {"type": "string", "description": "Project key.", "example": "PROJ"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def list_jira_project_statuses(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("jira", "get_statuses", project_key=input_data["project_key"]) + + +# ------------------------------------------------------------------ +# Agile — Boards, Sprints, Epics, Backlog +# Sub-set: jira_sprints +# ------------------------------------------------------------------ + +@action( + name="list_jira_boards", + description="List Agile boards (Scrum/Kanban). Optionally filter by project or type.", + action_sets=["jira_sprints", "jira"], + input_schema={ + "project_key": {"type": "string", "description": "Optional project key filter.", "example": "PROJ"}, + "board_type": {"type": "string", "description": "Optional 'scrum' or 'kanban'.", "example": "scrum"}, + "max_results": {"type": "integer", "description": "Max boards to return.", "example": 50}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def list_jira_boards(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "jira", "list_boards", + project_key=input_data.get("project_key") or None, + board_type=input_data.get("board_type") or None, + max_results=input_data.get("max_results", 50), + ) + + +@action( + name="get_jira_board", + description="Get details of a specific Agile board.", + action_sets=["jira_sprints"], + input_schema={ + "board_id": {"type": "integer", "description": "Board ID.", "example": 1}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def get_jira_board(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("jira", "get_board", board_id=input_data["board_id"]) + + +@action( + name="get_jira_board_issues", + description="List issues currently on a board.", + action_sets=["jira_sprints"], + input_schema={ + "board_id": {"type": "integer", "description": "Board ID.", "example": 1}, + "jql": {"type": "string", "description": "Optional JQL filter.", "example": ""}, + "max_results": {"type": "integer", "description": "Max issues.", "example": 50}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def get_jira_board_issues(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "jira", "get_board_issues", + board_id=input_data["board_id"], + jql=input_data.get("jql") or None, + max_results=input_data.get("max_results", 50), + ) + + +@action( + name="get_jira_board_sprints", + description="List sprints on a board (optionally filter by state).", + action_sets=["jira_sprints", "jira"], + input_schema={ + "board_id": {"type": "integer", "description": "Board ID.", "example": 1}, + "state": {"type": "string", "description": "Comma-separated states: 'active,closed,future'.", "example": "active"}, + "max_results": {"type": "integer", "description": "Max sprints.", "example": 50}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def get_jira_board_sprints(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "jira", "get_board_sprints", + board_id=input_data["board_id"], + state=input_data.get("state") or None, + max_results=input_data.get("max_results", 50), + ) + + +@action( + name="get_jira_board_backlog", + description="Get the backlog issues for a board (issues not yet in any sprint).", + action_sets=["jira_sprints"], + input_schema={ + "board_id": {"type": "integer", "description": "Board ID.", "example": 1}, + "max_results": {"type": "integer", "description": "Max issues.", "example": 50}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def get_jira_board_backlog(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "jira", "get_board_backlog", + board_id=input_data["board_id"], + max_results=input_data.get("max_results", 50), + ) + + +@action( + name="get_jira_sprint", + description="Get details of a specific sprint.", + action_sets=["jira_sprints"], + input_schema={ + "sprint_id": {"type": "integer", "description": "Sprint ID.", "example": 42}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def get_jira_sprint(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("jira", "get_sprint", sprint_id=input_data["sprint_id"]) + + +@action( + name="get_jira_sprint_issues", + description="List issues in a sprint.", + action_sets=["jira_sprints", "jira"], + input_schema={ + "sprint_id": {"type": "integer", "description": "Sprint ID.", "example": 42}, + "jql": {"type": "string", "description": "Optional JQL filter.", "example": ""}, + "max_results": {"type": "integer", "description": "Max issues.", "example": 50}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def get_jira_sprint_issues(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "jira", "get_sprint_issues", + sprint_id=input_data["sprint_id"], + jql=input_data.get("jql") or None, + max_results=input_data.get("max_results", 50), + ) + + +@action( + name="create_jira_sprint", + description="Create a new sprint on a board.", + action_sets=["jira_sprints"], + input_schema={ + "board_id": {"type": "integer", "description": "Origin board ID.", "example": 1}, + "name": {"type": "string", "description": "Sprint name.", "example": "Sprint 23"}, + "goal": {"type": "string", "description": "Optional sprint goal.", "example": ""}, + "start_date": {"type": "string", "description": "ISO start date.", "example": "2026-05-21T00:00:00.000Z"}, + "end_date": {"type": "string", "description": "ISO end date.", "example": "2026-06-04T00:00:00.000Z"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def create_jira_sprint(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "jira", "create_sprint", + name=input_data["name"], + board_id=input_data["board_id"], + goal=input_data.get("goal") or None, + start_date=input_data.get("start_date") or None, + end_date=input_data.get("end_date") or None, + ) + + +@action( + name="update_jira_sprint", + description="Update a sprint's name, state (active/closed/future), goal, or dates.", + action_sets=["jira_sprints"], + input_schema={ + "sprint_id": {"type": "integer", "description": "Sprint ID.", "example": 42}, + "name": {"type": "string", "description": "New name.", "example": ""}, + "state": {"type": "string", "description": "'active' (start) or 'closed' (complete).", "example": ""}, + "goal": {"type": "string", "description": "New goal.", "example": ""}, + "start_date": {"type": "string", "description": "ISO start date.", "example": ""}, + "end_date": {"type": "string", "description": "ISO end date.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def update_jira_sprint(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "jira", "update_sprint", + sprint_id=input_data["sprint_id"], + name=input_data.get("name") or None, + state=input_data.get("state") or None, + goal=input_data.get("goal") or None, + start_date=input_data.get("start_date") or None, + end_date=input_data.get("end_date") or None, + ) + + +@action( + name="delete_jira_sprint", + description="Delete a sprint.", + action_sets=["jira_sprints"], + input_schema={ + "sprint_id": {"type": "integer", "description": "Sprint ID.", "example": 42}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def delete_jira_sprint(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("jira", "delete_sprint", sprint_id=input_data["sprint_id"]) + + +@action( + name="move_issues_to_jira_sprint", + description="Move one or more issues into a sprint.", + action_sets=["jira_sprints", "jira"], + input_schema={ + "sprint_id": {"type": "integer", "description": "Target sprint ID.", "example": 42}, + "issue_keys": {"type": "string", "description": "Comma-separated issue keys.", "example": "PROJ-1,PROJ-2"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def move_issues_to_jira_sprint(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + keys = csv_list(input_data["issue_keys"]) + if not keys: + return {"status": "error", "message": "No issue keys provided."} + return await run_client( + "jira", "move_issues_to_sprint", + sprint_id=input_data["sprint_id"], + issue_keys=keys, + ) + + +@action( + name="move_issues_to_jira_backlog", + description="Move issues back to the backlog (remove from current sprint).", + action_sets=["jira_sprints"], + input_schema={ + "issue_keys": {"type": "string", "description": "Comma-separated issue keys.", "example": "PROJ-1,PROJ-2"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def move_issues_to_jira_backlog(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + keys = csv_list(input_data["issue_keys"]) + if not keys: + return {"status": "error", "message": "No issue keys provided."} + return await run_client("jira", "move_issues_to_backlog", issue_keys=keys) + + +@action( + name="get_jira_epic", + description="Get details of an epic.", + action_sets=["jira_sprints"], + input_schema={ + "epic_key": {"type": "string", "description": "Epic key or ID.", "example": "PROJ-100"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def get_jira_epic(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("jira", "get_epic", epic_id_or_key=input_data["epic_key"]) + + +@action( + name="get_jira_epic_issues", + description="List child issues of an epic.", + action_sets=["jira_sprints"], + input_schema={ + "epic_key": {"type": "string", "description": "Epic key or ID.", "example": "PROJ-100"}, + "max_results": {"type": "integer", "description": "Max issues.", "example": 50}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def get_jira_epic_issues(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "jira", "get_epic_issues", + epic_id_or_key=input_data["epic_key"], + max_results=input_data.get("max_results", 50), + ) + + +@action( + name="move_issues_to_jira_epic", + description="Move issues to an epic (use 'none' as epic key to unlink from epic).", + action_sets=["jira_sprints"], + input_schema={ + "epic_key": {"type": "string", "description": "Epic key, or 'none' to unlink.", "example": "PROJ-100"}, + "issue_keys": {"type": "string", "description": "Comma-separated issue keys.", "example": "PROJ-1,PROJ-2"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def move_issues_to_jira_epic(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + keys = csv_list(input_data["issue_keys"]) + if not keys: + return {"status": "error", "message": "No issue keys provided."} + return await run_client( + "jira", "move_issues_to_epic", + epic_id_or_key=input_data["epic_key"], + issue_keys=keys, + ) + + +# ------------------------------------------------------------------ +# Listener configuration (bespoke success messages, sync) +# Sub-set: jira_listener +# ------------------------------------------------------------------ + +@action( + name="set_jira_watch_tag", + description="Set a mention tag to watch for in Jira comments. Only comments containing this tag (e.g. '@craftbot') will trigger events. Pass empty string to disable and receive all updates.", + action_sets=["jira_listener"], + input_schema={ + "tag": {"type": "string", "description": "The mention tag to watch for in comments. e.g. '@craftbot'. Empty = disabled.", "example": "@craftbot"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def set_jira_watch_tag(input_data: dict) -> dict: + try: + from craftos_integrations import get_client + client = get_client("jira") + if not client or not client.has_credentials(): + return {"status": "error", "message": _NO_CRED_MSG} + tag = input_data.get("tag", "").strip() + client.set_watch_tag(tag) + if tag: + return {"status": "success", "message": f"Now only triggering on comments containing '{tag}'."} + return {"status": "success", "message": "Watch tag disabled. Triggering on all issue updates."} + except Exception as e: + return {"status": "error", "message": str(e)} + + +@action( + name="get_jira_watch_tag", + description="Get the current mention tag the Jira listener watches for in comments.", + action_sets=["jira_listener"], + input_schema={}, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_jira_watch_tag(input_data: dict) -> dict: + try: + from craftos_integrations import get_client + client = get_client("jira") + if not client or not client.has_credentials(): + return {"status": "error", "message": _NO_CRED_MSG} + tag = client.get_watch_tag() + if tag: + return {"status": "success", "tag": tag, "message": f"Watching for: '{tag}' in comments."} + return {"status": "success", "tag": "", "message": "No watch tag set. Triggering on all issue updates."} + except Exception as e: + return {"status": "error", "message": str(e)} + + +@action( + name="set_jira_watch_labels", + description="Set which labels the Jira listener watches for. Only issues with these labels will trigger events. Pass empty to watch all issues.", + action_sets=["jira_listener"], + input_schema={ + "labels": {"type": "string", "description": "Comma-separated labels to watch. Empty string = watch all issues.", "example": "craftos,agent-task"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def set_jira_watch_labels(input_data: dict) -> dict: + try: + from craftos_integrations import get_client + client = get_client("jira") + if not client or not client.has_credentials(): + return {"status": "error", "message": _NO_CRED_MSG} + labels = csv_list(input_data.get("labels", "")) + client.set_watch_labels(labels) + if labels: + return {"status": "success", "message": f"Now watching issues with labels: {', '.join(labels)}"} + return {"status": "success", "message": "Now watching all issues (no label filter)."} + except Exception as e: + return {"status": "error", "message": str(e)} + + +@action( + name="get_jira_watch_labels", + description="Get the current label filter for the Jira listener.", + action_sets=["jira_listener"], + input_schema={}, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_jira_watch_labels(input_data: dict) -> dict: + try: + from craftos_integrations import get_client client = get_client("jira") if not client or not client.has_credentials(): return {"status": "error", "message": _NO_CRED_MSG} labels = client.get_watch_labels() if labels: - return { - "status": "success", - "labels": labels, - "message": f"Watching: {', '.join(labels)}", - } - return { - "status": "success", - "labels": [], - "message": "Watching all issues (no label filter).", - } + return {"status": "success", "labels": labels, "message": f"Watching: {', '.join(labels)}"} + return {"status": "success", "labels": [], "message": "Watching all issues (no label filter)."} except Exception as e: return {"status": "error", "message": str(e)} diff --git a/app/data/action/integrations/lark/lark_actions.py b/app/data/action/integrations/lark/lark_actions.py index fe5f62b6..03afc33d 100644 --- a/app/data/action/integrations/lark/lark_actions.py +++ b/app/data/action/integrations/lark/lark_actions.py @@ -1,121 +1,1027 @@ from agent_core import action +# ═══════════════════════════════════════════════════════════════════════════════ +# Messages — send / get / edit / delete / reply / forward / list / reactions / pins +# ═══════════════════════════════════════════════════════════════════════════════ + @action( name="send_lark_message", - description="Send a text message via Lark to a user (by open_id), group chat (by chat_id), or company email. Use this when the agent needs to push a message via Lark.", - action_sets=["lark"], + description="Send a plain text message in Lark. receive_id_type: open_id | user_id | email | chat_id | union_id.", + action_sets=["lark_messages", "lark"], input_schema={ - "to": { - "type": "string", - "description": "Recipient identifier — Lark open_id (ou_...), user_id, group chat_id (oc_...), or company email.", - "example": "ou_abcdef0123456789", - }, - "text": { - "type": "string", - "description": "Message text.", - "example": "Hello from CraftBot!", - }, - "receive_id_type": { - "type": "string", - "description": "How to interpret 'to': 'open_id' (default), 'user_id', 'email', 'chat_id', or 'union_id'.", - "example": "open_id", - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "result": {"type": "object"}, + "receive_id": {"type": "string", "description": "Recipient identifier.", "example": ""}, + "text": {"type": "string", "description": "Message text.", "example": ""}, + "receive_id_type": {"type": "string", "description": "open_id | user_id | email | chat_id | union_id.", "example": "open_id"}, }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, ) async def send_lark_message(input_data: dict) -> dict: - from app.data.action.integrations._helpers import ( - record_outgoing_message, - run_client, - ) - - record_outgoing_message("Lark", input_data["to"], input_data["text"]) + from app.data.action.integrations._helpers import run_client return await run_client( - "lark", - "send_text", - receive_id=input_data["to"], + "lark", "send_text", + receive_id=input_data["receive_id"], text=input_data["text"], - receive_id_type=input_data.get("receive_id_type") or "open_id", + receive_id_type=input_data.get("receive_id_type", "open_id"), ) @action( name="reply_lark_message", - description="Reply to a Lark message in-thread, using the original message id (om_...).", - action_sets=["lark"], + description="Reply to a Lark message by message_id.", + action_sets=["lark_messages", "lark"], input_schema={ - "message_id": { - "type": "string", - "description": "The original Lark message id (starts with 'om_').", - "example": "om_abcdef0123", - }, - "text": {"type": "string", "description": "Reply text.", "example": "Got it"}, + "message_id": {"type": "string", "description": "Parent message ID (om_...).", "example": ""}, + "text": {"type": "string", "description": "Reply text.", "example": ""}, }, output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, ) async def reply_lark_message(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( - "lark", - "reply_text", + "lark", "reply_text", message_id=input_data["message_id"], text=input_data["text"], ) @action( - name="get_lark_user_by_email", - description="Look up a Lark user's open_id from their company email. Useful for 'message alice@example.com' workflows where only the email is known.", - action_sets=["lark"], + name="send_lark_rich_message", + description="Send a generic Lark message. msg_type: text | post | image | file | audio | media | sticker | interactive | share_chat | share_user. content is the per-type dict (this action JSON-encodes it for you).", + action_sets=["lark_messages", "lark"], input_schema={ - "email": { - "type": "string", - "description": "Company email address.", - "example": "alice@example.com", - }, + "receive_id": {"type": "string", "description": "Recipient ID.", "example": ""}, + "msg_type": {"type": "string", "description": "Message type.", "example": "interactive"}, + "content": {"type": "object", "description": "Per-type content dict.", "example": {}}, + "receive_id_type": {"type": "string", "description": "open_id | user_id | email | chat_id | union_id.", "example": "open_id"}, + "uuid": {"type": "string", "description": "Idempotency UUID (optional).", "example": ""}, }, output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, ) -async def get_lark_user_by_email(input_data: dict) -> dict: +async def send_lark_rich_message(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client + return await run_client( + "lark", "send_message", + receive_id=input_data["receive_id"], + msg_type=input_data["msg_type"], + content=input_data["content"], + receive_id_type=input_data.get("receive_id_type", "open_id"), + uuid=input_data.get("uuid") or None, + ) - return await run_client("lark", "get_user_by_email", email=input_data["email"]) + +@action( + name="send_lark_image", + description="Send an image (use upload_lark_image first to get image_key).", + action_sets=["lark_messages", "lark"], + input_schema={ + "receive_id": {"type": "string", "description": "Recipient ID.", "example": ""}, + "image_key": {"type": "string", "description": "Image key from upload_lark_image.", "example": ""}, + "receive_id_type": {"type": "string", "description": "open_id | chat_id | etc.", "example": "open_id"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def send_lark_image(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark", "send_image_message", + receive_id=input_data["receive_id"], + image_key=input_data["image_key"], + receive_id_type=input_data.get("receive_id_type", "open_id"), + ) + + +@action( + name="send_lark_file", + description="Send a file (use upload_lark_im_file first to get file_key).", + action_sets=["lark_messages", "lark"], + input_schema={ + "receive_id": {"type": "string", "description": "Recipient ID.", "example": ""}, + "file_key": {"type": "string", "description": "File key from upload_lark_im_file.", "example": ""}, + "receive_id_type": {"type": "string", "description": "open_id | chat_id | etc.", "example": "open_id"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def send_lark_file(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark", "send_file_message", + receive_id=input_data["receive_id"], + file_key=input_data["file_key"], + receive_id_type=input_data.get("receive_id_type", "open_id"), + ) + + +@action( + name="send_lark_card", + description="Send an interactive card (Lark's Block Kit equivalent). card is the card schema dict.", + action_sets=["lark_messages", "lark"], + input_schema={ + "receive_id": {"type": "string", "description": "Recipient ID.", "example": ""}, + "card": {"type": "object", "description": "Card schema.", "example": {}}, + "receive_id_type": {"type": "string", "description": "open_id | chat_id | etc.", "example": "open_id"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def send_lark_card(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark", "send_card_message", + receive_id=input_data["receive_id"], + card=input_data["card"], + receive_id_type=input_data.get("receive_id_type", "open_id"), + ) + + +@action( + name="send_lark_post", + description="Send a rich-text 'post' message (multi-line, styled). post is Lark's post schema: {zh_cn: {title, content: [[{tag,text}]]}}.", + action_sets=["lark_messages"], + input_schema={ + "receive_id": {"type": "string", "description": "Recipient ID.", "example": ""}, + "post": {"type": "object", "description": "Post schema.", "example": {}}, + "receive_id_type": {"type": "string", "description": "open_id | chat_id | etc.", "example": "open_id"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def send_lark_post(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark", "send_post_message", + receive_id=input_data["receive_id"], + post=input_data["post"], + receive_id_type=input_data.get("receive_id_type", "open_id"), + ) + + +@action( + name="reply_lark_rich_message", + description="Reply with non-text content (image / file / card / etc.). reply_in_thread starts a thread off the parent.", + action_sets=["lark_messages"], + input_schema={ + "message_id": {"type": "string", "description": "Parent message ID.", "example": ""}, + "msg_type": {"type": "string", "description": "Message type.", "example": "image"}, + "content": {"type": "object", "description": "Per-type content dict.", "example": {}}, + "reply_in_thread": {"type": "boolean", "description": "Start a thread off the parent.", "example": False}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def reply_lark_rich_message(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark", "reply_message", + message_id=input_data["message_id"], + msg_type=input_data["msg_type"], + content=input_data["content"], + reply_in_thread=bool(input_data.get("reply_in_thread", False)), + ) + + +@action( + name="get_lark_message", + description="Get a single Lark message by ID.", + action_sets=["lark_messages", "lark"], + input_schema={ + "message_id": {"type": "string", "description": "Message ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def get_lark_message(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("lark", "get_message", message_id=input_data["message_id"]) + + +@action( + name="delete_lark_message", + description="Recall (delete) a message the bot sent.", + action_sets=["lark_messages", "lark"], + input_schema={ + "message_id": {"type": "string", "description": "Message ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def delete_lark_message(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("lark", "delete_message", message_id=input_data["message_id"]) + + +@action( + name="update_lark_message", + description="Edit a previously-sent Lark message. Only text/interactive types are editable.", + action_sets=["lark_messages", "lark"], + input_schema={ + "message_id": {"type": "string", "description": "Message ID.", "example": ""}, + "msg_type": {"type": "string", "description": "text | interactive.", "example": "text"}, + "content": {"type": "object", "description": "New content dict.", "example": {"text": "Updated"}}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def update_lark_message(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark", "update_message", + message_id=input_data["message_id"], + msg_type=input_data["msg_type"], + content=input_data["content"], + ) + + +@action( + name="forward_lark_message", + description="Forward a message to another recipient.", + action_sets=["lark_messages", "lark"], + input_schema={ + "message_id": {"type": "string", "description": "Message ID.", "example": ""}, + "receive_id": {"type": "string", "description": "Destination ID.", "example": ""}, + "receive_id_type": {"type": "string", "description": "open_id | chat_id | etc.", "example": "open_id"}, + "uuid": {"type": "string", "description": "Idempotency UUID (optional).", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def forward_lark_message(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark", "forward_message", + message_id=input_data["message_id"], + receive_id=input_data["receive_id"], + receive_id_type=input_data.get("receive_id_type", "open_id"), + uuid=input_data.get("uuid") or None, + ) + + +@action( + name="list_lark_chat_messages", + description="List a chat's message history. container_id is usually a chat_id; start_time/end_time are unix seconds as strings.", + action_sets=["lark_messages", "lark"], + input_schema={ + "container_id": {"type": "string", "description": "Chat/thread ID.", "example": ""}, + "container_id_type": {"type": "string", "description": "chat (default) | thread.", "example": "chat"}, + "start_time": {"type": "string", "description": "Unix seconds (optional).", "example": ""}, + "end_time": {"type": "string", "description": "Unix seconds (optional).", "example": ""}, + "sort_type": {"type": "string", "description": "ByCreateTimeAsc | ByCreateTimeDesc.", "example": "ByCreateTimeAsc"}, + "page_size": {"type": "integer", "description": "Max 50.", "example": 50}, + "page_token": {"type": "string", "description": "Pagination cursor.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def list_lark_chat_messages(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark", "list_messages", + container_id=input_data["container_id"], + container_id_type=input_data.get("container_id_type", "chat"), + start_time=input_data.get("start_time") or None, + end_time=input_data.get("end_time") or None, + sort_type=input_data.get("sort_type", "ByCreateTimeAsc"), + page_size=input_data.get("page_size", 50), + page_token=input_data.get("page_token", ""), + ) + + +@action( + name="list_lark_message_read_users", + description="See who has read a message (returns user IDs + read timestamps).", + action_sets=["lark_messages"], + input_schema={ + "message_id": {"type": "string", "description": "Message ID.", "example": ""}, + "user_id_type": {"type": "string", "description": "open_id | user_id | union_id.", "example": "open_id"}, + "page_size": {"type": "integer", "description": "Max results.", "example": 100}, + "page_token": {"type": "string", "description": "Cursor.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def list_lark_message_read_users(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark", "list_message_read_users", + message_id=input_data["message_id"], + user_id_type=input_data.get("user_id_type", "open_id"), + page_size=input_data.get("page_size", 100), + page_token=input_data.get("page_token", ""), + ) + + +@action( + name="add_lark_reaction", + description="Add an emoji reaction to a message. emoji_type is Lark's emoji code (e.g. 'SMILE', 'THUMBSUP', 'HEART').", + action_sets=["lark_messages", "lark"], + input_schema={ + "message_id": {"type": "string", "description": "Message ID.", "example": ""}, + "emoji_type": {"type": "string", "description": "Lark emoji code.", "example": "SMILE"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def add_lark_reaction(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark", "add_reaction", + message_id=input_data["message_id"], + emoji_type=input_data["emoji_type"], + ) + + +@action( + name="remove_lark_reaction", + description="Remove a reaction by reaction_id.", + action_sets=["lark_messages"], + input_schema={ + "message_id": {"type": "string", "description": "Message ID.", "example": ""}, + "reaction_id": {"type": "string", "description": "Reaction ID (from add or list).", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def remove_lark_reaction(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark", "remove_reaction", + message_id=input_data["message_id"], + reaction_id=input_data["reaction_id"], + ) + + +@action( + name="list_lark_reactions", + description="List reactions on a message.", + action_sets=["lark_messages"], + input_schema={ + "message_id": {"type": "string", "description": "Message ID.", "example": ""}, + "emoji_type": {"type": "string", "description": "Filter by emoji (optional).", "example": ""}, + "page_size": {"type": "integer", "description": "Max results.", "example": 100}, + "page_token": {"type": "string", "description": "Cursor.", "example": ""}, + "user_id_type": {"type": "string", "description": "open_id | user_id | union_id.", "example": "open_id"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def list_lark_reactions(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark", "list_reactions", + message_id=input_data["message_id"], + emoji_type=input_data.get("emoji_type") or None, + page_size=input_data.get("page_size", 100), + page_token=input_data.get("page_token", ""), + user_id_type=input_data.get("user_id_type", "open_id"), + ) + + +@action( + name="pin_lark_message", + description="Pin a message in its chat.", + action_sets=["lark_messages", "lark"], + input_schema={ + "message_id": {"type": "string", "description": "Message ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def pin_lark_message(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("lark", "pin_message", message_id=input_data["message_id"]) + + +@action( + name="unpin_lark_message", + description="Unpin a previously-pinned message.", + action_sets=["lark_messages"], + input_schema={ + "message_id": {"type": "string", "description": "Message ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def unpin_lark_message(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("lark", "unpin_message", message_id=input_data["message_id"]) + + +@action( + name="list_lark_pinned_messages", + description="List pinned messages in a chat.", + action_sets=["lark_messages"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID.", "example": ""}, + "page_size": {"type": "integer", "description": "Max.", "example": 50}, + "page_token": {"type": "string", "description": "Cursor.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def list_lark_pinned_messages(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark", "list_pinned_messages", + chat_id=input_data["chat_id"], + page_size=input_data.get("page_size", 50), + page_token=input_data.get("page_token", ""), + ) + + +@action( + name="send_lark_urgent", + description="Escalate a message to selected users. urgent_type: app (in-app push) | sms | phone (call). Use sparingly — sms/phone require special permission.", + action_sets=["lark_messages"], + input_schema={ + "message_id": {"type": "string", "description": "Message ID.", "example": ""}, + "user_id_list": {"type": "array", "description": "Users to escalate to.", "example": []}, + "urgent_type": {"type": "string", "description": "app | sms | phone.", "example": "app"}, + "user_id_type": {"type": "string", "description": "open_id | user_id | union_id.", "example": "open_id"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def send_lark_urgent(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark", "send_urgent", + message_id=input_data["message_id"], + user_id_list=input_data["user_id_list"], + urgent_type=input_data.get("urgent_type", "app"), + user_id_type=input_data.get("user_id_type", "open_id"), + ) + + +@action( + name="batch_send_lark_message", + description="Broadcast the same message to many recipients in one call.", + action_sets=["lark_messages"], + input_schema={ + "msg_type": {"type": "string", "description": "Message type.", "example": "text"}, + "content": {"type": "object", "description": "Per-type content dict.", "example": {"text": "Hi"}}, + "open_ids": {"type": "array", "description": "Open IDs (optional).", "example": []}, + "user_ids": {"type": "array", "description": "User IDs (optional).", "example": []}, + "department_ids": {"type": "array", "description": "Department IDs (optional).", "example": []}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def batch_send_lark_message(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark", "batch_send_message", + msg_type=input_data["msg_type"], + content=input_data["content"], + open_ids=input_data.get("open_ids") or None, + user_ids=input_data.get("user_ids") or None, + department_ids=input_data.get("department_ids") or None, + ) + + +# ----- Resources (image / file upload + download) ----- + +@action( + name="upload_lark_image", + description="Upload a local image to Lark. Returns image_key for use in send_lark_image / cards / etc. image_type: message (default) | avatar.", + action_sets=["lark_messages", "lark"], + input_schema={ + "file_path": {"type": "string", "description": "Local image path.", "example": ""}, + "image_type": {"type": "string", "description": "message | avatar.", "example": "message"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def upload_lark_image(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark", "upload_image", + file_path=input_data["file_path"], + image_type=input_data.get("image_type", "message"), + ) +@action( + name="upload_lark_im_file", + description="Upload a local file to Lark IM. Returns file_key for send_lark_file. file_type: opus | mp4 | pdf | doc | xls | ppt | stream (default).", + action_sets=["lark_messages", "lark"], + input_schema={ + "file_path": {"type": "string", "description": "Local file path.", "example": ""}, + "file_type": {"type": "string", "description": "opus | mp4 | pdf | doc | xls | ppt | stream.", "example": "stream"}, + "file_name": {"type": "string", "description": "Override name (optional).", "example": ""}, + "duration": {"type": "integer", "description": "Duration in seconds for audio/video (optional).", "example": 0}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def upload_lark_im_file(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + dur = input_data.get("duration") + return await run_client( + "lark", "upload_im_file", + file_path=input_data["file_path"], + file_type=input_data.get("file_type", "stream"), + file_name=input_data.get("file_name") or None, + duration=dur if dur else None, + ) + + +@action( + name="download_lark_message_resource", + description="Download an attached image/file/audio from a Lark message to a local path. file_key comes from the message content.", + action_sets=["lark_messages", "lark"], + input_schema={ + "message_id": {"type": "string", "description": "Message ID containing the resource.", "example": ""}, + "file_key": {"type": "string", "description": "File key from message content.", "example": ""}, + "dest_path": {"type": "string", "description": "Local destination path.", "example": ""}, + "resource_type": {"type": "string", "description": "image | file.", "example": "file"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def download_lark_message_resource(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark", "download_message_resource", + message_id=input_data["message_id"], + file_key=input_data["file_key"], + dest_path=input_data["dest_path"], + resource_type=input_data.get("resource_type", "file"), + ) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Chats — CRUD + members + announcement + search + moderation +# ═══════════════════════════════════════════════════════════════════════════════ + @action( name="list_lark_chats", - description="List Lark group chats the bot is a member of.", - action_sets=["lark"], + description="List groups the bot is a member of.", + action_sets=["lark_chats", "lark"], input_schema={ - "page_size": { - "type": "integer", - "description": "Max chats to return (capped at 100).", - "example": 50, - }, + "page_size": {"type": "integer", "description": "Max 100.", "example": 50}, }, output_schema={"status": {"type": "string", "example": "success"}}, ) async def list_lark_chats(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client + return await run_client( + "lark", "list_chats", + page_size=input_data.get("page_size", 50), + ) + + +@action( + name="create_lark_chat", + description="Create a group chat. chat_mode: group | topic. chat_type: public | private.", + action_sets=["lark_chats", "lark"], + input_schema={ + "name": {"type": "string", "description": "Chat name.", "example": "Project X"}, + "description": {"type": "string", "description": "Description.", "example": ""}, + "owner_id": {"type": "string", "description": "Owner ID (optional, defaults to bot).", "example": ""}, + "user_id_list": {"type": "array", "description": "Initial user IDs.", "example": []}, + "bot_id_list": {"type": "array", "description": "Initial bot IDs.", "example": []}, + "chat_mode": {"type": "string", "description": "group | topic.", "example": "group"}, + "chat_type": {"type": "string", "description": "public | private.", "example": "private"}, + "user_id_type": {"type": "string", "description": "open_id | user_id | union_id.", "example": "open_id"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def create_lark_chat(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark", "create_chat", + name=input_data["name"], + description=input_data.get("description", ""), + owner_id=input_data.get("owner_id") or None, + user_id_list=input_data.get("user_id_list") or None, + bot_id_list=input_data.get("bot_id_list") or None, + chat_mode=input_data.get("chat_mode", "group"), + chat_type=input_data.get("chat_type", "private"), + user_id_type=input_data.get("user_id_type", "open_id"), + ) + + +@action( + name="get_lark_chat", + description="Get info about a Lark chat (members, owner, settings).", + action_sets=["lark_chats", "lark"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID.", "example": ""}, + "user_id_type": {"type": "string", "description": "open_id | user_id | union_id.", "example": "open_id"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def get_lark_chat(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark", "get_chat", + chat_id=input_data["chat_id"], + user_id_type=input_data.get("user_id_type", "open_id"), + ) + + +@action( + name="update_lark_chat", + description="Update a chat's settings.", + action_sets=["lark_chats", "lark"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID.", "example": ""}, + "name": {"type": "string", "description": "New name (optional).", "example": ""}, + "description": {"type": "string", "description": "New description (optional).", "example": ""}, + "avatar": {"type": "string", "description": "Avatar image_key (optional).", "example": ""}, + "add_member_permission": {"type": "string", "description": "all_members | only_owner (optional).", "example": ""}, + "share_card_permission": {"type": "string", "description": "allowed | not_allowed (optional).", "example": ""}, + "at_all_permission": {"type": "string", "description": "all_members | only_owner (optional).", "example": ""}, + "edit_permission": {"type": "string", "description": "all_members | only_owner (optional).", "example": ""}, + "chat_type": {"type": "string", "description": "Convert public | private (optional).", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def update_lark_chat(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark", "update_chat", + chat_id=input_data["chat_id"], + name=input_data.get("name") or None, + description=input_data["description"] if "description" in input_data else None, + avatar=input_data.get("avatar") or None, + add_member_permission=input_data.get("add_member_permission") or None, + share_card_permission=input_data.get("share_card_permission") or None, + at_all_permission=input_data.get("at_all_permission") or None, + edit_permission=input_data.get("edit_permission") or None, + chat_type=input_data.get("chat_type") or None, + ) + + +@action( + name="dissolve_lark_chat", + description="Dissolve a chat (delete the group). Only the owner can.", + action_sets=["lark_chats"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def dissolve_lark_chat(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("lark", "dissolve_chat", chat_id=input_data["chat_id"]) + + +@action( + name="list_lark_chat_members", + description="List members of a chat.", + action_sets=["lark_chats", "lark"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID.", "example": ""}, + "member_id_type": {"type": "string", "description": "open_id | user_id | union_id.", "example": "open_id"}, + "page_size": {"type": "integer", "description": "Max 100.", "example": 100}, + "page_token": {"type": "string", "description": "Cursor.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def list_lark_chat_members(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark", "list_chat_members", + chat_id=input_data["chat_id"], + member_id_type=input_data.get("member_id_type", "open_id"), + page_size=input_data.get("page_size", 100), + page_token=input_data.get("page_token", ""), + ) + + +@action( + name="add_lark_chat_members", + description="Add members to a chat. succeed_type: 0=fail on any error | 1=partial success | 2=skip existing.", + action_sets=["lark_chats", "lark"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID.", "example": ""}, + "id_list": {"type": "array", "description": "User IDs to add.", "example": []}, + "member_id_type": {"type": "string", "description": "open_id | user_id | union_id.", "example": "open_id"}, + "succeed_type": {"type": "integer", "description": "0 | 1 | 2.", "example": 0}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def add_lark_chat_members(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark", "add_chat_members", + chat_id=input_data["chat_id"], + id_list=input_data["id_list"], + member_id_type=input_data.get("member_id_type", "open_id"), + succeed_type=input_data.get("succeed_type", 0), + ) + + +@action( + name="remove_lark_chat_members", + description="Remove members from a chat.", + action_sets=["lark_chats", "lark"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID.", "example": ""}, + "id_list": {"type": "array", "description": "User IDs to remove.", "example": []}, + "member_id_type": {"type": "string", "description": "open_id | user_id | union_id.", "example": "open_id"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def remove_lark_chat_members(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark", "remove_chat_members", + chat_id=input_data["chat_id"], + id_list=input_data["id_list"], + member_id_type=input_data.get("member_id_type", "open_id"), + ) + + +@action( + name="search_lark_chats", + description="Search chats by name.", + action_sets=["lark_chats", "lark"], + input_schema={ + "query": {"type": "string", "description": "Search query.", "example": ""}, + "page_size": {"type": "integer", "description": "Max 100.", "example": 50}, + "page_token": {"type": "string", "description": "Cursor.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def search_lark_chats(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark", "search_chats", + query=input_data["query"], + page_size=input_data.get("page_size", 50), + page_token=input_data.get("page_token", ""), + ) + + +@action( + name="get_lark_chat_announcement", + description="Get the announcement (pinned doc) on a chat.", + action_sets=["lark_chats"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def get_lark_chat_announcement(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("lark", "get_chat_announcement", chat_id=input_data["chat_id"]) + + +@action( + name="update_lark_chat_announcement", + description="Update a chat's announcement. requests uses Lark block-update structures (same as Docx).", + action_sets=["lark_chats"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID.", "example": ""}, + "revision": {"type": "string", "description": "Current revision number (from get).", "example": ""}, + "requests": {"type": "array", "description": "Block-update operations.", "example": []}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def update_lark_chat_announcement(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark", "update_chat_announcement", + chat_id=input_data["chat_id"], + revision=input_data["revision"], + requests=input_data["requests"], + ) + + +@action( + name="set_lark_chat_moderation", + description="Set who can send messages in a chat. moderation_setting: all_members | only_owner | specific_users.", + action_sets=["lark_chats"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID.", "example": ""}, + "moderation_setting": {"type": "string", "description": "all_members | only_owner | specific_users.", "example": "all_members"}, + "user_id_list": {"type": "array", "description": "Allowed users (only if specific_users).", "example": []}, + "user_id_type": {"type": "string", "description": "open_id | user_id | union_id.", "example": "open_id"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def set_lark_chat_moderation(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark", "update_chat_moderation", + chat_id=input_data["chat_id"], + moderation_setting=input_data["moderation_setting"], + user_id_list=input_data.get("user_id_list") or None, + user_id_type=input_data.get("user_id_type", "open_id"), + ) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Contacts — users + departments +# ═══════════════════════════════════════════════════════════════════════════════ + +@action( + name="get_lark_user", + description="Get a single Lark user by ID.", + action_sets=["lark_contacts", "lark"], + input_schema={ + "user_id": {"type": "string", "description": "User ID.", "example": ""}, + "user_id_type": {"type": "string", "description": "open_id | user_id | union_id.", "example": "open_id"}, + "department_id_type": {"type": "string", "description": "open_department_id | department_id.", "example": "open_department_id"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def get_lark_user(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark", "get_user", + user_id=input_data["user_id"], + user_id_type=input_data.get("user_id_type", "open_id"), + department_id_type=input_data.get("department_id_type", "open_department_id"), + ) + +@action( + name="batch_get_lark_users", + description="Get multiple Lark users by ID in one call.", + action_sets=["lark_contacts"], + input_schema={ + "user_ids": {"type": "array", "description": "User IDs.", "example": []}, + "user_id_type": {"type": "string", "description": "open_id | user_id | union_id.", "example": "open_id"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def batch_get_lark_users(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client return await run_client( - "lark", "list_chats", page_size=input_data.get("page_size", 50) + "lark", "batch_get_users", + user_ids=input_data["user_ids"], + user_id_type=input_data.get("user_id_type", "open_id"), ) +@action( + name="get_lark_user_by_email", + description="Resolve a single user's open_id from a company email.", + action_sets=["lark_contacts", "lark"], + input_schema={ + "email": {"type": "string", "description": "Email.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def get_lark_user_by_email(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("lark", "get_user_by_email", email=input_data["email"]) + + +@action( + name="batch_lookup_lark_users", + description="Resolve multiple emails / mobiles to user IDs in one call.", + action_sets=["lark_contacts", "lark"], + input_schema={ + "emails": {"type": "array", "description": "Emails to look up (optional).", "example": []}, + "mobiles": {"type": "array", "description": "Mobiles to look up (optional).", "example": []}, + "user_id_type": {"type": "string", "description": "Return ID type.", "example": "open_id"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def batch_lookup_lark_users(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark", "batch_get_user_ids", + emails=input_data.get("emails") or None, + mobiles=input_data.get("mobiles") or None, + user_id_type=input_data.get("user_id_type", "open_id"), + ) + + +@action( + name="search_lark_users_by_name", + description="Search Lark users by name (visibility depends on app scope grants).", + action_sets=["lark_contacts", "lark"], + input_schema={ + "query": {"type": "string", "description": "Search query.", "example": ""}, + "page_size": {"type": "integer", "description": "Max 50.", "example": 50}, + "page_token": {"type": "string", "description": "Cursor.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def search_lark_users_by_name(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark", "search_users_by_name", + query=input_data["query"], + page_size=input_data.get("page_size", 50), + page_token=input_data.get("page_token", ""), + ) + + +@action( + name="list_lark_department_users", + description="List users in a department.", + action_sets=["lark_contacts"], + input_schema={ + "department_id": {"type": "string", "description": "Department ID.", "example": ""}, + "user_id_type": {"type": "string", "description": "open_id | user_id | union_id.", "example": "open_id"}, + "department_id_type": {"type": "string", "description": "open_department_id | department_id.", "example": "open_department_id"}, + "page_size": {"type": "integer", "description": "Max 50.", "example": 50}, + "page_token": {"type": "string", "description": "Cursor.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def list_lark_department_users(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark", "list_department_users", + department_id=input_data["department_id"], + user_id_type=input_data.get("user_id_type", "open_id"), + department_id_type=input_data.get("department_id_type", "open_department_id"), + page_size=input_data.get("page_size", 50), + page_token=input_data.get("page_token", ""), + ) + + +@action( + name="get_lark_department", + description="Get info about a department.", + action_sets=["lark_contacts"], + input_schema={ + "department_id": {"type": "string", "description": "Department ID.", "example": ""}, + "department_id_type": {"type": "string", "description": "open_department_id | department_id.", "example": "open_department_id"}, + "user_id_type": {"type": "string", "description": "open_id | user_id | union_id.", "example": "open_id"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def get_lark_department(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark", "get_department", + department_id=input_data["department_id"], + department_id_type=input_data.get("department_id_type", "open_department_id"), + user_id_type=input_data.get("user_id_type", "open_id"), + ) + + +@action( + name="list_lark_department_children", + description="List child departments under a parent.", + action_sets=["lark_contacts"], + input_schema={ + "parent_department_id": {"type": "string", "description": "Parent ID (use '0' for top-level).", "example": "0"}, + "department_id_type": {"type": "string", "description": "open_department_id | department_id.", "example": "open_department_id"}, + "fetch_child": {"type": "boolean", "description": "Fetch all descendants.", "example": False}, + "page_size": {"type": "integer", "description": "Max 50.", "example": 50}, + "page_token": {"type": "string", "description": "Cursor.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def list_lark_department_children(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark", "list_department_children", + parent_department_id=input_data["parent_department_id"], + department_id_type=input_data.get("department_id_type", "open_department_id"), + fetch_child=bool(input_data.get("fetch_child", False)), + page_size=input_data.get("page_size", 50), + page_token=input_data.get("page_token", ""), + ) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Bot info +# ═══════════════════════════════════════════════════════════════════════════════ + @action( name="get_lark_bot_info", - description="Get the connected Lark bot's profile (app name, open_id).", + description="Get info about the connected Lark bot (app_name, open_id, etc.).", action_sets=["lark"], input_schema={}, output_schema={"status": {"type": "string", "example": "success"}}, ) async def get_lark_bot_info(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client("lark", "get_bot_info") + + +# ================================================================== +# Intentionally NOT exposed as actions (and why) +# ================================================================== +# - Topic / thread CRUD (/im/v1/threads) +# Lark's thread feature is in flux; reply_in_thread on reply_lark_rich_message +# covers the realistic "thread reply" use case. +# - Encryption / message-encryption events +# Lark's encrypted event mode is a server-side webhook configuration +# not actionable per-call. +# - Workplace card / open_app cards +# Niche app-distribution surfaces. +# - Approval / Calendar / Helpdesk / Sheets-as-form integrations +# Each is a separate Lark sub-product; out of scope for this messaging +# integration. Add as new integrations if needed. +# - Long-running file uploads (multipart for >30MB IM files) +# Single-shot upload_lark_im_file covers the realistic interactive case. +# - User CRUD (create/delete users, update profile) +# Admin-only; the contact API exposed here is lookup-only by design. diff --git a/app/data/action/integrations/lark_calendar/lark_calendar_actions.py b/app/data/action/integrations/lark_calendar/lark_calendar_actions.py index 8973916c..72fb7f3e 100644 --- a/app/data/action/integrations/lark_calendar/lark_calendar_actions.py +++ b/app/data/action/integrations/lark_calendar/lark_calendar_actions.py @@ -1,33 +1,25 @@ from agent_core import action +# ------------------------------------------------------------------ +# Calendars — list, get, create, update, delete, search, subscribe +# Sub-set: lark_calendar_calendars +# ------------------------------------------------------------------ + @action( name="list_lark_calendars", description="List the bot's accessible Lark calendars (its own primary plus any shared with it).", - action_sets=["lark_calendar"], + action_sets=["lark_calendar_calendars", "lark_calendar"], input_schema={ - "page_size": { - "type": "integer", - "description": "Max calendars to return (capped at 1000).", - "example": 20, - }, - "page_token": { - "type": "string", - "description": "Pagination cursor from a previous response.", - "example": "", - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "result": {"type": "object"}, + "page_size": {"type": "integer", "description": "Max calendars to return (capped at 1000).", "example": 20}, + "page_token": {"type": "string", "description": "Pagination cursor from a previous response.", "example": ""}, }, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object"}}, ) async def list_lark_calendars(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( - "lark_calendar", - "list_calendars", + "lark_calendar", "list_calendars", page_size=input_data.get("page_size", 20), page_token=input_data.get("page_token", ""), ) @@ -36,56 +28,170 @@ async def list_lark_calendars(input_data: dict) -> dict: @action( name="get_lark_primary_calendar", description="Get the bot's primary Lark calendar — useful for finding the calendar_id to pass to other Calendar actions.", - action_sets=["lark_calendar"], + action_sets=["lark_calendar_calendars", "lark_calendar"], input_schema={}, - output_schema={ - "status": {"type": "string", "example": "success"}, - "result": {"type": "object"}, - }, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object"}}, ) async def get_lark_primary_calendar(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client("lark_calendar", "get_primary_calendar") +@action( + name="get_lark_calendar", + description="Fetch metadata for a specific Lark calendar.", + action_sets=["lark_calendar_calendars"], + input_schema={ + "calendar_id": {"type": "string", "description": "Calendar id.", "example": "feishu.cn_abc..."}, + }, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object"}}, +) +async def get_lark_calendar(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("lark_calendar", "get_calendar", calendar_id=input_data["calendar_id"]) + + +@action( + name="create_lark_calendar", + description="Create a new secondary Lark calendar owned by the bot.", + action_sets=["lark_calendar_calendars", "lark_calendar"], + input_schema={ + "summary": {"type": "string", "description": "Calendar name (max 255 chars).", "example": "Project X"}, + "description": {"type": "string", "description": "Optional description.", "example": ""}, + "permissions": {"type": "string", "description": "private | show_only_free_busy | public.", "example": "private"}, + "color": {"type": "integer", "description": "Optional RGB int32 (Lark encoding). -1 for default.", "example": -1}, + "summary_alias": {"type": "string", "description": "Optional alias / short name.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object"}}, + parallelizable=False, +) +async def create_lark_calendar(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_calendar", "create_calendar", + summary=input_data["summary"], + description=input_data.get("description", ""), + permissions=input_data.get("permissions", "private"), + color=input_data.get("color"), + summary_alias=input_data.get("summary_alias", ""), + ) + + +@action( + name="update_lark_calendar", + description="Patch fields on an existing Lark calendar. Only fields you supply are changed.", + action_sets=["lark_calendar_calendars"], + input_schema={ + "calendar_id": {"type": "string", "description": "Calendar id.", "example": "feishu.cn_abc..."}, + "summary": {"type": "string", "description": "New name.", "example": ""}, + "description": {"type": "string", "description": "New description.", "example": ""}, + "permissions": {"type": "string", "description": "private | show_only_free_busy | public.", "example": ""}, + "color": {"type": "integer", "description": "RGB int32.", "example": -1}, + "summary_alias": {"type": "string", "description": "Alias.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object"}}, + parallelizable=False, +) +async def update_lark_calendar(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_calendar", "update_calendar", + calendar_id=input_data["calendar_id"], + summary=input_data.get("summary") or None, + description=input_data.get("description") if input_data.get("description") is not None else None, + permissions=input_data.get("permissions") or None, + color=input_data.get("color"), + summary_alias=input_data.get("summary_alias") if input_data.get("summary_alias") is not None else None, + ) + + +@action( + name="delete_lark_calendar", + description="Delete a Lark calendar the bot owns.", + action_sets=["lark_calendar_calendars"], + input_schema={ + "calendar_id": {"type": "string", "description": "Calendar id.", "example": "feishu.cn_abc..."}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def delete_lark_calendar(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("lark_calendar", "delete_calendar", calendar_id=input_data["calendar_id"]) + + +@action( + name="search_lark_calendars", + description="Search calendars the bot can see by name.", + action_sets=["lark_calendar_calendars"], + input_schema={ + "query": {"type": "string", "description": "Search query.", "example": "Project X"}, + "page_size": {"type": "integer", "description": "Max results.", "example": 20}, + "page_token": {"type": "string", "description": "Pagination cursor.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object"}}, +) +async def search_lark_calendars(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_calendar", "search_calendars", + query=input_data["query"], + page_size=input_data.get("page_size", 20), + page_token=input_data.get("page_token", ""), + ) + + +@action( + name="subscribe_to_lark_calendar", + description="Subscribe to a shared Lark calendar so it appears in list_lark_calendars.", + action_sets=["lark_calendar_calendars"], + input_schema={ + "calendar_id": {"type": "string", "description": "Calendar id to subscribe to.", "example": "feishu.cn_abc..."}, + }, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object"}}, + parallelizable=False, +) +async def subscribe_to_lark_calendar(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("lark_calendar", "subscribe_calendar", calendar_id=input_data["calendar_id"]) + + +@action( + name="unsubscribe_from_lark_calendar", + description="Unsubscribe from a shared Lark calendar.", + action_sets=["lark_calendar_calendars"], + input_schema={ + "calendar_id": {"type": "string", "description": "Calendar id.", "example": "feishu.cn_abc..."}, + }, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object"}}, + parallelizable=False, +) +async def unsubscribe_from_lark_calendar(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("lark_calendar", "unsubscribe_calendar", calendar_id=input_data["calendar_id"]) + + +# ------------------------------------------------------------------ +# Events — list, get, create, update, delete, search, RSVP, instances +# Sub-set: lark_calendar_events +# ------------------------------------------------------------------ + @action( name="list_lark_calendar_events", description="List events on a Lark calendar between two Unix timestamps (seconds).", - action_sets=["lark_calendar"], + action_sets=["lark_calendar_events", "lark_calendar"], input_schema={ - "calendar_id": { - "type": "string", - "description": "Calendar id. Use list_lark_calendars or get_lark_primary_calendar to find it.", - "example": "primary", - }, - "start_time": { - "type": "integer", - "description": "Window start as Unix timestamp in seconds.", - "example": 1730000000, - }, - "end_time": { - "type": "integer", - "description": "Window end as Unix timestamp in seconds.", - "example": 1730086400, - }, - "page_size": { - "type": "integer", - "description": "Max events to return (capped at 1000).", - "example": 50, - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "result": {"type": "object"}, + "calendar_id": {"type": "string", "description": "Calendar id. Use list_lark_calendars or get_lark_primary_calendar to find it.", "example": "primary"}, + "start_time": {"type": "integer", "description": "Window start as Unix timestamp in seconds.", "example": 1730000000}, + "end_time": {"type": "integer", "description": "Window end as Unix timestamp in seconds.", "example": 1730086400}, + "page_size": {"type": "integer", "description": "Max events to return (capped at 1000).", "example": 50}, }, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object"}}, ) async def list_lark_calendar_events(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( - "lark_calendar", - "list_events", + "lark_calendar", "list_events", calendar_id=input_data["calendar_id"], start_time=input_data["start_time"], end_time=input_data["end_time"], @@ -96,30 +202,17 @@ async def list_lark_calendar_events(input_data: dict) -> dict: @action( name="get_lark_calendar_event", description="Fetch a single Lark calendar event by id.", - action_sets=["lark_calendar"], + action_sets=["lark_calendar_events", "lark_calendar"], input_schema={ - "calendar_id": { - "type": "string", - "description": "Calendar id holding the event.", - "example": "primary", - }, - "event_id": { - "type": "string", - "description": "Event id.", - "example": "0123abcd-...", - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "result": {"type": "object"}, + "calendar_id": {"type": "string", "description": "Calendar id holding the event.", "example": "primary"}, + "event_id": {"type": "string", "description": "Event id.", "example": "0123abcd-..."}, }, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object"}}, ) async def get_lark_calendar_event(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( - "lark_calendar", - "get_event", + "lark_calendar", "get_event", calendar_id=input_data["calendar_id"], event_id=input_data["event_id"], ) @@ -128,55 +221,23 @@ async def get_lark_calendar_event(input_data: dict) -> dict: @action( name="create_lark_calendar_event", description="Create a new event on a Lark calendar. To invite attendees, call add_lark_event_attendees afterwards with the returned event_id.", - action_sets=["lark_calendar"], + action_sets=["lark_calendar_events", "lark_calendar"], input_schema={ - "calendar_id": { - "type": "string", - "description": "Calendar id to create the event in.", - "example": "primary", - }, - "summary": { - "type": "string", - "description": "Event title.", - "example": "Q2 planning", - }, - "start_time": { - "type": "integer", - "description": "Start as Unix timestamp in seconds.", - "example": 1730000000, - }, - "end_time": { - "type": "integer", - "description": "End as Unix timestamp in seconds.", - "example": 1730003600, - }, - "description": { - "type": "string", - "description": "Event body / agenda.", - "example": "Review last quarter and align on Q2 goals.", - }, - "location": { - "type": "string", - "description": "Physical or virtual location label.", - "example": "Conf Room A", - }, - "with_video_meeting": { - "type": "boolean", - "description": "If true, Lark auto-attaches a Lark Meeting URL.", - "example": False, - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "result": {"type": "object"}, + "calendar_id": {"type": "string", "description": "Calendar id to create the event in.", "example": "primary"}, + "summary": {"type": "string", "description": "Event title.", "example": "Q2 planning"}, + "start_time": {"type": "integer", "description": "Start as Unix timestamp in seconds.", "example": 1730000000}, + "end_time": {"type": "integer", "description": "End as Unix timestamp in seconds.", "example": 1730003600}, + "description": {"type": "string", "description": "Event body / agenda.", "example": "Review last quarter and align on Q2 goals."}, + "location": {"type": "string", "description": "Physical or virtual location label.", "example": "Conf Room A"}, + "with_video_meeting": {"type": "boolean", "description": "If true, Lark auto-attaches a Lark Meeting URL.", "example": False}, }, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object"}}, + parallelizable=False, ) async def create_lark_calendar_event(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( - "lark_calendar", - "create_event", + "lark_calendar", "create_event", calendar_id=input_data["calendar_id"], summary=input_data["summary"], start_time=input_data["start_time"], @@ -190,55 +251,23 @@ async def create_lark_calendar_event(input_data: dict) -> dict: @action( name="update_lark_calendar_event", description="Patch fields on an existing Lark calendar event. Only fields you supply are changed.", - action_sets=["lark_calendar"], + action_sets=["lark_calendar_events", "lark_calendar"], input_schema={ - "calendar_id": { - "type": "string", - "description": "Calendar id holding the event.", - "example": "primary", - }, - "event_id": { - "type": "string", - "description": "Event id to update.", - "example": "0123abcd-...", - }, - "summary": { - "type": "string", - "description": "New event title (omit to keep).", - "example": "Q2 planning (rescheduled)", - }, - "description": { - "type": "string", - "description": "New description (omit to keep).", - "example": "", - }, - "start_time": { - "type": "integer", - "description": "New start as Unix seconds (omit to keep).", - "example": 1730086400, - }, - "end_time": { - "type": "integer", - "description": "New end as Unix seconds (omit to keep).", - "example": 1730090000, - }, - "location": { - "type": "string", - "description": "New location (omit to keep).", - "example": "", - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "result": {"type": "object"}, + "calendar_id": {"type": "string", "description": "Calendar id holding the event.", "example": "primary"}, + "event_id": {"type": "string", "description": "Event id to update.", "example": "0123abcd-..."}, + "summary": {"type": "string", "description": "New event title (omit to keep).", "example": "Q2 planning (rescheduled)"}, + "description": {"type": "string", "description": "New description (omit to keep).", "example": ""}, + "start_time": {"type": "integer", "description": "New start as Unix seconds (omit to keep).", "example": 1730086400}, + "end_time": {"type": "integer", "description": "New end as Unix seconds (omit to keep).", "example": 1730090000}, + "location": {"type": "string", "description": "New location (omit to keep).", "example": ""}, }, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object"}}, + parallelizable=False, ) async def update_lark_calendar_event(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( - "lark_calendar", - "update_event", + "lark_calendar", "update_event", calendar_id=input_data["calendar_id"], event_id=input_data["event_id"], summary=input_data.get("summary"), @@ -252,32 +281,19 @@ async def update_lark_calendar_event(input_data: dict) -> dict: @action( name="delete_lark_calendar_event", description="Delete a Lark calendar event by id.", - action_sets=["lark_calendar"], + action_sets=["lark_calendar_events", "lark_calendar"], input_schema={ - "calendar_id": { - "type": "string", - "description": "Calendar id holding the event.", - "example": "primary", - }, - "event_id": { - "type": "string", - "description": "Event id to delete.", - "example": "0123abcd-...", - }, - "need_notification": { - "type": "boolean", - "description": "Email attendees about the cancellation.", - "example": True, - }, + "calendar_id": {"type": "string", "description": "Calendar id holding the event.", "example": "primary"}, + "event_id": {"type": "string", "description": "Event id to delete.", "example": "0123abcd-..."}, + "need_notification": {"type": "boolean", "description": "Email attendees about the cancellation.", "example": True}, }, output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, ) async def delete_lark_calendar_event(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( - "lark_calendar", - "delete_event", + "lark_calendar", "delete_event", calendar_id=input_data["calendar_id"], event_id=input_data["event_id"], need_notification=input_data.get("need_notification", True), @@ -287,45 +303,20 @@ async def delete_lark_calendar_event(input_data: dict) -> dict: @action( name="search_lark_calendar_events", description="Full-text search over event titles and descriptions in a Lark calendar.", - action_sets=["lark_calendar"], + action_sets=["lark_calendar_events", "lark_calendar"], input_schema={ - "calendar_id": { - "type": "string", - "description": "Calendar id to search.", - "example": "primary", - }, - "query": { - "type": "string", - "description": "Search query.", - "example": "planning", - }, - "start_time": { - "type": "integer", - "description": "Optional window start as Unix seconds.", - "example": 1730000000, - }, - "end_time": { - "type": "integer", - "description": "Optional window end as Unix seconds.", - "example": 1732000000, - }, - "page_size": { - "type": "integer", - "description": "Max results (capped at 100).", - "example": 20, - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "result": {"type": "object"}, + "calendar_id": {"type": "string", "description": "Calendar id to search.", "example": "primary"}, + "query": {"type": "string", "description": "Search query.", "example": "planning"}, + "start_time": {"type": "integer", "description": "Optional window start as Unix seconds.", "example": 1730000000}, + "end_time": {"type": "integer", "description": "Optional window end as Unix seconds.", "example": 1732000000}, + "page_size": {"type": "integer", "description": "Max results (capped at 100).", "example": 20}, }, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object"}}, ) async def search_lark_calendar_events(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( - "lark_calendar", - "search_events", + "lark_calendar", "search_events", calendar_id=input_data["calendar_id"], query=input_data["query"], start_time=input_data.get("start_time"), @@ -334,53 +325,77 @@ async def search_lark_calendar_events(input_data: dict) -> dict: ) +@action( + name="rsvp_lark_calendar_event", + description="RSVP to a Lark calendar event invitation (accept / decline / tentative).", + action_sets=["lark_calendar_events", "lark_calendar"], + input_schema={ + "calendar_id": {"type": "string", "description": "Calendar id holding the event.", "example": "primary"}, + "event_id": {"type": "string", "description": "Event id.", "example": "0123abcd-..."}, + "rsvp_status": {"type": "string", "description": "accept | decline | tentative.", "example": "accept"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def rsvp_lark_calendar_event(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_calendar", "reply_event", + calendar_id=input_data["calendar_id"], + event_id=input_data["event_id"], + rsvp_status=input_data["rsvp_status"], + ) + + +@action( + name="list_lark_event_instances", + description="List the concrete occurrences of a recurring Lark event within a time window.", + action_sets=["lark_calendar_events"], + input_schema={ + "calendar_id": {"type": "string", "description": "Calendar id.", "example": "primary"}, + "event_id": {"type": "string", "description": "Master recurring event id.", "example": "0123abcd-..."}, + "start_time": {"type": "integer", "description": "Window start as Unix seconds.", "example": 1730000000}, + "end_time": {"type": "integer", "description": "Window end as Unix seconds.", "example": 1735689600}, + "page_size": {"type": "integer", "description": "Max instances.", "example": 50}, + }, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object"}}, +) +async def list_lark_event_instances(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_calendar", "list_event_instances", + calendar_id=input_data["calendar_id"], + event_id=input_data["event_id"], + start_time=input_data["start_time"], + end_time=input_data["end_time"], + page_size=input_data.get("page_size", 50), + ) + + +# ------------------------------------------------------------------ +# Attendees — add, list, batch-delete, chat-members, meeting rooms +# Sub-set: lark_calendar_attendees +# ------------------------------------------------------------------ + @action( name="add_lark_event_attendees", description="Invite attendees to a Lark calendar event. Pass user_ids (open_ids), emails (for external attendees), or chat_ids (invites everyone in a group).", - action_sets=["lark_calendar"], + action_sets=["lark_calendar_attendees", "lark_calendar"], input_schema={ - "calendar_id": { - "type": "string", - "description": "Calendar id holding the event.", - "example": "primary", - }, - "event_id": { - "type": "string", - "description": "Event id.", - "example": "0123abcd-...", - }, - "user_ids": { - "type": "array", - "description": "Lark open_ids (ou_...) to invite.", - "example": ["ou_abc"], - }, - "emails": { - "type": "array", - "description": "Email addresses to invite as external attendees.", - "example": ["alice@example.com"], - }, - "chat_ids": { - "type": "array", - "description": "Lark group chat_ids (oc_...) — every member gets invited.", - "example": [], - }, - "need_notification": { - "type": "boolean", - "description": "Email/notify the attendees about the invite.", - "example": True, - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "result": {"type": "object"}, + "calendar_id": {"type": "string", "description": "Calendar id holding the event.", "example": "primary"}, + "event_id": {"type": "string", "description": "Event id.", "example": "0123abcd-..."}, + "user_ids": {"type": "array", "description": "Lark open_ids (ou_...) to invite.", "example": ["ou_abc"]}, + "emails": {"type": "array", "description": "Email addresses to invite as external attendees.", "example": ["alice@example.com"]}, + "chat_ids": {"type": "array", "description": "Lark group chat_ids (oc_...) — every member gets invited.", "example": []}, + "need_notification": {"type": "boolean", "description": "Email/notify the attendees about the invite.", "example": True}, }, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object"}}, + parallelizable=False, ) async def add_lark_event_attendees(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( - "lark_calendar", - "add_event_attendees", + "lark_calendar", "add_event_attendees", calendar_id=input_data["calendar_id"], event_id=input_data["event_id"], user_ids=input_data.get("user_ids"), @@ -390,38 +405,183 @@ async def add_lark_event_attendees(input_data: dict) -> dict: ) +@action( + name="list_lark_event_attendees", + description="List the current attendees on a Lark calendar event.", + action_sets=["lark_calendar_attendees", "lark_calendar"], + input_schema={ + "calendar_id": {"type": "string", "description": "Calendar id.", "example": "primary"}, + "event_id": {"type": "string", "description": "Event id.", "example": "0123abcd-..."}, + "page_size": {"type": "integer", "description": "Max attendees per page (cap 200).", "example": 100}, + "page_token": {"type": "string", "description": "Pagination cursor.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object"}}, +) +async def list_lark_event_attendees(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_calendar", "list_event_attendees", + calendar_id=input_data["calendar_id"], + event_id=input_data["event_id"], + page_size=input_data.get("page_size", 100), + page_token=input_data.get("page_token", ""), + ) + + +@action( + name="remove_lark_event_attendees", + description="Remove one or more attendees from a Lark event in a single call.", + action_sets=["lark_calendar_attendees"], + input_schema={ + "calendar_id": {"type": "string", "description": "Calendar id.", "example": "primary"}, + "event_id": {"type": "string", "description": "Event id.", "example": "0123abcd-..."}, + "attendee_ids": {"type": "array", "description": "List of attendee_id values to remove.", "example": ["att_abc"]}, + "need_notification": {"type": "boolean", "description": "Notify removed attendees.", "example": True}, + }, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object"}}, + parallelizable=False, +) +async def remove_lark_event_attendees(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_calendar", "batch_delete_event_attendees", + calendar_id=input_data["calendar_id"], + event_id=input_data["event_id"], + attendee_ids=input_data["attendee_ids"], + need_notification=input_data.get("need_notification", True), + ) + + +@action( + name="list_lark_event_chat_attendee_members", + description="List the underlying chat members for a chat-type attendee on a Lark event.", + action_sets=["lark_calendar_attendees"], + input_schema={ + "calendar_id": {"type": "string", "description": "Calendar id.", "example": "primary"}, + "event_id": {"type": "string", "description": "Event id.", "example": "0123abcd-..."}, + "attendee_id": {"type": "string", "description": "Chat-type attendee id.", "example": "att_chat_..."}, + "page_size": {"type": "integer", "description": "Max members per page (cap 200).", "example": 100}, + "page_token": {"type": "string", "description": "Pagination cursor.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object"}}, +) +async def list_lark_event_chat_attendee_members(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_calendar", "list_event_attendee_chat_members", + calendar_id=input_data["calendar_id"], + event_id=input_data["event_id"], + attendee_id=input_data["attendee_id"], + page_size=input_data.get("page_size", 100), + page_token=input_data.get("page_token", ""), + ) + + +@action( + name="book_lark_meeting_room", + description="Attach a meeting room to a Lark calendar event as a resource attendee (effectively booking it).", + action_sets=["lark_calendar_attendees", "lark_calendar"], + input_schema={ + "calendar_id": {"type": "string", "description": "Calendar id holding the event.", "example": "primary"}, + "event_id": {"type": "string", "description": "Event id.", "example": "0123abcd-..."}, + "meeting_room_id": {"type": "string", "description": "Meeting room (room_id).", "example": "omm_..."}, + "need_notification": {"type": "boolean", "description": "Notify meeting room owners.", "example": True}, + }, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object"}}, + parallelizable=False, +) +async def book_lark_meeting_room(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_calendar", "add_meeting_room_to_event", + calendar_id=input_data["calendar_id"], + event_id=input_data["event_id"], + meeting_room_id=input_data["meeting_room_id"], + need_notification=input_data.get("need_notification", True), + ) + + +# ------------------------------------------------------------------ +# Sharing / ACL — list, create, delete +# Sub-set: lark_calendar_sharing +# ------------------------------------------------------------------ + +@action( + name="list_lark_calendar_acls", + description="List the access-control entries (sharing permissions) on a Lark calendar.", + action_sets=["lark_calendar_sharing"], + input_schema={ + "calendar_id": {"type": "string", "description": "Calendar id.", "example": "primary"}, + }, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object"}}, +) +async def list_lark_calendar_acls(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("lark_calendar", "list_calendar_acls", calendar_id=input_data["calendar_id"]) + + +@action( + name="share_lark_calendar_with_user", + description="Share a Lark calendar with a user by granting them a role (owner / reader / writer / free_busy_reader).", + action_sets=["lark_calendar_sharing", "lark_calendar"], + input_schema={ + "calendar_id": {"type": "string", "description": "Calendar id.", "example": "primary"}, + "user_id": {"type": "string", "description": "Lark user open_id (ou_...).", "example": "ou_abc"}, + "role": {"type": "string", "description": "owner | reader | writer | free_busy_reader.", "example": "reader"}, + }, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object"}}, + parallelizable=False, +) +async def share_lark_calendar_with_user(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_calendar", "create_calendar_acl", + calendar_id=input_data["calendar_id"], + user_id=input_data["user_id"], + role=input_data.get("role", "reader"), + ) + + +@action( + name="revoke_lark_calendar_share", + description="Revoke a previously granted calendar share (ACL entry).", + action_sets=["lark_calendar_sharing"], + input_schema={ + "calendar_id": {"type": "string", "description": "Calendar id.", "example": "primary"}, + "acl_id": {"type": "string", "description": "ACL entry id (from list_lark_calendar_acls).", "example": "user_..."}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def revoke_lark_calendar_share(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_calendar", "delete_calendar_acl", + calendar_id=input_data["calendar_id"], + acl_id=input_data["acl_id"], + ) + + +# ------------------------------------------------------------------ +# Free/busy +# Sub-set: lark_calendar_freebusy +# ------------------------------------------------------------------ + @action( name="check_lark_free_busy", description="Bulk free/busy query — returns each user's busy intervals over a time window. Useful for finding a meeting slot that works for everyone.", - action_sets=["lark_calendar"], + action_sets=["lark_calendar_freebusy", "lark_calendar"], input_schema={ - "user_ids": { - "type": "array", - "description": "List of Lark open_ids (ou_...) to query.", - "example": ["ou_abc", "ou_def"], - }, - "start_time": { - "type": "integer", - "description": "Window start as Unix timestamp in seconds.", - "example": 1730000000, - }, - "end_time": { - "type": "integer", - "description": "Window end as Unix timestamp in seconds.", - "example": 1730086400, - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "result": {"type": "object"}, + "user_ids": {"type": "array", "description": "List of Lark open_ids (ou_...) to query.", "example": ["ou_abc", "ou_def"]}, + "start_time": {"type": "integer", "description": "Window start as Unix timestamp in seconds.", "example": 1730000000}, + "end_time": {"type": "integer", "description": "Window end as Unix timestamp in seconds.", "example": 1730086400}, }, + output_schema={"status": {"type": "string", "example": "success"}, "result": {"type": "object"}}, ) async def check_lark_free_busy(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( - "lark_calendar", - "check_free_busy", + "lark_calendar", "check_free_busy", user_ids=input_data["user_ids"], start_time=input_data["start_time"], end_time=input_data["end_time"], diff --git a/app/data/action/integrations/lark_drive/lark_drive_actions.py b/app/data/action/integrations/lark_drive/lark_drive_actions.py index b55a2120..e52e8fd5 100644 --- a/app/data/action/integrations/lark_drive/lark_drive_actions.py +++ b/app/data/action/integrations/lark_drive/lark_drive_actions.py @@ -1,38 +1,26 @@ from agent_core import action +# ═══════════════════════════════════════════════════════════════════════════════ +# Drive — files: list / search / metadata / folder / upload / download / delete +# + move / copy / versions / stats +# ═══════════════════════════════════════════════════════════════════════════════ + @action( name="list_lark_drive_files", description="List files and folders in Lark Drive. Pass an empty folder_token to list the root.", - action_sets=["lark_drive"], - input_schema={ - "folder_token": { - "type": "string", - "description": "Folder token to list inside. Empty string lists the root.", - "example": "", - }, - "page_size": { - "type": "integer", - "description": "Max items to return (capped at 200).", - "example": 50, - }, - "page_token": { - "type": "string", - "description": "Pagination cursor from a previous response's next_page_token.", - "example": "", - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "result": {"type": "object"}, + action_sets=["lark_drive_files", "lark_drive"], + input_schema={ + "folder_token": {"type": "string", "description": "Folder token to list inside. Empty string lists the root.", "example": ""}, + "page_size": {"type": "integer", "description": "Max items (capped at 200).", "example": 50}, + "page_token": {"type": "string", "description": "Pagination cursor.", "example": ""}, }, + output_schema={"status": {"type": "string", "example": "success"}}, ) async def list_lark_drive_files(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( - "lark_drive", - "list_files", + "lark_drive", "list_files", folder_token=input_data.get("folder_token", ""), page_size=input_data.get("page_size", 50), page_token=input_data.get("page_token", ""), @@ -42,30 +30,17 @@ async def list_lark_drive_files(input_data: dict) -> dict: @action( name="get_lark_drive_file_metadata", description="Fetch metadata for one or more Lark Drive file tokens.", - action_sets=["lark_drive"], + action_sets=["lark_drive_files", "lark_drive"], input_schema={ - "file_tokens": { - "type": "array", - "description": "List of file tokens to look up.", - "example": ["boxcnabcdef0123"], - }, - "doc_type": { - "type": "string", - "description": "Document type — 'file' (default), 'doc', 'docx', 'sheet', 'bitable', 'mindnote', 'slides'.", - "example": "file", - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "result": {"type": "object"}, + "file_tokens": {"type": "array", "description": "List of file tokens.", "example": ["boxcnabcdef0123"]}, + "doc_type": {"type": "string", "description": "'file' (default), 'doc', 'docx', 'sheet', 'bitable', 'mindnote', 'slides'.", "example": "file"}, }, + output_schema={"status": {"type": "string", "example": "success"}}, ) async def get_lark_drive_file_metadata(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( - "lark_drive", - "get_file_metadata", + "lark_drive", "get_file_metadata", file_tokens=input_data["file_tokens"], doc_type=input_data.get("doc_type", "file"), ) @@ -74,30 +49,18 @@ async def get_lark_drive_file_metadata(input_data: dict) -> dict: @action( name="create_lark_drive_folder", description="Create a new folder in Lark Drive. Empty parent_folder_token creates at the root.", - action_sets=["lark_drive"], + action_sets=["lark_drive_files", "lark_drive"], input_schema={ - "name": { - "type": "string", - "description": "Folder name.", - "example": "Reports 2026", - }, - "parent_folder_token": { - "type": "string", - "description": "Parent folder token. Empty string for root.", - "example": "", - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "result": {"type": "object"}, + "name": {"type": "string", "description": "Folder name.", "example": "Reports 2026"}, + "parent_folder_token": {"type": "string", "description": "Parent folder token. Empty=root.", "example": ""}, }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, ) async def create_lark_drive_folder(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( - "lark_drive", - "create_folder", + "lark_drive", "create_folder", name=input_data["name"], parent_folder_token=input_data.get("parent_folder_token", ""), ) @@ -105,36 +68,20 @@ async def create_lark_drive_folder(input_data: dict) -> dict: @action( name="upload_lark_drive_file", - description="Upload a local file to a Lark Drive folder. Max 20MB — larger files require chunked upload (not yet supported).", - action_sets=["lark_drive"], - input_schema={ - "file_path": { - "type": "string", - "description": "Absolute path to the local file to upload.", - "example": "/home/user/report.pdf", - }, - "parent_folder_token": { - "type": "string", - "description": "Destination folder token in Lark Drive.", - "example": "fldcnabcdef0123", - }, - "file_name": { - "type": "string", - "description": "Name to give the file in Drive. Defaults to basename of file_path.", - "example": "report.pdf", - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "result": {"type": "object"}, + description="Upload a local file to a Lark Drive folder (max 20MB).", + action_sets=["lark_drive_files", "lark_drive"], + input_schema={ + "file_path": {"type": "string", "description": "Absolute path to the local file.", "example": "/home/user/report.pdf"}, + "parent_folder_token": {"type": "string", "description": "Destination folder token.", "example": ""}, + "file_name": {"type": "string", "description": "Name in Drive (defaults to basename).", "example": ""}, }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, ) async def upload_lark_drive_file(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( - "lark_drive", - "upload_file", + "lark_drive", "upload_file", file_path=input_data["file_path"], parent_folder_token=input_data["parent_folder_token"], file_name=input_data.get("file_name", ""), @@ -143,31 +90,19 @@ async def upload_lark_drive_file(input_data: dict) -> dict: @action( name="download_lark_drive_file", - description="Download a file from Lark Drive to a local path.", - action_sets=["lark_drive"], + description="Download a regular file from Lark Drive to a local path. For Docs/Sheets use export_lark_drive_file.", + action_sets=["lark_drive_files", "lark_drive"], input_schema={ - "file_token": { - "type": "string", - "description": "Lark Drive file token.", - "example": "boxcnabcdef0123", - }, - "dest_path": { - "type": "string", - "description": "Absolute local path to write the file to.", - "example": "/home/user/Downloads/report.pdf", - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "result": {"type": "object"}, + "file_token": {"type": "string", "description": "File token.", "example": ""}, + "dest_path": {"type": "string", "description": "Local path.", "example": ""}, }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, ) async def download_lark_drive_file(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( - "lark_drive", - "download_file", + "lark_drive", "download_file", file_token=input_data["file_token"], dest_path=input_data["dest_path"], ) @@ -175,28 +110,19 @@ async def download_lark_drive_file(input_data: dict) -> dict: @action( name="delete_lark_drive_file", - description="Delete a file or folder from Lark Drive by token.", - action_sets=["lark_drive"], + description="Delete a file/folder/doc/etc by token.", + action_sets=["lark_drive_files", "lark_drive"], input_schema={ - "file_token": { - "type": "string", - "description": "Lark Drive file token to delete.", - "example": "boxcnabcdef0123", - }, - "file_type": { - "type": "string", - "description": "Type — 'file' (default), 'folder', 'doc', 'docx', 'sheet', 'bitable', 'mindnote', 'shortcut', 'slides'.", - "example": "file", - }, + "file_token": {"type": "string", "description": "Token.", "example": ""}, + "file_type": {"type": "string", "description": "file | folder | doc | docx | sheet | bitable | mindnote | shortcut | slides.", "example": "file"}, }, output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, ) async def delete_lark_drive_file(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( - "lark_drive", - "delete_file", + "lark_drive", "delete_file", file_token=input_data["file_token"], file_type=input_data.get("file_type", "file"), ) @@ -204,31 +130,1615 @@ async def delete_lark_drive_file(input_data: dict) -> dict: @action( name="search_lark_drive_files", - description="Full-text search across files in Lark Drive that the bot has access to.", - action_sets=["lark_drive"], + description="Full-text search across files in Lark Drive.", + action_sets=["lark_drive_files", "lark_drive"], input_schema={ - "search_key": { - "type": "string", - "description": "Search query string.", - "example": "Q1 report", - }, - "count": { - "type": "integer", - "description": "Max results to return (capped at 50).", - "example": 20, - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "result": {"type": "object"}, + "search_key": {"type": "string", "description": "Query.", "example": "Q1 report"}, + "count": {"type": "integer", "description": "Max results (capped 50).", "example": 20}, }, + output_schema={"status": {"type": "string", "example": "success"}}, ) async def search_lark_drive_files(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( - "lark_drive", - "search_files", + "lark_drive", "search_files", search_key=input_data["search_key"], count=input_data.get("count", 20), ) + + +@action( + name="copy_lark_drive_file", + description="Copy a file/doc/sheet/etc into a folder.", + action_sets=["lark_drive_files", "lark_drive"], + input_schema={ + "file_token": {"type": "string", "description": "Source token.", "example": ""}, + "name": {"type": "string", "description": "Copy name.", "example": ""}, + "folder_token": {"type": "string", "description": "Destination folder token.", "example": ""}, + "copy_type": {"type": "string", "description": "file | folder | doc | docx | sheet | bitable | mindnote | slides.", "example": "file"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def copy_lark_drive_file(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "copy_file", + file_token=input_data["file_token"], + name=input_data["name"], + folder_token=input_data["folder_token"], + copy_type=input_data.get("copy_type", "file"), + ) + + +@action( + name="move_lark_drive_file", + description="Move a file/folder/doc to another folder.", + action_sets=["lark_drive_files", "lark_drive"], + input_schema={ + "file_token": {"type": "string", "description": "Token.", "example": ""}, + "target_folder_token": {"type": "string", "description": "Destination folder token.", "example": ""}, + "file_type": {"type": "string", "description": "file | folder | doc | docx | sheet | bitable | mindnote | shortcut | slides.", "example": "file"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def move_lark_drive_file(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "move_file", + file_token=input_data["file_token"], + target_folder_token=input_data["target_folder_token"], + file_type=input_data.get("file_type", "file"), + ) + + +@action( + name="list_lark_drive_file_versions", + description="List version history for a Doc/Sheet (Docx/Doc/Sheet only).", + action_sets=["lark_drive_files"], + input_schema={ + "file_token": {"type": "string", "description": "Token.", "example": ""}, + "file_type": {"type": "string", "description": "docx | doc | sheet.", "example": "docx"}, + "page_size": {"type": "integer", "description": "Max (capped 50).", "example": 50}, + "page_token": {"type": "string", "description": "Pagination cursor.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def list_lark_drive_file_versions(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "list_file_versions", + file_token=input_data["file_token"], + file_type=input_data.get("file_type", "docx"), + page_size=input_data.get("page_size", 50), + page_token=input_data.get("page_token", ""), + ) + + +@action( + name="get_lark_drive_file_statistics", + description="Get views/likes/comments stats for a file.", + action_sets=["lark_drive_files"], + input_schema={ + "file_token": {"type": "string", "description": "Token.", "example": ""}, + "file_type": {"type": "string", "description": "docx | doc | sheet | bitable | file.", "example": "docx"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def get_lark_drive_file_statistics(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "file_statistics", + file_token=input_data["file_token"], + file_type=input_data.get("file_type", "docx"), + ) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Drive — Permissions (sharing) +# ═══════════════════════════════════════════════════════════════════════════════ + +@action( + name="list_lark_drive_permissions", + description="List members with access to a file/doc/etc.", + action_sets=["lark_drive_permissions", "lark_drive"], + input_schema={ + "file_token": {"type": "string", "description": "Token.", "example": ""}, + "file_type": {"type": "string", "description": "doc | docx | sheet | bitable | file | folder | mindnote | slides.", "example": "docx"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def list_lark_drive_permissions(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "list_permission_members", + file_token=input_data["file_token"], + file_type=input_data.get("file_type", "docx"), + ) + + +@action( + name="add_lark_drive_permission", + description="Grant access. member_type: email|openid|userid|unionid|chatid|departmentid|openchat|opendepartment|groupid. perm: view|edit|full_access. perm_type: container|single_page.", + action_sets=["lark_drive_permissions", "lark_drive"], + input_schema={ + "file_token": {"type": "string", "description": "Token.", "example": ""}, + "member_type": {"type": "string", "description": "Member type.", "example": "email"}, + "member_id": {"type": "string", "description": "Member identifier (email / user_id / etc.).", "example": "alice@example.com"}, + "perm": {"type": "string", "description": "view | edit | full_access.", "example": "view"}, + "file_type": {"type": "string", "description": "Doc type.", "example": "docx"}, + "perm_type": {"type": "string", "description": "container | single_page.", "example": "container"}, + "notify_lark": {"type": "boolean", "description": "Send a Lark notification.", "example": False}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def add_lark_drive_permission(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "add_permission_member", + file_token=input_data["file_token"], + member_type=input_data["member_type"], + member_id=input_data["member_id"], + perm=input_data["perm"], + file_type=input_data.get("file_type", "docx"), + perm_type=input_data.get("perm_type", "container"), + notify_lark=bool(input_data.get("notify_lark", False)), + ) + + +@action( + name="update_lark_drive_permission", + description="Change a member's permission level.", + action_sets=["lark_drive_permissions"], + input_schema={ + "file_token": {"type": "string", "description": "Token.", "example": ""}, + "member_id": {"type": "string", "description": "Member ID.", "example": ""}, + "member_type": {"type": "string", "description": "Member type.", "example": "email"}, + "perm": {"type": "string", "description": "view | edit | full_access.", "example": "edit"}, + "file_type": {"type": "string", "description": "Doc type.", "example": "docx"}, + "perm_type": {"type": "string", "description": "container | single_page.", "example": "container"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def update_lark_drive_permission(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "update_permission_member", + file_token=input_data["file_token"], + member_id=input_data["member_id"], + member_type=input_data["member_type"], + perm=input_data["perm"], + file_type=input_data.get("file_type", "docx"), + perm_type=input_data.get("perm_type", "container"), + ) + + +@action( + name="remove_lark_drive_permission", + description="Revoke a member's access.", + action_sets=["lark_drive_permissions", "lark_drive"], + input_schema={ + "file_token": {"type": "string", "description": "Token.", "example": ""}, + "member_id": {"type": "string", "description": "Member ID.", "example": ""}, + "member_type": {"type": "string", "description": "Member type.", "example": "email"}, + "file_type": {"type": "string", "description": "Doc type.", "example": "docx"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def remove_lark_drive_permission(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "delete_permission_member", + file_token=input_data["file_token"], + member_id=input_data["member_id"], + member_type=input_data["member_type"], + file_type=input_data.get("file_type", "docx"), + ) + + +@action( + name="get_lark_drive_public_permission", + description="Get public-link settings for a file.", + action_sets=["lark_drive_permissions"], + input_schema={ + "file_token": {"type": "string", "description": "Token.", "example": ""}, + "file_type": {"type": "string", "description": "Doc type.", "example": "docx"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def get_lark_drive_public_permission(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "get_public_permission", + file_token=input_data["file_token"], + file_type=input_data.get("file_type", "docx"), + ) + + +@action( + name="update_lark_drive_public_permission", + description="Update public-link settings (sharing scope, comments, security). Values are Lark enums like 'tenant_readable' / 'anyone_readable' / 'closed' / 'anyone_editable' — see Lark docs per field.", + action_sets=["lark_drive_permissions"], + input_schema={ + "file_token": {"type": "string", "description": "Token.", "example": ""}, + "file_type": {"type": "string", "description": "Doc type.", "example": "docx"}, + "link_share_entity": {"type": "string", "description": "Who can access via link (optional).", "example": "closed"}, + "share_entity": {"type": "string", "description": "Who can share (optional).", "example": ""}, + "comment_entity": {"type": "string", "description": "Who can comment (optional).", "example": ""}, + "security_entity": {"type": "string", "description": "Security setting (optional).", "example": ""}, + "external_access_entity": {"type": "string", "description": "External access (optional).", "example": ""}, + "invite_external": {"type": "boolean", "description": "Allow external invites (optional).", "example": False}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def update_lark_drive_public_permission(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "update_public_permission", + file_token=input_data["file_token"], + file_type=input_data.get("file_type", "docx"), + link_share_entity=input_data.get("link_share_entity") or None, + share_entity=input_data.get("share_entity") or None, + comment_entity=input_data.get("comment_entity") or None, + security_entity=input_data.get("security_entity") or None, + external_access_entity=input_data.get("external_access_entity") or None, + invite_external=input_data["invite_external"] if "invite_external" in input_data else None, + ) + + +@action( + name="transfer_lark_drive_ownership", + description="Transfer ownership of a file to another user.", + action_sets=["lark_drive_permissions"], + input_schema={ + "file_token": {"type": "string", "description": "Token.", "example": ""}, + "member_type": {"type": "string", "description": "email|openid|userid.", "example": "email"}, + "member_id": {"type": "string", "description": "New owner's identifier.", "example": ""}, + "file_type": {"type": "string", "description": "Doc type.", "example": "docx"}, + "remove_old_owner": {"type": "boolean", "description": "Strip old owner's access.", "example": False}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def transfer_lark_drive_ownership(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "transfer_owner", + file_token=input_data["file_token"], + member_type=input_data["member_type"], + member_id=input_data["member_id"], + file_type=input_data.get("file_type", "docx"), + remove_old_owner=bool(input_data.get("remove_old_owner", False)), + ) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Drive — Comments +# ═══════════════════════════════════════════════════════════════════════════════ + +@action( + name="list_lark_drive_comments", + description="List comments on a file.", + action_sets=["lark_drive_comments", "lark_drive"], + input_schema={ + "file_token": {"type": "string", "description": "Token.", "example": ""}, + "file_type": {"type": "string", "description": "Doc type.", "example": "docx"}, + "is_whole": {"type": "boolean", "description": "Whole-doc comments (true) vs anchored (false).", "example": True}, + "page_size": {"type": "integer", "description": "Max results.", "example": 100}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def list_lark_drive_comments(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "list_comments", + file_token=input_data["file_token"], + file_type=input_data.get("file_type", "docx"), + is_whole=bool(input_data.get("is_whole", True)), + page_size=input_data.get("page_size", 100), + ) + + +@action( + name="create_lark_drive_comment", + description="Post a comment on a file. content_elements is a rich-text array: e.g. [{type:'text_run', text_run:{text:'...'}}].", + action_sets=["lark_drive_comments", "lark_drive"], + input_schema={ + "file_token": {"type": "string", "description": "Token.", "example": ""}, + "content_elements": {"type": "array", "description": "Rich-text elements.", "example": [{"type": "text_run", "text_run": {"text": "Looks good"}}]}, + "file_type": {"type": "string", "description": "Doc type.", "example": "docx"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def create_lark_drive_comment(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "create_comment", + file_token=input_data["file_token"], + content_elements=input_data["content_elements"], + file_type=input_data.get("file_type", "docx"), + ) + + +@action( + name="get_lark_drive_comment", + description="Get a single comment.", + action_sets=["lark_drive_comments"], + input_schema={ + "file_token": {"type": "string", "description": "Token.", "example": ""}, + "comment_id": {"type": "string", "description": "Comment ID.", "example": ""}, + "file_type": {"type": "string", "description": "Doc type.", "example": "docx"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def get_lark_drive_comment(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "get_comment", + file_token=input_data["file_token"], + comment_id=input_data["comment_id"], + file_type=input_data.get("file_type", "docx"), + ) + + +@action( + name="resolve_lark_drive_comment", + description="Mark a comment resolved (or unresolved).", + action_sets=["lark_drive_comments"], + input_schema={ + "file_token": {"type": "string", "description": "Token.", "example": ""}, + "comment_id": {"type": "string", "description": "Comment ID.", "example": ""}, + "is_solved": {"type": "boolean", "description": "True=resolve, False=unresolve.", "example": True}, + "file_type": {"type": "string", "description": "Doc type.", "example": "docx"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def resolve_lark_drive_comment(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "resolve_comment", + file_token=input_data["file_token"], + comment_id=input_data["comment_id"], + is_solved=bool(input_data.get("is_solved", True)), + file_type=input_data.get("file_type", "docx"), + ) + + +@action( + name="list_lark_drive_comment_replies", + description="List replies on a comment.", + action_sets=["lark_drive_comments"], + input_schema={ + "file_token": {"type": "string", "description": "Token.", "example": ""}, + "comment_id": {"type": "string", "description": "Comment ID.", "example": ""}, + "file_type": {"type": "string", "description": "Doc type.", "example": "docx"}, + "page_size": {"type": "integer", "description": "Max results.", "example": 100}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def list_lark_drive_comment_replies(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "list_comment_replies", + file_token=input_data["file_token"], + comment_id=input_data["comment_id"], + file_type=input_data.get("file_type", "docx"), + page_size=input_data.get("page_size", 100), + ) + + +@action( + name="update_lark_drive_comment_reply", + description="Edit a reply.", + action_sets=["lark_drive_comments"], + input_schema={ + "file_token": {"type": "string", "description": "Token.", "example": ""}, + "comment_id": {"type": "string", "description": "Comment ID.", "example": ""}, + "reply_id": {"type": "string", "description": "Reply ID.", "example": ""}, + "content_elements": {"type": "array", "description": "New rich-text content.", "example": []}, + "file_type": {"type": "string", "description": "Doc type.", "example": "docx"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def update_lark_drive_comment_reply(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "update_comment_reply", + file_token=input_data["file_token"], + comment_id=input_data["comment_id"], + reply_id=input_data["reply_id"], + content_elements=input_data["content_elements"], + file_type=input_data.get("file_type", "docx"), + ) + + +@action( + name="delete_lark_drive_comment_reply", + description="Delete a reply.", + action_sets=["lark_drive_comments"], + input_schema={ + "file_token": {"type": "string", "description": "Token.", "example": ""}, + "comment_id": {"type": "string", "description": "Comment ID.", "example": ""}, + "reply_id": {"type": "string", "description": "Reply ID.", "example": ""}, + "file_type": {"type": "string", "description": "Doc type.", "example": "docx"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def delete_lark_drive_comment_reply(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "delete_comment_reply", + file_token=input_data["file_token"], + comment_id=input_data["comment_id"], + reply_id=input_data["reply_id"], + file_type=input_data.get("file_type", "docx"), + ) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Drive — Import / Export tasks +# ═══════════════════════════════════════════════════════════════════════════════ + +@action( + name="import_lark_drive_file", + description="Convert a regular file into a Doc/Sheet/Bitable. Step 1: upload via upload_lark_drive_file → use its file_token here. Returns a ticket; poll with get_lark_drive_import_task until done.", + action_sets=["lark_drive_import_export"], + input_schema={ + "file_extension": {"type": "string", "description": "docx | xlsx | csv | pdf etc.", "example": "docx"}, + "file_name": {"type": "string", "description": "Target file name.", "example": ""}, + "file_token": {"type": "string", "description": "Source file token (already uploaded).", "example": ""}, + "file_type": {"type": "string", "description": "Target type: docx | sheet | bitable.", "example": "docx"}, + "folder_token": {"type": "string", "description": "Destination folder.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def import_lark_drive_file(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "create_import_task", + file_extension=input_data["file_extension"], + file_name=input_data["file_name"], + file_token=input_data["file_token"], + file_type=input_data["file_type"], + folder_token=input_data.get("folder_token", ""), + ) + + +@action( + name="get_lark_drive_import_task", + description="Poll an import task. When job_status='success' the result token is the new Doc/Sheet/Bitable.", + action_sets=["lark_drive_import_export"], + input_schema={ + "ticket": {"type": "string", "description": "Ticket from import_lark_drive_file.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def get_lark_drive_import_task(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "get_import_task", + ticket=input_data["ticket"], + ) + + +@action( + name="export_lark_drive_file", + description="Convert a Doc/Sheet/Bitable into a regular file (e.g. docx → pdf, sheet → xlsx). Returns a ticket; poll with get_lark_drive_export_task, then download_lark_drive_export.", + action_sets=["lark_drive_import_export", "lark_drive"], + input_schema={ + "file_extension": {"type": "string", "description": "docx | xlsx | csv | pdf.", "example": "pdf"}, + "file_token": {"type": "string", "description": "Source Doc/Sheet/Bitable token.", "example": ""}, + "file_type": {"type": "string", "description": "Source type: docx | sheet | bitable.", "example": "docx"}, + "sub_id": {"type": "string", "description": "Sub-sheet/view ID (optional, for sheets/bitable).", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def export_lark_drive_file(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "create_export_task", + file_extension=input_data["file_extension"], + file_token=input_data["file_token"], + file_type=input_data["file_type"], + sub_id=input_data.get("sub_id", ""), + ) + + +@action( + name="get_lark_drive_export_task", + description="Poll an export task. When job_status='success', use the returned file_token with download_lark_drive_export.", + action_sets=["lark_drive_import_export"], + input_schema={ + "ticket": {"type": "string", "description": "Ticket from export_lark_drive_file.", "example": ""}, + "file_token": {"type": "string", "description": "Original source token (same as passed to export).", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def get_lark_drive_export_task(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "get_export_task", + ticket=input_data["ticket"], + file_token=input_data["file_token"], + ) + + +@action( + name="download_lark_drive_export", + description="Download the final blob produced by a finished export task.", + action_sets=["lark_drive_import_export"], + input_schema={ + "result_file_token": {"type": "string", "description": "Token from get_lark_drive_export_task response.", "example": ""}, + "dest_path": {"type": "string", "description": "Local destination path.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def download_lark_drive_export(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "download_export", + result_file_token=input_data["result_file_token"], + dest_path=input_data["dest_path"], + ) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Docx (new Docs) — documents + blocks +# ═══════════════════════════════════════════════════════════════════════════════ + +@action( + name="create_lark_doc", + description="Create a new Lark Doc (Docx). Returns document_id.", + action_sets=["lark_docs", "lark_drive"], + input_schema={ + "title": {"type": "string", "description": "Doc title.", "example": "Meeting notes"}, + "folder_token": {"type": "string", "description": "Parent folder (optional, defaults to root).", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def create_lark_doc(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "create_document", + title=input_data.get("title", ""), + folder_token=input_data.get("folder_token", ""), + ) + + +@action( + name="get_lark_doc", + description="Get a Doc's metadata (title, revision_id, etc.).", + action_sets=["lark_docs", "lark_drive"], + input_schema={ + "document_id": {"type": "string", "description": "Doc ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def get_lark_doc(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("lark_drive", "get_document", document_id=input_data["document_id"]) + + +@action( + name="get_lark_doc_raw_content", + description="Get a Doc's plain-text content (for skimming/summarizing).", + action_sets=["lark_docs", "lark_drive"], + input_schema={ + "document_id": {"type": "string", "description": "Doc ID.", "example": ""}, + "lang": {"type": "integer", "description": "0=default, 1=en, 2=zh, 3=ja.", "example": 0}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def get_lark_doc_raw_content(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "get_document_raw_content", + document_id=input_data["document_id"], + lang=input_data.get("lang", 0), + ) + + +@action( + name="list_lark_doc_blocks", + description="List a Doc's blocks (paragraphs, headings, tables, etc.).", + action_sets=["lark_docs", "lark_drive"], + input_schema={ + "document_id": {"type": "string", "description": "Doc ID.", "example": ""}, + "page_size": {"type": "integer", "description": "Max blocks (capped 500).", "example": 500}, + "page_token": {"type": "string", "description": "Pagination cursor.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def list_lark_doc_blocks(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "list_document_blocks", + document_id=input_data["document_id"], + page_size=input_data.get("page_size", 500), + page_token=input_data.get("page_token", ""), + ) + + +@action( + name="get_lark_doc_block", + description="Get a single block.", + action_sets=["lark_docs"], + input_schema={ + "document_id": {"type": "string", "description": "Doc ID.", "example": ""}, + "block_id": {"type": "string", "description": "Block ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def get_lark_doc_block(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "get_document_block", + document_id=input_data["document_id"], + block_id=input_data["block_id"], + ) + + +@action( + name="append_lark_doc_blocks", + description="Append child blocks under a parent block. Pass document_id as block_id to add at top level. children is an array of block objects (paragraph / heading / bullet / etc.).", + action_sets=["lark_docs", "lark_drive"], + input_schema={ + "document_id": {"type": "string", "description": "Doc ID.", "example": ""}, + "block_id": {"type": "string", "description": "Parent block ID (or document_id for top level).", "example": ""}, + "children": {"type": "array", "description": "Block objects to insert.", "example": []}, + "index": {"type": "integer", "description": "Insert position (-1 = end).", "example": -1}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def append_lark_doc_blocks(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "create_document_block_children", + document_id=input_data["document_id"], + block_id=input_data["block_id"], + children=input_data["children"], + index=input_data.get("index", -1), + ) + + +@action( + name="update_lark_doc_block", + description="Update a block. update_payload uses Docx's update structures, e.g. {update_text_elements: {elements: [...]}} for a paragraph.", + action_sets=["lark_docs", "lark_drive"], + input_schema={ + "document_id": {"type": "string", "description": "Doc ID.", "example": ""}, + "block_id": {"type": "string", "description": "Block ID.", "example": ""}, + "update_payload": {"type": "object", "description": "Per-block-type update body.", "example": {}}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def update_lark_doc_block(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "update_document_block", + document_id=input_data["document_id"], + block_id=input_data["block_id"], + update_payload=input_data["update_payload"], + ) + + +@action( + name="batch_update_lark_doc_blocks", + description="Batch-update multiple blocks in one round-trip. requests is a list of {block_id, ...update_fields}.", + action_sets=["lark_docs"], + input_schema={ + "document_id": {"type": "string", "description": "Doc ID.", "example": ""}, + "requests": {"type": "array", "description": "Update objects.", "example": []}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def batch_update_lark_doc_blocks(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "batch_update_document_blocks", + document_id=input_data["document_id"], + requests=input_data["requests"], + ) + + +@action( + name="delete_lark_doc_blocks", + description="Delete a contiguous range of children of a parent block. Range is [start_index, end_index) (half-open).", + action_sets=["lark_docs"], + input_schema={ + "document_id": {"type": "string", "description": "Doc ID.", "example": ""}, + "block_id": {"type": "string", "description": "Parent block ID.", "example": ""}, + "start_index": {"type": "integer", "description": "Start (inclusive).", "example": 0}, + "end_index": {"type": "integer", "description": "End (exclusive).", "example": 1}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def delete_lark_doc_blocks(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "delete_document_blocks", + document_id=input_data["document_id"], + block_id=input_data["block_id"], + start_index=input_data["start_index"], + end_index=input_data["end_index"], + ) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Sheets — spreadsheets + values +# ═══════════════════════════════════════════════════════════════════════════════ + +@action( + name="create_lark_sheet", + description="Create a new Lark Spreadsheet. Returns spreadsheet_token.", + action_sets=["lark_sheets", "lark_drive"], + input_schema={ + "title": {"type": "string", "description": "Spreadsheet title.", "example": ""}, + "folder_token": {"type": "string", "description": "Parent folder (optional).", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def create_lark_sheet(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "create_spreadsheet", + title=input_data.get("title", ""), + folder_token=input_data.get("folder_token", ""), + ) + + +@action( + name="get_lark_sheet", + description="Get spreadsheet metadata (title, owner, url).", + action_sets=["lark_sheets", "lark_drive"], + input_schema={ + "spreadsheet_token": {"type": "string", "description": "Spreadsheet token.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def get_lark_sheet(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "get_spreadsheet", + spreadsheet_token=input_data["spreadsheet_token"], + ) + + +@action( + name="rename_lark_sheet", + description="Rename a spreadsheet.", + action_sets=["lark_sheets"], + input_schema={ + "spreadsheet_token": {"type": "string", "description": "Token.", "example": ""}, + "title": {"type": "string", "description": "New title.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def rename_lark_sheet(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "update_spreadsheet_title", + spreadsheet_token=input_data["spreadsheet_token"], + title=input_data["title"], + ) + + +@action( + name="list_lark_sheet_tabs", + description="List child sheets (tabs) in a spreadsheet.", + action_sets=["lark_sheets", "lark_drive"], + input_schema={ + "spreadsheet_token": {"type": "string", "description": "Token.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def list_lark_sheet_tabs(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "list_spreadsheet_sheets", + spreadsheet_token=input_data["spreadsheet_token"], + ) + + +@action( + name="get_lark_sheet_tab", + description="Get info about a single sheet tab (rows, cols, grid_properties).", + action_sets=["lark_sheets"], + input_schema={ + "spreadsheet_token": {"type": "string", "description": "Token.", "example": ""}, + "sheet_id": {"type": "string", "description": "Tab/sheet ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def get_lark_sheet_tab(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "get_spreadsheet_sheet", + spreadsheet_token=input_data["spreadsheet_token"], + sheet_id=input_data["sheet_id"], + ) + + +@action( + name="read_lark_sheet_values", + description="Read a range of cells. range format: '!A1:D10'.", + action_sets=["lark_sheets", "lark_drive"], + input_schema={ + "spreadsheet_token": {"type": "string", "description": "Token.", "example": ""}, + "range": {"type": "string", "description": "Range like 'sheet1!A1:D10'.", "example": ""}, + "value_render_option": {"type": "string", "description": "ToString | FormattedValue | Formula | UnformattedValue.", "example": "ToString"}, + "date_time_render_option": {"type": "string", "description": "FormattedString or UnformattedValue.", "example": "FormattedString"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def read_lark_sheet_values(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "get_sheet_values", + spreadsheet_token=input_data["spreadsheet_token"], + range_=input_data["range"], + value_render_option=input_data.get("value_render_option", "ToString"), + date_time_render_option=input_data.get("date_time_render_option", "FormattedString"), + ) + + +@action( + name="batch_read_lark_sheet_values", + description="Read multiple ranges in one call.", + action_sets=["lark_sheets"], + input_schema={ + "spreadsheet_token": {"type": "string", "description": "Token.", "example": ""}, + "ranges": {"type": "array", "description": "Array of range strings.", "example": []}, + "value_render_option": {"type": "string", "description": "Render option.", "example": "ToString"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def batch_read_lark_sheet_values(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "batch_get_sheet_values", + spreadsheet_token=input_data["spreadsheet_token"], + ranges=input_data["ranges"], + value_render_option=input_data.get("value_render_option", "ToString"), + ) + + +@action( + name="write_lark_sheet_values", + description="Write a 2D values array into a range (overwrites existing cells).", + action_sets=["lark_sheets", "lark_drive"], + input_schema={ + "spreadsheet_token": {"type": "string", "description": "Token.", "example": ""}, + "range": {"type": "string", "description": "Range like 'sheet1!A1'.", "example": ""}, + "values": {"type": "array", "description": "2D array of cell values.", "example": [["A1", "B1"], ["A2", "B2"]]}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def write_lark_sheet_values(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "update_sheet_values", + spreadsheet_token=input_data["spreadsheet_token"], + range_=input_data["range"], + values=input_data["values"], + ) + + +@action( + name="append_lark_sheet_values", + description="Append rows after the last filled row. insert_data_option: OVERWRITE | INSERT_ROWS.", + action_sets=["lark_sheets", "lark_drive"], + input_schema={ + "spreadsheet_token": {"type": "string", "description": "Token.", "example": ""}, + "range": {"type": "string", "description": "Range like 'sheet1!A:D' (search range).", "example": ""}, + "values": {"type": "array", "description": "2D array of rows to append.", "example": []}, + "insert_data_option": {"type": "string", "description": "OVERWRITE | INSERT_ROWS.", "example": "OVERWRITE"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def append_lark_sheet_values(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "append_sheet_values", + spreadsheet_token=input_data["spreadsheet_token"], + range_=input_data["range"], + values=input_data["values"], + insert_data_option=input_data.get("insert_data_option", "OVERWRITE"), + ) + + +@action( + name="batch_write_lark_sheet_values", + description="Write to multiple ranges in one call. value_ranges: [{range, values}, ...].", + action_sets=["lark_sheets"], + input_schema={ + "spreadsheet_token": {"type": "string", "description": "Token.", "example": ""}, + "value_ranges": {"type": "array", "description": "[{range, values}, ...].", "example": []}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def batch_write_lark_sheet_values(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "batch_update_sheet_values", + spreadsheet_token=input_data["spreadsheet_token"], + value_ranges=input_data["value_ranges"], + ) + + +@action( + name="find_in_lark_sheet", + description="Find cells matching a text within a range.", + action_sets=["lark_sheets"], + input_schema={ + "spreadsheet_token": {"type": "string", "description": "Token.", "example": ""}, + "sheet_id": {"type": "string", "description": "Sheet tab ID.", "example": ""}, + "find_text": {"type": "string", "description": "Text to find.", "example": ""}, + "range": {"type": "string", "description": "Search range like 'sheet1!A1:Z1000'.", "example": ""}, + "match_case": {"type": "boolean", "description": "Case sensitive.", "example": False}, + "match_entire_cell": {"type": "boolean", "description": "Match whole cell.", "example": False}, + "search_by_regex": {"type": "boolean", "description": "Regex mode.", "example": False}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def find_in_lark_sheet(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "find_in_sheet", + spreadsheet_token=input_data["spreadsheet_token"], + sheet_id=input_data["sheet_id"], + find_text=input_data["find_text"], + range_=input_data["range"], + match_case=bool(input_data.get("match_case", False)), + match_entire_cell=bool(input_data.get("match_entire_cell", False)), + search_by_regex=bool(input_data.get("search_by_regex", False)), + include_formulas=bool(input_data.get("include_formulas", False)), + ) + + +@action( + name="replace_in_lark_sheet", + description="Find-and-replace across a range.", + action_sets=["lark_sheets"], + input_schema={ + "spreadsheet_token": {"type": "string", "description": "Token.", "example": ""}, + "sheet_id": {"type": "string", "description": "Sheet tab ID.", "example": ""}, + "find_text": {"type": "string", "description": "Text to find.", "example": ""}, + "replacement": {"type": "string", "description": "Replacement text.", "example": ""}, + "range": {"type": "string", "description": "Search range.", "example": ""}, + "match_case": {"type": "boolean", "description": "Case sensitive.", "example": False}, + "match_entire_cell": {"type": "boolean", "description": "Match whole cell.", "example": False}, + "search_by_regex": {"type": "boolean", "description": "Regex.", "example": False}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def replace_in_lark_sheet(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "replace_in_sheet", + spreadsheet_token=input_data["spreadsheet_token"], + sheet_id=input_data["sheet_id"], + find_text=input_data["find_text"], + replacement=input_data["replacement"], + range_=input_data["range"], + match_case=bool(input_data.get("match_case", False)), + match_entire_cell=bool(input_data.get("match_entire_cell", False)), + search_by_regex=bool(input_data.get("search_by_regex", False)), + include_formulas=bool(input_data.get("include_formulas", False)), + ) + + +@action( + name="insert_lark_sheet_rows_or_cols", + description="Insert rows or columns into a sheet tab. major_dimension: ROWS | COLUMNS.", + action_sets=["lark_sheets"], + input_schema={ + "spreadsheet_token": {"type": "string", "description": "Token.", "example": ""}, + "sheet_id": {"type": "string", "description": "Sheet tab ID.", "example": ""}, + "major_dimension": {"type": "string", "description": "ROWS | COLUMNS.", "example": "ROWS"}, + "start_index": {"type": "integer", "description": "Insert before this index (0-based).", "example": 0}, + "end_index": {"type": "integer", "description": "Insert up to (exclusive).", "example": 1}, + "inherit_style": {"type": "string", "description": "BEFORE | AFTER.", "example": "BEFORE"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def insert_lark_sheet_rows_or_cols(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "insert_sheet_dimension_range", + spreadsheet_token=input_data["spreadsheet_token"], + sheet_id=input_data["sheet_id"], + major_dimension=input_data["major_dimension"], + start_index=input_data["start_index"], + end_index=input_data["end_index"], + inherit_style=input_data.get("inherit_style", "BEFORE"), + ) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Bitable — Bases / tables / records / fields / views +# ═══════════════════════════════════════════════════════════════════════════════ + +@action( + name="create_lark_bitable", + description="Create a new Bitable (multi-dimensional table). Returns app_token.", + action_sets=["lark_bitable", "lark_drive"], + input_schema={ + "name": {"type": "string", "description": "Bitable name.", "example": ""}, + "folder_token": {"type": "string", "description": "Parent folder (optional).", "example": ""}, + "time_zone": {"type": "string", "description": "Time zone.", "example": "Asia/Shanghai"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def create_lark_bitable(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "create_bitable_app", + name=input_data.get("name", ""), + folder_token=input_data.get("folder_token", ""), + time_zone=input_data.get("time_zone", "Asia/Shanghai"), + ) + + +@action( + name="get_lark_bitable", + description="Get a Bitable's metadata.", + action_sets=["lark_bitable", "lark_drive"], + input_schema={ + "app_token": {"type": "string", "description": "Bitable app_token.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def get_lark_bitable(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("lark_drive", "get_bitable_app", app_token=input_data["app_token"]) + + +@action( + name="update_lark_bitable", + description="Update a Bitable's name or is_advanced flag.", + action_sets=["lark_bitable"], + input_schema={ + "app_token": {"type": "string", "description": "Bitable token.", "example": ""}, + "name": {"type": "string", "description": "New name (optional).", "example": ""}, + "is_advanced": {"type": "boolean", "description": "Advanced mode (optional).", "example": False}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def update_lark_bitable(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "update_bitable_app", + app_token=input_data["app_token"], + name=input_data.get("name") if "name" in input_data else None, + is_advanced=input_data["is_advanced"] if "is_advanced" in input_data else None, + ) + + +@action( + name="list_lark_bitable_tables", + description="List tables in a Bitable.", + action_sets=["lark_bitable", "lark_drive"], + input_schema={ + "app_token": {"type": "string", "description": "Bitable token.", "example": ""}, + "page_size": {"type": "integer", "description": "Max (capped 100).", "example": 100}, + "page_token": {"type": "string", "description": "Pagination cursor.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def list_lark_bitable_tables(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "list_bitable_tables", + app_token=input_data["app_token"], + page_size=input_data.get("page_size", 100), + page_token=input_data.get("page_token", ""), + ) + + +@action( + name="create_lark_bitable_table", + description="Create a new table in a Bitable.", + action_sets=["lark_bitable", "lark_drive"], + input_schema={ + "app_token": {"type": "string", "description": "Bitable token.", "example": ""}, + "name": {"type": "string", "description": "Table name.", "example": ""}, + "default_view_name": {"type": "string", "description": "Initial view name (optional).", "example": ""}, + "fields": {"type": "array", "description": "Initial field schema (optional).", "example": []}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def create_lark_bitable_table(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "create_bitable_table", + app_token=input_data["app_token"], + name=input_data["name"], + default_view_name=input_data.get("default_view_name") or None, + fields=input_data.get("fields") or None, + ) + + +@action( + name="delete_lark_bitable_table", + description="Delete a table from a Bitable.", + action_sets=["lark_bitable"], + input_schema={ + "app_token": {"type": "string", "description": "Bitable token.", "example": ""}, + "table_id": {"type": "string", "description": "Table ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def delete_lark_bitable_table(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "delete_bitable_table", + app_token=input_data["app_token"], table_id=input_data["table_id"], + ) + + +@action( + name="list_lark_bitable_records", + description="List records in a table.", + action_sets=["lark_bitable", "lark_drive"], + input_schema={ + "app_token": {"type": "string", "description": "Bitable token.", "example": ""}, + "table_id": {"type": "string", "description": "Table ID.", "example": ""}, + "view_id": {"type": "string", "description": "View ID (optional).", "example": ""}, + "page_size": {"type": "integer", "description": "Max records (capped 500).", "example": 100}, + "page_token": {"type": "string", "description": "Pagination cursor.", "example": ""}, + "field_names": {"type": "array", "description": "Specific field names to fetch.", "example": []}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def list_lark_bitable_records(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "list_bitable_records", + app_token=input_data["app_token"], + table_id=input_data["table_id"], + view_id=input_data.get("view_id", ""), + page_size=input_data.get("page_size", 100), + page_token=input_data.get("page_token", ""), + field_names=input_data.get("field_names") or None, + ) + + +@action( + name="get_lark_bitable_record", + description="Get a single record.", + action_sets=["lark_bitable", "lark_drive"], + input_schema={ + "app_token": {"type": "string", "description": "Bitable token.", "example": ""}, + "table_id": {"type": "string", "description": "Table ID.", "example": ""}, + "record_id": {"type": "string", "description": "Record ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def get_lark_bitable_record(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "get_bitable_record", + app_token=input_data["app_token"], + table_id=input_data["table_id"], + record_id=input_data["record_id"], + ) + + +@action( + name="create_lark_bitable_record", + description="Create a record in a table. fields is a dict mapping field name → value (per the field's type).", + action_sets=["lark_bitable", "lark_drive"], + input_schema={ + "app_token": {"type": "string", "description": "Bitable token.", "example": ""}, + "table_id": {"type": "string", "description": "Table ID.", "example": ""}, + "fields": {"type": "object", "description": "Field-name → value map.", "example": {"Name": "Alice"}}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def create_lark_bitable_record(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "create_bitable_record", + app_token=input_data["app_token"], + table_id=input_data["table_id"], + fields=input_data["fields"], + ) + + +@action( + name="update_lark_bitable_record", + description="Update a record.", + action_sets=["lark_bitable", "lark_drive"], + input_schema={ + "app_token": {"type": "string", "description": "Bitable token.", "example": ""}, + "table_id": {"type": "string", "description": "Table ID.", "example": ""}, + "record_id": {"type": "string", "description": "Record ID.", "example": ""}, + "fields": {"type": "object", "description": "Fields to update.", "example": {}}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def update_lark_bitable_record(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "update_bitable_record", + app_token=input_data["app_token"], + table_id=input_data["table_id"], + record_id=input_data["record_id"], + fields=input_data["fields"], + ) + + +@action( + name="delete_lark_bitable_record", + description="Delete a record.", + action_sets=["lark_bitable", "lark_drive"], + input_schema={ + "app_token": {"type": "string", "description": "Bitable token.", "example": ""}, + "table_id": {"type": "string", "description": "Table ID.", "example": ""}, + "record_id": {"type": "string", "description": "Record ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def delete_lark_bitable_record(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "delete_bitable_record", + app_token=input_data["app_token"], + table_id=input_data["table_id"], + record_id=input_data["record_id"], + ) + + +@action( + name="batch_create_lark_bitable_records", + description="Create multiple records in one call. records: [{fields: {...}}, ...].", + action_sets=["lark_bitable", "lark_drive"], + input_schema={ + "app_token": {"type": "string", "description": "Bitable token.", "example": ""}, + "table_id": {"type": "string", "description": "Table ID.", "example": ""}, + "records": {"type": "array", "description": "[{fields: {...}}, ...].", "example": []}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def batch_create_lark_bitable_records(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "batch_create_bitable_records", + app_token=input_data["app_token"], + table_id=input_data["table_id"], + records=input_data["records"], + ) + + +@action( + name="batch_update_lark_bitable_records", + description="Update multiple records. records: [{record_id, fields}, ...].", + action_sets=["lark_bitable"], + input_schema={ + "app_token": {"type": "string", "description": "Bitable token.", "example": ""}, + "table_id": {"type": "string", "description": "Table ID.", "example": ""}, + "records": {"type": "array", "description": "[{record_id, fields}, ...].", "example": []}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def batch_update_lark_bitable_records(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "batch_update_bitable_records", + app_token=input_data["app_token"], + table_id=input_data["table_id"], + records=input_data["records"], + ) + + +@action( + name="batch_delete_lark_bitable_records", + description="Delete multiple records.", + action_sets=["lark_bitable"], + input_schema={ + "app_token": {"type": "string", "description": "Bitable token.", "example": ""}, + "table_id": {"type": "string", "description": "Table ID.", "example": ""}, + "record_ids": {"type": "array", "description": "Record IDs.", "example": []}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def batch_delete_lark_bitable_records(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "batch_delete_bitable_records", + app_token=input_data["app_token"], + table_id=input_data["table_id"], + record_ids=input_data["record_ids"], + ) + + +@action( + name="search_lark_bitable_records", + description="Search records using Bitable's filter+sort syntax. filter_obj: {conjunction:'and'|'or', conditions:[{field_name, operator, value}]}. sort: [{field_name, desc}].", + action_sets=["lark_bitable", "lark_drive"], + input_schema={ + "app_token": {"type": "string", "description": "Bitable token.", "example": ""}, + "table_id": {"type": "string", "description": "Table ID.", "example": ""}, + "filter": {"type": "object", "description": "Filter spec (optional).", "example": {}}, + "sort": {"type": "array", "description": "Sort spec (optional).", "example": []}, + "field_names": {"type": "array", "description": "Field names to return (optional).", "example": []}, + "view_id": {"type": "string", "description": "View ID (optional).", "example": ""}, + "page_size": {"type": "integer", "description": "Max (capped 500).", "example": 100}, + "page_token": {"type": "string", "description": "Pagination cursor.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def search_lark_bitable_records(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "search_bitable_records", + app_token=input_data["app_token"], + table_id=input_data["table_id"], + filter_obj=input_data.get("filter") or None, + sort=input_data.get("sort") or None, + field_names=input_data.get("field_names") or None, + view_id=input_data.get("view_id", ""), + page_size=input_data.get("page_size", 100), + page_token=input_data.get("page_token", ""), + ) + + +@action( + name="list_lark_bitable_fields", + description="List fields (column definitions) in a table.", + action_sets=["lark_bitable"], + input_schema={ + "app_token": {"type": "string", "description": "Bitable token.", "example": ""}, + "table_id": {"type": "string", "description": "Table ID.", "example": ""}, + "view_id": {"type": "string", "description": "View ID (optional).", "example": ""}, + "page_size": {"type": "integer", "description": "Max (capped 100).", "example": 100}, + "page_token": {"type": "string", "description": "Pagination cursor.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def list_lark_bitable_fields(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "list_bitable_fields", + app_token=input_data["app_token"], + table_id=input_data["table_id"], + view_id=input_data.get("view_id", ""), + page_size=input_data.get("page_size", 100), + page_token=input_data.get("page_token", ""), + ) + + +@action( + name="create_lark_bitable_field", + description="Create a new field. field_type: 1=Text, 2=Number, 3=SingleSelect, 4=MultiSelect, 5=DateTime, 7=Checkbox, 11=User, 13=Phone, 15=URL, 17=Attachment, 18=Link, 19=Lookup, 20=Formula, 22=Location, 23=Group, 1001=CreatedTime, 1002=ModifiedTime, 1003=CreatedUser, 1004=ModifiedUser, 1005=AutoNumber.", + action_sets=["lark_bitable"], + input_schema={ + "app_token": {"type": "string", "description": "Bitable token.", "example": ""}, + "table_id": {"type": "string", "description": "Table ID.", "example": ""}, + "field_name": {"type": "string", "description": "Field name.", "example": ""}, + "field_type": {"type": "integer", "description": "Type code.", "example": 1}, + "property": {"type": "object", "description": "Field-type-specific property (e.g. options for select).", "example": {}}, + "description": {"type": "object", "description": "Description object (optional).", "example": {}}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def create_lark_bitable_field(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "create_bitable_field", + app_token=input_data["app_token"], + table_id=input_data["table_id"], + field_name=input_data["field_name"], + field_type=input_data["field_type"], + property=input_data.get("property") or None, + description=input_data.get("description") or None, + ) + + +@action( + name="list_lark_bitable_views", + description="List views in a table.", + action_sets=["lark_bitable"], + input_schema={ + "app_token": {"type": "string", "description": "Bitable token.", "example": ""}, + "table_id": {"type": "string", "description": "Table ID.", "example": ""}, + "page_size": {"type": "integer", "description": "Max.", "example": 100}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def list_lark_bitable_views(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "list_bitable_views", + app_token=input_data["app_token"], + table_id=input_data["table_id"], + page_size=input_data.get("page_size", 100), + ) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Wiki — spaces + nodes +# ═══════════════════════════════════════════════════════════════════════════════ + +@action( + name="list_lark_wiki_spaces", + description="List Wiki spaces accessible to the bot.", + action_sets=["lark_wiki", "lark_drive"], + input_schema={ + "page_size": {"type": "integer", "description": "Max (capped 50).", "example": 50}, + "page_token": {"type": "string", "description": "Pagination cursor.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def list_lark_wiki_spaces(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "list_wiki_spaces", + page_size=input_data.get("page_size", 50), + page_token=input_data.get("page_token", ""), + ) + + +@action( + name="get_lark_wiki_space", + description="Get info about a Wiki space.", + action_sets=["lark_wiki"], + input_schema={ + "space_id": {"type": "string", "description": "Space ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def get_lark_wiki_space(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("lark_drive", "get_wiki_space", space_id=input_data["space_id"]) + + +@action( + name="list_lark_wiki_nodes", + description="List wiki nodes (pages) in a space.", + action_sets=["lark_wiki", "lark_drive"], + input_schema={ + "space_id": {"type": "string", "description": "Space ID.", "example": ""}, + "parent_node_token": {"type": "string", "description": "Parent node (optional, empty=top level).", "example": ""}, + "page_size": {"type": "integer", "description": "Max (capped 50).", "example": 50}, + "page_token": {"type": "string", "description": "Pagination cursor.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def list_lark_wiki_nodes(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "list_wiki_nodes", + space_id=input_data["space_id"], + parent_node_token=input_data.get("parent_node_token", ""), + page_size=input_data.get("page_size", 50), + page_token=input_data.get("page_token", ""), + ) + + +@action( + name="get_lark_wiki_node", + description="Resolve a wiki node token to its underlying obj_token + obj_type. ESSENTIAL when given a Wiki URL — the token in the URL isn't the doc_token of the underlying Doc/Sheet/Bitable.", + action_sets=["lark_wiki", "lark_drive"], + input_schema={ + "token": {"type": "string", "description": "Wiki node token (from a wiki URL).", "example": ""}, + "obj_type": {"type": "string", "description": "wiki (default) | doc | docx | sheet | bitable | mindnote | file | slides.", "example": "wiki"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def get_lark_wiki_node(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "get_wiki_node", + token=input_data["token"], + obj_type=input_data.get("obj_type", "wiki"), + ) + + +@action( + name="create_lark_wiki_node", + description="Create a new wiki node. obj_type: doc | docx | sheet | bitable | mindnote | file | slides. node_type: origin (new doc) | shortcut (link to existing).", + action_sets=["lark_wiki"], + input_schema={ + "space_id": {"type": "string", "description": "Space ID.", "example": ""}, + "obj_type": {"type": "string", "description": "Underlying doc type.", "example": "docx"}, + "node_type": {"type": "string", "description": "origin | shortcut.", "example": "origin"}, + "parent_node_token": {"type": "string", "description": "Parent node (optional).", "example": ""}, + "origin_node_token": {"type": "string", "description": "Source token (for shortcut).", "example": ""}, + "title": {"type": "string", "description": "Title (optional).", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def create_lark_wiki_node(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "create_wiki_node", + space_id=input_data["space_id"], + obj_type=input_data["obj_type"], + node_type=input_data.get("node_type", "origin"), + parent_node_token=input_data.get("parent_node_token", ""), + origin_node_token=input_data.get("origin_node_token", ""), + title=input_data.get("title", ""), + ) + + +@action( + name="move_lark_wiki_node", + description="Move a wiki node to another parent / space.", + action_sets=["lark_wiki"], + input_schema={ + "space_id": {"type": "string", "description": "Current space ID.", "example": ""}, + "node_token": {"type": "string", "description": "Node token to move.", "example": ""}, + "target_parent_token": {"type": "string", "description": "New parent (optional).", "example": ""}, + "target_space_id": {"type": "string", "description": "New space (optional).", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def move_lark_wiki_node(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "lark_drive", "move_wiki_node", + space_id=input_data["space_id"], + node_token=input_data["node_token"], + target_parent_token=input_data.get("target_parent_token", ""), + target_space_id=input_data.get("target_space_id", ""), + ) + + +# ================================================================== +# Intentionally NOT exposed as actions (and why) +# ================================================================== +# - Chunked upload (upload_prepare / upload_part / upload_finish) +# Required for files >20MB. The single-shot upload_lark_drive_file +# covers the realistic interactive case. +# - Subscription / event webhooks (file.subscribe, file.edit, etc.) +# Server-side push plumbing — handled by the listener if needed. +# - Bitable workflow / automation, role/perm management +# Admin-style configuration; out of scope for daily-driver use. +# - Sheets cell formatting (border / merge_cells / cell_style) +# Niche presentational tweaks that complicate the surface heavily. +# Add via batch_update_sheet_values' style payload when needed. +# - Mindnote / Slides surfaces +# Niche editors; create/move/share work via the generic Drive endpoints. +# - Docx Tables / Bitable Lookup/Formula field schemas +# Heavy data-shape surface; the action's `property` dict accepts the +# raw Lark shape so the agent can construct it from docs without a +# per-field-type wrapper. diff --git a/app/data/action/integrations/line/line_actions.py b/app/data/action/integrations/line/line_actions.py index 3395e779..1580f96d 100644 --- a/app/data/action/integrations/line/line_actions.py +++ b/app/data/action/integrations/line/line_actions.py @@ -1,155 +1,1059 @@ from agent_core import action +# ═══════════════════════════════════════════════════════════════════════════════ +# Messages — text + rich types (image / video / audio / location / sticker / +# Flex / template / imagemap) + content download +# ═══════════════════════════════════════════════════════════════════════════════ + @action( name="send_line_message", - description="Send a text message via LINE to a user, group, or room ID. Use this ONLY when the agent needs to push a message via LINE.", - action_sets=["line"], + description="Push a text message to a LINE user/group/room.", + action_sets=["line_messages", "line"], + input_schema={ + "to": {"type": "string", "description": "Recipient userId / groupId / roomId.", "example": "U..."}, + "text": {"type": "string", "description": "Message text.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def send_line_message(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("line", "push_text", to=input_data["to"], text=input_data["text"]) + + +@action( + name="reply_line_message", + description="Reply to a LINE message using the reply token (1-minute window).", + action_sets=["line_messages", "line"], + input_schema={ + "reply_token": {"type": "string", "description": "Reply token from the webhook event.", "example": ""}, + "text": {"type": "string", "description": "Reply text.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def reply_line_message(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("line", "reply_text", + reply_token=input_data["reply_token"], text=input_data["text"]) + + +@action( + name="multicast_line_message", + description="Send the same text to up to 500 user IDs.", + action_sets=["line_messages", "line"], input_schema={ - "to": { - "type": "string", - "description": "LINE user ID, group ID, or room ID. Starts with U, C, or R.", - "example": "U4af4980629...", - }, - "text": { - "type": "string", - "description": "Message text to send.", - "example": "Hello from CraftBot!", - }, + "to": {"type": "array", "description": "List of user IDs.", "example": []}, + "text": {"type": "string", "description": "Message text.", "example": ""}, }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "result": {"type": "object"}, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def multicast_line_message(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("line", "multicast_text", to=input_data["to"], text=input_data["text"]) + + +@action( + name="broadcast_line_message", + description="Broadcast a text to all friends.", + action_sets=["line_messages", "line"], + input_schema={ + "text": {"type": "string", "description": "Message text.", "example": ""}, }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, ) -async def send_line_message(input_data: dict) -> dict: - from app.data.action.integrations._helpers import ( - record_outgoing_message, - run_client, +def broadcast_line_message(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("line", "broadcast_text", text=input_data["text"]) + + +@action( + name="push_line_messages", + description="Push up to 5 LINE message objects to a recipient. messages is a list of LINE-formatted dicts (e.g. {type:'text',text:'...'}, {type:'image',originalContentUrl:'...'}). Use for sending multiple message types in one call or for full control over message shape.", + action_sets=["line_messages", "line"], + input_schema={ + "to": {"type": "string", "description": "Recipient.", "example": ""}, + "messages": {"type": "array", "description": "Array of LINE message objects.", "example": []}, + "notification_disabled": {"type": "boolean", "description": "Silent delivery (optional).", "example": False}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def push_line_messages(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + nd = input_data.get("notification_disabled") + return run_client_sync( + "line", "push_messages", + to=input_data["to"], messages=input_data["messages"], + notification_disabled=nd if nd is not None else None, ) - record_outgoing_message("LINE", input_data["to"], input_data["text"]) - return await run_client( - "line", - "push_text", + +@action( + name="reply_line_messages", + description="Reply with up to 5 LINE message objects (rich reply).", + action_sets=["line_messages", "line"], + input_schema={ + "reply_token": {"type": "string", "description": "Reply token.", "example": ""}, + "messages": {"type": "array", "description": "Array of LINE message objects.", "example": []}, + "notification_disabled": {"type": "boolean", "description": "Silent delivery (optional).", "example": False}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def reply_line_messages(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + nd = input_data.get("notification_disabled") + return run_client_sync( + "line", "reply_messages", + reply_token=input_data["reply_token"], messages=input_data["messages"], + notification_disabled=nd if nd is not None else None, + ) + + +@action( + name="multicast_line_messages", + description="Multicast up to 5 LINE message objects to many users.", + action_sets=["line_messages"], + input_schema={ + "to": {"type": "array", "description": "User IDs (max 500).", "example": []}, + "messages": {"type": "array", "description": "Message objects.", "example": []}, + "notification_disabled": {"type": "boolean", "description": "Silent.", "example": False}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def multicast_line_messages(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + nd = input_data.get("notification_disabled") + return run_client_sync( + "line", "multicast_messages", + to=input_data["to"], messages=input_data["messages"], + notification_disabled=nd if nd is not None else None, + ) + + +@action( + name="broadcast_line_messages", + description="Broadcast up to 5 LINE message objects to all friends.", + action_sets=["line_messages"], + input_schema={ + "messages": {"type": "array", "description": "Message objects.", "example": []}, + "notification_disabled": {"type": "boolean", "description": "Silent.", "example": False}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def broadcast_line_messages(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + nd = input_data.get("notification_disabled") + return run_client_sync( + "line", "broadcast_messages", + messages=input_data["messages"], + notification_disabled=nd if nd is not None else None, + ) + + +# ----- Convenience builders for common message types ----- + +@action( + name="send_line_image", + description="Push an image. Image must be publicly accessible HTTPS URL.", + action_sets=["line_messages", "line"], + input_schema={ + "to": {"type": "string", "description": "Recipient.", "example": ""}, + "original_content_url": {"type": "string", "description": "HTTPS URL to full image.", "example": ""}, + "preview_image_url": {"type": "string", "description": "Preview URL (optional, defaults to original).", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def send_line_image(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "line", "push_image", to=input_data["to"], - text=input_data["text"], + original_content_url=input_data["original_content_url"], + preview_image_url=input_data.get("preview_image_url") or None, ) @action( - name="reply_line_message", - description="Reply to a LINE webhook event using its reply token (valid for ~1 minute after the event arrives). Free of quota; prefer over push when a reply token is available.", - action_sets=["line"], + name="send_line_video", + description="Push a video (HTTPS URL + preview image).", + action_sets=["line_messages", "line"], input_schema={ - "reply_token": { - "type": "string", - "description": "Reply token from the inbound LINE webhook event.", - "example": "nHuyWi...", - }, - "text": {"type": "string", "description": "Reply text.", "example": "Got it!"}, + "to": {"type": "string", "description": "Recipient.", "example": ""}, + "original_content_url": {"type": "string", "description": "HTTPS URL to MP4.", "example": ""}, + "preview_image_url": {"type": "string", "description": "Preview HTTPS URL.", "example": ""}, }, output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, ) -async def reply_line_message(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client +def send_line_video(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "line", "push_video", + to=input_data["to"], + original_content_url=input_data["original_content_url"], + preview_image_url=input_data["preview_image_url"], + ) + - return await run_client( - "line", - "reply_text", - reply_token=input_data["reply_token"], - text=input_data["text"], +@action( + name="send_line_audio", + description="Push an audio file. duration_ms is required.", + action_sets=["line_messages", "line"], + input_schema={ + "to": {"type": "string", "description": "Recipient.", "example": ""}, + "original_content_url": {"type": "string", "description": "HTTPS URL.", "example": ""}, + "duration_ms": {"type": "integer", "description": "Duration in milliseconds.", "example": 0}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def send_line_audio(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "line", "push_audio", + to=input_data["to"], + original_content_url=input_data["original_content_url"], + duration_ms=input_data["duration_ms"], ) @action( - name="multicast_line_message", - description="Send the same LINE text message to up to 500 user IDs in a single call. Counts against the monthly push quota for each recipient.", - action_sets=["line"], + name="send_line_location", + description="Push a location pin.", + action_sets=["line_messages", "line"], input_schema={ - "to": { - "type": "array", - "description": "List of LINE user IDs (max 500).", - "example": ["U4af4980629...", "Ub1234..."], - }, - "text": { - "type": "string", - "description": "Message text.", - "example": "Heads up team", - }, + "to": {"type": "string", "description": "Recipient.", "example": ""}, + "title": {"type": "string", "description": "Title.", "example": ""}, + "address": {"type": "string", "description": "Address.", "example": ""}, + "latitude": {"type": "number", "description": "Latitude.", "example": 35.6762}, + "longitude": {"type": "number", "description": "Longitude.", "example": 139.6503}, }, output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, ) -async def multicast_line_message(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client +def send_line_location(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "line", "push_location", + to=input_data["to"], title=input_data["title"], address=input_data["address"], + latitude=input_data["latitude"], longitude=input_data["longitude"], + ) - return await run_client( - "line", - "multicast_text", + +@action( + name="send_line_sticker", + description="Push a LINE sticker. See https://developers.line.biz/en/docs/messaging-api/sticker-list/ for IDs.", + action_sets=["line_messages", "line"], + input_schema={ + "to": {"type": "string", "description": "Recipient.", "example": ""}, + "package_id": {"type": "string", "description": "Sticker package ID.", "example": "446"}, + "sticker_id": {"type": "string", "description": "Sticker ID.", "example": "1988"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def send_line_sticker(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "line", "push_sticker", to=input_data["to"], - text=input_data["text"], + package_id=input_data["package_id"], sticker_id=input_data["sticker_id"], ) @action( - name="broadcast_line_message", - description="Broadcast a LINE text message to every user that has the bot as a friend. Counts heavily against the monthly push quota — use sparingly.", - action_sets=["line"], + name="send_line_flex", + description="Push a Flex Message — LINE's rich, interactive card format. contents is the Flex container JSON (bubble or carousel).", + action_sets=["line_messages", "line"], input_schema={ - "text": { - "type": "string", - "description": "Message text.", - "example": "Service announcement", - }, + "to": {"type": "string", "description": "Recipient.", "example": ""}, + "alt_text": {"type": "string", "description": "Fallback text shown on devices without Flex support.", "example": "New notification"}, + "contents": {"type": "object", "description": "Flex container JSON.", "example": {}}, }, output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, ) -async def broadcast_line_message(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client +def send_line_flex(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "line", "push_flex", + to=input_data["to"], + alt_text=input_data["alt_text"], + contents=input_data["contents"], + ) - return await run_client("line", "broadcast_text", text=input_data["text"]) + +@action( + name="send_line_template", + description="Push a template message: buttons / confirm / carousel / image_carousel. template is the Template object.", + action_sets=["line_messages", "line"], + input_schema={ + "to": {"type": "string", "description": "Recipient.", "example": ""}, + "alt_text": {"type": "string", "description": "Fallback text.", "example": ""}, + "template": {"type": "object", "description": "Template object.", "example": {}}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def send_line_template(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "line", "push_template", + to=input_data["to"], + alt_text=input_data["alt_text"], + template=input_data["template"], + ) +@action( + name="send_line_imagemap", + description="Push an imagemap: a clickable image overlaid with tappable regions. actions is a list of imagemap-action objects.", + action_sets=["line_messages"], + input_schema={ + "to": {"type": "string", "description": "Recipient.", "example": ""}, + "base_url": {"type": "string", "description": "Base HTTPS URL of the image set.", "example": ""}, + "alt_text": {"type": "string", "description": "Alt text.", "example": ""}, + "base_width": {"type": "integer", "description": "Base width (px).", "example": 1040}, + "base_height": {"type": "integer", "description": "Base height (px).", "example": 1040}, + "actions": {"type": "array", "description": "Imagemap actions.", "example": []}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def send_line_imagemap(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "line", "push_imagemap", + to=input_data["to"], base_url=input_data["base_url"], + alt_text=input_data["alt_text"], + base_width=input_data["base_width"], base_height=input_data["base_height"], + actions=input_data["actions"], + ) + + +@action( + name="download_line_message_content", + description="Download the binary content of a user-sent image/video/audio/file message to a local path.", + action_sets=["line_messages", "line"], + input_schema={ + "message_id": {"type": "string", "description": "Message ID.", "example": ""}, + "dest_path": {"type": "string", "description": "Local destination path.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def download_line_message_content(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "line", "get_message_content", + message_id=input_data["message_id"], + dest_path=input_data["dest_path"], + ) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Profile + bot info + quota +# ═══════════════════════════════════════════════════════════════════════════════ + @action( name="get_line_profile", - description="Fetch a LINE user's display name and picture URL by user ID.", + description="Fetch a LINE user's display name + picture URL.", action_sets=["line"], input_schema={ - "user_id": { - "type": "string", - "description": "LINE user ID (starts with U).", - "example": "U4af4980629...", - }, + "user_id": {"type": "string", "description": "User ID.", "example": "U..."}, }, output_schema={"status": {"type": "string", "example": "success"}}, ) -async def get_line_profile(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client - - return await run_client("line", "get_profile", user_id=input_data["user_id"]) +def get_line_profile(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("line", "get_profile", user_id=input_data["user_id"]) @action( name="get_line_bot_info", - description="Get the connected LINE bot's own profile (userId, displayName, picture).", + description="Get the connected LINE bot's own profile.", action_sets=["line"], input_schema={}, output_schema={"status": {"type": "string", "example": "success"}}, ) -async def get_line_bot_info(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client - - return await run_client("line", "get_bot_info") +def get_line_bot_info(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("line", "get_bot_info") @action( name="get_line_quota", - description="Get the LINE bot's remaining monthly push-message quota.", + description="Get the bot's monthly push-message quota.", action_sets=["line"], input_schema={}, output_schema={"status": {"type": "string", "example": "success"}}, ) -async def get_line_quota(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client +def get_line_quota(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("line", "get_quota") + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Groups / rooms — info / members / leave +# ═══════════════════════════════════════════════════════════════════════════════ + +@action( + name="get_line_group_summary", + description="Get a LINE group's name + picture URL.", + action_sets=["line_groups", "line"], + input_schema={ + "group_id": {"type": "string", "description": "Group ID (starts with 'C').", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_line_group_summary(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("line", "get_group_summary", group_id=input_data["group_id"]) + + +@action( + name="get_line_group_member_count", + description="Get the member count of a group.", + action_sets=["line_groups", "line"], + input_schema={ + "group_id": {"type": "string", "description": "Group ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_line_group_member_count(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("line", "get_group_member_count", group_id=input_data["group_id"]) + + +@action( + name="list_line_group_members", + description="List user IDs of group members (paginated via start cursor).", + action_sets=["line_groups", "line"], + input_schema={ + "group_id": {"type": "string", "description": "Group ID.", "example": ""}, + "start": {"type": "string", "description": "Pagination cursor (optional).", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def list_line_group_members(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "line", "list_group_member_user_ids", + group_id=input_data["group_id"], + start=input_data.get("start") or None, + ) + + +@action( + name="get_line_group_member_profile", + description="Get a group member's display name + picture URL.", + action_sets=["line_groups", "line"], + input_schema={ + "group_id": {"type": "string", "description": "Group ID.", "example": ""}, + "user_id": {"type": "string", "description": "User ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_line_group_member_profile(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "line", "get_group_member_profile", + group_id=input_data["group_id"], user_id=input_data["user_id"], + ) + + +@action( + name="leave_line_group", + description="Leave a LINE group.", + action_sets=["line_groups", "line"], + input_schema={ + "group_id": {"type": "string", "description": "Group ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def leave_line_group(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("line", "leave_group", group_id=input_data["group_id"]) + + +@action( + name="get_line_room_member_count", + description="Get a multi-person chat (room)'s member count.", + action_sets=["line_groups"], + input_schema={ + "room_id": {"type": "string", "description": "Room ID (starts with 'R').", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_line_room_member_count(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("line", "get_room_member_count", room_id=input_data["room_id"]) + + +@action( + name="list_line_room_members", + description="List user IDs in a room.", + action_sets=["line_groups"], + input_schema={ + "room_id": {"type": "string", "description": "Room ID.", "example": ""}, + "start": {"type": "string", "description": "Pagination cursor.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def list_line_room_members(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "line", "list_room_member_user_ids", + room_id=input_data["room_id"], + start=input_data.get("start") or None, + ) + + +@action( + name="get_line_room_member_profile", + description="Get a room member's display name + picture URL.", + action_sets=["line_groups"], + input_schema={ + "room_id": {"type": "string", "description": "Room ID.", "example": ""}, + "user_id": {"type": "string", "description": "User ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_line_room_member_profile(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "line", "get_room_member_profile", + room_id=input_data["room_id"], user_id=input_data["user_id"], + ) + + +@action( + name="leave_line_room", + description="Leave a LINE room (multi-person chat).", + action_sets=["line_groups"], + input_schema={ + "room_id": {"type": "string", "description": "Room ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def leave_line_room(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("line", "leave_room", room_id=input_data["room_id"]) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Rich menus +# ═══════════════════════════════════════════════════════════════════════════════ + +@action( + name="create_line_rich_menu", + description="Create a rich menu definition. rich_menu is a RichMenu object: {size:{width,height}, selected:bool, name, chatBarText, areas:[{bounds,action},...]}. Image must be uploaded separately via upload_line_rich_menu_image.", + action_sets=["line_rich_menus", "line"], + input_schema={ + "rich_menu": {"type": "object", "description": "RichMenu object.", "example": {}}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def create_line_rich_menu(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("line", "create_rich_menu", rich_menu=input_data["rich_menu"]) + + +@action( + name="get_line_rich_menu", + description="Get a rich menu definition by ID.", + action_sets=["line_rich_menus"], + input_schema={ + "rich_menu_id": {"type": "string", "description": "Rich menu ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_line_rich_menu(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("line", "get_rich_menu", rich_menu_id=input_data["rich_menu_id"]) + + +@action( + name="list_line_rich_menus", + description="List all rich menus the bot has created.", + action_sets=["line_rich_menus", "line"], + input_schema={}, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def list_line_rich_menus(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("line", "list_rich_menus") + + +@action( + name="delete_line_rich_menu", + description="Delete a rich menu.", + action_sets=["line_rich_menus"], + input_schema={ + "rich_menu_id": {"type": "string", "description": "Rich menu ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def delete_line_rich_menu(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("line", "delete_rich_menu", rich_menu_id=input_data["rich_menu_id"]) + + +@action( + name="upload_line_rich_menu_image", + description="Upload the PNG/JPEG image for a rich menu (image dimensions must match the menu's size).", + action_sets=["line_rich_menus", "line"], + input_schema={ + "rich_menu_id": {"type": "string", "description": "Rich menu ID.", "example": ""}, + "file_path": {"type": "string", "description": "Local PNG or JPEG path.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def upload_line_rich_menu_image(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "line", "upload_rich_menu_image", + rich_menu_id=input_data["rich_menu_id"], + file_path=input_data["file_path"], + ) + + +@action( + name="set_line_default_rich_menu", + description="Make a rich menu the default for all users.", + action_sets=["line_rich_menus", "line"], + input_schema={ + "rich_menu_id": {"type": "string", "description": "Rich menu ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def set_line_default_rich_menu(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("line", "set_default_rich_menu", rich_menu_id=input_data["rich_menu_id"]) + + +@action( + name="get_line_default_rich_menu", + description="Get the current default rich menu ID.", + action_sets=["line_rich_menus"], + input_schema={}, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_line_default_rich_menu(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("line", "get_default_rich_menu") + + +@action( + name="cancel_line_default_rich_menu", + description="Unset the default rich menu.", + action_sets=["line_rich_menus"], + input_schema={}, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def cancel_line_default_rich_menu(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("line", "cancel_default_rich_menu") + + +@action( + name="link_line_rich_menu_to_user", + description="Show a specific rich menu to a single user.", + action_sets=["line_rich_menus", "line"], + input_schema={ + "user_id": {"type": "string", "description": "User ID.", "example": ""}, + "rich_menu_id": {"type": "string", "description": "Rich menu ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def link_line_rich_menu_to_user(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "line", "link_rich_menu_to_user", + user_id=input_data["user_id"], rich_menu_id=input_data["rich_menu_id"], + ) + + +@action( + name="unlink_line_rich_menu_from_user", + description="Remove the per-user rich menu override (falls back to default).", + action_sets=["line_rich_menus"], + input_schema={ + "user_id": {"type": "string", "description": "User ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def unlink_line_rich_menu_from_user(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("line", "unlink_rich_menu_from_user", user_id=input_data["user_id"]) + + +@action( + name="get_line_user_rich_menu", + description="Get the rich menu ID currently linked to a user.", + action_sets=["line_rich_menus"], + input_schema={ + "user_id": {"type": "string", "description": "User ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_line_user_rich_menu(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("line", "get_user_rich_menu", user_id=input_data["user_id"]) + + +@action( + name="bulk_link_line_rich_menu", + description="Link many users (max 500) to a rich menu in one call. Returns 202; runs async.", + action_sets=["line_rich_menus"], + input_schema={ + "rich_menu_id": {"type": "string", "description": "Rich menu ID.", "example": ""}, + "user_ids": {"type": "array", "description": "User IDs (max 500).", "example": []}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def bulk_link_line_rich_menu(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "line", "bulk_link_rich_menu", + rich_menu_id=input_data["rich_menu_id"], user_ids=input_data["user_ids"], + ) + + +@action( + name="bulk_unlink_line_rich_menu", + description="Unlink rich menus from many users in one call.", + action_sets=["line_rich_menus"], + input_schema={ + "user_ids": {"type": "array", "description": "User IDs.", "example": []}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def bulk_unlink_line_rich_menu(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("line", "bulk_unlink_rich_menu", user_ids=input_data["user_ids"]) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Narrowcast + Audiences +# ═══════════════════════════════════════════════════════════════════════════════ + +@action( + name="send_line_narrowcast", + description="Send messages to a filtered subset of friends (demographics or audience groups). Returns a request_id; poll with get_line_narrowcast_progress.", + action_sets=["line_audiences", "line"], + input_schema={ + "messages": {"type": "array", "description": "LINE message objects.", "example": []}, + "recipient": {"type": "object", "description": "Audience recipient spec (optional).", "example": {}}, + "demographic": {"type": "object", "description": "Demographic filter (optional).", "example": {}}, + "limit": {"type": "object", "description": "Limit spec (optional).", "example": {}}, + "notification_disabled": {"type": "boolean", "description": "Silent.", "example": False}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def send_line_narrowcast(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + nd = input_data.get("notification_disabled") + return run_client_sync( + "line", "send_narrowcast", + messages=input_data["messages"], + recipient=input_data.get("recipient") or None, + demographic=input_data.get("demographic") or None, + limit=input_data.get("limit") or None, + notification_disabled=nd if nd is not None else None, + ) + + +@action( + name="get_line_narrowcast_progress", + description="Poll a narrowcast request's delivery progress.", + action_sets=["line_audiences"], + input_schema={ + "request_id": {"type": "string", "description": "Request ID from send_line_narrowcast.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_line_narrowcast_progress(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("line", "get_narrowcast_progress", request_id=input_data["request_id"]) + + +@action( + name="create_line_user_id_audience", + description="Create an audience group from explicit user IDs. audiences: [{id:''}, ...].", + action_sets=["line_audiences"], + input_schema={ + "description": {"type": "string", "description": "Audience description.", "example": ""}, + "audiences": {"type": "array", "description": "List of {id: user_id} dicts.", "example": []}, + "is_ifa_audience": {"type": "boolean", "description": "True for advertising-ID audience.", "example": False}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def create_line_user_id_audience(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "line", "create_user_id_audience", + description=input_data["description"], + audiences=input_data.get("audiences") or None, + is_ifa_audience=bool(input_data.get("is_ifa_audience", False)), + ) + + +@action( + name="get_line_audience", + description="Get an audience group's metadata + status.", + action_sets=["line_audiences"], + input_schema={ + "audience_group_id": {"type": "integer", "description": "Audience group ID.", "example": 0}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_line_audience(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("line", "get_audience", audience_group_id=input_data["audience_group_id"]) + + +@action( + name="list_line_audiences", + description="List the bot's audience groups (with optional filters).", + action_sets=["line_audiences"], + input_schema={ + "page": {"type": "integer", "description": "Page number.", "example": 1}, + "size": {"type": "integer", "description": "Page size (max 40).", "example": 20}, + "description": {"type": "string", "description": "Filter by description substring.", "example": ""}, + "status": {"type": "string", "description": "Filter by status: IN_PROGRESS | READY | FAILED | EXPIRED.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def list_line_audiences(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "line", "list_audiences", + page=input_data.get("page", 1), + size=input_data.get("size", 20), + description=input_data.get("description") or None, + status=input_data.get("status") or None, + ) + + +@action( + name="update_line_audience_description", + description="Change an audience group's description.", + action_sets=["line_audiences"], + input_schema={ + "audience_group_id": {"type": "integer", "description": "Audience group ID.", "example": 0}, + "description": {"type": "string", "description": "New description.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def update_line_audience_description(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "line", "update_audience_description", + audience_group_id=input_data["audience_group_id"], + description=input_data["description"], + ) + + +@action( + name="delete_line_audience", + description="Delete an audience group.", + action_sets=["line_audiences"], + input_schema={ + "audience_group_id": {"type": "integer", "description": "Audience group ID.", "example": 0}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def delete_line_audience(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("line", "delete_audience", audience_group_id=input_data["audience_group_id"]) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Insights +# ═══════════════════════════════════════════════════════════════════════════════ + +@action( + name="get_line_followers_count", + description="Number of followers on a given date (YYYYMMDD).", + action_sets=["line_insights", "line"], + input_schema={ + "date": {"type": "string", "description": "YYYYMMDD.", "example": "20260520"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_line_followers_count(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("line", "get_number_of_followers", date=input_data["date"]) + + +@action( + name="get_line_friend_demographics", + description="Demographic breakdown of friends (gender, age, area).", + action_sets=["line_insights"], + input_schema={}, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_line_friend_demographics(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("line", "get_friend_demographics") + + +@action( + name="get_line_message_delivery_stats", + description="Number of pushes/multicasts/broadcasts sent on a date.", + action_sets=["line_insights"], + input_schema={ + "date": {"type": "string", "description": "YYYYMMDD.", "example": "20260520"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_line_message_delivery_stats(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("line", "get_message_delivery_stats", date=input_data["date"]) + + +@action( + name="get_line_message_event_stats", + description="Per-narrowcast/broadcast click/impression/open stats.", + action_sets=["line_insights"], + input_schema={ + "request_id": {"type": "string", "description": "Request ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_line_message_event_stats(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("line", "get_message_event_stats", request_id=input_data["request_id"]) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Webhook + channel token admin +# ═══════════════════════════════════════════════════════════════════════════════ + +@action( + name="set_line_webhook_endpoint", + description="Set the HTTPS endpoint where LINE will POST incoming events.", + action_sets=["line_channel"], + input_schema={ + "endpoint": {"type": "string", "description": "HTTPS URL.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def set_line_webhook_endpoint(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("line", "set_webhook_endpoint", endpoint=input_data["endpoint"]) + + +@action( + name="get_line_webhook_endpoint", + description="Get the current webhook endpoint URL.", + action_sets=["line_channel"], + input_schema={}, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_line_webhook_endpoint(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("line", "get_webhook_endpoint") + + +@action( + name="test_line_webhook_endpoint", + description="Test the webhook (LINE sends a synthetic event). Returns status code + latency.", + action_sets=["line_channel"], + input_schema={ + "endpoint": {"type": "string", "description": "Override URL (optional, defaults to configured one).", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def test_line_webhook_endpoint(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("line", "test_webhook_endpoint", endpoint=input_data.get("endpoint") or None) + + +@action( + name="issue_line_channel_access_token", + description="Issue a short-lived channel access token (v2.1). Useful for credential rotation.", + action_sets=["line_channel"], + input_schema={ + "channel_id": {"type": "string", "description": "Channel ID.", "example": ""}, + "channel_secret": {"type": "string", "description": "Channel secret.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def issue_line_channel_access_token(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "line", "issue_channel_access_token", + channel_id=input_data["channel_id"], channel_secret=input_data["channel_secret"], + ) + + +@action( + name="revoke_line_channel_access_token", + description="Revoke a channel access token.", + action_sets=["line_channel"], + input_schema={ + "access_token": {"type": "string", "description": "Token to revoke.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def revoke_line_channel_access_token(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("line", "revoke_channel_access_token", access_token=input_data["access_token"]) + + +@action( + name="verify_line_access_token", + description="Verify an access token is valid and show its scope/expiry.", + action_sets=["line_channel"], + input_schema={ + "access_token": {"type": "string", "description": "Token to verify.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def verify_line_access_token(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("line", "verify_access_token", access_token=input_data["access_token"]) + - return await run_client("line", "get_quota") +# ================================================================== +# Intentionally NOT exposed as actions (and why) +# ================================================================== +# - Webhook signature verification helper +# Library-side concern; would only be useful if CraftBot ran the +# webhook server itself, which it doesn't (send-only). +# - LIFF endpoints (LINE Front-end Framework) +# Frontend mini-app surface, not interactive bot work. +# - LINE Login / LINE Profile+ +# Separate API; distinct integration. +# - LINE Pay +# Separate billing API, out of scope. +# - statisticsPerUnit aggregated insights +# Niche; standard insights cover the common reporting case. diff --git a/app/data/action/integrations/notion/notion_actions.py b/app/data/action/integrations/notion/notion_actions.py index 1f9252bc..4b9f5eb7 100644 --- a/app/data/action/integrations/notion/notion_actions.py +++ b/app/data/action/integrations/notion/notion_actions.py @@ -1,88 +1,63 @@ from agent_core import action +# ------------------------------------------------------------------ +# Search (workspace-wide) +# ------------------------------------------------------------------ + @action( name="search_notion", description="Search Notion workspace for pages and databases.", action_sets=["notion"], input_schema={ - "query": { - "type": "string", - "description": "Search query.", - "example": "meeting notes", - }, - "filter_type": { - "type": "string", - "description": "Optional: 'page' or 'database'.", - "example": "page", - }, + "query": {"type": "string", "description": "Search query.", "example": "meeting notes"}, + "filter_type": {"type": "string", "description": "Optional: 'page' or 'database'.", "example": "page"}, }, output_schema={"status": {"type": "string", "example": "success"}}, ) def search_notion(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync( - "notion", - "search", - query=input_data["query"], - filter_type=input_data.get("filter_type"), + "notion", "search", + query=input_data["query"], filter_type=input_data.get("filter_type"), ) +# ------------------------------------------------------------------ +# Pages +# ------------------------------------------------------------------ + @action( name="get_notion_page", - description="Get a Notion page by ID.", - action_sets=["notion"], + description="Get a Notion page by ID (returns metadata + properties, not block content).", + action_sets=["notion_pages", "notion"], input_schema={ - "page_id": { - "type": "string", - "description": "Notion page ID.", - "example": "abc123", - }, + "page_id": {"type": "string", "description": "Notion page ID.", "example": "abc123"}, }, output_schema={"status": {"type": "string", "example": "success"}}, ) def get_notion_page(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync("notion", "get_page", page_id=input_data["page_id"]) @action( name="create_notion_page", description="Create a new page in Notion.", - action_sets=["notion"], + action_sets=["notion_pages", "notion"], input_schema={ - "parent_id": { - "type": "string", - "description": "Parent page or database ID.", - "example": "abc123", - }, - "parent_type": { - "type": "string", - "description": "'page_id' or 'database_id'.", - "example": "page_id", - }, - "properties": { - "type": "object", - "description": "Page properties.", - "example": {"title": [{"text": {"content": "New Page"}}]}, - }, - "children": { - "type": "array", - "description": "Optional content blocks.", - "example": [], - }, + "parent_id": {"type": "string", "description": "Parent page or database ID.", "example": "abc123"}, + "parent_type": {"type": "string", "description": "'page_id' or 'database_id'.", "example": "page_id"}, + "properties": {"type": "object", "description": "Page properties.", "example": {"title": [{"text": {"content": "New Page"}}]}}, + "children": {"type": "array", "description": "Optional content blocks.", "example": []}, }, output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, ) def create_notion_page(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync( - "notion", - "create_page", + "notion", "create_page", parent_id=input_data["parent_id"], parent_type=input_data["parent_type"], properties=input_data["properties"], @@ -90,35 +65,109 @@ def create_notion_page(input_data: dict) -> dict: ) +@action( + name="update_notion_page", + description="Update a Notion page's properties (and/or archive state).", + action_sets=["notion_pages", "notion"], + input_schema={ + "page_id": {"type": "string", "description": "Page ID to update.", "example": "abc123"}, + "properties": {"type": "object", "description": "Properties to update.", "example": {}}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def update_notion_page(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "notion", "update_page", + page_id=input_data["page_id"], properties=input_data["properties"], + ) + + +@action( + name="archive_notion_page", + description="Archive a Notion page (send to trash). Reversible via restore_notion_page.", + action_sets=["notion_pages", "notion"], + input_schema={ + "page_id": {"type": "string", "description": "Page ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def archive_notion_page(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("notion", "archive_page", page_id=input_data["page_id"]) + + +@action( + name="restore_notion_page", + description="Restore a previously-archived Notion page.", + action_sets=["notion_pages"], + input_schema={ + "page_id": {"type": "string", "description": "Page ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def restore_notion_page(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("notion", "restore_page", page_id=input_data["page_id"]) + + +@action( + name="get_notion_page_property", + description="Get a single page property's value. For rollup/relation/people properties that paginate, this returns the full list.", + action_sets=["notion_pages"], + input_schema={ + "page_id": {"type": "string", "description": "Page ID.", "example": ""}, + "property_id": {"type": "string", "description": "Property ID (from page schema).", "example": ""}, + "page_size": {"type": "integer", "description": "Pagination size.", "example": 100}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_notion_page_property(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "notion", "get_page_property", + page_id=input_data["page_id"], + property_id=input_data["property_id"], + page_size=input_data.get("page_size", 100), + ) + + +# ------------------------------------------------------------------ +# Databases +# ------------------------------------------------------------------ + +@action( + name="get_notion_database_schema", + description="Get a Notion database schema by ID.", + action_sets=["notion_databases", "notion"], + input_schema={ + "database_id": {"type": "string", "description": "Database ID.", "example": "abc123"}, + }, + output_schema={"status": {"type": "string", "example": "success"}, "database": {"type": "object"}}, +) +def get_notion_database_schema(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("notion", "get_database", database_id=input_data["database_id"]) + + @action( name="query_notion_database", description="Query a Notion database with optional filters and sorts.", - action_sets=["notion"], + action_sets=["notion_databases", "notion"], input_schema={ - "database_id": { - "type": "string", - "description": "Database ID.", - "example": "abc123", - }, - "filter": { - "type": "object", - "description": "Optional Notion filter object.", - "example": {}, - }, - "sorts": { - "type": "array", - "description": "Optional sort array.", - "example": [], - }, + "database_id": {"type": "string", "description": "Database ID.", "example": "abc123"}, + "filter": {"type": "object", "description": "Optional Notion filter object.", "example": {}}, + "sorts": {"type": "array", "description": "Optional sort array.", "example": []}, }, output_schema={"status": {"type": "string", "example": "success"}}, ) def query_notion_database(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync( - "notion", - "query_database", + "notion", "query_database", database_id=input_data["database_id"], filter_obj=input_data.get("filter"), sorts=input_data.get("sorts"), @@ -126,98 +175,416 @@ def query_notion_database(input_data: dict) -> dict: @action( - name="update_notion_page", - description="Update a Notion page's properties.", - action_sets=["notion"], + name="create_notion_database", + description="Create a new database under a parent page. Schema goes in 'properties' (each value is a property type config like {'title': {}} / {'rich_text': {}} / {'select': {'options': [...]}}).", + action_sets=["notion_databases", "notion"], input_schema={ - "page_id": { - "type": "string", - "description": "Page ID to update.", - "example": "abc123", - }, - "properties": { - "type": "object", - "description": "Properties to update.", - "example": {}, - }, + "parent_page_id": {"type": "string", "description": "Parent page ID.", "example": ""}, + "title": {"type": "array", "description": "Title rich_text array.", "example": [{"text": {"content": "Tasks"}}]}, + "description": {"type": "array", "description": "Description rich_text array (optional).", "example": []}, + "properties": {"type": "object", "description": "Property schema (column definitions). Required.", "example": {"Name": {"title": {}}}}, + "is_inline": {"type": "boolean", "description": "Render inline.", "example": False}, + "icon": {"type": "object", "description": "Icon (optional). e.g. {'type':'emoji','emoji':'📋'}.", "example": {}}, + "cover": {"type": "object", "description": "Cover (optional).", "example": {}}, }, output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, ) -def update_notion_page(input_data: dict) -> dict: +def create_notion_database(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "notion", "create_database", + parent_page_id=input_data["parent_page_id"], + title=input_data.get("title"), + description=input_data.get("description"), + properties=input_data.get("properties"), + is_inline=bool(input_data.get("is_inline", False)), + icon=input_data.get("icon") or None, + cover=input_data.get("cover") or None, + ) + +@action( + name="update_notion_database", + description="Update a Notion database (title, description, schema, inline state).", + action_sets=["notion_databases", "notion"], + input_schema={ + "database_id": {"type": "string", "description": "Database ID.", "example": ""}, + "title": {"type": "array", "description": "New title rich_text (optional).", "example": []}, + "description": {"type": "array", "description": "New description rich_text (optional).", "example": []}, + "properties": {"type": "object", "description": "Property updates (rename / change type / remove with null) (optional).", "example": {}}, + "is_inline": {"type": "boolean", "description": "Set inline (optional).", "example": False}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def update_notion_database(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync return run_client_sync( - "notion", - "update_page", - page_id=input_data["page_id"], - properties=input_data["properties"], + "notion", "update_database", + database_id=input_data["database_id"], + title=input_data.get("title"), + description=input_data.get("description"), + properties=input_data.get("properties"), + is_inline=input_data["is_inline"] if "is_inline" in input_data else None, ) @action( - name="get_notion_database_schema", - description="Get a Notion database schema by ID.", - action_sets=["notion"], + name="archive_notion_database", + description="Archive a Notion database.", + action_sets=["notion_databases"], + input_schema={ + "database_id": {"type": "string", "description": "Database ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def archive_notion_database(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("notion", "archive_database", database_id=input_data["database_id"]) + + +@action( + name="restore_notion_database", + description="Restore an archived Notion database.", + action_sets=["notion_databases"], input_schema={ - "database_id": { - "type": "string", - "description": "Database ID.", - "example": "abc123", - }, + "database_id": {"type": "string", "description": "Database ID.", "example": ""}, }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "database": {"type": "object"}, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def restore_notion_database(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("notion", "restore_database", database_id=input_data["database_id"]) + + +# ------------------------------------------------------------------ +# Blocks +# ------------------------------------------------------------------ + +@action( + name="get_notion_page_content", + description="Get the content blocks of a Notion page (or any block that has children).", + action_sets=["notion_blocks", "notion"], + input_schema={ + "page_id": {"type": "string", "description": "Page ID (or block ID for nested children).", "example": "abc123"}, }, + output_schema={"status": {"type": "string", "example": "success"}, "content": {"type": "array"}}, ) -def get_notion_database_schema(input_data: dict) -> dict: +def get_notion_page_content(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("notion", "get_block_children", block_id=input_data["page_id"]) + +@action( + name="append_notion_page_content", + description="Append content blocks to a Notion page (or any block).", + action_sets=["notion_blocks", "notion"], + input_schema={ + "page_id": {"type": "string", "description": "Page ID (or block ID).", "example": "abc123"}, + "children": {"type": "array", "description": "List of block objects.", "example": []}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def append_notion_page_content(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync return run_client_sync( - "notion", "get_database", database_id=input_data["database_id"] + "notion", "append_block_children", + block_id=input_data["page_id"], children=input_data["children"], ) @action( - name="get_notion_page_content", - description="Get the content blocks of a Notion page.", - action_sets=["notion"], + name="get_notion_block", + description="Get a single block (not its children) by block ID.", + action_sets=["notion_blocks", "notion"], + input_schema={ + "block_id": {"type": "string", "description": "Block ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_notion_block(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("notion", "get_block", block_id=input_data["block_id"]) + + +@action( + name="update_notion_block", + description="Update a block's content. block_update has the per-block-type key as the top-level field, e.g. {'to_do': {'rich_text': [...], 'checked': true}} for a to-do, {'paragraph': {'rich_text': [...]}} for a paragraph. Pass {'in_trash': true} to soft-delete.", + action_sets=["notion_blocks", "notion"], input_schema={ - "page_id": {"type": "string", "description": "Page ID.", "example": "abc123"}, + "block_id": {"type": "string", "description": "Block ID.", "example": ""}, + "block_update": {"type": "object", "description": "Per-block-type update object.", "example": {"paragraph": {"rich_text": [{"text": {"content": "Updated"}}]}}}, }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "content": {"type": "array"}, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def update_notion_block(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "notion", "update_block", + block_id=input_data["block_id"], + block_update=input_data["block_update"], + ) + + +@action( + name="delete_notion_block", + description="Delete (soft delete, send to trash) a Notion block.", + action_sets=["notion_blocks", "notion"], + input_schema={ + "block_id": {"type": "string", "description": "Block ID.", "example": ""}, }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, ) -def get_notion_page_content(input_data: dict) -> dict: +def delete_notion_block(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("notion", "delete_block", block_id=input_data["block_id"]) + +# ------------------------------------------------------------------ +# Comments +# ------------------------------------------------------------------ + +@action( + name="list_notion_comments", + description="List comments on a page or block.", + action_sets=["notion_comments", "notion"], + input_schema={ + "block_id": {"type": "string", "description": "Block or page ID.", "example": ""}, + "page_size": {"type": "integer", "description": "Max results.", "example": 100}, + "start_cursor": {"type": "string", "description": "Pagination cursor (optional).", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def list_notion_comments(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync return run_client_sync( - "notion", "get_block_children", block_id=input_data["page_id"] + "notion", "list_comments", + block_id=input_data["block_id"], + page_size=input_data.get("page_size", 100), + start_cursor=input_data.get("start_cursor") or None, ) @action( - name="append_notion_page_content", - description="Append content blocks to a Notion page.", - action_sets=["notion"], + name="create_notion_comment", + description="Post a comment on a page/block, or reply in a discussion. Provide exactly one of parent_page_id, parent_block_id, or discussion_id.", + action_sets=["notion_comments", "notion"], input_schema={ - "page_id": {"type": "string", "description": "Page ID.", "example": "abc123"}, - "children": { - "type": "array", - "description": "List of block objects.", - "example": [], - }, + "rich_text": {"type": "array", "description": "Comment content as rich_text array.", "example": [{"text": {"content": "Looks good!"}}]}, + "parent_page_id": {"type": "string", "description": "Page ID for a new top-level discussion (optional).", "example": ""}, + "parent_block_id": {"type": "string", "description": "Block ID for a new top-level discussion (optional).", "example": ""}, + "discussion_id": {"type": "string", "description": "Discussion ID to reply to (optional).", "example": ""}, }, output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, ) -def append_notion_page_content(input_data: dict) -> dict: +def create_notion_comment(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "notion", "create_comment", + rich_text=input_data["rich_text"], + parent_page_id=input_data.get("parent_page_id") or None, + parent_block_id=input_data.get("parent_block_id") or None, + discussion_id=input_data.get("discussion_id") or None, + ) + + +# ------------------------------------------------------------------ +# Users +# ------------------------------------------------------------------ + +@action( + name="list_notion_users", + description="List workspace members visible to the integration.", + action_sets=["notion_users", "notion"], + input_schema={ + "page_size": {"type": "integer", "description": "Max results.", "example": 100}, + "start_cursor": {"type": "string", "description": "Pagination cursor (optional).", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def list_notion_users(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "notion", "list_users", + page_size=input_data.get("page_size", 100), + start_cursor=input_data.get("start_cursor") or None, + ) + + +@action( + name="get_notion_user", + description="Get a single Notion user by ID.", + action_sets=["notion_users", "notion"], + input_schema={ + "user_id": {"type": "string", "description": "User ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_notion_user(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("notion", "get_user", user_id=input_data["user_id"]) + + +@action( + name="get_notion_bot_info", + description="Get info about the authenticated Notion bot (workspace_name, owner, capabilities).", + action_sets=["notion_users", "notion"], + input_schema={}, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_notion_bot_info(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("notion", "get_bot_info") + + +# ------------------------------------------------------------------ +# File uploads +# ------------------------------------------------------------------ + +@action( + name="upload_notion_file", + description="High-level: upload a local file in one call (single-part). Returns the file_upload object with id+status='uploaded'. Attach to a block via {'type':'file_upload','file_upload':{'id': }}. Use multi-part flow for files >20 MB.", + action_sets=["notion_files", "notion"], + input_schema={ + "file_path": {"type": "string", "description": "Absolute path to local file.", "example": "C:/Users/me/report.pdf"}, + "content_type": {"type": "string", "description": "MIME type (autodetect if omitted).", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def upload_notion_file(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "notion", "upload_local_file", + file_path=input_data["file_path"], + content_type=input_data.get("content_type") or None, + ) + + +@action( + name="create_notion_file_upload", + description="Step 1 of file upload: initialise a file_upload resource. Returns id + upload_url. Use mode=single_part for <20 MB, multi_part for larger, or external_url to import from a URL.", + action_sets=["notion_files"], + input_schema={ + "mode": {"type": "string", "description": "single_part | multi_part | external_url.", "example": "single_part"}, + "filename": {"type": "string", "description": "Required for multi_part.", "example": ""}, + "content_type": {"type": "string", "description": "MIME type (recommended).", "example": ""}, + "number_of_parts": {"type": "integer", "description": "Required for multi_part.", "example": 0}, + "external_url": {"type": "string", "description": "Required for external_url mode.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def create_notion_file_upload(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + parts = input_data.get("number_of_parts") + return run_client_sync( + "notion", "create_file_upload", + mode=input_data.get("mode", "single_part"), + filename=input_data.get("filename") or None, + content_type=input_data.get("content_type") or None, + number_of_parts=parts if parts else None, + external_url=input_data.get("external_url") or None, + ) + + +@action( + name="send_notion_file_upload", + description="Step 2: send file bytes to a pending file_upload. For multi_part uploads, repeat with each part_number.", + action_sets=["notion_files"], + input_schema={ + "file_upload_id": {"type": "string", "description": "ID from create_notion_file_upload.", "example": ""}, + "file_path": {"type": "string", "description": "Absolute path to local file (or one part for multi_part).", "example": ""}, + "part_number": {"type": "integer", "description": "1..1000, only for multi_part.", "example": 0}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def send_notion_file_upload(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + pn = input_data.get("part_number") + return run_client_sync( + "notion", "send_file_upload", + file_upload_id=input_data["file_upload_id"], + file_path=input_data["file_path"], + part_number=pn if pn else None, + ) + + +@action( + name="complete_notion_file_upload", + description="Step 3 (multi_part only): finalize a multi-part upload after all parts sent.", + action_sets=["notion_files"], + input_schema={ + "file_upload_id": {"type": "string", "description": "File upload ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def complete_notion_file_upload(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "notion", "complete_file_upload", + file_upload_id=input_data["file_upload_id"], + ) + + +@action( + name="get_notion_file_upload", + description="Get the current status of a file upload.", + action_sets=["notion_files"], + input_schema={ + "file_upload_id": {"type": "string", "description": "File upload ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_notion_file_upload(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "notion", "get_file_upload", + file_upload_id=input_data["file_upload_id"], + ) + +@action( + name="list_notion_file_uploads", + description="List file uploads created by this integration. Filter by status (pending|uploaded|expired|failed).", + action_sets=["notion_files"], + input_schema={ + "status": {"type": "string", "description": "Filter (optional).", "example": ""}, + "page_size": {"type": "integer", "description": "Max results.", "example": 100}, + "start_cursor": {"type": "string", "description": "Pagination cursor (optional).", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def list_notion_file_uploads(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync return run_client_sync( - "notion", - "append_block_children", - block_id=input_data["page_id"], - children=input_data["children"], + "notion", "list_file_uploads", + status=input_data.get("status") or None, + page_size=input_data.get("page_size", 100), + start_cursor=input_data.get("start_cursor") or None, ) + + +# ================================================================== +# Intentionally NOT exposed as actions (and why) +# ================================================================== +# - Data sources (multi-source databases) sub-resource +# Newer feature; the standard property-on-database surface covers the +# common single-source case. Add when an agent task actually needs it. +# - OAuth invite / token refresh endpoints +# Handled by the integration handler (/notion invite/login), not as +# per-task actions. +# - Direct upload_url PUT (signed S3 URL approach) +# The send_file_upload helper covers the realistic case; signed-URL +# PUT is reserved for very large multi-part flows. +# - Workspace settings / sharing / page permissions +# Notion does not expose these via REST; they're UI-only. diff --git a/app/data/action/integrations/outlook/outlook_actions.py b/app/data/action/integrations/outlook/outlook_actions.py index 40ff4147..f2761a05 100644 --- a/app/data/action/integrations/outlook/outlook_actions.py +++ b/app/data/action/integrations/outlook/outlook_actions.py @@ -1,10 +1,14 @@ from agent_core import action +# ------------------------------------------------------------------ +# Mail — read / send / reply / forward / draft / lifecycle +# ------------------------------------------------------------------ + @action( name="send_outlook_email", description="Send an email via Outlook (Microsoft 365).", - action_sets=["outlook"], + action_sets=["outlook_mail", "outlook"], input_schema={ "to": { "type": "string", @@ -28,6 +32,7 @@ }, }, output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, ) def send_outlook_email(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync @@ -48,7 +53,7 @@ def send_outlook_email(input_data: dict) -> dict: @action( name="list_outlook_emails", description="List recent emails from Outlook inbox.", - action_sets=["outlook"], + action_sets=["outlook_mail", "outlook"], input_schema={ "count": { "type": "integer", @@ -79,7 +84,7 @@ def list_outlook_emails(input_data: dict) -> dict: @action( name="get_outlook_email", description="Get full details of a specific Outlook email by message ID.", - action_sets=["outlook"], + action_sets=["outlook_mail", "outlook"], input_schema={ "message_id": { "type": "string", @@ -104,7 +109,7 @@ def get_outlook_email(input_data: dict) -> dict: @action( name="read_top_outlook_emails", description="Read the top N recent Outlook emails with details.", - action_sets=["outlook"], + action_sets=["outlook_mail", "outlook"], input_schema={ "count": { "type": "integer", @@ -132,10 +137,293 @@ def read_top_outlook_emails(input_data: dict) -> dict: ) +@action( + name="search_outlook_emails", + description="Search Outlook messages by free-text query (matches subject, body, attachments). Sorted by relevance.", + action_sets=["outlook_mail", "outlook"], + input_schema={ + "query": {"type": "string", "description": "Search text.", "example": "invoice contoso"}, + "top": {"type": "integer", "description": "Max results.", "example": 25}, + "folder": {"type": "string", "description": "Optional folder name (inbox/sentitems/etc.) or ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def search_outlook_emails(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "outlook", "search_messages", + unwrap_envelope=True, fail_message="Failed to search.", + query=input_data["query"], + top=input_data.get("top", 25), + folder=input_data.get("folder") or None, + ) + + +@action( + name="reply_outlook_email", + description="Reply to the sender of an email. Sent immediately.", + action_sets=["outlook_mail", "outlook"], + input_schema={ + "message_id": {"type": "string", "description": "Original message ID.", "example": "AAMk..."}, + "comment": {"type": "string", "description": "Reply body (plain text).", "example": "Thanks, sounds good."}, + "to_recipients": {"type": "string", "description": "Optional comma-separated extra recipients.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def reply_outlook_email(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + from app.utils.text import csv_list + to = csv_list(input_data.get("to_recipients", ""), default=None) if input_data.get("to_recipients") else None + return run_client_sync( + "outlook", "reply_to_message", + unwrap_envelope=True, fail_message="Failed to reply.", + message_id=input_data["message_id"], + comment=input_data["comment"], + to_recipients=to, + ) + + +@action( + name="reply_all_outlook_email", + description="Reply-all to an email. Sent immediately.", + action_sets=["outlook_mail", "outlook"], + input_schema={ + "message_id": {"type": "string", "description": "Original message ID.", "example": "AAMk..."}, + "comment": {"type": "string", "description": "Reply body.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def reply_all_outlook_email(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "outlook", "reply_all_to_message", + unwrap_envelope=True, fail_message="Failed to reply-all.", + message_id=input_data["message_id"], + comment=input_data["comment"], + ) + + +@action( + name="forward_outlook_email", + description="Forward an email to other recipients.", + action_sets=["outlook_mail", "outlook"], + input_schema={ + "message_id": {"type": "string", "description": "Message ID.", "example": "AAMk..."}, + "to_recipients": {"type": "string", "description": "Comma-separated recipient emails.", "example": "bob@example.com"}, + "comment": {"type": "string", "description": "Optional intro comment.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def forward_outlook_email(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + from app.utils.text import csv_list + to = csv_list(input_data["to_recipients"]) + if not to: + return {"status": "error", "message": "No recipients provided."} + return run_client_sync( + "outlook", "forward_message", + unwrap_envelope=True, fail_message="Failed to forward.", + message_id=input_data["message_id"], + to_recipients=to, + comment=input_data.get("comment", ""), + ) + + +@action( + name="create_outlook_reply_draft", + description="Create a draft reply (pre-populated with quoted original). Edit with update_outlook_draft, then send with send_outlook_draft.", + action_sets=["outlook_mail"], + input_schema={ + "message_id": {"type": "string", "description": "Original message ID.", "example": "AAMk..."}, + "comment": {"type": "string", "description": "Optional initial reply text.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def create_outlook_reply_draft(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "outlook", "create_reply_draft", + unwrap_envelope=True, fail_message="Failed to create reply draft.", + message_id=input_data["message_id"], + comment=input_data.get("comment", ""), + ) + + +@action( + name="create_outlook_forward_draft", + description="Create a draft forward (pre-populated with quoted original). Edit and send later.", + action_sets=["outlook_mail"], + input_schema={ + "message_id": {"type": "string", "description": "Original message ID.", "example": "AAMk..."}, + "to_recipients": {"type": "string", "description": "Comma-separated recipient emails.", "example": ""}, + "comment": {"type": "string", "description": "Optional intro.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def create_outlook_forward_draft(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + from app.utils.text import csv_list + to = csv_list(input_data.get("to_recipients", "")) + return run_client_sync( + "outlook", "create_forward_draft", + unwrap_envelope=True, fail_message="Failed to create forward draft.", + message_id=input_data["message_id"], + to_recipients=to, + comment=input_data.get("comment", ""), + ) + + +@action( + name="create_outlook_draft", + description="Create a new email draft (not sent). Returns the draft_id for later editing/sending.", + action_sets=["outlook_mail", "outlook"], + input_schema={ + "subject": {"type": "string", "description": "Subject.", "example": "Quick question"}, + "body": {"type": "string", "description": "Body.", "example": ""}, + "to": {"type": "string", "description": "Comma-separated recipients (optional).", "example": ""}, + "cc": {"type": "string", "description": "Comma-separated CC (optional).", "example": ""}, + "bcc": {"type": "string", "description": "Comma-separated BCC (optional).", "example": ""}, + "html": {"type": "boolean", "description": "Body is HTML.", "example": False}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def create_outlook_draft(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + from app.utils.text import csv_list + return run_client_sync( + "outlook", "create_draft", + unwrap_envelope=True, fail_message="Failed to create draft.", + subject=input_data["subject"], + body=input_data["body"], + to=csv_list(input_data.get("to", ""), default=None), + cc=csv_list(input_data.get("cc", ""), default=None), + bcc=csv_list(input_data.get("bcc", ""), default=None), + html=bool(input_data.get("html", False)), + ) + + +@action( + name="update_outlook_draft", + description="Edit a draft's subject/body/recipients before sending.", + action_sets=["outlook_mail"], + input_schema={ + "message_id": {"type": "string", "description": "Draft ID.", "example": ""}, + "subject": {"type": "string", "description": "New subject (optional).", "example": ""}, + "body": {"type": "string", "description": "New body (optional).", "example": ""}, + "html": {"type": "boolean", "description": "Body is HTML.", "example": False}, + "to": {"type": "string", "description": "New comma-separated recipients (optional, replaces).", "example": ""}, + "cc": {"type": "string", "description": "New CC (optional).", "example": ""}, + "bcc": {"type": "string", "description": "New BCC (optional).", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def update_outlook_draft(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + from app.utils.text import csv_list + return run_client_sync( + "outlook", "update_draft", + unwrap_envelope=True, fail_message="Failed to update draft.", + message_id=input_data["message_id"], + subject=input_data.get("subject") if "subject" in input_data else None, + body=input_data.get("body") if "body" in input_data else None, + html=bool(input_data.get("html", False)), + to=csv_list(input_data["to"], default=None) if "to" in input_data else None, + cc=csv_list(input_data["cc"], default=None) if "cc" in input_data else None, + bcc=csv_list(input_data["bcc"], default=None) if "bcc" in input_data else None, + ) + + +@action( + name="send_outlook_draft", + description="Send a previously-created draft.", + action_sets=["outlook_mail", "outlook"], + input_schema={ + "message_id": {"type": "string", "description": "Draft ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def send_outlook_draft(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "outlook", "send_draft", + unwrap_envelope=True, fail_message="Failed to send draft.", + message_id=input_data["message_id"], + ) + + +@action( + name="delete_outlook_email", + description="Permanently delete a message. Use move_outlook_email to deleteditems for a soft delete.", + action_sets=["outlook_mail", "outlook"], + input_schema={ + "message_id": {"type": "string", "description": "Message ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def delete_outlook_email(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "outlook", "delete_message", + unwrap_envelope=True, fail_message="Failed to delete.", + message_id=input_data["message_id"], + ) + + +@action( + name="move_outlook_email", + description="Move a message to another folder. destination_folder_id can be a well-known name (inbox, drafts, sentitems, deleteditems, archive, junkemail) or a custom folder ID.", + action_sets=["outlook_mail", "outlook"], + input_schema={ + "message_id": {"type": "string", "description": "Message ID.", "example": ""}, + "destination_folder_id": {"type": "string", "description": "Folder ID or well-known name.", "example": "archive"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def move_outlook_email(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "outlook", "move_message", + unwrap_envelope=True, fail_message="Failed to move.", + message_id=input_data["message_id"], + destination_folder_id=input_data["destination_folder_id"], + ) + + +@action( + name="copy_outlook_email", + description="Copy a message to another folder (original stays).", + action_sets=["outlook_mail"], + input_schema={ + "message_id": {"type": "string", "description": "Message ID.", "example": ""}, + "destination_folder_id": {"type": "string", "description": "Folder ID or well-known name.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def copy_outlook_email(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "outlook", "copy_message", + unwrap_envelope=True, fail_message="Failed to copy.", + message_id=input_data["message_id"], + destination_folder_id=input_data["destination_folder_id"], + ) + + @action( name="mark_outlook_email_read", description="Mark an Outlook email as read.", - action_sets=["outlook"], + action_sets=["outlook_mail", "outlook"], input_schema={ "message_id": { "type": "string", @@ -144,6 +432,7 @@ def read_top_outlook_emails(input_data: dict) -> dict: }, }, output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, ) def mark_outlook_email_read(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync @@ -158,10 +447,166 @@ def mark_outlook_email_read(input_data: dict) -> dict: ) +@action( + name="mark_outlook_email_unread", + description="Mark an Outlook email as unread.", + action_sets=["outlook_mail"], + input_schema={ + "message_id": {"type": "string", "description": "Message ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def mark_outlook_email_unread(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "outlook", "mark_as_unread", + unwrap_envelope=True, fail_message="Failed to mark unread.", + message_id=input_data["message_id"], + ) + + +@action( + name="flag_outlook_email", + description="Set the flag status on an email. flag_status: notFlagged | flagged | complete.", + action_sets=["outlook_mail", "outlook"], + input_schema={ + "message_id": {"type": "string", "description": "Message ID.", "example": ""}, + "flag_status": {"type": "string", "description": "notFlagged, flagged, or complete.", "example": "flagged"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def flag_outlook_email(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "outlook", "flag_message", + unwrap_envelope=True, fail_message="Failed to flag.", + message_id=input_data["message_id"], + flag_status=input_data.get("flag_status", "flagged"), + ) + + +@action( + name="set_outlook_email_categories", + description="Replace the categories on an Outlook message (use list_outlook_categories to see available ones).", + action_sets=["outlook_mail"], + input_schema={ + "message_id": {"type": "string", "description": "Message ID.", "example": ""}, + "categories": {"type": "string", "description": "Comma-separated category display names.", "example": "Personal,Important"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def set_outlook_email_categories(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + from app.utils.text import csv_list + categories = csv_list(input_data.get("categories", "")) + return run_client_sync( + "outlook", "set_message_categories", + unwrap_envelope=True, fail_message="Failed to set categories.", + message_id=input_data["message_id"], + categories=categories, + ) + + +# ------------------------------------------------------------------ +# Attachments +# ------------------------------------------------------------------ + +@action( + name="list_outlook_attachments", + description="List attachments on an Outlook message.", + action_sets=["outlook_attachments", "outlook"], + input_schema={ + "message_id": {"type": "string", "description": "Message ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def list_outlook_attachments(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "outlook", "list_attachments", + unwrap_envelope=True, fail_message="Failed to list attachments.", + message_id=input_data["message_id"], + ) + + +@action( + name="download_outlook_attachment", + description="Download an attachment to a local path. Only works for fileAttachment type.", + action_sets=["outlook_attachments", "outlook"], + input_schema={ + "message_id": {"type": "string", "description": "Message ID.", "example": ""}, + "attachment_id": {"type": "string", "description": "Attachment ID.", "example": ""}, + "save_to": {"type": "string", "description": "Local path to save to.", "example": "C:/Users/me/downloads/file.pdf"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def download_outlook_attachment(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "outlook", "download_attachment", + unwrap_envelope=True, fail_message="Failed to download.", + message_id=input_data["message_id"], + attachment_id=input_data["attachment_id"], + save_to=input_data["save_to"], + ) + + +@action( + name="add_outlook_attachment", + description="Attach a local file to a DRAFT message (under 3 MB).", + action_sets=["outlook_attachments"], + input_schema={ + "message_id": {"type": "string", "description": "Draft message ID.", "example": ""}, + "file_path": {"type": "string", "description": "Absolute path to the local file.", "example": ""}, + "content_type": {"type": "string", "description": "MIME type (autodetect if omitted).", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def add_outlook_attachment(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "outlook", "add_attachment", + unwrap_envelope=True, fail_message="Failed to add attachment.", + message_id=input_data["message_id"], + file_path=input_data["file_path"], + content_type=input_data.get("content_type") or None, + ) + + +@action( + name="delete_outlook_attachment", + description="Remove an attachment from a draft.", + action_sets=["outlook_attachments"], + input_schema={ + "message_id": {"type": "string", "description": "Message ID.", "example": ""}, + "attachment_id": {"type": "string", "description": "Attachment ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def delete_outlook_attachment(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "outlook", "delete_attachment", + unwrap_envelope=True, fail_message="Failed to delete attachment.", + message_id=input_data["message_id"], + attachment_id=input_data["attachment_id"], + ) + + +# ------------------------------------------------------------------ +# Folders +# ------------------------------------------------------------------ + @action( name="list_outlook_folders", description="List mail folders in Outlook.", - action_sets=["outlook"], + action_sets=["outlook_folders", "outlook"], input_schema={}, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -174,3 +619,319 @@ def list_outlook_folders(input_data: dict) -> dict: unwrap_envelope=True, fail_message="Failed to list folders.", ) + + +@action( + name="get_outlook_folder", + description="Get metadata for a single mail folder (counts, parent).", + action_sets=["outlook_folders"], + input_schema={ + "folder_id": {"type": "string", "description": "Folder ID or well-known name (inbox, drafts, sentitems, etc.).", "example": "inbox"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_outlook_folder(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "outlook", "get_folder", + unwrap_envelope=True, fail_message="Failed to get folder.", + folder_id=input_data["folder_id"], + ) + + +@action( + name="create_outlook_folder", + description="Create a new mail folder. Defaults to top-level (under msgfolderroot).", + action_sets=["outlook_folders", "outlook"], + input_schema={ + "display_name": {"type": "string", "description": "Folder name.", "example": "Receipts"}, + "parent_folder_id": {"type": "string", "description": "Parent folder ID or well-known name. Default msgfolderroot.", "example": "msgfolderroot"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def create_outlook_folder(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "outlook", "create_folder", + unwrap_envelope=True, fail_message="Failed to create folder.", + display_name=input_data["display_name"], + parent_folder_id=input_data.get("parent_folder_id", "msgfolderroot"), + ) + + +@action( + name="update_outlook_folder", + description="Rename a mail folder.", + action_sets=["outlook_folders"], + input_schema={ + "folder_id": {"type": "string", "description": "Folder ID.", "example": ""}, + "display_name": {"type": "string", "description": "New name.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def update_outlook_folder(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "outlook", "update_folder", + unwrap_envelope=True, fail_message="Failed to rename folder.", + folder_id=input_data["folder_id"], + display_name=input_data["display_name"], + ) + + +@action( + name="delete_outlook_folder", + description="Delete a mail folder (and all messages in it). Cannot delete well-known folders.", + action_sets=["outlook_folders"], + input_schema={ + "folder_id": {"type": "string", "description": "Folder ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def delete_outlook_folder(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "outlook", "delete_folder", + unwrap_envelope=True, fail_message="Failed to delete folder.", + folder_id=input_data["folder_id"], + ) + + +@action( + name="list_outlook_child_folders", + description="List child folders of a mail folder.", + action_sets=["outlook_folders"], + input_schema={ + "folder_id": {"type": "string", "description": "Parent folder ID or well-known name. Default msgfolderroot.", "example": "msgfolderroot"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def list_outlook_child_folders(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "outlook", "list_child_folders", + unwrap_envelope=True, fail_message="Failed to list child folders.", + folder_id=input_data.get("folder_id", "msgfolderroot"), + ) + + +@action( + name="list_outlook_folder_messages", + description="List messages in a specific folder.", + action_sets=["outlook_folders", "outlook"], + input_schema={ + "folder_id": {"type": "string", "description": "Folder ID or well-known name.", "example": "inbox"}, + "count": {"type": "integer", "description": "Max results.", "example": 25}, + "unread_only": {"type": "boolean", "description": "Filter to unread.", "example": False}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def list_outlook_folder_messages(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "outlook", "list_folder_messages", + unwrap_envelope=True, fail_message="Failed to list messages.", + folder_id=input_data["folder_id"], + n=input_data.get("count", 25), + unread_only=bool(input_data.get("unread_only", False)), + ) + + +# ------------------------------------------------------------------ +# Mailbox settings + auto-replies + rules + categories +# ------------------------------------------------------------------ + +@action( + name="get_outlook_mailbox_settings", + description="Get the user's mailbox settings (timezone, locale, working hours, etc.).", + action_sets=["outlook_settings"], + input_schema={}, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_outlook_mailbox_settings(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "outlook", "get_mailbox_settings", + unwrap_envelope=True, fail_message="Failed to get settings.", + ) + + +@action( + name="get_outlook_automatic_replies", + description="Get the current out-of-office / automatic reply settings.", + action_sets=["outlook_settings", "outlook"], + input_schema={}, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_outlook_automatic_replies(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "outlook", "get_automatic_replies", + unwrap_envelope=True, fail_message="Failed to get auto-replies.", + ) + + +@action( + name="update_outlook_automatic_replies", + description="Set out-of-office reply. status: disabled | alwaysEnabled | scheduled. external_audience: none | contactsOnly | all.", + action_sets=["outlook_settings", "outlook"], + input_schema={ + "status": {"type": "string", "description": "disabled, alwaysEnabled, or scheduled.", "example": "alwaysEnabled"}, + "internal_reply": {"type": "string", "description": "Reply text shown to internal senders (optional).", "example": "Out of office until Friday."}, + "external_reply": {"type": "string", "description": "Reply text shown to external senders (optional).", "example": ""}, + "external_audience": {"type": "string", "description": "none, contactsOnly, or all.", "example": "all"}, + "scheduled_start": {"type": "string", "description": "ISO 8601 start (only for status=scheduled).", "example": ""}, + "scheduled_end": {"type": "string", "description": "ISO 8601 end (only for status=scheduled).", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def update_outlook_automatic_replies(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "outlook", "update_automatic_replies", + unwrap_envelope=True, fail_message="Failed to set auto-replies.", + status=input_data["status"], + internal_reply=input_data.get("internal_reply") if "internal_reply" in input_data else None, + external_reply=input_data.get("external_reply") if "external_reply" in input_data else None, + external_audience=input_data.get("external_audience", "all"), + scheduled_start=input_data.get("scheduled_start") or None, + scheduled_end=input_data.get("scheduled_end") or None, + ) + + +@action( + name="list_outlook_inbox_rules", + description="List inbox rules (server-side mail rules).", + action_sets=["outlook_settings"], + input_schema={}, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def list_outlook_inbox_rules(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "outlook", "list_inbox_rules", + unwrap_envelope=True, fail_message="Failed to list rules.", + ) + + +@action( + name="create_outlook_inbox_rule", + description="Create an inbox rule. conditions and actions are Graph rule objects — e.g. conditions={'fromAddresses': [{'emailAddress': {'address': 'x@y.com'}}]}, actions={'moveToFolder': ''}.", + action_sets=["outlook_settings"], + input_schema={ + "display_name": {"type": "string", "description": "Rule name.", "example": "From boss to Important"}, + "conditions": {"type": "object", "description": "Graph messageRulePredicates object.", "example": {}}, + "actions": {"type": "object", "description": "Graph messageRuleActions object.", "example": {}}, + "sequence": {"type": "integer", "description": "Run order (lower runs first).", "example": 1}, + "is_enabled": {"type": "boolean", "description": "Enable on create.", "example": True}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def create_outlook_inbox_rule(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "outlook", "create_inbox_rule", + unwrap_envelope=True, fail_message="Failed to create rule.", + display_name=input_data["display_name"], + conditions=input_data["conditions"], + actions=input_data["actions"], + sequence=input_data.get("sequence", 1), + is_enabled=bool(input_data.get("is_enabled", True)), + ) + + +@action( + name="delete_outlook_inbox_rule", + description="Delete an inbox rule.", + action_sets=["outlook_settings"], + input_schema={ + "rule_id": {"type": "string", "description": "Rule ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def delete_outlook_inbox_rule(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "outlook", "delete_inbox_rule", + unwrap_envelope=True, fail_message="Failed to delete rule.", + rule_id=input_data["rule_id"], + ) + + +@action( + name="list_outlook_categories", + description="List the user's master categories (color-coded tags for messages, calendar items, etc.).", + action_sets=["outlook_settings"], + input_schema={}, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def list_outlook_categories(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "outlook", "list_categories", + unwrap_envelope=True, fail_message="Failed to list categories.", + ) + + +@action( + name="create_outlook_category", + description="Create a master category. color: preset0..preset24 from Graph categoryColor enum.", + action_sets=["outlook_settings"], + input_schema={ + "display_name": {"type": "string", "description": "Category name.", "example": "Personal"}, + "color": {"type": "string", "description": "preset0..preset24.", "example": "preset0"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def create_outlook_category(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "outlook", "create_category", + unwrap_envelope=True, fail_message="Failed to create category.", + display_name=input_data["display_name"], + color=input_data.get("color", "preset0"), + ) + + +@action( + name="delete_outlook_category", + description="Delete a master category.", + action_sets=["outlook_settings"], + input_schema={ + "category_id": {"type": "string", "description": "Category ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def delete_outlook_category(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "outlook", "delete_category", + unwrap_envelope=True, fail_message="Failed to delete category.", + category_id=input_data["category_id"], + ) + + +# ================================================================== +# Intentionally NOT exposed as actions (and why) +# ================================================================== +# - Subscriptions / webhooks (subscribe to mailbox changes) +# Server-side push notification setup; not interactive. +# - Large attachment upload sessions (>3 MB via uploadSession) +# The simple add_attachment covers the realistic agent use case (<3 MB). +# - Schema extensions and open extensions +# Custom property storage on resources; niche developer tooling. +# - Find meeting times / get schedule +# Calendar surface — would belong to a separate outlook_calendar action set, +# not this mail-focused expansion. +# - Delta queries (incremental sync via $deltaToken) +# Synchronization plumbing, not per-action work. +# - Permissions delegation (sharedMailbox, sendOnBehalf) +# Admin / multi-user concerns. diff --git a/app/data/action/integrations/slack/slack_actions.py b/app/data/action/integrations/slack/slack_actions.py index 9d1b9258..6a45a09e 100644 --- a/app/data/action/integrations/slack/slack_actions.py +++ b/app/data/action/integrations/slack/slack_actions.py @@ -1,290 +1,1140 @@ from agent_core import action +# ------------------------------------------------------------------ +# Messages — post / update / delete / ephemeral / schedule / permalink / threads +# ------------------------------------------------------------------ + @action( name="send_slack_message", - description="Send a message to a Slack channel or DM.", - action_sets=["slack"], - input_schema={ - "channel": { - "type": "string", - "description": "Channel ID or name.", - "example": "C01234567", - }, - "text": { - "type": "string", - "description": "Message text.", - "example": "Hello team!", - }, - "thread_ts": { - "type": "string", - "description": "Optional thread timestamp for replies.", - "example": "", - }, + description="Send a message to a Slack channel or DM. Pass thread_ts to reply in a thread.", + action_sets=["slack_messages", "slack"], + input_schema={ + "channel": {"type": "string", "description": "Channel ID or name.", "example": "C01234567"}, + "text": {"type": "string", "description": "Message text.", "example": "Hello team!"}, + "thread_ts": {"type": "string", "description": "Optional thread timestamp for replies.", "example": ""}, }, output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, ) async def send_slack_message(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( - "slack", - "send_message", + "slack", "send_message", recipient=input_data["channel"], text=input_data["text"], thread_ts=input_data.get("thread_ts"), ) +@action( + name="update_slack_message", + description="Edit a previously-sent Slack message. ts is the timestamp returned when posting.", + action_sets=["slack_messages", "slack"], + input_schema={ + "channel": {"type": "string", "description": "Channel ID.", "example": "C01234567"}, + "ts": {"type": "string", "description": "Timestamp of the message to edit.", "example": "1234567890.123456"}, + "text": {"type": "string", "description": "New text (optional).", "example": ""}, + "blocks": {"type": "array", "description": "New Block Kit blocks (optional).", "example": []}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def update_slack_message(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "slack", "update_message", + channel=input_data["channel"], + ts=input_data["ts"], + text=input_data["text"] if "text" in input_data else None, + blocks=input_data["blocks"] if "blocks" in input_data else None, + ) + + +@action( + name="delete_slack_message", + description="Delete a Slack message.", + action_sets=["slack_messages", "slack"], + input_schema={ + "channel": {"type": "string", "description": "Channel ID.", "example": "C01234567"}, + "ts": {"type": "string", "description": "Message timestamp.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def delete_slack_message(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "slack", "delete_message", + channel=input_data["channel"], ts=input_data["ts"], + ) + + +@action( + name="send_slack_ephemeral", + description="Send an ephemeral message visible only to one user in a channel.", + action_sets=["slack_messages", "slack"], + input_schema={ + "channel": {"type": "string", "description": "Channel ID.", "example": "C01234567"}, + "user": {"type": "string", "description": "User ID who will see the message.", "example": "U12345"}, + "text": {"type": "string", "description": "Message text.", "example": ""}, + "blocks": {"type": "array", "description": "Block Kit blocks (optional).", "example": []}, + "thread_ts": {"type": "string", "description": "Reply in a thread (optional).", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def send_slack_ephemeral(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "slack", "post_ephemeral", + channel=input_data["channel"], user=input_data["user"], + text=input_data["text"], + blocks=input_data["blocks"] if "blocks" in input_data else None, + thread_ts=input_data.get("thread_ts") or None, + ) + + +@action( + name="schedule_slack_message", + description="Schedule a Slack message to be sent at a future time. post_at is a Unix timestamp.", + action_sets=["slack_messages", "slack"], + input_schema={ + "channel": {"type": "string", "description": "Channel ID.", "example": "C01234567"}, + "post_at": {"type": "integer", "description": "Unix timestamp when to send.", "example": 0}, + "text": {"type": "string", "description": "Message text.", "example": ""}, + "blocks": {"type": "array", "description": "Block Kit blocks (optional).", "example": []}, + "thread_ts": {"type": "string", "description": "Optional thread reply.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def schedule_slack_message(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "slack", "schedule_message", + channel=input_data["channel"], + post_at=input_data["post_at"], + text=input_data["text"], + blocks=input_data["blocks"] if "blocks" in input_data else None, + thread_ts=input_data.get("thread_ts") or None, + ) + + +@action( + name="delete_scheduled_slack_message", + description="Cancel a previously-scheduled Slack message.", + action_sets=["slack_messages"], + input_schema={ + "channel": {"type": "string", "description": "Channel ID.", "example": ""}, + "scheduled_message_id": {"type": "string", "description": "Scheduled message ID (from schedule_slack_message response).", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def delete_scheduled_slack_message(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "slack", "delete_scheduled_message", + channel=input_data["channel"], + scheduled_message_id=input_data["scheduled_message_id"], + ) + + +@action( + name="list_scheduled_slack_messages", + description="List the bot's pending scheduled messages.", + action_sets=["slack_messages"], + input_schema={ + "channel": {"type": "string", "description": "Filter to one channel (optional).", "example": ""}, + "limit": {"type": "integer", "description": "Max results.", "example": 100}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def list_scheduled_slack_messages(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "slack", "list_scheduled_messages", + channel=input_data.get("channel") or None, + limit=input_data.get("limit", 100), + ) + + +@action( + name="get_slack_message_permalink", + description="Get a shareable permalink URL for a Slack message.", + action_sets=["slack_messages", "slack"], + input_schema={ + "channel": {"type": "string", "description": "Channel ID.", "example": "C01234567"}, + "message_ts": {"type": "string", "description": "Message timestamp.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_slack_message_permalink(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "slack", "get_permalink", + channel=input_data["channel"], message_ts=input_data["message_ts"], + ) + + +@action( + name="get_slack_thread_replies", + description="Get all messages in a Slack thread (the parent + all replies).", + action_sets=["slack_messages", "slack"], + input_schema={ + "channel": {"type": "string", "description": "Channel ID.", "example": "C01234567"}, + "ts": {"type": "string", "description": "Parent message timestamp (thread_ts).", "example": ""}, + "limit": {"type": "integer", "description": "Max messages.", "example": 100}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_slack_thread_replies(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "slack", "get_thread_replies", + channel=input_data["channel"], ts=input_data["ts"], + limit=input_data.get("limit", 100), + ) + + +# ----- Reactions ----- + +@action( + name="add_slack_reaction", + description="Add an emoji reaction to a Slack message. name is the emoji code without colons (e.g. 'thumbsup', 'eyes').", + action_sets=["slack_messages", "slack"], + input_schema={ + "channel": {"type": "string", "description": "Channel ID.", "example": "C01234567"}, + "timestamp": {"type": "string", "description": "Message timestamp.", "example": ""}, + "name": {"type": "string", "description": "Emoji name without colons.", "example": "thumbsup"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def add_slack_reaction(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "slack", "add_reaction", + channel=input_data["channel"], timestamp=input_data["timestamp"], + name=input_data["name"], + ) + + +@action( + name="remove_slack_reaction", + description="Remove an emoji reaction from a Slack message.", + action_sets=["slack_messages", "slack"], + input_schema={ + "channel": {"type": "string", "description": "Channel ID.", "example": ""}, + "timestamp": {"type": "string", "description": "Message timestamp.", "example": ""}, + "name": {"type": "string", "description": "Emoji name without colons.", "example": "thumbsup"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def remove_slack_reaction(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "slack", "remove_reaction", + channel=input_data["channel"], timestamp=input_data["timestamp"], + name=input_data["name"], + ) + + +@action( + name="get_slack_reactions", + description="Get all reactions on a Slack message.", + action_sets=["slack_messages"], + input_schema={ + "channel": {"type": "string", "description": "Channel ID.", "example": ""}, + "timestamp": {"type": "string", "description": "Message timestamp.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_slack_reactions(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "slack", "get_reactions", + channel=input_data["channel"], timestamp=input_data["timestamp"], + ) + + +@action( + name="list_slack_user_reactions", + description="List messages a user has reacted to.", + action_sets=["slack_messages"], + input_schema={ + "user": {"type": "string", "description": "User ID (optional, defaults to auth'd user).", "example": ""}, + "count": {"type": "integer", "description": "Max results.", "example": 100}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def list_slack_user_reactions(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "slack", "list_user_reactions", + user=input_data.get("user") or None, + count=input_data.get("count", 100), + ) + + +# ----- Pins ----- + +@action( + name="pin_slack_message", + description="Pin a message to a Slack channel.", + action_sets=["slack_messages", "slack"], + input_schema={ + "channel": {"type": "string", "description": "Channel ID.", "example": ""}, + "timestamp": {"type": "string", "description": "Message timestamp.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def pin_slack_message(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "slack", "pin_message", + channel=input_data["channel"], timestamp=input_data["timestamp"], + ) + + +@action( + name="unpin_slack_message", + description="Unpin a message from a Slack channel.", + action_sets=["slack_messages"], + input_schema={ + "channel": {"type": "string", "description": "Channel ID.", "example": ""}, + "timestamp": {"type": "string", "description": "Message timestamp.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def unpin_slack_message(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "slack", "unpin_message", + channel=input_data["channel"], timestamp=input_data["timestamp"], + ) + + +@action( + name="list_slack_pins", + description="List pinned items in a Slack channel.", + action_sets=["slack_messages"], + input_schema={ + "channel": {"type": "string", "description": "Channel ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def list_slack_pins(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("slack", "list_pins", channel=input_data["channel"]) + + +# ------------------------------------------------------------------ +# Conversations — list/info/create/invite/open/archive/rename/topic/members +# ------------------------------------------------------------------ + @action( name="list_slack_channels", description="List channels in the Slack workspace.", - action_sets=["slack"], + action_sets=["slack_conversations", "slack"], input_schema={ - "limit": { - "type": "integer", - "description": "Max channels to return.", - "example": 100, - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "channels": {"type": "array"}, + "limit": {"type": "integer", "description": "Max channels to return.", "example": 100}, }, + output_schema={"status": {"type": "string", "example": "success"}, "channels": {"type": "array"}}, ) def list_slack_channels(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync("slack", "list_channels", limit=input_data.get("limit", 100)) +@action( + name="get_slack_channel_info", + description="Get info about a Slack channel.", + action_sets=["slack_conversations", "slack"], + input_schema={ + "channel": {"type": "string", "description": "Channel ID.", "example": "C1234567"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_slack_channel_info(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("slack", "get_channel_info", channel=input_data["channel"]) + + @action( name="get_slack_channel_history", description="Get message history from a Slack channel.", - action_sets=["slack"], + action_sets=["slack_conversations", "slack"], input_schema={ - "channel": { - "type": "string", - "description": "Channel ID.", - "example": "C01234567", - }, + "channel": {"type": "string", "description": "Channel ID.", "example": "C01234567"}, "limit": {"type": "integer", "description": "Max messages.", "example": 50}, }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "messages": {"type": "array"}, - }, + output_schema={"status": {"type": "string", "example": "success"}, "messages": {"type": "array"}}, ) def get_slack_channel_history(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "slack", "get_channel_history", + channel=input_data["channel"], limit=input_data.get("limit", 50), + ) + +@action( + name="list_slack_channel_members", + description="List members of a Slack channel.", + action_sets=["slack_conversations", "slack"], + input_schema={ + "channel": {"type": "string", "description": "Channel ID.", "example": ""}, + "limit": {"type": "integer", "description": "Max members.", "example": 100}, + "cursor": {"type": "string", "description": "Pagination cursor.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def list_slack_channel_members(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync return run_client_sync( - "slack", - "get_channel_history", + "slack", "list_channel_members", channel=input_data["channel"], - limit=input_data.get("limit", 50), + limit=input_data.get("limit", 100), + cursor=input_data.get("cursor") or None, ) @action( - name="list_slack_users", - description="List users in the Slack workspace.", - action_sets=["slack"], + name="create_slack_channel", + description="Create a new Slack channel.", + action_sets=["slack_conversations", "slack"], input_schema={ - "limit": { - "type": "integer", - "description": "Max users to return.", - "example": 100, - }, + "name": {"type": "string", "description": "Channel name.", "example": "project-alpha"}, + "is_private": {"type": "boolean", "description": "Is private?", "example": False}, }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "users": {"type": "array"}, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def create_slack_channel(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "slack", "create_channel", + name=input_data["name"], is_private=input_data.get("is_private", False), + ) + + +@action( + name="invite_to_slack_channel", + description="Invite users to a Slack channel.", + action_sets=["slack_conversations", "slack"], + input_schema={ + "channel": {"type": "string", "description": "Channel ID.", "example": "C1234567"}, + "users": {"type": "array", "description": "List of user IDs.", "example": ["U123"]}, }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, ) -def list_slack_users(input_data: dict) -> dict: +def invite_to_slack_channel(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "slack", "invite_to_channel", + channel=input_data["channel"], users=input_data["users"], + ) - return run_client_sync("slack", "list_users", limit=input_data.get("limit", 100)) + +@action( + name="open_slack_dm", + description="Open a DM with Slack users.", + action_sets=["slack_conversations", "slack"], + input_schema={ + "users": {"type": "array", "description": "List of user IDs.", "example": ["U123"]}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def open_slack_dm(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("slack", "open_dm", users=input_data["users"]) @action( - name="search_slack_messages", - description="Search for messages in the Slack workspace.", - action_sets=["slack"], - input_schema={ - "query": { - "type": "string", - "description": "Search query.", - "example": "project update", - }, - "count": {"type": "integer", "description": "Max results.", "example": 20}, + name="archive_slack_channel", + description="Archive a Slack channel.", + action_sets=["slack_conversations", "slack"], + input_schema={ + "channel": {"type": "string", "description": "Channel ID.", "example": ""}, }, output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, ) -def search_slack_messages(input_data: dict) -> dict: +def archive_slack_channel(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("slack", "archive_channel", channel=input_data["channel"]) + + +@action( + name="unarchive_slack_channel", + description="Unarchive a previously-archived Slack channel.", + action_sets=["slack_conversations"], + input_schema={ + "channel": {"type": "string", "description": "Channel ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def unarchive_slack_channel(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("slack", "unarchive_channel", channel=input_data["channel"]) + + +@action( + name="rename_slack_channel", + description="Rename a Slack channel.", + action_sets=["slack_conversations"], + input_schema={ + "channel": {"type": "string", "description": "Channel ID.", "example": ""}, + "name": {"type": "string", "description": "New channel name.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def rename_slack_channel(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "slack", "rename_channel", + channel=input_data["channel"], name=input_data["name"], + ) + + +@action( + name="set_slack_channel_topic", + description="Set a Slack channel's topic.", + action_sets=["slack_conversations", "slack"], + input_schema={ + "channel": {"type": "string", "description": "Channel ID.", "example": ""}, + "topic": {"type": "string", "description": "New topic.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def set_slack_channel_topic(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "slack", "set_channel_topic", + channel=input_data["channel"], topic=input_data["topic"], + ) + + +@action( + name="set_slack_channel_purpose", + description="Set a Slack channel's purpose / description.", + action_sets=["slack_conversations"], + input_schema={ + "channel": {"type": "string", "description": "Channel ID.", "example": ""}, + "purpose": {"type": "string", "description": "New purpose.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def set_slack_channel_purpose(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "slack", "set_channel_purpose", + channel=input_data["channel"], purpose=input_data["purpose"], + ) + + +@action( + name="join_slack_channel", + description="Have the bot join a Slack channel.", + action_sets=["slack_conversations", "slack"], + input_schema={ + "channel": {"type": "string", "description": "Channel ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def join_slack_channel(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("slack", "join_channel", channel=input_data["channel"]) + + +@action( + name="leave_slack_channel", + description="Have the bot leave a Slack channel.", + action_sets=["slack_conversations"], + input_schema={ + "channel": {"type": "string", "description": "Channel ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def leave_slack_channel(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("slack", "leave_channel", channel=input_data["channel"]) + +@action( + name="kick_user_from_slack_channel", + description="Remove a user from a Slack channel.", + action_sets=["slack_conversations"], + input_schema={ + "channel": {"type": "string", "description": "Channel ID.", "example": ""}, + "user": {"type": "string", "description": "User ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def kick_user_from_slack_channel(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync return run_client_sync( - "slack", - "search_messages", - query=input_data["query"], - count=input_data.get("count", 20), + "slack", "kick_user", + channel=input_data["channel"], user=input_data["user"], ) +@action( + name="close_slack_conversation", + description="Close a DM, MPDM, or private channel.", + action_sets=["slack_conversations"], + input_schema={ + "channel": {"type": "string", "description": "Conversation ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def close_slack_conversation(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("slack", "close_conversation", channel=input_data["channel"]) + + +# ------------------------------------------------------------------ +# Files +# ------------------------------------------------------------------ + @action( name="upload_slack_file", - description="Upload a file to a Slack channel.", - action_sets=["slack"], - input_schema={ - "channels": { - "type": "string", - "description": "Channel ID to upload to.", - "example": "C01234567", - }, - "file_path": { - "type": "string", - "description": "Local file path to upload.", - "example": "/path/to/file.txt", - }, - "title": {"type": "string", "description": "File title.", "example": "Report"}, - "initial_comment": { - "type": "string", - "description": "Message with the file.", - "example": "Here's the report", - }, + description="Upload a local file to Slack using the modern 3-step files.getUploadURLExternal flow. Optionally share into a channel + post initial comment.", + action_sets=["slack_files", "slack"], + input_schema={ + "file_path": {"type": "string", "description": "Absolute path to local file.", "example": "C:/Users/me/report.pdf"}, + "channel_id": {"type": "string", "description": "Channel ID to share into (optional).", "example": "C01234567"}, + "initial_comment": {"type": "string", "description": "Message text with the file (optional).", "example": ""}, + "title": {"type": "string", "description": "File title (optional).", "example": ""}, + "thread_ts": {"type": "string", "description": "Reply in a thread (optional).", "example": ""}, + "filename": {"type": "string", "description": "Override filename (optional).", "example": ""}, }, output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, ) def upload_slack_file(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "slack", "upload_file_v2", + file_path=input_data["file_path"], + channel_id=input_data.get("channel_id") or None, + initial_comment=input_data.get("initial_comment") or None, + title=input_data.get("title") or None, + thread_ts=input_data.get("thread_ts") or None, + filename=input_data.get("filename") or None, + ) - channels = input_data["channels"] - if isinstance(channels, str): - channels = [channels] + +@action( + name="list_slack_files", + description="List files in the workspace (optionally filter by channel, user, or types like 'images,zips').", + action_sets=["slack_files", "slack"], + input_schema={ + "channel": {"type": "string", "description": "Filter to channel (optional).", "example": ""}, + "user": {"type": "string", "description": "Filter to user (optional).", "example": ""}, + "types": {"type": "string", "description": "Comma-separated types: all, spaces, snippets, images, gdocs, zips, pdfs (optional).", "example": ""}, + "count": {"type": "integer", "description": "Max results.", "example": 100}, + "page": {"type": "integer", "description": "Page number.", "example": 1}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def list_slack_files(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync return run_client_sync( - "slack", - "upload_file", - channels=channels, - file_path=input_data.get("file_path"), - title=input_data.get("title"), - initial_comment=input_data.get("initial_comment"), + "slack", "list_files", + channel=input_data.get("channel") or None, + user=input_data.get("user") or None, + types=input_data.get("types") or None, + count=input_data.get("count", 100), + page=input_data.get("page", 1), ) +@action( + name="get_slack_file_info", + description="Get metadata for a Slack file (name, size, URL, channels shared into).", + action_sets=["slack_files", "slack"], + input_schema={ + "file_id": {"type": "string", "description": "File ID.", "example": "F0123ABC"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_slack_file_info(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("slack", "get_file_info", file_id=input_data["file_id"]) + + +@action( + name="delete_slack_file", + description="Delete a Slack file. Irreversible.", + action_sets=["slack_files"], + input_schema={ + "file_id": {"type": "string", "description": "File ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def delete_slack_file(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("slack", "delete_file", file_id=input_data["file_id"]) + + +# ------------------------------------------------------------------ +# Users + usergroups + presence +# ------------------------------------------------------------------ + +@action( + name="list_slack_users", + description="List users in the Slack workspace.", + action_sets=["slack_users", "slack"], + input_schema={ + "limit": {"type": "integer", "description": "Max users to return.", "example": 100}, + }, + output_schema={"status": {"type": "string", "example": "success"}, "users": {"type": "array"}}, +) +def list_slack_users(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("slack", "list_users", limit=input_data.get("limit", 100)) + + @action( name="get_slack_user_info", description="Get info about a Slack user.", - action_sets=["slack"], + action_sets=["slack_users", "slack"], input_schema={ - "slack_user_id": { - "type": "string", - "description": "User ID.", - "example": "U1234567", - }, + "slack_user_id": {"type": "string", "description": "User ID.", "example": "U1234567"}, }, output_schema={"status": {"type": "string", "example": "success"}}, ) def get_slack_user_info(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("slack", "get_user_info", user_id=input_data["slack_user_id"]) + + +@action( + name="lookup_slack_user_by_email", + description="Resolve a Slack user by their email address.", + action_sets=["slack_users", "slack"], + input_schema={ + "email": {"type": "string", "description": "Email address.", "example": "alice@example.com"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def lookup_slack_user_by_email(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("slack", "lookup_user_by_email", email=input_data["email"]) + + +@action( + name="get_slack_user_presence", + description="Check whether a Slack user is online (active) or offline (away).", + action_sets=["slack_users"], + input_schema={ + "user": {"type": "string", "description": "User ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_slack_user_presence(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("slack", "get_user_presence", user=input_data["user"]) + +@action( + name="set_slack_user_presence", + description="Set the authenticated user's presence (requires user token xoxp-, not bot token).", + action_sets=["slack_users"], + input_schema={ + "presence": {"type": "string", "description": "auto or away.", "example": "auto"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def set_slack_user_presence(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("slack", "set_user_presence", presence=input_data["presence"]) + + +@action( + name="list_slack_usergroups", + description="List Slack usergroups (@team mentions) in the workspace.", + action_sets=["slack_users", "slack"], + input_schema={ + "include_disabled": {"type": "boolean", "description": "Include disabled groups.", "example": False}, + "include_count": {"type": "boolean", "description": "Include member counts.", "example": False}, + "include_users": {"type": "boolean", "description": "Include user list per group.", "example": False}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def list_slack_usergroups(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync return run_client_sync( - "slack", "get_user_info", user_id=input_data["slack_user_id"] + "slack", "list_usergroups", + include_disabled=bool(input_data.get("include_disabled", False)), + include_count=bool(input_data.get("include_count", False)), + include_users=bool(input_data.get("include_users", False)), ) @action( - name="get_slack_channel_info", - description="Get info about a Slack channel.", - action_sets=["slack"], + name="create_slack_usergroup", + description="Create a new Slack usergroup.", + action_sets=["slack_users"], input_schema={ - "channel": { - "type": "string", - "description": "Channel ID.", - "example": "C1234567", - }, + "name": {"type": "string", "description": "Group name (e.g. 'Marketing').", "example": ""}, + "handle": {"type": "string", "description": "Handle without @ (optional).", "example": ""}, + "description": {"type": "string", "description": "Description (optional).", "example": ""}, + "channels": {"type": "array", "description": "Default channels (optional).", "example": []}, }, output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, ) -def get_slack_channel_info(input_data: dict) -> dict: +def create_slack_usergroup(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "slack", "create_usergroup", + name=input_data["name"], + handle=input_data.get("handle") or None, + description=input_data.get("description") or None, + channels=input_data.get("channels") or None, + ) - return run_client_sync("slack", "get_channel_info", channel=input_data["channel"]) + +@action( + name="update_slack_usergroup", + description="Update a Slack usergroup's name/handle/description/channels.", + action_sets=["slack_users"], + input_schema={ + "usergroup": {"type": "string", "description": "Usergroup ID.", "example": ""}, + "name": {"type": "string", "description": "New name (optional).", "example": ""}, + "handle": {"type": "string", "description": "New handle (optional).", "example": ""}, + "description": {"type": "string", "description": "New description (optional).", "example": ""}, + "channels": {"type": "array", "description": "New default channels (optional).", "example": []}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def update_slack_usergroup(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "slack", "update_usergroup", + usergroup=input_data["usergroup"], + name=input_data["name"] if "name" in input_data else None, + handle=input_data["handle"] if "handle" in input_data else None, + description=input_data["description"] if "description" in input_data else None, + channels=input_data["channels"] if "channels" in input_data else None, + ) @action( - name="create_slack_channel", - description="Create a new Slack channel.", - action_sets=["slack"], + name="list_slack_usergroup_users", + description="List the users in a Slack usergroup.", + action_sets=["slack_users"], input_schema={ - "name": { - "type": "string", - "description": "Channel name.", - "example": "project-alpha", - }, - "is_private": { - "type": "boolean", - "description": "Is private?", - "example": False, - }, + "usergroup": {"type": "string", "description": "Usergroup ID.", "example": ""}, + "include_disabled": {"type": "boolean", "description": "Include disabled users.", "example": False}, }, output_schema={"status": {"type": "string", "example": "success"}}, ) -def create_slack_channel(input_data: dict) -> dict: +def list_slack_usergroup_users(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "slack", "list_usergroup_users", + usergroup=input_data["usergroup"], + include_disabled=bool(input_data.get("include_disabled", False)), + ) + +@action( + name="set_slack_usergroup_users", + description="REPLACE the members of a Slack usergroup.", + action_sets=["slack_users"], + input_schema={ + "usergroup": {"type": "string", "description": "Usergroup ID.", "example": ""}, + "users": {"type": "array", "description": "List of user IDs to set as members.", "example": []}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def set_slack_usergroup_users(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync return run_client_sync( - "slack", - "create_channel", - name=input_data["name"], - is_private=input_data.get("is_private", False), + "slack", "update_usergroup_users", + usergroup=input_data["usergroup"], users=input_data["users"], ) @action( - name="invite_to_slack_channel", - description="Invite users to a Slack channel.", - action_sets=["slack"], + name="enable_slack_usergroup", + description="Enable a previously-disabled Slack usergroup.", + action_sets=["slack_users"], input_schema={ - "channel": { - "type": "string", - "description": "Channel ID.", - "example": "C1234567", - }, - "users": { - "type": "array", - "description": "List of user IDs.", - "example": ["U123"], - }, + "usergroup": {"type": "string", "description": "Usergroup ID.", "example": ""}, }, output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, ) -def invite_to_slack_channel(input_data: dict) -> dict: +def enable_slack_usergroup(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("slack", "enable_usergroup", usergroup=input_data["usergroup"]) + + +@action( + name="disable_slack_usergroup", + description="Disable a Slack usergroup (keeps it but hides from autocomplete).", + action_sets=["slack_users"], + input_schema={ + "usergroup": {"type": "string", "description": "Usergroup ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def disable_slack_usergroup(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("slack", "disable_usergroup", usergroup=input_data["usergroup"]) + + +# ------------------------------------------------------------------ +# Workspace: auth / team / search / bookmarks / reminders +# ------------------------------------------------------------------ + +@action( + name="get_slack_auth_info", + description="Get info about the authenticated Slack bot/user (team, user, bot_id).", + action_sets=["slack_workspace", "slack"], + input_schema={}, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_slack_auth_info(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("slack", "auth_test") + +@action( + name="get_slack_team_info", + description="Get info about the Slack workspace (team name, domain, icon).", + action_sets=["slack_workspace", "slack"], + input_schema={ + "team": {"type": "string", "description": "Team ID (optional, defaults to current).", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_slack_team_info(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("slack", "get_team_info", team=input_data.get("team") or None) + + +@action( + name="search_slack_messages", + description="Search for messages in the Slack workspace (requires user token / search:read).", + action_sets=["slack_workspace", "slack"], + input_schema={ + "query": {"type": "string", "description": "Search query.", "example": "project update"}, + "count": {"type": "integer", "description": "Max results.", "example": 20}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def search_slack_messages(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync return run_client_sync( - "slack", - "invite_to_channel", - channel=input_data["channel"], - users=input_data["users"], + "slack", "search_messages", + query=input_data["query"], count=input_data.get("count", 20), ) @action( - name="open_slack_dm", - description="Open a DM with Slack users.", - action_sets=["slack"], + name="list_slack_bookmarks", + description="List bookmarks pinned to a Slack channel.", + action_sets=["slack_workspace", "slack"], input_schema={ - "users": { - "type": "array", - "description": "List of user IDs.", - "example": ["U123"], - }, + "channel_id": {"type": "string", "description": "Channel ID.", "example": ""}, }, output_schema={"status": {"type": "string", "example": "success"}}, ) -def open_slack_dm(input_data: dict) -> dict: +def list_slack_bookmarks(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("slack", "list_bookmarks", channel_id=input_data["channel_id"]) - return run_client_sync("slack", "open_dm", users=input_data["users"]) + +@action( + name="add_slack_bookmark", + description="Add a bookmark to a Slack channel.", + action_sets=["slack_workspace", "slack"], + input_schema={ + "channel_id": {"type": "string", "description": "Channel ID.", "example": ""}, + "title": {"type": "string", "description": "Bookmark title.", "example": "Project doc"}, + "type": {"type": "string", "description": "Bookmark type (link).", "example": "link"}, + "link": {"type": "string", "description": "URL (for type=link).", "example": ""}, + "emoji": {"type": "string", "description": "Emoji shortcode (optional).", "example": ":bookmark:"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def add_slack_bookmark(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "slack", "add_bookmark", + channel_id=input_data["channel_id"], + title=input_data["title"], + type=input_data.get("type", "link"), + link=input_data.get("link") or None, + emoji=input_data.get("emoji") or None, + ) + + +@action( + name="edit_slack_bookmark", + description="Edit an existing channel bookmark.", + action_sets=["slack_workspace"], + input_schema={ + "channel_id": {"type": "string", "description": "Channel ID.", "example": ""}, + "bookmark_id": {"type": "string", "description": "Bookmark ID.", "example": ""}, + "title": {"type": "string", "description": "New title (optional).", "example": ""}, + "link": {"type": "string", "description": "New URL (optional).", "example": ""}, + "emoji": {"type": "string", "description": "New emoji (optional).", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def edit_slack_bookmark(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "slack", "edit_bookmark", + channel_id=input_data["channel_id"], + bookmark_id=input_data["bookmark_id"], + title=input_data["title"] if "title" in input_data else None, + link=input_data["link"] if "link" in input_data else None, + emoji=input_data["emoji"] if "emoji" in input_data else None, + ) + + +@action( + name="remove_slack_bookmark", + description="Delete a channel bookmark.", + action_sets=["slack_workspace"], + input_schema={ + "channel_id": {"type": "string", "description": "Channel ID.", "example": ""}, + "bookmark_id": {"type": "string", "description": "Bookmark ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def remove_slack_bookmark(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "slack", "remove_bookmark", + channel_id=input_data["channel_id"], bookmark_id=input_data["bookmark_id"], + ) + + +@action( + name="add_slack_reminder", + description="Add a Slack reminder. time can be a Unix timestamp or natural-language ('in 15 minutes'). Requires user token (xoxp-) — bot tokens can't create reminders.", + action_sets=["slack_workspace", "slack"], + input_schema={ + "text": {"type": "string", "description": "Reminder text.", "example": "Send the weekly report"}, + "time": {"type": "string", "description": "Unix timestamp OR natural-language ('in 15 minutes').", "example": "in 15 minutes"}, + "user": {"type": "string", "description": "User ID (optional, defaults to self).", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def add_slack_reminder(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync( + "slack", "add_reminder", + text=input_data["text"], time=input_data["time"], + user=input_data.get("user") or None, + ) + + +@action( + name="list_slack_reminders", + description="List the authenticated user's Slack reminders.", + action_sets=["slack_workspace"], + input_schema={}, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def list_slack_reminders(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("slack", "list_reminders") + + +@action( + name="get_slack_reminder", + description="Get info about a single Slack reminder.", + action_sets=["slack_workspace"], + input_schema={ + "reminder": {"type": "string", "description": "Reminder ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +def get_slack_reminder(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("slack", "get_reminder_info", reminder=input_data["reminder"]) + + +@action( + name="complete_slack_reminder", + description="Mark a Slack reminder as complete.", + action_sets=["slack_workspace"], + input_schema={ + "reminder": {"type": "string", "description": "Reminder ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def complete_slack_reminder(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("slack", "complete_reminder", reminder=input_data["reminder"]) + + +@action( + name="delete_slack_reminder", + description="Delete a Slack reminder.", + action_sets=["slack_workspace"], + input_schema={ + "reminder": {"type": "string", "description": "Reminder ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +def delete_slack_reminder(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client_sync + return run_client_sync("slack", "delete_reminder", reminder=input_data["reminder"]) + + +# ================================================================== +# Intentionally NOT exposed as actions (and why) +# ================================================================== +# - Events API subscriptions, RTM (deprecated), Socket Mode setup +# Server-side event-receiving plumbing. The listener handles it internally. +# - views.* (modal/home/app views) and interactions.* (block button responses) +# Interactive UI surface that requires a paired Events API endpoint to +# handle callbacks. Not actionable from a one-shot agent loop. +# - canvases / lists (canvases.create/edit/listcategories, slackLists) +# New Block Kit-adjacent surfaces; not stable enough across plans. +# - admin.* and scim +# Enterprise Grid admin. Requires enterprise tokens. +# - apps.connections.open (Socket Mode tokens) +# Realtime infrastructure. +# - dnd.* (Do-not-disturb) +# User-token-only, rarely needed by an assistant. +# - migration.exchange / stars / dialog.* (deprecated) +# Legacy surfaces. +# - chat.unfurl / link_shared +# Event-driven; requires Events API loop. diff --git a/app/data/action/integrations/telegram/telegram_actions.py b/app/data/action/integrations/telegram/telegram_actions.py index 6f63f95a..c2d45675 100644 --- a/app/data/action/integrations/telegram/telegram_actions.py +++ b/app/data/action/integrations/telegram/telegram_actions.py @@ -2,256 +2,1497 @@ # ===================================================================== -# Bot API actions +# Bot API — Messages (text lifecycle, forward/copy/pin/reactions) +# Sub-set: telegram_messages # ===================================================================== +@action( + name="send_telegram_bot_message", + description="Send a text message to a Telegram chat via bot. Use this ONLY when replying to Telegram Bot messages.", + action_sets=["telegram_messages", "telegram"], + input_schema={ + "chat_id": {"type": "string", "description": "Telegram chat ID or @username.", "example": "123456789"}, + "text": {"type": "string", "description": "Message text to send.", "example": "Hello!"}, + "parse_mode": {"type": "string", "description": "Optional parse mode: HTML or MarkdownV2.", "example": "HTML"}, + "reply_to_message_id": {"type": "integer", "description": "Optional message to reply to.", "example": 42}, + "disable_web_page_preview": {"type": "boolean", "description": "Disable link previews.", "example": False}, + "reply_markup": {"type": "object", "description": "Optional reply markup (inline keyboard etc.).", "example": {}}, + }, + output_schema={ + "status": {"type": "string", "example": "success"}, + }, +) +async def send_telegram_bot_message(input_data: dict) -> dict: + from app.data.action.integrations._helpers import record_outgoing_message, run_client + record_outgoing_message("Telegram", input_data["chat_id"], input_data["text"]) + return await run_client( + "telegram_bot", "send_message", + recipient=input_data["chat_id"], + text=input_data["text"], + parse_mode=input_data.get("parse_mode"), + reply_to_message_id=input_data.get("reply_to_message_id"), + disable_web_page_preview=input_data.get("disable_web_page_preview"), + reply_markup=input_data.get("reply_markup"), + ) + + +@action( + name="send_telegram_text_message", + description="Send a text message via Telegram bot (alias for sendMessage with full options).", + action_sets=["telegram_messages"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID or @username.", "example": "123456789"}, + "text": {"type": "string", "description": "Message text.", "example": "Hi"}, + "parse_mode": {"type": "string", "description": "HTML or MarkdownV2.", "example": "HTML"}, + "reply_to_message_id": {"type": "integer", "description": "Reply target message id.", "example": 42}, + "disable_web_page_preview": {"type": "boolean", "description": "Disable preview.", "example": False}, + "disable_notification": {"type": "boolean", "description": "Send silently.", "example": False}, + "reply_markup": {"type": "object", "description": "Reply markup.", "example": {}}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def send_telegram_text_message(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "telegram_bot", "send_text_message", + chat_id=input_data["chat_id"], + text=input_data["text"], + parse_mode=input_data.get("parse_mode"), + reply_to_message_id=input_data.get("reply_to_message_id"), + disable_web_page_preview=input_data.get("disable_web_page_preview"), + disable_notification=input_data.get("disable_notification"), + reply_markup=input_data.get("reply_markup"), + ) + + +@action( + name="edit_telegram_message_text", + description="Edit the text of a message sent by the bot.", + action_sets=["telegram_messages", "telegram"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID.", "example": "123"}, + "message_id": {"type": "integer", "description": "Message ID.", "example": 42}, + "text": {"type": "string", "description": "New text.", "example": "Edited"}, + "parse_mode": {"type": "string", "description": "Parse mode.", "example": "HTML"}, + "reply_markup": {"type": "object", "description": "New reply markup.", "example": {}}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def edit_telegram_message_text(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "telegram_bot", "edit_message_text", + chat_id=input_data["chat_id"], + message_id=input_data["message_id"], + text=input_data["text"], + parse_mode=input_data.get("parse_mode"), + reply_markup=input_data.get("reply_markup"), + ) + + +@action( + name="edit_telegram_message_caption", + description="Edit the caption of a media message.", + action_sets=["telegram_messages"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID.", "example": "123"}, + "message_id": {"type": "integer", "description": "Message ID.", "example": 42}, + "caption": {"type": "string", "description": "New caption.", "example": "New caption"}, + "parse_mode": {"type": "string", "description": "Parse mode.", "example": "HTML"}, + "reply_markup": {"type": "object", "description": "Reply markup.", "example": {}}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def edit_telegram_message_caption(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "telegram_bot", "edit_message_caption", + chat_id=input_data["chat_id"], + message_id=input_data["message_id"], + caption=input_data.get("caption"), + parse_mode=input_data.get("parse_mode"), + reply_markup=input_data.get("reply_markup"), + ) + + +@action( + name="edit_telegram_message_reply_markup", + description="Edit only the reply markup of a message.", + action_sets=["telegram_messages"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID.", "example": "123"}, + "message_id": {"type": "integer", "description": "Message ID.", "example": 42}, + "reply_markup": {"type": "object", "description": "Reply markup.", "example": {}}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def edit_telegram_message_reply_markup(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "telegram_bot", "edit_message_reply_markup", + chat_id=input_data["chat_id"], + message_id=input_data["message_id"], + reply_markup=input_data.get("reply_markup"), + ) + + +@action( + name="delete_telegram_message", + description="Delete a single message sent by or visible to the bot.", + action_sets=["telegram_messages", "telegram"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID.", "example": "123"}, + "message_id": {"type": "integer", "description": "Message ID.", "example": 42}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def delete_telegram_message(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "telegram_bot", "delete_message", + chat_id=input_data["chat_id"], + message_id=input_data["message_id"], + ) + + +@action( + name="delete_telegram_messages", + description="Delete multiple messages in a chat in one call.", + action_sets=["telegram_messages"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID.", "example": "123"}, + "message_ids": {"type": "array", "description": "List of message IDs.", "example": [1, 2, 3]}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def delete_telegram_messages(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "telegram_bot", "delete_messages", + chat_id=input_data["chat_id"], + message_ids=input_data["message_ids"], + ) + + +@action( + name="copy_telegram_message", + description="Copy a message to another chat (does not include the 'forwarded from' header).", + action_sets=["telegram_messages"], + input_schema={ + "chat_id": {"type": "string", "description": "Destination chat.", "example": "123"}, + "from_chat_id": {"type": "string", "description": "Source chat.", "example": "456"}, + "message_id": {"type": "integer", "description": "Source message ID.", "example": 42}, + "caption": {"type": "string", "description": "Optional new caption.", "example": "Copied"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def copy_telegram_message(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "telegram_bot", "copy_message", + chat_id=input_data["chat_id"], + from_chat_id=input_data["from_chat_id"], + message_id=input_data["message_id"], + caption=input_data.get("caption"), + ) + + +@action( + name="forward_telegram_message", + description="Forward a message via bot.", + action_sets=["telegram_messages", "telegram"], + input_schema={ + "chat_id": {"type": "string", "description": "Destination chat.", "example": "123"}, + "from_chat_id": {"type": "string", "description": "Source chat.", "example": "456"}, + "message_id": {"type": "integer", "description": "Message ID.", "example": 1}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def forward_telegram_message(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "telegram_bot", "forward_message", + chat_id=input_data["chat_id"], + from_chat_id=input_data["from_chat_id"], + message_id=input_data["message_id"], + ) + + +@action( + name="forward_telegram_messages", + description="Forward multiple messages of any kind.", + action_sets=["telegram_messages"], + input_schema={ + "chat_id": {"type": "string", "description": "Destination chat.", "example": "123"}, + "from_chat_id": {"type": "string", "description": "Source chat.", "example": "456"}, + "message_ids": {"type": "array", "description": "List of message IDs.", "example": [1, 2, 3]}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def forward_telegram_messages(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "telegram_bot", "forward_messages", + chat_id=input_data["chat_id"], + from_chat_id=input_data["from_chat_id"], + message_ids=input_data["message_ids"], + ) + + +@action( + name="pin_telegram_message", + description="Pin a message in a chat.", + action_sets=["telegram_messages"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID.", "example": "123"}, + "message_id": {"type": "integer", "description": "Message ID.", "example": 42}, + "disable_notification": {"type": "boolean", "description": "Silent pin.", "example": True}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def pin_telegram_message(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "telegram_bot", "pin_message", + chat_id=input_data["chat_id"], + message_id=input_data["message_id"], + disable_notification=input_data.get("disable_notification"), + ) + + +@action( + name="unpin_telegram_message", + description="Unpin a specific message (or the most recent if omitted).", + action_sets=["telegram_messages"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID.", "example": "123"}, + "message_id": {"type": "integer", "description": "Optional message ID.", "example": 42}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def unpin_telegram_message(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "telegram_bot", "unpin_message", + chat_id=input_data["chat_id"], + message_id=input_data.get("message_id"), + ) + + +@action( + name="unpin_all_telegram_messages", + description="Clear the list of pinned messages in a chat.", + action_sets=["telegram_messages"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID.", "example": "123"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def unpin_all_telegram_messages(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "telegram_bot", "unpin_all_messages", + chat_id=input_data["chat_id"], + ) + + +@action( + name="set_telegram_message_reaction", + description="Set or remove emoji reactions on a message.", + action_sets=["telegram_messages"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID.", "example": "123"}, + "message_id": {"type": "integer", "description": "Message ID.", "example": 42}, + "reactions": {"type": "array", "description": "Array of reaction objects, e.g. [{type:'emoji', emoji:'👍'}].", "example": [{"type": "emoji", "emoji": "👍"}]}, + "is_big": {"type": "boolean", "description": "Animated big reaction.", "example": False}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def set_telegram_message_reaction(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "telegram_bot", "set_message_reaction", + chat_id=input_data["chat_id"], + message_id=input_data["message_id"], + reactions=input_data.get("reactions"), + is_big=input_data.get("is_big"), + ) + + +@action( + name="send_telegram_chat_action", + description="Show 'typing', 'upload_photo', etc. indicators to the user.", + action_sets=["telegram_messages"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID.", "example": "123"}, + "action_type": {"type": "string", "description": "typing | upload_photo | record_video | upload_video | record_voice | upload_voice | upload_document | choose_sticker | find_location | record_video_note | upload_video_note.", "example": "typing"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def send_telegram_chat_action(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "telegram_bot", "send_chat_action", + chat_id=input_data["chat_id"], + action=input_data["action_type"], + ) + + +# ===================================================================== +# Bot API — Media (photo/video/audio/voice/document/poll/etc.) +# Sub-set: telegram_media +# ===================================================================== + +@action( + name="send_telegram_photo", + description="Send a photo to a Telegram chat via bot.", + action_sets=["telegram_media", "telegram"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID.", "example": "123"}, + "photo": {"type": "string", "description": "URL or file_id.", "example": "https://example.com/p.jpg"}, + "caption": {"type": "string", "description": "Caption.", "example": "Cool pic"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def send_telegram_photo(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "telegram_bot", "send_photo", + chat_id=input_data["chat_id"], + photo=input_data["photo"], + caption=input_data.get("caption"), + ) + + +@action( + name="send_telegram_document", + description="Send a document to a Telegram chat via bot.", + action_sets=["telegram_media", "telegram"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID.", "example": "123"}, + "document": {"type": "string", "description": "File ID or URL.", "example": "https://example.com/doc.pdf"}, + "caption": {"type": "string", "description": "Caption.", "example": "Here is the file"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def send_telegram_document(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "telegram_bot", "send_document", + chat_id=input_data["chat_id"], + document=input_data["document"], + caption=input_data.get("caption"), + ) + + +@action( + name="send_telegram_video", + description="Send a video file via bot.", + action_sets=["telegram_media"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID.", "example": "123"}, + "video": {"type": "string", "description": "File ID or URL.", "example": "https://example.com/v.mp4"}, + "caption": {"type": "string", "description": "Caption.", "example": ""}, + "duration": {"type": "integer", "description": "Duration in seconds.", "example": 30}, + "supports_streaming": {"type": "boolean", "description": "Streaming-capable.", "example": True}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def send_telegram_video(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "telegram_bot", "send_video", + chat_id=input_data["chat_id"], + video=input_data["video"], + caption=input_data.get("caption"), + duration=input_data.get("duration"), + supports_streaming=input_data.get("supports_streaming"), + ) + + +@action( + name="send_telegram_audio", + description="Send an audio file (music) via bot.", + action_sets=["telegram_media"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID.", "example": "123"}, + "audio": {"type": "string", "description": "File ID or URL.", "example": "https://example.com/a.mp3"}, + "caption": {"type": "string", "description": "Caption.", "example": ""}, + "title": {"type": "string", "description": "Track title.", "example": "Song"}, + "performer": {"type": "string", "description": "Artist.", "example": "Artist"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def send_telegram_audio(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "telegram_bot", "send_audio", + chat_id=input_data["chat_id"], + audio=input_data["audio"], + caption=input_data.get("caption"), + title=input_data.get("title"), + performer=input_data.get("performer"), + ) + + +@action( + name="send_telegram_voice", + description="Send a voice message (OGG opus) via bot.", + action_sets=["telegram_media"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID.", "example": "123"}, + "voice": {"type": "string", "description": "File ID or URL.", "example": "https://example.com/v.ogg"}, + "caption": {"type": "string", "description": "Caption.", "example": ""}, + "duration": {"type": "integer", "description": "Duration in seconds.", "example": 10}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def send_telegram_voice(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "telegram_bot", "send_voice", + chat_id=input_data["chat_id"], + voice=input_data["voice"], + caption=input_data.get("caption"), + duration=input_data.get("duration"), + ) + + +@action( + name="send_telegram_video_note", + description="Send a rounded square video note (short circular video).", + action_sets=["telegram_media"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID.", "example": "123"}, + "video_note": {"type": "string", "description": "File ID or URL.", "example": "https://example.com/note.mp4"}, + "duration": {"type": "integer", "description": "Duration in seconds.", "example": 10}, + "length": {"type": "integer", "description": "Side length.", "example": 240}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def send_telegram_video_note(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "telegram_bot", "send_video_note", + chat_id=input_data["chat_id"], + video_note=input_data["video_note"], + duration=input_data.get("duration"), + length=input_data.get("length"), + ) + + +@action( + name="send_telegram_animation", + description="Send an animation (GIF or H.264/MPEG-4 without sound).", + action_sets=["telegram_media"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID.", "example": "123"}, + "animation": {"type": "string", "description": "File ID or URL.", "example": "https://example.com/anim.gif"}, + "caption": {"type": "string", "description": "Caption.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def send_telegram_animation(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "telegram_bot", "send_animation", + chat_id=input_data["chat_id"], + animation=input_data["animation"], + caption=input_data.get("caption"), + ) + + +@action( + name="send_telegram_sticker", + description="Send a sticker (.webp / .tgs / .webm).", + action_sets=["telegram_media"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID.", "example": "123"}, + "sticker": {"type": "string", "description": "File ID or URL or emoji.", "example": "CAACAgQA..."}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def send_telegram_sticker(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "telegram_bot", "send_sticker", + chat_id=input_data["chat_id"], + sticker=input_data["sticker"], + ) + + +@action( + name="send_telegram_location", + description="Send a geographic location.", + action_sets=["telegram_media"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID.", "example": "123"}, + "latitude": {"type": "number", "description": "Latitude.", "example": 37.7749}, + "longitude": {"type": "number", "description": "Longitude.", "example": -122.4194}, + "live_period": {"type": "integer", "description": "Live location duration in seconds.", "example": 60}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def send_telegram_location(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "telegram_bot", "send_location", + chat_id=input_data["chat_id"], + latitude=input_data["latitude"], + longitude=input_data["longitude"], + live_period=input_data.get("live_period"), + ) + + +@action( + name="send_telegram_venue", + description="Send a venue with name and address.", + action_sets=["telegram_media"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID.", "example": "123"}, + "latitude": {"type": "number", "description": "Latitude.", "example": 37.7749}, + "longitude": {"type": "number", "description": "Longitude.", "example": -122.4194}, + "title": {"type": "string", "description": "Venue name.", "example": "Cafe X"}, + "address": {"type": "string", "description": "Venue address.", "example": "1 Main St"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def send_telegram_venue(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "telegram_bot", "send_venue", + chat_id=input_data["chat_id"], + latitude=input_data["latitude"], + longitude=input_data["longitude"], + title=input_data["title"], + address=input_data["address"], + ) + + +@action( + name="send_telegram_contact", + description="Send a phone contact card.", + action_sets=["telegram_media"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID.", "example": "123"}, + "phone_number": {"type": "string", "description": "Phone number.", "example": "+15551234567"}, + "first_name": {"type": "string", "description": "First name.", "example": "John"}, + "last_name": {"type": "string", "description": "Last name.", "example": "Doe"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def send_telegram_contact(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "telegram_bot", "send_contact", + chat_id=input_data["chat_id"], + phone_number=input_data["phone_number"], + first_name=input_data["first_name"], + last_name=input_data.get("last_name"), + ) + + +@action( + name="send_telegram_dice", + description="Send an animated dice / emoji-game (🎲 🎯 🏀 ⚽ 🎳 🎰).", + action_sets=["telegram_media"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID.", "example": "123"}, + "emoji": {"type": "string", "description": "One of 🎲 🎯 🏀 ⚽ 🎳 🎰.", "example": "🎲"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def send_telegram_dice(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "telegram_bot", "send_dice", + chat_id=input_data["chat_id"], + emoji=input_data.get("emoji"), + ) + + +@action( + name="send_telegram_poll", + description="Send a poll to a chat.", + action_sets=["telegram_media"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID.", "example": "123"}, + "question": {"type": "string", "description": "Poll question.", "example": "Best language?"}, + "options": {"type": "array", "description": "Poll option strings.", "example": ["Python", "Go", "Rust"]}, + "is_anonymous": {"type": "boolean", "description": "Anonymous poll.", "example": True}, + "type": {"type": "string", "description": "quiz | regular.", "example": "regular"}, + "allows_multiple_answers": {"type": "boolean", "description": "Allow multi-select.", "example": False}, + "correct_option_id": {"type": "integer", "description": "Quiz correct option index.", "example": 0}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def send_telegram_poll(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "telegram_bot", "send_poll", + chat_id=input_data["chat_id"], + question=input_data["question"], + options=input_data["options"], + is_anonymous=input_data.get("is_anonymous"), + type=input_data.get("type"), + allows_multiple_answers=input_data.get("allows_multiple_answers"), + correct_option_id=input_data.get("correct_option_id"), + ) + + +@action( + name="stop_telegram_poll", + description="Stop an active poll.", + action_sets=["telegram_media"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID.", "example": "123"}, + "message_id": {"type": "integer", "description": "Poll message ID.", "example": 42}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def stop_telegram_poll(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "telegram_bot", "stop_poll", + chat_id=input_data["chat_id"], + message_id=input_data["message_id"], + ) + + +@action( + name="send_telegram_media_group", + description="Send a group of photos/videos/audios/documents as an album.", + action_sets=["telegram_media"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID.", "example": "123"}, + "media": {"type": "array", "description": "Array of InputMedia objects (type, media, caption).", "example": [{"type": "photo", "media": "https://example.com/1.jpg"}, {"type": "photo", "media": "https://example.com/2.jpg"}]}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def send_telegram_media_group(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "telegram_bot", "send_media_group", + chat_id=input_data["chat_id"], + media=input_data["media"], + ) + + +@action( + name="get_telegram_file", + description="Get file metadata (including file_path) for a file_id.", + action_sets=["telegram_media"], + input_schema={ + "file_id": {"type": "string", "description": "File ID.", "example": "AgAC..."}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def get_telegram_file(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "telegram_bot", "get_file", + file_id=input_data["file_id"], + ) + + +@action( + name="download_telegram_file", + description="Resolve a file_id and stream the bytes to a local path.", + action_sets=["telegram_media"], + input_schema={ + "file_id": {"type": "string", "description": "File ID.", "example": "AgAC..."}, + "dest_path": {"type": "string", "description": "Local file path to save to.", "example": "/tmp/file.bin"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def download_telegram_file(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "telegram_bot", "download_file", + file_id=input_data["file_id"], + dest_path=input_data["dest_path"], + ) + + +# ===================================================================== +# Bot API — Chats (info, members, admin, invite links) +# Sub-set: telegram_chats +# ===================================================================== + +@action( + name="get_telegram_chat", + description="Get information about a Telegram chat via bot.", + action_sets=["telegram_chats", "telegram"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID or @username.", "example": "123456789"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def get_telegram_chat(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("telegram_bot", "get_chat", chat_id=input_data["chat_id"]) + + +@action( + name="get_telegram_chat_members_count", + description="Get chat members count via bot.", + action_sets=["telegram_chats", "telegram"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID.", "example": "123"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def get_telegram_chat_members_count(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "telegram_bot", "get_chat_members_count", chat_id=input_data["chat_id"], + ) + + +@action( + name="get_telegram_chat_administrators", + description="List the administrators of a chat.", + action_sets=["telegram_chats"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID.", "example": "123"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def get_telegram_chat_administrators(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "telegram_bot", "get_chat_administrators", chat_id=input_data["chat_id"], + ) + + +@action( + name="ban_telegram_chat_member", + description="Ban a user from a group/supergroup/channel.", + action_sets=["telegram_chats"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID.", "example": "123"}, + "user_id": {"type": "integer", "description": "User ID.", "example": 987654321}, + "until_date": {"type": "integer", "description": "Unix timestamp ban-until (0 = forever).", "example": 0}, + "revoke_messages": {"type": "boolean", "description": "Delete all messages from user.", "example": False}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def ban_telegram_chat_member(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "telegram_bot", "ban_chat_member", + chat_id=input_data["chat_id"], + user_id=input_data["user_id"], + until_date=input_data.get("until_date"), + revoke_messages=input_data.get("revoke_messages"), + ) + + +@action( + name="unban_telegram_chat_member", + description="Unban a previously banned user.", + action_sets=["telegram_chats"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID.", "example": "123"}, + "user_id": {"type": "integer", "description": "User ID.", "example": 987654321}, + "only_if_banned": {"type": "boolean", "description": "Only if currently banned.", "example": True}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def unban_telegram_chat_member(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "telegram_bot", "unban_chat_member", + chat_id=input_data["chat_id"], + user_id=input_data["user_id"], + only_if_banned=input_data.get("only_if_banned"), + ) + + +@action( + name="restrict_telegram_chat_member", + description="Restrict a user in a supergroup with specific ChatPermissions.", + action_sets=["telegram_chats"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID.", "example": "123"}, + "user_id": {"type": "integer", "description": "User ID.", "example": 987654321}, + "permissions": {"type": "object", "description": "ChatPermissions object.", "example": {"can_send_messages": False}}, + "until_date": {"type": "integer", "description": "Unix timestamp restrict-until.", "example": 0}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def restrict_telegram_chat_member(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "telegram_bot", "restrict_chat_member", + chat_id=input_data["chat_id"], + user_id=input_data["user_id"], + permissions=input_data["permissions"], + until_date=input_data.get("until_date"), + ) + + +@action( + name="promote_telegram_chat_member", + description="Promote or demote a user. Pass False to remove a privilege.", + action_sets=["telegram_chats"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID.", "example": "123"}, + "user_id": {"type": "integer", "description": "User ID.", "example": 987654321}, + "is_anonymous": {"type": "boolean", "description": "Anonymous admin.", "example": False}, + "can_manage_chat": {"type": "boolean", "description": "Manage chat privilege.", "example": True}, + "can_delete_messages": {"type": "boolean", "description": "Delete messages.", "example": True}, + "can_manage_video_chats": {"type": "boolean", "description": "Manage video chats.", "example": False}, + "can_restrict_members": {"type": "boolean", "description": "Restrict members.", "example": True}, + "can_promote_members": {"type": "boolean", "description": "Promote members.", "example": False}, + "can_change_info": {"type": "boolean", "description": "Change chat info.", "example": False}, + "can_invite_users": {"type": "boolean", "description": "Invite users.", "example": True}, + "can_post_messages": {"type": "boolean", "description": "Channel post.", "example": False}, + "can_edit_messages": {"type": "boolean", "description": "Channel edit.", "example": False}, + "can_pin_messages": {"type": "boolean", "description": "Pin messages.", "example": False}, + "can_manage_topics": {"type": "boolean", "description": "Manage forum topics.", "example": False}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def promote_telegram_chat_member(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "telegram_bot", "promote_chat_member", + chat_id=input_data["chat_id"], + user_id=input_data["user_id"], + is_anonymous=input_data.get("is_anonymous"), + can_manage_chat=input_data.get("can_manage_chat"), + can_delete_messages=input_data.get("can_delete_messages"), + can_manage_video_chats=input_data.get("can_manage_video_chats"), + can_restrict_members=input_data.get("can_restrict_members"), + can_promote_members=input_data.get("can_promote_members"), + can_change_info=input_data.get("can_change_info"), + can_invite_users=input_data.get("can_invite_users"), + can_post_messages=input_data.get("can_post_messages"), + can_edit_messages=input_data.get("can_edit_messages"), + can_pin_messages=input_data.get("can_pin_messages"), + can_manage_topics=input_data.get("can_manage_topics"), + ) + + +@action( + name="set_telegram_chat_administrator_custom_title", + description="Set a custom title for an administrator.", + action_sets=["telegram_chats"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID.", "example": "123"}, + "user_id": {"type": "integer", "description": "Admin user ID.", "example": 987654321}, + "custom_title": {"type": "string", "description": "Custom title (max 16 chars).", "example": "Owner"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def set_telegram_chat_administrator_custom_title(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "telegram_bot", "set_chat_administrator_custom_title", + chat_id=input_data["chat_id"], + user_id=input_data["user_id"], + custom_title=input_data["custom_title"], + ) + + +@action( + name="set_telegram_chat_permissions", + description="Set default chat permissions for all non-admin members.", + action_sets=["telegram_chats"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID.", "example": "123"}, + "permissions": {"type": "object", "description": "ChatPermissions object.", "example": {"can_send_messages": True}}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def set_telegram_chat_permissions(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "telegram_bot", "set_chat_permissions", + chat_id=input_data["chat_id"], + permissions=input_data["permissions"], + ) + + +@action( + name="set_telegram_chat_title", + description="Change the title of a chat.", + action_sets=["telegram_chats"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID.", "example": "123"}, + "title": {"type": "string", "description": "New title (1-128 chars).", "example": "New Chat Name"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def set_telegram_chat_title(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "telegram_bot", "set_chat_title", + chat_id=input_data["chat_id"], + title=input_data["title"], + ) + + +@action( + name="set_telegram_chat_description", + description="Change the description of a group/supergroup/channel.", + action_sets=["telegram_chats"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID.", "example": "123"}, + "description": {"type": "string", "description": "New description (0-255 chars).", "example": "About this chat"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def set_telegram_chat_description(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "telegram_bot", "set_chat_description", + chat_id=input_data["chat_id"], + description=input_data.get("description"), + ) + + +@action( + name="delete_telegram_chat_photo", + description="Delete the photo of a group/supergroup/channel.", + action_sets=["telegram_chats"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID.", "example": "123"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def delete_telegram_chat_photo(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "telegram_bot", "delete_chat_photo", + chat_id=input_data["chat_id"], + ) + + +@action( + name="leave_telegram_chat", + description="Make the bot leave a chat.", + action_sets=["telegram_chats"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID.", "example": "123"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def leave_telegram_chat(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "telegram_bot", "leave_chat", + chat_id=input_data["chat_id"], + ) + @action( - name="send_telegram_bot_message", - description="Send a text message to a Telegram chat via bot. Use this ONLY when replying to Telegram Bot messages.", - action_sets=["telegram_bot"], - input_schema={ - "chat_id": { - "type": "string", - "description": "Telegram chat ID or @username.", - "example": "123456789", - }, - "text": { - "type": "string", - "description": "Message text to send.", - "example": "Hello!", - }, - "parse_mode": { - "type": "string", - "description": "Optional parse mode: HTML or Markdown.", - "example": "HTML", - }, + name="get_telegram_chat_member", + description="Get information about a member of a chat.", + action_sets=["telegram_chats"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID.", "example": "123"}, + "user_id": {"type": "integer", "description": "User ID.", "example": 987654321}, }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "message": {"type": "string", "example": "Message sent"}, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def get_telegram_chat_member(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "telegram_bot", "get_chat_member", + chat_id=input_data["chat_id"], + user_id=input_data["user_id"], + ) + + +@action( + name="export_telegram_chat_invite_link", + description="Generate a new primary invite link, revoking previous primary.", + action_sets=["telegram_chats"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID.", "example": "123"}, }, + output_schema={"status": {"type": "string", "example": "success"}}, ) -async def send_telegram_bot_message(input_data: dict) -> dict: - from app.data.action.integrations._helpers import ( - record_outgoing_message, - run_client, +async def export_telegram_chat_invite_link(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "telegram_bot", "export_chat_invite_link", + chat_id=input_data["chat_id"], ) - record_outgoing_message("Telegram", input_data["chat_id"], input_data["text"]) + +@action( + name="create_telegram_chat_invite_link", + description="Create an additional invite link (does not revoke the primary).", + action_sets=["telegram_chats"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID.", "example": "123"}, + "name": {"type": "string", "description": "Invite name.", "example": "VIP"}, + "expire_date": {"type": "integer", "description": "Unix timestamp expire.", "example": 1735689600}, + "member_limit": {"type": "integer", "description": "Max members 1-99999.", "example": 10}, + "creates_join_request": {"type": "boolean", "description": "Require admin approval.", "example": False}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def create_telegram_chat_invite_link(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client return await run_client( - "telegram_bot", - "send_message", - recipient=input_data["chat_id"], - text=input_data["text"], - parse_mode=input_data.get("parse_mode"), + "telegram_bot", "create_chat_invite_link", + chat_id=input_data["chat_id"], + name=input_data.get("name"), + expire_date=input_data.get("expire_date"), + member_limit=input_data.get("member_limit"), + creates_join_request=input_data.get("creates_join_request"), ) @action( - name="send_telegram_photo", - description="Send a photo to a Telegram chat via bot.", - action_sets=["telegram_bot"], - input_schema={ - "chat_id": { - "type": "string", - "description": "Telegram chat ID.", - "example": "123456789", - }, - "photo": { - "type": "string", - "description": "URL or file_id of the photo.", - "example": "https://example.com/photo.jpg", - }, - "caption": { - "type": "string", - "description": "Optional photo caption.", - "example": "Check this out", - }, + name="edit_telegram_chat_invite_link", + description="Edit an existing non-primary invite link.", + action_sets=["telegram_chats"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID.", "example": "123"}, + "invite_link": {"type": "string", "description": "Invite link to edit.", "example": "https://t.me/+abc"}, + "name": {"type": "string", "description": "Name.", "example": "VIP-renamed"}, + "expire_date": {"type": "integer", "description": "Unix timestamp.", "example": 1735689600}, + "member_limit": {"type": "integer", "description": "Max members.", "example": 20}, + "creates_join_request": {"type": "boolean", "description": "Approval flow.", "example": False}, }, output_schema={"status": {"type": "string", "example": "success"}}, ) -async def send_telegram_photo(input_data: dict) -> dict: +async def edit_telegram_chat_invite_link(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client + return await run_client( + "telegram_bot", "edit_chat_invite_link", + chat_id=input_data["chat_id"], + invite_link=input_data["invite_link"], + name=input_data.get("name"), + expire_date=input_data.get("expire_date"), + member_limit=input_data.get("member_limit"), + creates_join_request=input_data.get("creates_join_request"), + ) + +@action( + name="revoke_telegram_chat_invite_link", + description="Revoke an invite link.", + action_sets=["telegram_chats"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID.", "example": "123"}, + "invite_link": {"type": "string", "description": "Invite link.", "example": "https://t.me/+abc"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def revoke_telegram_chat_invite_link(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client return await run_client( - "telegram_bot", - "send_photo", + "telegram_bot", "revoke_chat_invite_link", chat_id=input_data["chat_id"], - photo=input_data["photo"], - caption=input_data.get("caption"), + invite_link=input_data["invite_link"], ) @action( - name="get_telegram_updates", - description="Get incoming updates (messages) for the Telegram bot.", - action_sets=["telegram_bot"], - input_schema={ - "limit": { - "type": "integer", - "description": "Max number of updates to retrieve.", - "example": 10, - }, - "offset": { - "type": "integer", - "description": "Update offset for pagination.", - "example": 0, - }, + name="approve_telegram_chat_join_request", + description="Approve a pending chat join request.", + action_sets=["telegram_chats"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID.", "example": "123"}, + "user_id": {"type": "integer", "description": "User ID.", "example": 987654321}, }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "updates": {"type": "array", "description": "List of update objects."}, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def approve_telegram_chat_join_request(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "telegram_bot", "approve_chat_join_request", + chat_id=input_data["chat_id"], + user_id=input_data["user_id"], + ) + + +@action( + name="decline_telegram_chat_join_request", + description="Decline a pending chat join request.", + action_sets=["telegram_chats"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID.", "example": "123"}, + "user_id": {"type": "integer", "description": "User ID.", "example": 987654321}, }, + output_schema={"status": {"type": "string", "example": "success"}}, ) -async def get_telegram_updates(input_data: dict) -> dict: +async def decline_telegram_chat_join_request(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client + return await run_client( + "telegram_bot", "decline_chat_join_request", + chat_id=input_data["chat_id"], + user_id=input_data["user_id"], + ) + + +# ===================================================================== +# Bot API — Bot configuration (commands, descriptions, menu button) +# Sub-set: telegram_bot_config +# ===================================================================== +@action( + name="set_telegram_my_commands", + description="Set the list of bot commands shown in the Telegram UI.", + action_sets=["telegram_bot_config"], + input_schema={ + "commands": {"type": "array", "description": "List of {command, description} objects.", "example": [{"command": "start", "description": "Start the bot"}]}, + "scope": {"type": "object", "description": "BotCommandScope.", "example": {"type": "default"}}, + "language_code": {"type": "string", "description": "IETF tag.", "example": "en"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def set_telegram_my_commands(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client return await run_client( - "telegram_bot", - "get_updates", - offset=input_data.get("offset"), - limit=input_data.get("limit", 100), + "telegram_bot", "set_my_commands", + commands=input_data["commands"], + scope=input_data.get("scope"), + language_code=input_data.get("language_code"), ) @action( - name="get_telegram_chat", - description="Get information about a Telegram chat via bot.", - action_sets=["telegram_bot"], + name="get_telegram_my_commands", + description="Get the current list of bot commands.", + action_sets=["telegram_bot_config"], input_schema={ - "chat_id": { - "type": "string", - "description": "Chat ID or @username.", - "example": "123456789", - }, + "scope": {"type": "object", "description": "BotCommandScope.", "example": {"type": "default"}}, + "language_code": {"type": "string", "description": "IETF tag.", "example": "en"}, }, output_schema={"status": {"type": "string", "example": "success"}}, ) -async def get_telegram_chat(input_data: dict) -> dict: +async def get_telegram_my_commands(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client + return await run_client( + "telegram_bot", "get_my_commands", + scope=input_data.get("scope"), + language_code=input_data.get("language_code"), + ) - return await run_client("telegram_bot", "get_chat", chat_id=input_data["chat_id"]) + +@action( + name="delete_telegram_my_commands", + description="Delete the bot commands list for a given scope.", + action_sets=["telegram_bot_config"], + input_schema={ + "scope": {"type": "object", "description": "BotCommandScope.", "example": {"type": "default"}}, + "language_code": {"type": "string", "description": "IETF tag.", "example": "en"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def delete_telegram_my_commands(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "telegram_bot", "delete_my_commands", + scope=input_data.get("scope"), + language_code=input_data.get("language_code"), + ) @action( - name="search_telegram_contact", - description="Search for a Telegram contact by name from bot's recent chat history.", - action_sets=["telegram_bot"], + name="set_telegram_my_description", + description="Set the bot's long description (shown on empty-chat screen).", + action_sets=["telegram_bot_config"], input_schema={ - "name": { - "type": "string", - "description": "Contact name to search for.", - "example": "John", - }, + "description": {"type": "string", "description": "0-512 chars.", "example": "My great bot"}, + "language_code": {"type": "string", "description": "IETF tag.", "example": "en"}, }, output_schema={"status": {"type": "string", "example": "success"}}, ) -async def search_telegram_contact(input_data: dict) -> dict: +async def set_telegram_my_description(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client + return await run_client( + "telegram_bot", "set_my_description", + description=input_data.get("description"), + language_code=input_data.get("language_code"), + ) - return await run_client("telegram_bot", "search_contact", name=input_data["name"]) + +@action( + name="get_telegram_my_description", + description="Get the bot's current description.", + action_sets=["telegram_bot_config"], + input_schema={ + "language_code": {"type": "string", "description": "IETF tag.", "example": "en"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def get_telegram_my_description(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "telegram_bot", "get_my_description", + language_code=input_data.get("language_code"), + ) @action( - name="send_telegram_document", - description="Send a document to a Telegram chat via bot.", - action_sets=["telegram_bot"], + name="set_telegram_my_short_description", + description="Set the bot's short description (shown on profile page and link previews).", + action_sets=["telegram_bot_config"], input_schema={ - "chat_id": {"type": "string", "description": "Chat ID.", "example": "123"}, - "document": { - "type": "string", - "description": "File ID or URL.", - "example": "https://example.com/doc.pdf", - }, - "caption": { - "type": "string", - "description": "Caption.", - "example": "Here is the file", - }, + "short_description": {"type": "string", "description": "0-120 chars.", "example": "Helpful AI"}, + "language_code": {"type": "string", "description": "IETF tag.", "example": "en"}, }, output_schema={"status": {"type": "string", "example": "success"}}, ) -async def send_telegram_document(input_data: dict) -> dict: +async def set_telegram_my_short_description(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "telegram_bot", "set_my_short_description", + short_description=input_data.get("short_description"), + language_code=input_data.get("language_code"), + ) + + +@action( + name="set_telegram_my_name", + description="Set the bot's display name.", + action_sets=["telegram_bot_config"], + input_schema={ + "name": {"type": "string", "description": "0-64 chars.", "example": "CraftBot"}, + "language_code": {"type": "string", "description": "IETF tag.", "example": "en"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def set_telegram_my_name(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client + return await run_client( + "telegram_bot", "set_my_name", + name=input_data.get("name"), + language_code=input_data.get("language_code"), + ) + +@action( + name="set_telegram_chat_menu_button", + description="Set the menu button shown in a specific chat (or default).", + action_sets=["telegram_bot_config"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID (omit for default).", "example": "123"}, + "menu_button": {"type": "object", "description": "MenuButton object.", "example": {"type": "commands"}}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def set_telegram_chat_menu_button(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client return await run_client( - "telegram_bot", - "send_document", - chat_id=input_data["chat_id"], - document=input_data["document"], - caption=input_data.get("caption"), + "telegram_bot", "set_chat_menu_button", + chat_id=input_data.get("chat_id"), + menu_button=input_data.get("menu_button"), ) @action( - name="forward_telegram_message", - description="Forward a message via bot.", - action_sets=["telegram_bot"], - input_schema={ - "chat_id": {"type": "string", "description": "Dest Chat ID.", "example": "123"}, - "from_chat_id": { - "type": "string", - "description": "Source Chat ID.", - "example": "456", - }, - "message_id": {"type": "integer", "description": "Message ID.", "example": 1}, + name="get_telegram_chat_menu_button", + description="Get the menu button for a chat or default.", + action_sets=["telegram_bot_config"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID (omit for default).", "example": "123"}, }, output_schema={"status": {"type": "string", "example": "success"}}, ) -async def forward_telegram_message(input_data: dict) -> dict: +async def get_telegram_chat_menu_button(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "telegram_bot", "get_chat_menu_button", + chat_id=input_data.get("chat_id"), + ) + + +@action( + name="set_telegram_my_default_administrator_rights", + description="Set default admin rights requested when bot is added to a group/channel.", + action_sets=["telegram_bot_config"], + input_schema={ + "rights": {"type": "object", "description": "ChatAdministratorRights object.", "example": {"is_anonymous": False, "can_manage_chat": True}}, + "for_channels": {"type": "boolean", "description": "True for channels, false/omit for groups.", "example": False}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def set_telegram_my_default_administrator_rights(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client + return await run_client( + "telegram_bot", "set_my_default_administrator_rights", + rights=input_data.get("rights"), + for_channels=input_data.get("for_channels"), + ) + +@action( + name="get_telegram_my_default_administrator_rights", + description="Get default admin rights.", + action_sets=["telegram_bot_config"], + input_schema={ + "for_channels": {"type": "boolean", "description": "True for channels.", "example": False}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def get_telegram_my_default_administrator_rights(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client return await run_client( - "telegram_bot", - "forward_message", - chat_id=input_data["chat_id"], - from_chat_id=input_data["from_chat_id"], - message_id=input_data["message_id"], + "telegram_bot", "get_my_default_administrator_rights", + for_channels=input_data.get("for_channels"), ) @action( name="get_telegram_bot_info", - description="Get bot info.", - action_sets=["telegram_bot"], + description="Get bot info (getMe).", + action_sets=["telegram_bot_config", "telegram"], input_schema={}, output_schema={"status": {"type": "string", "example": "success"}}, ) async def get_telegram_bot_info(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client("telegram_bot", "get_me") +# ===================================================================== +# Bot API — Callback queries +# Sub-set: telegram_callbacks +# ===================================================================== + @action( - name="get_telegram_chat_members_count", - description="Get chat members count via bot.", - action_sets=["telegram_bot"], + name="answer_telegram_callback_query", + description="Answer an inline-keyboard callback query (optional notification text or alert).", + action_sets=["telegram_callbacks"], input_schema={ - "chat_id": {"type": "string", "description": "Chat ID.", "example": "123"}, + "callback_query_id": {"type": "string", "description": "Callback query ID.", "example": "abc123"}, + "text": {"type": "string", "description": "Notification text (0-200 chars).", "example": "Got it"}, + "show_alert": {"type": "boolean", "description": "Show as alert dialog.", "example": False}, + "url": {"type": "string", "description": "Open this URL.", "example": "https://example.com"}, + "cache_time": {"type": "integer", "description": "Cache seconds.", "example": 0}, }, output_schema={"status": {"type": "string", "example": "success"}}, ) -async def get_telegram_chat_members_count(input_data: dict) -> dict: +async def answer_telegram_callback_query(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "telegram_bot", "answer_callback_query", + callback_query_id=input_data["callback_query_id"], + text=input_data.get("text"), + show_alert=input_data.get("show_alert"), + url=input_data.get("url"), + cache_time=input_data.get("cache_time"), + ) + + +# ===================================================================== +# Bot API — Webhooks +# Sub-set: telegram_webhooks +# ===================================================================== + +@action( + name="set_telegram_webhook", + description="Register a webhook URL to receive updates via HTTPS POST.", + action_sets=["telegram_webhooks"], + input_schema={ + "url": {"type": "string", "description": "HTTPS URL.", "example": "https://example.com/tg-webhook"}, + "secret_token": {"type": "string", "description": "Header secret 1-256 chars.", "example": "topsecret"}, + "max_connections": {"type": "integer", "description": "Max concurrent updates 1-100.", "example": 40}, + "allowed_updates": {"type": "array", "description": "List of update types to receive.", "example": ["message", "callback_query"]}, + "drop_pending_updates": {"type": "boolean", "description": "Drop pending updates.", "example": False}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def set_telegram_webhook(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client + return await run_client( + "telegram_bot", "set_webhook", + url=input_data["url"], + secret_token=input_data.get("secret_token"), + max_connections=input_data.get("max_connections"), + allowed_updates=input_data.get("allowed_updates"), + drop_pending_updates=input_data.get("drop_pending_updates"), + ) + +@action( + name="delete_telegram_webhook", + description="Remove the registered webhook (returns to long polling).", + action_sets=["telegram_webhooks"], + input_schema={ + "drop_pending_updates": {"type": "boolean", "description": "Drop pending updates.", "example": False}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def delete_telegram_webhook(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client return await run_client( - "telegram_bot", - "get_chat_members_count", - chat_id=input_data["chat_id"], + "telegram_bot", "delete_webhook", + drop_pending_updates=input_data.get("drop_pending_updates"), ) +@action( + name="get_telegram_webhook_info", + description="Get current webhook registration info.", + action_sets=["telegram_webhooks"], + input_schema={}, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def get_telegram_webhook_info(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("telegram_bot", "get_webhook_info") + + # ===================================================================== -# MTProto (user account) actions +# Bot API — Updates / utility +# Sub-set: telegram_messages # ===================================================================== +@action( + name="get_telegram_updates", + description="Get incoming updates (messages) for the Telegram bot.", + action_sets=["telegram_messages", "telegram"], + input_schema={ + "limit": {"type": "integer", "description": "Max number of updates.", "example": 10}, + "offset": {"type": "integer", "description": "Update offset for pagination.", "example": 0}, + }, + output_schema={ + "status": {"type": "string", "example": "success"}, + "updates": {"type": "array", "description": "List of update objects."}, + }, +) +async def get_telegram_updates(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "telegram_bot", "get_updates", + offset=input_data.get("offset"), + limit=input_data.get("limit", 100), + ) + + +@action( + name="search_telegram_contact", + description="Search for a Telegram contact by name from bot's recent chat history.", + action_sets=["telegram_chats"], + input_schema={ + "name": {"type": "string", "description": "Contact name to search for.", "example": "John"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def search_telegram_contact(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("telegram_bot", "search_contact", name=input_data["name"]) + + +# ===================================================================== +# MTProto (user account) actions +# Sub-set: telegram_user +# ===================================================================== @action( name="get_telegram_chats", description="Get chats via Telegram user account.", - action_sets=["telegram_user"], + action_sets=["telegram_user", "telegram"], input_schema={ "limit": {"type": "integer", "description": "Limit.", "example": 50}, }, @@ -259,18 +1500,15 @@ async def get_telegram_chat_members_count(input_data: dict) -> dict: ) async def get_telegram_chats(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( - "telegram_user", - "get_dialogs", - limit=input_data.get("limit", 50), + "telegram_user", "get_dialogs", limit=input_data.get("limit", 50), ) @action( name="read_telegram_messages", description="Read messages via Telegram user account.", - action_sets=["telegram_user"], + action_sets=["telegram_user", "telegram"], input_schema={ "chat_id": {"type": "string", "description": "Chat ID.", "example": "123"}, "limit": {"type": "integer", "description": "Limit.", "example": 50}, @@ -279,10 +1517,8 @@ async def get_telegram_chats(input_data: dict) -> dict: ) async def read_telegram_messages(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( - "telegram_user", - "get_messages", + "telegram_user", "get_messages", chat_id=input_data["chat_id"], limit=input_data.get("limit", 50), ) @@ -291,27 +1527,18 @@ async def read_telegram_messages(input_data: dict) -> dict: @action( name="send_telegram_user_message", description="Send a text message via Telegram user account. IMPORTANT: Use @username (e.g., '@emadtavana7') NOT numeric ID. Use 'self' or 'user' to message the owner's Saved Messages.", - action_sets=["telegram_user"], + action_sets=["telegram_user", "telegram"], input_schema={ - "chat_id": { - "type": "string", - "description": "Recipient: @username (preferred), phone number, or 'self' for Saved Messages. Do NOT use numeric IDs.", - "example": "@emadtavana7", - }, + "chat_id": {"type": "string", "description": "Recipient: @username (preferred), phone number, or 'self' for Saved Messages. Do NOT use numeric IDs.", "example": "@emadtavana7"}, "text": {"type": "string", "description": "Text.", "example": "Hi"}, }, output_schema={"status": {"type": "string", "example": "success"}}, ) async def send_telegram_user_message(input_data: dict) -> dict: - from app.data.action.integrations._helpers import ( - record_outgoing_message, - run_client, - ) - + from app.data.action.integrations._helpers import record_outgoing_message, run_client record_outgoing_message("Telegram", input_data["chat_id"], input_data["text"]) return await run_client( - "telegram_user", - "send_message", + "telegram_user", "send_message", recipient=input_data["chat_id"], text=input_data["text"], ) @@ -323,20 +1550,14 @@ async def send_telegram_user_message(input_data: dict) -> dict: action_sets=["telegram_user"], input_schema={ "chat_id": {"type": "string", "description": "Chat ID.", "example": "123"}, - "file_path": { - "type": "string", - "description": "Path.", - "example": "/path/to/file", - }, + "file_path": {"type": "string", "description": "Path.", "example": "/path/to/file"}, }, output_schema={"status": {"type": "string", "example": "success"}}, ) async def send_telegram_user_file(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( - "telegram_user", - "send_file", + "telegram_user", "send_file", chat_id=input_data["chat_id"], file_path=input_data["file_path"], ) @@ -353,11 +1574,8 @@ async def send_telegram_user_file(input_data: dict) -> dict: ) async def search_telegram_user_contacts(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( - "telegram_user", - "search_contacts", - query=input_data["query"], + "telegram_user", "search_contacts", query=input_data["query"], ) @@ -370,5 +1588,4 @@ async def search_telegram_user_contacts(input_data: dict) -> dict: ) async def get_telegram_user_account_info(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client("telegram_user", "get_me") diff --git a/app/data/action/integrations/twitter/twitter_actions.py b/app/data/action/integrations/twitter/twitter_actions.py index 8051946d..a86ba568 100644 --- a/app/data/action/integrations/twitter/twitter_actions.py +++ b/app/data/action/integrations/twitter/twitter_actions.py @@ -1,10 +1,15 @@ from agent_core import action +# ------------------------------------------------------------------ +# Tweets — post, reply, delete, lookup, mentions, quote, hide, search +# Sub-set: twitter_tweets +# ------------------------------------------------------------------ + @action( name="post_tweet", description="Post a tweet on Twitter/X.", - action_sets=["twitter"], + action_sets=["twitter_tweets", "twitter"], input_schema={ "text": { "type": "string", @@ -34,7 +39,7 @@ async def post_tweet(input_data: dict) -> dict: @action( name="reply_to_tweet", description="Reply to a tweet on Twitter/X.", - action_sets=["twitter"], + action_sets=["twitter_tweets", "twitter"], input_schema={ "tweet_id": { "type": "string", @@ -62,7 +67,7 @@ async def reply_to_tweet(input_data: dict) -> dict: @action( name="delete_tweet", description="Delete a tweet.", - action_sets=["twitter"], + action_sets=["twitter_tweets", "twitter"], input_schema={ "tweet_id": { "type": "string", @@ -79,10 +84,38 @@ async def delete_tweet(input_data: dict) -> dict: return await run_client("twitter", "delete_tweet", tweet_id=input_data["tweet_id"]) +@action( + name="get_tweet", + description="Fetch a single tweet by ID.", + action_sets=["twitter_tweets", "twitter"], + input_schema={ + "tweet_id": {"type": "string", "description": "Tweet ID.", "example": "1234567890"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def get_tweet(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("twitter", "get_tweet", tweet_id=input_data["tweet_id"]) + + +@action( + name="lookup_tweets", + description="Batch-lookup up to 100 tweets by their IDs.", + action_sets=["twitter_tweets"], + input_schema={ + "tweet_ids": {"type": "array", "description": "List of tweet IDs (max 100).", "example": ["123", "456"]}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def lookup_tweets(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("twitter", "lookup_tweets", tweet_ids=input_data["tweet_ids"]) + + @action( name="search_tweets", description="Search recent tweets on Twitter/X.", - action_sets=["twitter"], + action_sets=["twitter_tweets", "twitter"], input_schema={ "query": { "type": "string", @@ -111,7 +144,7 @@ async def search_tweets(input_data: dict) -> dict: @action( name="get_twitter_timeline", description="Get recent tweets from a user's timeline.", - action_sets=["twitter"], + action_sets=["twitter_tweets", "twitter"], input_schema={ "user_id": { "type": "string", @@ -137,10 +170,96 @@ async def get_twitter_timeline(input_data: dict) -> dict: ) +@action( + name="get_twitter_mentions", + description="Get recent mentions of a user (defaults to the authenticated user).", + action_sets=["twitter_tweets", "twitter"], + input_schema={ + "user_id": {"type": "string", "description": "User ID. Leave empty for self.", "example": ""}, + "max_results": {"type": "integer", "description": "Max mentions.", "example": 10}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def get_twitter_mentions(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "twitter", "get_user_mentions", + user_id=input_data.get("user_id") or None, + max_results=input_data.get("max_results", 10), + ) + + +@action( + name="post_quote_tweet", + description="Post a quote tweet that wraps another tweet with your own commentary.", + action_sets=["twitter_tweets", "twitter"], + input_schema={ + "text": {"type": "string", "description": "Your commentary.", "example": "Great point —"}, + "quoted_tweet_id": {"type": "string", "description": "Tweet ID being quoted.", "example": "1234567890"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def post_quote_tweet(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "twitter", "post_quote_tweet", + text=input_data["text"], + quoted_tweet_id=input_data["quoted_tweet_id"], + ) + + +@action( + name="hide_tweet_reply", + description="Hide (or unhide) a reply to one of your tweets.", + action_sets=["twitter_tweets"], + input_schema={ + "reply_tweet_id": {"type": "string", "description": "ID of the reply tweet.", "example": "1234567890"}, + "hidden": {"type": "boolean", "description": "True to hide, False to unhide.", "example": True}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def hide_tweet_reply(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "twitter", "hide_reply", + reply_tweet_id=input_data["reply_tweet_id"], + hidden=input_data.get("hidden", True), + ) + + +@action( + name="post_tweet_with_media", + description="Post a tweet that includes already-uploaded media (use upload_twitter_media first to get media_ids).", + action_sets=["twitter_tweets", "twitter"], + input_schema={ + "text": {"type": "string", "description": "Tweet text.", "example": "Check this out!"}, + "media_ids": {"type": "array", "description": "Up to 4 media_id_string values from upload_twitter_media.", "example": ["1234567890"]}, + "reply_to": {"type": "string", "description": "Optional tweet ID to reply to.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def post_tweet_with_media(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "twitter", "post_tweet_with_media", + text=input_data["text"], + media_ids=input_data["media_ids"], + reply_to=input_data.get("reply_to") or None, + ) + + +# ------------------------------------------------------------------ +# Engagement — like, unlike, retweet, unretweet, bookmarks, lookups +# Sub-set: twitter_engagement +# ------------------------------------------------------------------ + @action( name="like_tweet", description="Like a tweet on Twitter/X.", - action_sets=["twitter"], + action_sets=["twitter_engagement", "twitter"], input_schema={ "tweet_id": { "type": "string", @@ -157,10 +276,25 @@ async def like_tweet(input_data: dict) -> dict: return await run_client("twitter", "like_tweet", tweet_id=input_data["tweet_id"]) +@action( + name="unlike_tweet", + description="Unlike a previously liked tweet.", + action_sets=["twitter_engagement"], + input_schema={ + "tweet_id": {"type": "string", "description": "Tweet ID to unlike.", "example": "1234567890"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def unlike_tweet(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("twitter", "unlike_tweet", tweet_id=input_data["tweet_id"]) + + @action( name="retweet", description="Retweet a tweet on Twitter/X.", - action_sets=["twitter"], + action_sets=["twitter_engagement", "twitter"], input_schema={ "tweet_id": { "type": "string", @@ -177,10 +311,112 @@ async def retweet(input_data: dict) -> dict: return await run_client("twitter", "retweet", tweet_id=input_data["tweet_id"]) +@action( + name="unretweet", + description="Undo a retweet.", + action_sets=["twitter_engagement"], + input_schema={ + "tweet_id": {"type": "string", "description": "Original tweet ID that was retweeted.", "example": "1234567890"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def unretweet(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("twitter", "unretweet", tweet_id=input_data["tweet_id"]) + + +@action( + name="add_twitter_bookmark", + description="Bookmark a tweet (saves to the authed user's bookmarks).", + action_sets=["twitter_engagement", "twitter"], + input_schema={ + "tweet_id": {"type": "string", "description": "Tweet ID to bookmark.", "example": "1234567890"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def add_twitter_bookmark(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("twitter", "add_bookmark", tweet_id=input_data["tweet_id"]) + + +@action( + name="remove_twitter_bookmark", + description="Remove a tweet from bookmarks.", + action_sets=["twitter_engagement"], + input_schema={ + "tweet_id": {"type": "string", "description": "Tweet ID to remove from bookmarks.", "example": "1234567890"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def remove_twitter_bookmark(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("twitter", "remove_bookmark", tweet_id=input_data["tweet_id"]) + + +@action( + name="list_twitter_bookmarks", + description="List the authenticated user's bookmarked tweets.", + action_sets=["twitter_engagement", "twitter"], + input_schema={ + "max_results": {"type": "integer", "description": "Max results.", "example": 50}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def list_twitter_bookmarks(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("twitter", "list_bookmarks", max_results=input_data.get("max_results", 50)) + + +@action( + name="list_tweet_liking_users", + description="List users who liked a specific tweet.", + action_sets=["twitter_engagement"], + input_schema={ + "tweet_id": {"type": "string", "description": "Tweet ID.", "example": "1234567890"}, + "max_results": {"type": "integer", "description": "Max users.", "example": 50}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def list_tweet_liking_users(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "twitter", "list_liking_users", + tweet_id=input_data["tweet_id"], + max_results=input_data.get("max_results", 50), + ) + + +@action( + name="list_tweet_retweeted_by", + description="List users who retweeted a specific tweet.", + action_sets=["twitter_engagement"], + input_schema={ + "tweet_id": {"type": "string", "description": "Tweet ID.", "example": "1234567890"}, + "max_results": {"type": "integer", "description": "Max users.", "example": 50}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def list_tweet_retweeted_by(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "twitter", "list_retweeted_by", + tweet_id=input_data["tweet_id"], + max_results=input_data.get("max_results", 50), + ) + + +# ------------------------------------------------------------------ +# Users — lookup, follow, block, mute +# Sub-set: twitter_users +# ------------------------------------------------------------------ + @action( name="get_twitter_user", description="Look up a Twitter/X user by username.", - action_sets=["twitter"], + action_sets=["twitter_users", "twitter"], input_schema={ "username": { "type": "string", @@ -201,7 +437,7 @@ async def get_twitter_user(input_data: dict) -> dict: @action( name="get_twitter_me", description="Get the authenticated Twitter/X user's profile.", - action_sets=["twitter"], + action_sets=["twitter_users", "twitter"], input_schema={}, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -211,15 +447,444 @@ async def get_twitter_me(input_data: dict) -> dict: return await run_client("twitter", "get_me") +@action( + name="follow_twitter_user", + description="Follow a Twitter/X user by their numeric user_id.", + action_sets=["twitter_users", "twitter"], + input_schema={ + "target_user_id": {"type": "string", "description": "Target user_id (numeric).", "example": "44196397"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def follow_twitter_user(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("twitter", "follow_user", target_user_id=input_data["target_user_id"]) + + +@action( + name="unfollow_twitter_user", + description="Unfollow a Twitter/X user.", + action_sets=["twitter_users"], + input_schema={ + "target_user_id": {"type": "string", "description": "Target user_id (numeric).", "example": "44196397"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def unfollow_twitter_user(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("twitter", "unfollow_user", target_user_id=input_data["target_user_id"]) + + +@action( + name="list_twitter_following", + description="List who a user is following (defaults to the authed user).", + action_sets=["twitter_users", "twitter"], + input_schema={ + "user_id": {"type": "string", "description": "User ID. Leave empty for self.", "example": ""}, + "max_results": {"type": "integer", "description": "Max users to return.", "example": 100}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def list_twitter_following(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "twitter", "list_following", + user_id=input_data.get("user_id") or None, + max_results=input_data.get("max_results", 100), + ) + + +@action( + name="list_twitter_followers", + description="List a user's followers (defaults to the authed user).", + action_sets=["twitter_users", "twitter"], + input_schema={ + "user_id": {"type": "string", "description": "User ID. Leave empty for self.", "example": ""}, + "max_results": {"type": "integer", "description": "Max users.", "example": 100}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def list_twitter_followers(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "twitter", "list_followers", + user_id=input_data.get("user_id") or None, + max_results=input_data.get("max_results", 100), + ) + + +@action( + name="block_twitter_user", + description="Block a Twitter/X user.", + action_sets=["twitter_users"], + input_schema={ + "target_user_id": {"type": "string", "description": "Target user_id.", "example": "44196397"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def block_twitter_user(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("twitter", "block_user", target_user_id=input_data["target_user_id"]) + + +@action( + name="unblock_twitter_user", + description="Unblock a Twitter/X user.", + action_sets=["twitter_users"], + input_schema={ + "target_user_id": {"type": "string", "description": "Target user_id.", "example": "44196397"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def unblock_twitter_user(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("twitter", "unblock_user", target_user_id=input_data["target_user_id"]) + + +@action( + name="mute_twitter_user", + description="Mute a Twitter/X user (hides their content from your timeline).", + action_sets=["twitter_users"], + input_schema={ + "target_user_id": {"type": "string", "description": "Target user_id.", "example": "44196397"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def mute_twitter_user(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("twitter", "mute_user", target_user_id=input_data["target_user_id"]) + + +@action( + name="unmute_twitter_user", + description="Unmute a previously muted user.", + action_sets=["twitter_users"], + input_schema={ + "target_user_id": {"type": "string", "description": "Target user_id.", "example": "44196397"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def unmute_twitter_user(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("twitter", "unmute_user", target_user_id=input_data["target_user_id"]) + + +# ------------------------------------------------------------------ +# Lists — create, get, update, delete, members +# Sub-set: twitter_lists +# ------------------------------------------------------------------ + +@action( + name="create_twitter_list", + description="Create a new Twitter/X list.", + action_sets=["twitter_lists", "twitter"], + input_schema={ + "name": {"type": "string", "description": "List name.", "example": "Tech founders"}, + "description": {"type": "string", "description": "Optional description.", "example": ""}, + "private": {"type": "boolean", "description": "Private list.", "example": False}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def create_twitter_list(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "twitter", "create_list", + name=input_data["name"], + description=input_data.get("description", ""), + private=input_data.get("private", False), + ) + + +@action( + name="get_twitter_list", + description="Get a Twitter/X list by ID.", + action_sets=["twitter_lists"], + input_schema={ + "list_id": {"type": "string", "description": "List ID.", "example": "1234567890"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def get_twitter_list(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("twitter", "get_list", list_id=input_data["list_id"]) + + +@action( + name="update_twitter_list", + description="Update a Twitter/X list's name, description, or privacy.", + action_sets=["twitter_lists"], + input_schema={ + "list_id": {"type": "string", "description": "List ID.", "example": "1234567890"}, + "name": {"type": "string", "description": "New name.", "example": ""}, + "description": {"type": "string", "description": "New description.", "example": ""}, + "private": {"type": "boolean", "description": "Private flag.", "example": False}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def update_twitter_list(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "twitter", "update_list", + list_id=input_data["list_id"], + name=input_data.get("name") or None, + description=input_data.get("description") if input_data.get("description") is not None else None, + private=input_data.get("private"), + ) + + +@action( + name="delete_twitter_list", + description="Delete a Twitter/X list.", + action_sets=["twitter_lists"], + input_schema={ + "list_id": {"type": "string", "description": "List ID.", "example": "1234567890"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def delete_twitter_list(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("twitter", "delete_list", list_id=input_data["list_id"]) + + +@action( + name="list_twitter_owned_lists", + description="List the lists owned by a user (defaults to the authed user).", + action_sets=["twitter_lists", "twitter"], + input_schema={ + "user_id": {"type": "string", "description": "User ID. Leave empty for self.", "example": ""}, + "max_results": {"type": "integer", "description": "Max lists.", "example": 100}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def list_twitter_owned_lists(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "twitter", "list_owned_lists", + user_id=input_data.get("user_id") or None, + max_results=input_data.get("max_results", 100), + ) + + +@action( + name="add_twitter_list_member", + description="Add a user to a Twitter/X list.", + action_sets=["twitter_lists"], + input_schema={ + "list_id": {"type": "string", "description": "List ID.", "example": "1234567890"}, + "user_id": {"type": "string", "description": "User ID to add.", "example": "44196397"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def add_twitter_list_member(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "twitter", "add_list_member", + list_id=input_data["list_id"], + user_id=input_data["user_id"], + ) + + +@action( + name="remove_twitter_list_member", + description="Remove a user from a Twitter/X list.", + action_sets=["twitter_lists"], + input_schema={ + "list_id": {"type": "string", "description": "List ID.", "example": "1234567890"}, + "user_id": {"type": "string", "description": "User ID to remove.", "example": "44196397"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def remove_twitter_list_member(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "twitter", "remove_list_member", + list_id=input_data["list_id"], + user_id=input_data["user_id"], + ) + + +@action( + name="list_twitter_list_members", + description="List members of a Twitter/X list.", + action_sets=["twitter_lists"], + input_schema={ + "list_id": {"type": "string", "description": "List ID.", "example": "1234567890"}, + "max_results": {"type": "integer", "description": "Max users.", "example": 100}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def list_twitter_list_members(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "twitter", "list_list_members", + list_id=input_data["list_id"], + max_results=input_data.get("max_results", 100), + ) + + +@action( + name="list_twitter_list_tweets", + description="List recent tweets in a Twitter/X list.", + action_sets=["twitter_lists"], + input_schema={ + "list_id": {"type": "string", "description": "List ID.", "example": "1234567890"}, + "max_results": {"type": "integer", "description": "Max tweets.", "example": 100}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def list_twitter_list_tweets(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "twitter", "list_list_tweets", + list_id=input_data["list_id"], + max_results=input_data.get("max_results", 100), + ) + + +# ------------------------------------------------------------------ +# Direct Messages +# Sub-set: twitter_dms +# ------------------------------------------------------------------ + +@action( + name="send_twitter_dm", + description="Send a one-on-one direct message on Twitter/X (creates the conversation if needed).", + action_sets=["twitter_dms", "twitter"], + input_schema={ + "participant_id": {"type": "string", "description": "Recipient user_id (numeric).", "example": "44196397"}, + "text": {"type": "string", "description": "Message text.", "example": "Hello!"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def send_twitter_dm(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "twitter", "send_dm_to_user", + participant_id=input_data["participant_id"], + text=input_data["text"], + ) + + +@action( + name="send_twitter_dm_to_conversation", + description="Send a DM into an existing conversation by ID.", + action_sets=["twitter_dms"], + input_schema={ + "dm_conversation_id": {"type": "string", "description": "Conversation ID.", "example": "1234567890-987654321"}, + "text": {"type": "string", "description": "Message text.", "example": "Following up..."}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def send_twitter_dm_to_conversation(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "twitter", "send_dm_to_conversation", + dm_conversation_id=input_data["dm_conversation_id"], + text=input_data["text"], + ) + + +@action( + name="create_twitter_group_dm", + description="Create a new group DM conversation and send the first message.", + action_sets=["twitter_dms"], + input_schema={ + "participant_ids": {"type": "array", "description": "List of user_ids to add.", "example": ["44196397", "987654321"]}, + "text": {"type": "string", "description": "First message.", "example": "Hi everyone"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def create_twitter_group_dm(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "twitter", "create_group_dm", + participant_ids=input_data["participant_ids"], + text=input_data["text"], + ) + + +@action( + name="list_twitter_dm_events", + description="List recent DM events across all conversations for the authed user.", + action_sets=["twitter_dms", "twitter"], + input_schema={ + "max_results": {"type": "integer", "description": "Max events.", "example": 100}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def list_twitter_dm_events(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("twitter", "list_dm_events", max_results=input_data.get("max_results", 100)) + + +@action( + name="list_twitter_dm_events_with_user", + description="List DM events in the conversation with a specific user.", + action_sets=["twitter_dms"], + input_schema={ + "participant_id": {"type": "string", "description": "Other user's user_id.", "example": "44196397"}, + "max_results": {"type": "integer", "description": "Max events.", "example": 100}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def list_twitter_dm_events_with_user(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "twitter", "list_dm_events_with_user", + participant_id=input_data["participant_id"], + max_results=input_data.get("max_results", 100), + ) + + +# ------------------------------------------------------------------ +# Media +# Sub-set: twitter_media +# ------------------------------------------------------------------ + +@action( + name="upload_twitter_media", + description="Upload an image / GIF / video for use in a tweet. Returns the media_id_string to pass to post_tweet_with_media.", + action_sets=["twitter_media", "twitter"], + input_schema={ + "file_path": {"type": "string", "description": "Local file path.", "example": "/tmp/image.jpg"}, + "media_category": {"type": "string", "description": "tweet_image | tweet_gif | tweet_video | dm_image | dm_video.", "example": "tweet_image"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def upload_twitter_media(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "twitter", "upload_media", + file_path=input_data["file_path"], + media_category=input_data.get("media_category", "tweet_image"), + ) + + # ------------------------------------------------------------------ -# Watch Settings (custom: bespoke success messages, no async) +# Listener configuration (custom: sync, bespoke success messages) +# Sub-set: twitter_listener # ------------------------------------------------------------------ @action( name="set_twitter_watch_tag", description="Set a keyword the Twitter listener watches for in mentions. Only mentions containing this keyword will trigger events.", - action_sets=["twitter"], + action_sets=["twitter_listener"], input_schema={ "tag": { "type": "string", diff --git a/app/data/action/integrations/whatsapp/whatsapp_actions.py b/app/data/action/integrations/whatsapp/whatsapp_actions.py index 66a8ebe0..b4d5de76 100644 --- a/app/data/action/integrations/whatsapp/whatsapp_actions.py +++ b/app/data/action/integrations/whatsapp/whatsapp_actions.py @@ -1,35 +1,26 @@ from agent_core import action +# ═══════════════════════════════════════════════════════════════════════════════ +# Messages — send / edit / delete / reply / forward / react / star / download +# ═══════════════════════════════════════════════════════════════════════════════ + @action( name="send_whatsapp_web_text_message", description="Send a text message via WhatsApp Web.", - action_sets=["whatsapp"], + action_sets=["whatsapp_messages", "whatsapp"], input_schema={ - "to": { - "type": "string", - "description": "Recipient phone number (e.g. '1234567890') OR the exact `number` / `id` value returned by search_whatsapp_contact (e.g. '185628603977847@lid'). Pass the value verbatim — do NOT strip the '@lid' or '@c.us' suffix.", - "example": "1234567890", - }, - "message": { - "type": "string", - "description": "Message text.", - "example": "Hello!", - }, + "to": {"type": "string", "description": "Recipient phone number (e.g. '1234567890') OR the exact `number` / `id` value returned by search_whatsapp_contact (e.g. '185628603977847@lid'). Pass the value verbatim — do NOT strip the '@lid' or '@c.us' suffix.", "example": "1234567890"}, + "message": {"type": "string", "description": "Message text.", "example": "Hello!"}, }, output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, ) async def send_whatsapp_web_text_message(input_data: dict) -> dict: - from app.data.action.integrations._helpers import ( - record_outgoing_message, - run_client, - ) - - # Record to conversation history BEFORE sending (ensures correct ordering) + from app.data.action.integrations._helpers import record_outgoing_message, run_client record_outgoing_message("WhatsApp", input_data["to"], input_data["message"]) return await run_client( - "whatsapp_web", - "send_message", + "whatsapp_web", "send_message", recipient=input_data["to"], text=input_data["message"], ) @@ -37,59 +28,255 @@ async def send_whatsapp_web_text_message(input_data: dict) -> dict: @action( name="send_whatsapp_web_media_message", - description="Send a media message via WhatsApp Web.", - action_sets=["whatsapp"], + description="Send a media file (image / video / audio / document) via WhatsApp Web. Set send_as_sticker / send_as_voice / send_as_document to override the default mode.", + action_sets=["whatsapp_messages", "whatsapp"], input_schema={ - "to": { - "type": "string", - "description": "Recipient phone number (e.g. '1234567890') OR the exact `number` / `id` value returned by search_whatsapp_contact (e.g. '185628603977847@lid'). Pass the value verbatim — do NOT strip the '@lid' or '@c.us' suffix.", - "example": "1234567890", - }, - "media_path": { - "type": "string", - "description": "Local media path.", - "example": "/path/to/img.jpg", - }, - "caption": { - "type": "string", - "description": "Optional caption.", - "example": "Caption", - }, + "to": {"type": "string", "description": "Recipient phone number OR the `number` / `id` from search_whatsapp_contact.", "example": "1234567890"}, + "media_path": {"type": "string", "description": "Absolute local path to the media file.", "example": "C:/Users/me/photo.jpg"}, + "caption": {"type": "string", "description": "Optional caption.", "example": ""}, + "send_as_sticker": {"type": "boolean", "description": "Send image as sticker.", "example": False}, + "send_as_voice": {"type": "boolean", "description": "Send audio as voice note.", "example": False}, + "send_as_document": {"type": "boolean", "description": "Send as document (preserves filename).", "example": False}, + "quoted_message_id": {"type": "string", "description": "Quote-reply to this message ID (optional).", "example": ""}, }, output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, ) async def send_whatsapp_web_media_message(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( - "whatsapp_web", - "send_media", + "whatsapp_web", "send_media", recipient=input_data["to"], media_path=input_data["media_path"], caption=input_data.get("caption"), + send_as_sticker=bool(input_data.get("send_as_sticker", False)), + send_as_voice=bool(input_data.get("send_as_voice", False)), + send_as_document=bool(input_data.get("send_as_document", False)), + quoted_message_id=input_data.get("quoted_message_id") or None, + ) + + +@action( + name="send_whatsapp_location", + description="Send a location pin via WhatsApp Web.", + action_sets=["whatsapp_messages", "whatsapp"], + input_schema={ + "to": {"type": "string", "description": "Recipient.", "example": ""}, + "latitude": {"type": "number", "description": "Latitude.", "example": 37.7749}, + "longitude": {"type": "number", "description": "Longitude.", "example": -122.4194}, + "description": {"type": "string", "description": "Optional label.", "example": "Meeting spot"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def send_whatsapp_location(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "whatsapp_web", "send_location", + recipient=input_data["to"], + latitude=input_data["latitude"], + longitude=input_data["longitude"], + description=input_data.get("description", ""), ) +@action( + name="reply_whatsapp_message", + description="Quote-reply to a specific WhatsApp message.", + action_sets=["whatsapp_messages", "whatsapp"], + input_schema={ + "to": {"type": "string", "description": "Recipient (usually the chat ID where the original message is).", "example": ""}, + "text": {"type": "string", "description": "Reply text.", "example": ""}, + "quoted_message_id": {"type": "string", "description": "Message ID being quoted.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def reply_whatsapp_message(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "whatsapp_web", "send_reply", + recipient=input_data["to"], + text=input_data["text"], + quoted_message_id=input_data["quoted_message_id"], + ) + + +@action( + name="edit_whatsapp_message", + description="Edit a previously-sent WhatsApp message (within WhatsApp's edit window, ~15 min).", + action_sets=["whatsapp_messages", "whatsapp"], + input_schema={ + "message_id": {"type": "string", "description": "Message ID.", "example": ""}, + "new_body": {"type": "string", "description": "New message text.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def edit_whatsapp_message(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "whatsapp_web", "edit_message", + message_id=input_data["message_id"], + new_body=input_data["new_body"], + ) + + +@action( + name="delete_whatsapp_message", + description="Delete a WhatsApp message. everyone=true uses 'Delete for everyone' (within WhatsApp's recall window).", + action_sets=["whatsapp_messages", "whatsapp"], + input_schema={ + "message_id": {"type": "string", "description": "Message ID.", "example": ""}, + "everyone": {"type": "boolean", "description": "Delete for everyone (vs only me).", "example": False}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def delete_whatsapp_message(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "whatsapp_web", "delete_message", + message_id=input_data["message_id"], + everyone=bool(input_data.get("everyone", False)), + ) + + +@action( + name="forward_whatsapp_message", + description="Forward a message to another chat.", + action_sets=["whatsapp_messages", "whatsapp"], + input_schema={ + "message_id": {"type": "string", "description": "Message ID.", "example": ""}, + "to": {"type": "string", "description": "Destination chat ID or phone number.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def forward_whatsapp_message(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "whatsapp_web", "forward_message", + message_id=input_data["message_id"], + recipient=input_data["to"], + ) + + +@action( + name="react_to_whatsapp_message", + description="Add (or remove with empty emoji) an emoji reaction to a WhatsApp message.", + action_sets=["whatsapp_messages", "whatsapp"], + input_schema={ + "message_id": {"type": "string", "description": "Message ID.", "example": ""}, + "emoji": {"type": "string", "description": "Unicode emoji ('' to remove).", "example": "👍"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def react_to_whatsapp_message(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "whatsapp_web", "react_message", + message_id=input_data["message_id"], + emoji=input_data.get("emoji", ""), + ) + + +@action( + name="star_whatsapp_message", + description="Star or unstar a WhatsApp message.", + action_sets=["whatsapp_messages"], + input_schema={ + "message_id": {"type": "string", "description": "Message ID.", "example": ""}, + "starred": {"type": "boolean", "description": "True=star, False=unstar.", "example": True}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def star_whatsapp_message(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "whatsapp_web", "star_message", + message_id=input_data["message_id"], + starred=bool(input_data.get("starred", True)), + ) + + +@action( + name="download_whatsapp_message_media", + description="Download an attached image/video/audio/document from a WhatsApp message to a local path.", + action_sets=["whatsapp_messages", "whatsapp"], + input_schema={ + "message_id": {"type": "string", "description": "Message ID.", "example": ""}, + "dest_path": {"type": "string", "description": "Local destination path.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def download_whatsapp_message_media(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "whatsapp_web", "download_message_media", + message_id=input_data["message_id"], + dest_path=input_data["dest_path"], + ) + + +@action( + name="get_whatsapp_quoted_message", + description="If a message is a reply, get the message it's quoting.", + action_sets=["whatsapp_messages"], + input_schema={ + "message_id": {"type": "string", "description": "Message ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def get_whatsapp_quoted_message(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "whatsapp_web", "get_quoted_message", + message_id=input_data["message_id"], + ) + + +@action( + name="send_whatsapp_typing_state", + description="Show typing/recording state in a chat (sends presence). state: typing | recording | clear.", + action_sets=["whatsapp_messages"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID.", "example": ""}, + "state": {"type": "string", "description": "typing | recording | clear.", "example": "typing"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def send_whatsapp_typing_state(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "whatsapp_web", "send_typing_state", + chat_id=input_data["chat_id"], + state=input_data.get("state", "typing"), + ) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Chats — history / mark-read / archive / pin / mute / clear / delete +# ═══════════════════════════════════════════════════════════════════════════════ + @action( name="get_whatsapp_chat_history", - description="Get chat history (WhatsApp Web).", - action_sets=["whatsapp"], + description="Get chat message history.", + action_sets=["whatsapp_chats", "whatsapp"], input_schema={ - "phone_number": { - "type": "string", - "description": "Phone number.", - "example": "1234567890", - }, - "limit": {"type": "integer", "description": "Limit.", "example": 50}, + "phone_number": {"type": "string", "description": "Phone number or chat ID.", "example": "1234567890"}, + "limit": {"type": "integer", "description": "Max messages.", "example": 50}, }, output_schema={"status": {"type": "string", "example": "success"}}, ) async def get_whatsapp_chat_history(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client( - "whatsapp_web", - "get_chat_messages", + "whatsapp_web", "get_chat_messages", phone_number=input_data["phone_number"], limit=input_data.get("limit", 50), ) @@ -97,44 +284,486 @@ async def get_whatsapp_chat_history(input_data: dict) -> dict: @action( name="get_whatsapp_unread_chats", - description="Get unread chats (WhatsApp Web).", - action_sets=["whatsapp"], + description="List chats with unread messages.", + action_sets=["whatsapp_chats", "whatsapp"], input_schema={}, output_schema={"status": {"type": "string", "example": "success"}}, ) async def get_whatsapp_unread_chats(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client("whatsapp_web", "get_unread_chats") +@action( + name="mark_whatsapp_chat_read", + description="Mark a WhatsApp chat as read (clears unread badge + sends read receipts).", + action_sets=["whatsapp_chats", "whatsapp"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def mark_whatsapp_chat_read(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("whatsapp_web", "mark_chat_read", chat_id=input_data["chat_id"]) + + +@action( + name="mark_whatsapp_chat_unread", + description="Mark a chat as unread (flag for follow-up without replying).", + action_sets=["whatsapp_chats"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def mark_whatsapp_chat_unread(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("whatsapp_web", "mark_chat_unread", chat_id=input_data["chat_id"]) + + +@action( + name="archive_whatsapp_chat", + description="Archive (archive=true) or unarchive (archive=false) a chat.", + action_sets=["whatsapp_chats", "whatsapp"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID.", "example": ""}, + "archive": {"type": "boolean", "description": "True=archive, False=unarchive.", "example": True}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def archive_whatsapp_chat(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "whatsapp_web", "archive_chat", + chat_id=input_data["chat_id"], + archive=bool(input_data.get("archive", True)), + ) + + +@action( + name="pin_whatsapp_chat", + description="Pin (pin=true) or unpin (pin=false) a chat.", + action_sets=["whatsapp_chats", "whatsapp"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID.", "example": ""}, + "pin": {"type": "boolean", "description": "True=pin, False=unpin.", "example": True}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def pin_whatsapp_chat(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "whatsapp_web", "pin_chat", + chat_id=input_data["chat_id"], + pin=bool(input_data.get("pin", True)), + ) + + +@action( + name="mute_whatsapp_chat", + description="Mute (mute=true, optionally until unmute_date unix-seconds) or unmute a chat.", + action_sets=["whatsapp_chats", "whatsapp"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID.", "example": ""}, + "mute": {"type": "boolean", "description": "True=mute, False=unmute.", "example": True}, + "unmute_date": {"type": "integer", "description": "Unix seconds when mute expires (optional, omit for forever).", "example": 0}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def mute_whatsapp_chat(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + ud = input_data.get("unmute_date") + return await run_client( + "whatsapp_web", "mute_chat", + chat_id=input_data["chat_id"], + mute=bool(input_data.get("mute", True)), + unmute_date=ud if ud else None, + ) + + +@action( + name="clear_whatsapp_chat_messages", + description="Clear all messages in a chat (the chat itself stays). Local only — doesn't delete for other party.", + action_sets=["whatsapp_chats"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def clear_whatsapp_chat_messages(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("whatsapp_web", "clear_chat_messages", chat_id=input_data["chat_id"]) + + +@action( + name="delete_whatsapp_chat", + description="Delete a chat entirely (local). For groups, you must leave_whatsapp_group first.", + action_sets=["whatsapp_chats"], + input_schema={ + "chat_id": {"type": "string", "description": "Chat ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def delete_whatsapp_chat(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("whatsapp_web", "delete_chat", chat_id=input_data["chat_id"]) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Groups — create / members / subject / description / invite / leave +# ═══════════════════════════════════════════════════════════════════════════════ + +@action( + name="create_whatsapp_group", + description="Create a WhatsApp group. participants can be phone numbers (digits) or JIDs.", + action_sets=["whatsapp_groups", "whatsapp"], + input_schema={ + "name": {"type": "string", "description": "Group name.", "example": "Project X"}, + "participants": {"type": "array", "description": "Phone numbers or JIDs.", "example": ["1234567890"]}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def create_whatsapp_group(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "whatsapp_web", "create_group", + name=input_data["name"], + participants=input_data["participants"], + ) + + +@action( + name="add_whatsapp_group_participants", + description="Add participants to a group (requires admin).", + action_sets=["whatsapp_groups", "whatsapp"], + input_schema={ + "group_id": {"type": "string", "description": "Group ID.", "example": ""}, + "participants": {"type": "array", "description": "Participant JIDs.", "example": []}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def add_whatsapp_group_participants(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "whatsapp_web", "group_add_participants", + group_id=input_data["group_id"], + participants=input_data["participants"], + ) + + +@action( + name="remove_whatsapp_group_participants", + description="Remove participants from a group (requires admin).", + action_sets=["whatsapp_groups", "whatsapp"], + input_schema={ + "group_id": {"type": "string", "description": "Group ID.", "example": ""}, + "participants": {"type": "array", "description": "Participant JIDs to remove.", "example": []}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def remove_whatsapp_group_participants(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "whatsapp_web", "group_remove_participants", + group_id=input_data["group_id"], + participants=input_data["participants"], + ) + + +@action( + name="promote_whatsapp_group_participants", + description="Promote participants to admin (requires admin).", + action_sets=["whatsapp_groups"], + input_schema={ + "group_id": {"type": "string", "description": "Group ID.", "example": ""}, + "participants": {"type": "array", "description": "Participant JIDs.", "example": []}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def promote_whatsapp_group_participants(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "whatsapp_web", "group_promote_participants", + group_id=input_data["group_id"], + participants=input_data["participants"], + ) + + +@action( + name="demote_whatsapp_group_participants", + description="Remove admin status from participants (requires admin).", + action_sets=["whatsapp_groups"], + input_schema={ + "group_id": {"type": "string", "description": "Group ID.", "example": ""}, + "participants": {"type": "array", "description": "Participant JIDs.", "example": []}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def demote_whatsapp_group_participants(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "whatsapp_web", "group_demote_participants", + group_id=input_data["group_id"], + participants=input_data["participants"], + ) + + +@action( + name="set_whatsapp_group_subject", + description="Change a group's name/subject (requires admin or 'all members can edit info').", + action_sets=["whatsapp_groups", "whatsapp"], + input_schema={ + "group_id": {"type": "string", "description": "Group ID.", "example": ""}, + "subject": {"type": "string", "description": "New name.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def set_whatsapp_group_subject(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "whatsapp_web", "group_set_subject", + group_id=input_data["group_id"], + subject=input_data["subject"], + ) + + +@action( + name="set_whatsapp_group_description", + description="Change a group's description.", + action_sets=["whatsapp_groups"], + input_schema={ + "group_id": {"type": "string", "description": "Group ID.", "example": ""}, + "description": {"type": "string", "description": "New description.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def set_whatsapp_group_description(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "whatsapp_web", "group_set_description", + group_id=input_data["group_id"], + description=input_data["description"], + ) + + +@action( + name="get_whatsapp_group_info", + description="Get group info: name, description, owner, participants (with admin flags).", + action_sets=["whatsapp_groups", "whatsapp"], + input_schema={ + "group_id": {"type": "string", "description": "Group ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def get_whatsapp_group_info(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("whatsapp_web", "group_get_info", group_id=input_data["group_id"]) + + +@action( + name="leave_whatsapp_group", + description="Leave a WhatsApp group.", + action_sets=["whatsapp_groups", "whatsapp"], + input_schema={ + "group_id": {"type": "string", "description": "Group ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def leave_whatsapp_group(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("whatsapp_web", "group_leave", group_id=input_data["group_id"]) + + +@action( + name="get_whatsapp_group_invite_code", + description="Get a group's invite code + chat.whatsapp.com URL (requires admin).", + action_sets=["whatsapp_groups", "whatsapp"], + input_schema={ + "group_id": {"type": "string", "description": "Group ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def get_whatsapp_group_invite_code(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("whatsapp_web", "group_invite_code", group_id=input_data["group_id"]) + + +@action( + name="revoke_whatsapp_group_invite", + description="Invalidate the current invite link and generate a new one (requires admin).", + action_sets=["whatsapp_groups"], + input_schema={ + "group_id": {"type": "string", "description": "Group ID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def revoke_whatsapp_group_invite(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("whatsapp_web", "group_revoke_invite", group_id=input_data["group_id"]) + + +@action( + name="accept_whatsapp_group_invite", + description="Join a WhatsApp group by invite code (or full chat.whatsapp.com URL).", + action_sets=["whatsapp_groups", "whatsapp"], + input_schema={ + "invite_code": {"type": "string", "description": "Invite code or full URL.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def accept_whatsapp_group_invite(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("whatsapp_web", "accept_group_invite", invite_code=input_data["invite_code"]) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Contacts — search / block / profile pic / about / get all / check number +# ═══════════════════════════════════════════════════════════════════════════════ + @action( name="search_whatsapp_contact", description="Search contact by name (WhatsApp Web).", - action_sets=["whatsapp"], + action_sets=["whatsapp_contacts", "whatsapp"], input_schema={ - "name": { - "type": "string", - "description": "Contact name.", - "example": "John Doe", - }, + "name": {"type": "string", "description": "Contact name.", "example": "John Doe"}, }, output_schema={"status": {"type": "string", "example": "success"}}, ) async def search_whatsapp_contact(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client("whatsapp_web", "search_contact", name=input_data["name"]) +@action( + name="get_whatsapp_contact", + description="Get full contact details (name, pushname, business flag, about/status, etc.).", + action_sets=["whatsapp_contacts", "whatsapp"], + input_schema={ + "contact_id": {"type": "string", "description": "Contact JID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def get_whatsapp_contact(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("whatsapp_web", "get_contact", contact_id=input_data["contact_id"]) + + +@action( + name="get_whatsapp_all_contacts", + description="List all contacts. By default filters to 'my contacts' (saved in phonebook). Set my_contacts_only=false to include everyone the user has ever interacted with.", + action_sets=["whatsapp_contacts", "whatsapp"], + input_schema={ + "my_contacts_only": {"type": "boolean", "description": "Filter to saved contacts.", "example": True}, + "limit": {"type": "integer", "description": "Max results.", "example": 500}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def get_whatsapp_all_contacts(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "whatsapp_web", "get_all_contacts", + my_contacts_only=bool(input_data.get("my_contacts_only", True)), + limit=input_data.get("limit", 500), + ) + + +@action( + name="get_whatsapp_profile_pic_url", + description="Get a contact's profile picture URL (empty string if none / privacy restricted).", + action_sets=["whatsapp_contacts", "whatsapp"], + input_schema={ + "contact_id": {"type": "string", "description": "Contact JID.", "example": ""}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def get_whatsapp_profile_pic_url(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("whatsapp_web", "get_profile_pic_url", contact_id=input_data["contact_id"]) + + +@action( + name="block_whatsapp_contact", + description="Block (block=true) or unblock (block=false) a contact.", + action_sets=["whatsapp_contacts", "whatsapp"], + input_schema={ + "contact_id": {"type": "string", "description": "Contact JID.", "example": ""}, + "block": {"type": "boolean", "description": "True=block, False=unblock.", "example": True}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, + parallelizable=False, +) +async def block_whatsapp_contact(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client( + "whatsapp_web", "block_contact", + contact_id=input_data["contact_id"], + block=bool(input_data.get("block", True)), + ) + + +@action( + name="check_number_on_whatsapp", + description="Check whether a phone number is registered on WhatsApp. Returns canonical JID if so.", + action_sets=["whatsapp_contacts", "whatsapp"], + input_schema={ + "number": {"type": "string", "description": "Phone number.", "example": "1234567890"}, + }, + output_schema={"status": {"type": "string", "example": "success"}}, +) +async def check_number_on_whatsapp(input_data: dict) -> dict: + from app.data.action.integrations._helpers import run_client + return await run_client("whatsapp_web", "check_number_on_whatsapp", number=input_data["number"]) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Session +# ═══════════════════════════════════════════════════════════════════════════════ + @action( name="get_whatsapp_web_session_status", - description="Get WhatsApp Web session status.", + description="Get WhatsApp Web session status (connected/waiting/disconnected).", action_sets=["whatsapp"], input_schema={}, output_schema={"status": {"type": "string", "example": "success"}}, ) async def get_whatsapp_web_session_status(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client("whatsapp_web", "get_session_status") + + +# ================================================================== +# Intentionally NOT exposed as actions (and why) +# ================================================================== +# - Polls / Buttons / Lists / Interactive messages +# Mostly business-API features; whatsapp-web.js support is partial +# and unstable across WhatsApp Web protocol changes. +# - Channels (newsletters / one-way broadcast) +# Heavy WhatsApp-side feature with limited library coverage today. +# - Broadcast lists / status updates +# Niche; better tooling exists outside the bot context. +# - Set my profile pic / name / about (user-side) +# Account admin, rarely needed mid-task. +# - Group icon (setPicture) +# Requires MessageMedia; deferred (action could be added if needed). +# - End-to-end encrypted backup / device list management +# Account security plumbing, not per-interaction. +# - Read more than 50 contacts at a time via getContacts on huge accounts +# Wrapped with a 500-default cap to avoid Puppeteer protocolTimeout. diff --git a/app/onboarding/interfaces/__init__.py b/app/onboarding/interfaces/__init__.py index d976df45..89e25f01 100644 --- a/app/onboarding/interfaces/__init__.py +++ b/app/onboarding/interfaces/__init__.py @@ -13,7 +13,7 @@ ProviderStep, ApiKeyStep, AgentNameStep, - MCPStep, + IntegrationStep, SkillsStep, ) @@ -24,6 +24,6 @@ "ProviderStep", "ApiKeyStep", "AgentNameStep", - "MCPStep", + "IntegrationStep", "SkillsStep", ] diff --git a/app/onboarding/interfaces/base.py b/app/onboarding/interfaces/base.py index b93a64d2..d3c21514 100644 --- a/app/onboarding/interfaces/base.py +++ b/app/onboarding/interfaces/base.py @@ -36,7 +36,7 @@ async def run_hard_onboarding(self) -> Dict[str, Any]: - API key input - User name (optional) - Agent name (optional) - - MCP servers to enable (optional) + - External app integrations to set up (optional) - Skills to enable (optional) Returns: @@ -46,7 +46,7 @@ async def run_hard_onboarding(self) -> Dict[str, Any]: "api_key": str, # API key for the provider "user_name": str, # User's preferred name "agent_name": str, # Agent's given name - "mcp_servers": list, # List of enabled MCP server names + "integrations": list, # List of integration ids the user picked "skills": list, # List of enabled skill names "completed": bool, # Whether onboarding completed (not cancelled) } diff --git a/app/onboarding/interfaces/steps.py b/app/onboarding/interfaces/steps.py index 3c8d03a1..b8e45374 100644 --- a/app/onboarding/interfaces/steps.py +++ b/app/onboarding/interfaces/steps.py @@ -7,42 +7,37 @@ not the presentation. """ +from abc import ABC, abstractmethod from dataclasses import dataclass, field from typing import Any, Dict, List, Optional, Protocol, runtime_checkable +import os @dataclass class StepOption: """An option that can be selected in a step.""" - - value: str # Internal value (e.g., "openai") - label: str # Display label (e.g., "OpenAI") + value: str # Internal value (e.g., "openai") + label: str # Display label (e.g., "OpenAI") description: str = "" # Optional description default: bool = False # Whether this is the default selection icon: str = "" # Lucide icon name (e.g., "Folder", "Search") - requires_setup: bool = ( - False # Whether this option requires additional setup (API key, etc.) - ) + requires_setup: bool = False # Whether this option requires additional setup (API key, etc.) @dataclass class FormField: """A field in a multi-field form step (e.g., User Profile).""" - - name: str # Field key (e.g., "user_name") - label: str # Display label - field_type: str # "text", "select", "multi_checkbox" - options: List["StepOption"] = field( - default_factory=list - ) # For select/checkbox types - default: Any = "" # Default value - placeholder: str = "" # Hint text + name: str # Field key (e.g., "user_name") + label: str # Display label + field_type: str # "text", "select", "multi_checkbox" + options: List["StepOption"] = field(default_factory=list) # For select/checkbox types + default: Any = "" # Default value + placeholder: str = "" # Hint text @dataclass class StepResult: """Result of completing an onboarding step.""" - success: bool data: Dict[str, Any] = field(default_factory=dict) error: Optional[str] = None @@ -118,7 +113,6 @@ class ProviderStep: ("moonshot", "Moonshot", "Moonshot models"), ("grok", "Grok (xAI)", "Grok models"), ("remote", "Ollama (Local)", "Self-hosted models"), - ("bedrock", "AWS Bedrock", "Claude / Llama / Titan via AWS"), ] def get_options(self) -> List[StepOption]: @@ -127,7 +121,7 @@ def get_options(self) -> List[StepOption]: value=provider_id, label=label, description=desc, - default=(provider_id == "openai"), + default=(provider_id == "openai") ) for provider_id, label, desc in self.PROVIDERS ] @@ -141,7 +135,6 @@ def validate(self, value: Any) -> tuple[bool, Optional[str]]: def get_default(self) -> str: # Check settings.json for existing provider from app.config import get_llm_provider - current_provider = get_llm_provider().lower() if current_provider and current_provider in [p[0] for p in self.PROVIDERS]: return current_provider @@ -165,7 +158,6 @@ class ApiKeyStep: "moonshot": "MOONSHOT_API_KEY", "grok": "XAI_API_KEY", "remote": None, # Ollama uses a base URL, not an API key - "bedrock": None, # Bedrock uses the boto3 credential chain } def __init__(self, provider: str = "openai"): @@ -179,8 +171,6 @@ def __init__(self, provider: str = "openai"): def title(self) -> str: if self.provider == "remote": return "Connect Ollama" - if self.provider == "bedrock": - return "Configure AWS Bedrock" if self.provider in self.OPENROUTER_PROXIED: display = self.OPENROUTER_PROXIED_DISPLAY.get(self.provider, self.provider) return f"Enter {display} API Key" @@ -193,13 +183,6 @@ def description(self) -> str: "Connect to your local Ollama instance.\n" "If Ollama isn't installed yet, we'll help you set it up." ) - if self.provider == "bedrock": - return ( - "AWS Bedrock uses the boto3 credential chain. You can either " - "leave this blank to use environment variables / IAM role / SSO " - "profile already configured on this host, or set explicit " - "credentials later under Settings → Model." - ) if self.provider in self.OPENROUTER_PROXIED: display = self.OPENROUTER_PROXIED_DISPLAY.get(self.provider, self.provider) return ( @@ -222,12 +205,6 @@ def validate(self, value: Any) -> tuple[bool, Optional[str]]: return False, "Please enter a valid URL (e.g. http://localhost:11434)" return True, None - if self.provider == "bedrock": - # Bedrock uses the boto3 credential chain — no value is required - # at onboarding. The user can plug explicit creds in later from - # Settings → Model → AWS Bedrock. - return True, None - # Proxied providers submit {api_key, via, or_model?} dict if self.provider in self.OPENROUTER_PROXIED and isinstance(value, dict): api_key = value.get("api_key", "") @@ -246,11 +223,8 @@ def validate(self, value: Any) -> tuple[bool, Optional[str]]: def get_default(self) -> str: if self.provider == "remote": return "http://localhost:11434" - if self.provider == "bedrock": - return "" # Boto3 credential chain — nothing to prefill # Check settings.json for existing key from app.config import get_api_key - return get_api_key(self.provider) def get_env_var_name(self) -> Optional[str]: @@ -301,10 +275,7 @@ def validate(self, value: Any) -> tuple[bool, Optional[str]]: return False, "Agent name must be 20 characters or fewer" picture = value.get("agent_profile_picture") if picture not in (None, ""): - if ( - not isinstance(picture, str) - or picture.lower() not in self.ALLOWED_PICTURE_EXTS - ): + if not isinstance(picture, str) or picture.lower() not in self.ALLOWED_PICTURE_EXTS: return False, "Unsupported avatar format" return True, None return False, "Invalid agent identity submission" @@ -358,7 +329,6 @@ def fetch_geolocation() -> str: """Fetch user's location from IP. Returns 'City, Country' or '' on failure.""" try: import requests - resp = requests.get("http://ip-api.com/json", timeout=3) if resp.status_code == 200: data = resp.json() @@ -396,14 +366,12 @@ def get_language_options() -> List[StepOption]: # Only include 2-letter codes (ISO 639-1) to keep list manageable if len(code) == 2 and code not in seen: seen.add(code) - options.append( - StepOption( - value=code, - label=display_name, - description=code, - default=(code == os_lang), - ) - ) + options.append(StepOption( + value=code, + label=display_name, + description=code, + default=(code == os_lang), + )) return options except ImportError: # Fallback if babel not installed — return a minimal list @@ -475,12 +443,7 @@ def get_form_fields(self) -> List[FormField]: label="Proactive Level", field_type="select", options=[ - StepOption( - value=val, - label=label, - description=desc, - default=(val == "medium"), - ) + StepOption(value=val, label=label, description=desc, default=(val == "medium")) for val, label, desc in self.PROACTIVITY_OPTIONS ], default="medium", @@ -512,17 +475,17 @@ def get_options(self) -> List[StepOption]: return [] def validate(self, value: Any) -> tuple[bool, Optional[str]]: - """Validate the form data dict. All fields are optional.""" - if not isinstance(value, dict): - return False, "Expected a dictionary of form values" - user_name = value.get("user_name") - if user_name and len(str(user_name)) > 20: - return False, "Name must be 20 characters or fewer" - # Validate approval is a list if present - approval = value.get("approval") - if approval is not None and not isinstance(approval, list): - return False, "Approval settings must be a list" - return True, None + """Validate the form data dict. All fields are optional.""" + if not isinstance(value, dict): + return False, "Expected a dictionary of form values" + user_name = value.get("user_name") + if user_name and len(str(user_name)) > 20: + return False, "Name must be 20 characters or fewer" + # Validate approval is a list if present + approval = value.get("approval") + if approval is not None and not isinstance(approval, list): + return False, "Approval settings must be a list" + return True, None def get_default(self) -> Dict[str, Any]: """Return defaults for all fields.""" @@ -530,75 +493,29 @@ def get_default(self) -> Dict[str, Any]: return {f.name: f.default for f in fields} -class MCPStep: - """MCP server selection step.""" +class IntegrationStep: + """External app integration setup step. - name = "mcp" - title = "Recommended MCP Servers" - description = "MCP servers are your agent's toolbox. Each one adds extra tools that let your agent work with apps like Gmail, Slack, or Notion on your behalf.\nItems marked 'Setup required' need API keys - configure them in Settings after onboarding." - required = False + Renders the full Integrations settings panel inside the wizard so the + user can connect any registered integration in place. The step has no + submittable value of its own — clicking Next moves on whether or not + the user connected anything. + """ - # Top 10 recommended MCP servers for onboarding (most popular/useful) - # Names must match exactly with names in mcp_config.json - # Format: {name: (icon, requires_setup)} - RECOMMENDED_SERVERS = { - "filesystem": ("Folder", False), # Local file access - works out of the box - "brave-search": ("Search", True), # Web search - needs BRAVE_API_KEY - "github": ("Github", True), # Git/GitHub - needs GITHUB_PERSONAL_ACCESS_TOKEN - "playwright-mcp": ("Globe", False), # Browser automation - works out of the box - "notion-mcp": ("FileText", True), # Note-taking - needs NOTION_API_KEY - "slack-mcp": ("MessageSquare", True), # Team communication - needs Slack OAuth - "gmail-mcp": ("Mail", True), # Email - needs Google OAuth - "google-calendar-mcp": ("Calendar", True), # Calendar - needs Google OAuth - "todoist-mcp": ("CheckSquare", True), # Task management - needs TODOIST_API_KEY - "obsidian-mcp": ("Gem", True), # Knowledge management - needs Obsidian plugin - } + name = "integrations" + title = "Connect External Apps" + description = "Connect any external apps you want your agent to use — Gmail, Slack, GitHub, Notion, and more. You can connect now, or skip and connect later from Settings → Integrations." + required = False def get_options(self) -> List[StepOption]: - """Get top 10 recommended MCP servers for onboarding.""" - try: - from app.ui_layer.settings.mcp_settings import list_mcp_servers - - servers = list_mcp_servers() - except Exception: - # If MCP config is completely broken, show nothing rather than - # crashing the wizard — the user can configure later in Settings. - return [] - - # Create a lookup by name - server_lookup = {s["name"]: s for s in servers} - - # Return only recommended servers that exist in config - options = [] - for name, (icon, requires_setup) in self.RECOMMENDED_SERVERS.items(): - if name in server_lookup: - server = server_lookup[name] - label = server["name"].replace("-", " ").replace(" mcp", "").title() - # Append platform warning to description when server paths - # are incompatible with the current OS - desc = server.get("description", f"MCP server: {server['name']}") - if server.get("platform_blocked"): - label += " (⚠ Windows-only — requires setup on this OS)" - options.append( - StepOption( - value=server["name"], - label=label, - description=desc, - default=server.get("enabled", False), - icon=icon, - requires_setup=requires_setup, - ) - ) - return options + return [] def validate(self, value: Any) -> tuple[bool, Optional[str]]: - # Value should be a list of server names - if not isinstance(value, list): - return False, "Expected a list of server names" + # The step is a UI panel — any value (including empty) is acceptable. return True, None - def get_default(self) -> List[str]: - return [] + def get_default(self) -> str: + return "" class SkillsStep: @@ -627,13 +544,13 @@ class SkillsStep: def get_options(self) -> List[StepOption]: """Get top 10 recommended skills for onboarding.""" try: - from app.ui_layer.settings.skill_settings import list_skills - + from app.tui.skill_settings import list_skills skills = list_skills() # Create a lookup by name (only user-invocable skills) skill_lookup = { - s["name"]: s for s in skills if s.get("user_invocable", True) + s["name"]: s for s in skills + if s.get("user_invocable", True) } # Return only recommended skills that exist @@ -641,15 +558,13 @@ def get_options(self) -> List[StepOption]: for name, icon in self.RECOMMENDED_SKILLS.items(): if name in skill_lookup: skill = skill_lookup[name] - options.append( - StepOption( - value=skill["name"], - label=skill["name"].replace("-", " ").title(), - description=skill.get("description", ""), - default=skill.get("enabled", False), - icon=icon, - ) - ) + options.append(StepOption( + value=skill["name"], + label=skill['name'].replace('-', ' ').title(), + description=skill.get("description", ""), + default=skill.get("enabled", False), + icon=icon + )) return options except ImportError: return [] @@ -670,6 +585,6 @@ def get_default(self) -> List[str]: ApiKeyStep, AgentNameStep, UserProfileStep, - MCPStep, SkillsStep, + IntegrationStep, ] diff --git a/app/tui/onboarding/hard_onboarding.py b/app/tui/onboarding/hard_onboarding.py new file mode 100644 index 00000000..b09f17c5 --- /dev/null +++ b/app/tui/onboarding/hard_onboarding.py @@ -0,0 +1,192 @@ +# -*- coding: utf-8 -*- +""" +TUI implementation of hard onboarding using Textual. +""" + +from typing import Any, Dict, Optional, TYPE_CHECKING + +from app.onboarding.interfaces.base import OnboardingInterface +from app.onboarding.interfaces.steps import ( + ProviderStep, + ApiKeyStep, + AgentNameStep, + UserProfileStep, + IntegrationStep, + SkillsStep, +) +from app.onboarding import onboarding_manager +from app.tui.settings import save_settings_to_json +from app.logger import logger + +if TYPE_CHECKING: + from app.tui.app import CraftApp + + +class TUIHardOnboarding(OnboardingInterface): + """ + TUI implementation of hard onboarding using Textual widgets. + + Presents a step-by-step wizard for initial configuration: + 1. LLM Provider selection + 2. API Key input + 3. Agent name (optional) + 4. Skills selection (optional) + 5. External app integration setup (optional) + + Note: User name is collected during soft onboarding (conversational interview). + """ + + def __init__(self, app: "CraftApp"): + self._app = app + self._collected_data: Dict[str, Any] = {} + self._current_step = 0 + self._steps = [ + ProviderStep(), + None, # ApiKeyStep - created dynamically based on provider + AgentNameStep(), + UserProfileStep(), + SkillsStep(), + IntegrationStep(), + ] + + async def run_hard_onboarding(self) -> Dict[str, Any]: + """ + Execute the hard onboarding wizard. + + This is called by the TUI app when onboarding is needed. + The actual wizard UI is handled by the OnboardingWizardScreen. + + Returns: + Dictionary with collected configuration data. + """ + from app.tui.onboarding.widgets import OnboardingWizardScreen + + # Create and push the wizard screen + screen = OnboardingWizardScreen(self) + + # The screen will call on_complete when done + await self._app.push_screen(screen) + + return self._collected_data + + def get_step(self, index: int) -> Any: + """Get step by index, creating ApiKeyStep dynamically if needed.""" + if index == 1: + # Create ApiKeyStep with current provider + provider = self._collected_data.get("provider", "openai") + return ApiKeyStep(provider) + return self._steps[index] + + def get_step_count(self) -> int: + """Get total number of steps.""" + return len(self._steps) + + def set_step_data(self, step_name: str, value: Any) -> None: + """Store data collected from a step.""" + self._collected_data[step_name] = value + logger.debug(f"[ONBOARDING] Step {step_name} = {value if step_name != 'api_key' else '***'}") + + def get_collected_data(self) -> Dict[str, Any]: + """Get all collected data.""" + return self._collected_data.copy() + + def on_complete(self, cancelled: bool = False) -> None: + """ + Called when the wizard completes. + + Saves the configuration and marks hard onboarding as complete. + """ + if cancelled: + self._collected_data["completed"] = False + logger.info("[ONBOARDING] Hard onboarding cancelled by user") + return + + self._collected_data["completed"] = True + + # Save provider and API key to settings.json + provider = self._collected_data.get("provider", "openai") + api_key = self._collected_data.get("api_key", "") + + if provider and api_key: + # save_settings_to_json also syncs to os.environ for current session + save_settings_to_json(provider, api_key) + logger.info(f"[ONBOARDING] Saved provider={provider} to settings.json") + + # Update the app's provider and api_key + self._app._provider = provider + self._app._api_key = api_key + self._app._saved_api_keys[provider] = api_key + + # Configure the interface with the new provider and reinitialize the LLM + if self._app._interface and provider and api_key: + self._app._interface.configure_provider(provider, api_key) + if self._app._interface._agent: + self._app._interface._agent.llm.reinitialize(provider) + logger.info(f"[ONBOARDING] Reinitialized LLM with provider: {provider}") + + # Write user profile data to USER.md + profile_data = self._collected_data.get("user_profile", {}) + if profile_data: + from app.onboarding.profile_writer import write_profile_to_user_md + write_profile_to_user_md(profile_data) + + # Mark hard onboarding as complete + agent_name = self._collected_data.get("agent_name", "Agent") + user_name = profile_data.get("user_name") if profile_data else None + success = onboarding_manager.mark_hard_complete(user_name=user_name, agent_name=agent_name) + if success: + logger.info("[ONBOARDING] Hard onboarding completed successfully") + else: + logger.error( + "[ONBOARDING] Hard onboarding state could not be persisted — " + "onboarding will re-trigger on next launch. " + "Check disk space or file permissions." + ) + + # Trigger soft onboarding now that hard onboarding is done + # This is needed because the soft onboarding check in agent.run() happens + # before interface starts (and thus before hard onboarding completes) + if onboarding_manager.needs_soft_onboarding: + import asyncio + asyncio.create_task(self._trigger_soft_onboarding_async()) + + async def _trigger_soft_onboarding_async(self) -> None: + """ + Async helper to trigger soft onboarding after hard onboarding completes. + + Uses the agent's trigger_soft_onboarding method which properly creates + the task and fires a trigger to start it. + """ + if not self._app._interface or not self._app._interface._agent: + logger.warning("[ONBOARDING] Cannot trigger soft onboarding: no agent reference") + return + + agent = self._app._interface._agent + task_id = await agent.trigger_soft_onboarding() + if task_id: + logger.info(f"[ONBOARDING] Soft onboarding triggered after hard onboarding: {task_id}") + + async def trigger_soft_onboarding(self) -> Optional[str]: + """ + Trigger soft onboarding by creating the interview task. + + Returns: + Task ID if created successfully, None otherwise. + """ + if not self._app._interface or not self._app._interface._agent: + logger.warning("[ONBOARDING] Cannot trigger soft onboarding: no agent reference") + return None + + from app.onboarding.soft.task_creator import create_soft_onboarding_task + + task_id = create_soft_onboarding_task(self._app._interface._agent.task_manager) + logger.info(f"[ONBOARDING] Created soft onboarding task: {task_id}") + return task_id + + def is_hard_onboarding_complete(self) -> bool: + """Check if hard onboarding is complete.""" + return onboarding_manager.state.hard_completed + + def is_soft_onboarding_complete(self) -> bool: + """Check if soft onboarding is complete.""" + return onboarding_manager.state.soft_completed diff --git a/app/tui/onboarding/widgets.py b/app/tui/onboarding/widgets.py new file mode 100644 index 00000000..ac1d66be --- /dev/null +++ b/app/tui/onboarding/widgets.py @@ -0,0 +1,736 @@ +# -*- coding: utf-8 -*- +""" +Textual widgets for the onboarding wizard. +""" + +from typing import TYPE_CHECKING, Any, Dict, List, Optional + +from textual.app import ComposeResult +from textual.containers import Container, Horizontal, Vertical, VerticalScroll +from textual.screen import Screen +from textual.widgets import Static, ListView, ListItem, Label, Button, Input + +from rich.text import Text + +if TYPE_CHECKING: + from app.tui.onboarding.hard_onboarding import TUIHardOnboarding + + +ONBOARDING_CSS = """ +/* Onboarding wizard screen - matches settings-card style */ +OnboardingWizardScreen { + align: center middle; + background: #000000; +} + +#onboarding-container { + max-width: 100%; + height: 100%; + border: none; + background: #000000; + padding: 2 3 3 3; + content-align: center top; + overflow: auto; + layout: vertical; +} + +#onboarding-header { + height: auto; + margin-bottom: 1; +} + +#onboarding-title { + text-style: bold; + color: #ffffff; + margin-bottom: 1; +} + +#onboarding-progress { + color: #666666; +} + +#step-container { + height: auto; + margin-bottom: 1; + padding: 1 0; +} + +#step-title { + text-style: bold; + color: #ffffff; + margin-bottom: 1; +} + +#step-description { + color: #a0a0a0; + margin-bottom: 1; +} + +#step-content { + height: auto; + margin: 1 0; +} + +/* Option list for selections - matches provider-options style */ +.option-list { + width: 28; + height: auto; + max-height: 12; + margin: 1 0; + background: transparent; + border: none; +} + +.option-list > ListItem { + padding: 0 0; +} + +.option-list > ListItem.--highlight .option-label { + background: #ff4f18; + color: #ffffff; + text-style: bold; +} + +.option-label { + color: #a0a0a0; +} + +.option-desc { + color: #666666; + margin-left: 2; +} + +/* Text input - matches settings-card Input style */ +.step-input { + width: 100%; + border: solid #2a2a2a; + background: #0a0a0a; + color: #e5e5e5; +} + +.step-input:focus { + border: solid #ff4f18; +} + +/* Multi-select list - matches skills-list/mcp-server-list style */ +.multi-select-list { + height: auto; + max-height: 15; + margin: 1 0; + border: solid #2a2a2a; + background: #0a0a0a; + padding: 1; +} + +.multi-select-row { + height: 1; + margin-bottom: 1; +} + +.multi-select-toggle { + width: 3; + min-width: 3; + height: 1; + background: #333333; + color: #666666; + border: none; + margin-right: 1; +} + +.multi-select-toggle.-selected { + color: #00cc00; +} + +.multi-select-toggle:hover { + background: #00cc00; + color: #000000; +} + +.multi-select-label { + width: 1fr; + color: #a0a0a0; +} + +/* Error message */ +#step-error { + color: #ff4444; + margin-top: 1; +} + +/* Navigation actions - matches settings-actions-list style */ +#nav-actions { + width: 24; + height: auto; + margin-top: 1; + content-align: center middle; + background: transparent; + border: none; +} + +#nav-actions > ListItem { + padding: 0 0; +} + +#nav-actions > ListItem.--highlight .nav-item { + background: #ff4f18; + color: #ffffff; + text-style: bold; +} + +.nav-item { + color: #a0a0a0; +} + +.nav-item.-disabled { + color: #444444; +} + +/* Skip hint */ +#skip-hint { + color: #666666; + text-style: italic; + margin-top: 1; +} + +/* Profile form - compact scrollable multi-field form */ +.profile-form { + height: auto; + max-height: 22; + padding: 0 1; +} + +.form-field { + height: auto; + margin-bottom: 1; +} + +.form-label { + color: #ff4f18; + text-style: bold; + height: 1; +} + +.form-input { + width: 100%; + border: solid #2a2a2a; + background: #0a0a0a; + color: #e5e5e5; +} + +.form-input:focus { + border: solid #ff4f18; +} + +.form-select { + width: 30; + height: auto; + max-height: 4; + background: transparent; + border: none; + margin: 0 0; +} + +.form-select > ListItem { + padding: 0 0; +} + +.form-select > ListItem.--highlight .option-label { + background: #ff4f18; + color: #ffffff; + text-style: bold; +} + +.form-checkbox-row { + height: 1; + margin-bottom: 0; +} + +.form-checkbox-toggle { + width: 3; + min-width: 3; + height: 1; + background: #333333; + color: #666666; + border: none; + margin-right: 1; +} + +.form-checkbox-toggle.-checked { + color: #00cc00; +} + +.form-checkbox-toggle:hover { + background: #00cc00; + color: #000000; +} + +.form-checkbox-label { + color: #a0a0a0; +} +""" + + +class OnboardingWizardScreen(Screen): + """ + Multi-step wizard screen for hard onboarding. + + Guides user through: + 1. LLM Provider selection + 2. API Key input + 3. Agent name (optional) + 4. Skills selection (optional) + 5. External app integration setup (optional) + + User name is collected during soft onboarding (conversational interview). + """ + + CSS = ONBOARDING_CSS + + BINDINGS = [ + ("ctrl+s", "skip_step", "Skip"), + ("escape", "cancel", "Cancel"), + ] + + def __init__(self, handler: "TUIHardOnboarding"): + super().__init__() + self._handler = handler + self._current_step = 0 + self._multi_select_values: List[str] = [] + # Form step state + self._form_fields: List[Any] = [] + self._form_checkbox_values: Dict[str, List[str]] = {} + + def compose(self) -> ComposeResult: + with Container(id="onboarding-container"): + with Container(id="onboarding-header"): + yield Static("Setup", id="onboarding-title") + yield Static(self._get_progress_text(), id="onboarding-progress") + + with Container(id="step-container"): + yield Static("", id="step-title") + yield Static("", id="step-description") + yield Container(id="step-content") + yield Static("", id="step-error") + + yield ListView( + ListItem(Label("next", classes="nav-item"), id="nav-next"), + ListItem(Label("skip", classes="nav-item"), id="nav-skip"), + ListItem(Label("back", classes="nav-item"), id="nav-back"), + id="nav-actions", + ) + + yield Static("", id="skip-hint") + + def on_mount(self) -> None: + """Initialize the first step when mounted.""" + # Set initial navigation selection + nav_list = self.query_one("#nav-actions", ListView) + nav_list.index = 0 + self._show_step(0) + + def _get_progress_text(self) -> str: + """Get progress indicator text.""" + total = self._handler.get_step_count() + current = self._current_step + 1 + return f"Step {current} of {total}" + + def _show_step(self, index: int) -> None: + """Display the step at the given index.""" + self._current_step = index + step = self._handler.get_step(index) + + # Update progress + self.query_one("#onboarding-progress", Static).update(self._get_progress_text()) + + # Update step title and description + self.query_one("#step-title", Static).update(step.title) + self.query_one("#step-description", Static).update(step.description) + + # Clear error + self.query_one("#step-error", Static).update("") + + # Update navigation items visibility and styling + self._update_nav_items(index, step.required) + + # Update skip hint + skip_hint = self.query_one("#skip-hint", Static) + if not step.required: + skip_hint.update("This step is optional - you can skip it") + else: + skip_hint.update("") + + # Build step content + content = self.query_one("#step-content", Container) + content.remove_children() + + # Check for form step (e.g., UserProfileStep) + form_fields = getattr(step, 'get_form_fields', lambda: [])() + options = step.get_options() + + if form_fields: + # Multi-field form + self._form_fields = form_fields + self._form_checkbox_values = {} + self._build_form(content, step, form_fields) + elif step.name == "skills": + # Multi-select list + self._form_fields = [] + self._multi_select_values = step.get_default() + self._build_multi_select(content, options) + elif step.name == "integrations": + # Panel step — the integrations connect UI is web-only. In the + # TUI, show a notice and let the user advance. + self._form_fields = [] + self._build_integration_notice(content) + elif options: + # Single-select list + self._form_fields = [] + self._build_option_list(content, options, step.get_default()) + else: + # Text input + self._form_fields = [] + self._build_text_input(content, step.get_default()) + + def _update_nav_items(self, index: int, required: bool) -> None: + """Update navigation items based on current step.""" + # Update back item - disable on first step + back_item = self.query_one("#nav-back", ListItem) + back_label = back_item.query_one(Label) + if index == 0: + back_label.add_class("-disabled") + else: + back_label.remove_class("-disabled") + + # Update skip item - hide if step is required + skip_item = self.query_one("#nav-skip", ListItem) + skip_item.display = not required + + # Set initial selection to "next" + nav_list = self.query_one("#nav-actions", ListView) + nav_list.index = 0 + + def _build_option_list(self, container: Container, options: list, default: str) -> None: + """Build a single-select option list.""" + items = [] + highlight_idx = 0 + step = self._handler.get_step(self._current_step) + + for i, opt in enumerate(options): + label_text = f" {opt.label}" + if opt.description: + label_text += f" ({opt.description})" + + items.append(ListItem(Label(label_text, classes="option-label"), id=f"opt-{step.name}-{opt.value}")) + + if opt.value == default: + highlight_idx = i + + list_view = ListView(*items, id=f"option-list-{step.name}", classes="option-list") + container.mount(list_view) + + # Highlight default after mount + def set_highlight(): + list_view.index = highlight_idx + self.call_after_refresh(set_highlight) + + def _build_text_input(self, container: Container, default: str) -> None: + """Build a text input field.""" + # Check if this is API key step (should be password field) + step = self._handler.get_step(self._current_step) + is_password = step.name == "api_key" + + input_widget = Input( + value=default, + placeholder="Enter value..." if not is_password else "Enter API key (Ctrl+V to paste)", + password=False, # Show API key for clarity during setup + id=f"step-input-{step.name}", + classes="step-input" + ) + container.mount(input_widget) + self.call_after_refresh(input_widget.focus) + + def _build_integration_notice(self, container: Container) -> None: + """Render a static notice for the integrations panel step in TUI.""" + notice = Static( + "Integration setup is available in the browser interface " + "(Settings → Integrations). Press Next to continue.", + classes="option-desc", + ) + container.mount(notice) + + def _build_multi_select(self, container: Container, options: list) -> None: + """Build a multi-select list with toggle buttons.""" + step = self._handler.get_step(self._current_step) + scroll = VerticalScroll(id=f"multi-select-list-{step.name}", classes="multi-select-list") + + for opt in options: + is_selected = opt.value in self._multi_select_values + toggle_text = "[+]" if is_selected else "[-]" + toggle_class = "multi-select-toggle -selected" if is_selected else "multi-select-toggle" + + row = Horizontal( + Button(toggle_text, id=f"toggle-{opt.value}", classes=toggle_class), + Static(opt.label, classes="multi-select-label"), + classes="multi-select-row" + ) + scroll.compose_add_child(row) + + container.mount(scroll) + + def _build_form(self, container: Container, step: Any, fields: list) -> None: + """Build a compact scrollable form with multiple field types.""" + scroll = VerticalScroll(id="profile-form", classes="profile-form") + + for f in fields: + field_container = Vertical(classes="form-field") + + # Label + field_container.compose_add_child( + Static(f.label, classes="form-label") + ) + + if f.field_type == "text": + inp = Input( + value=str(f.default) if f.default else "", + placeholder=f.placeholder or "Enter value...", + id=f"form-{f.name}", + classes="form-input", + ) + field_container.compose_add_child(inp) + + elif f.field_type == "select": + items = [] + highlight_idx = 0 + for i, opt in enumerate(f.options): + label_text = f" {opt.label}" + if opt.description and opt.description != opt.label: + label_text += f" ({opt.description})" + items.append( + ListItem( + Label(label_text, classes="option-label"), + id=f"fopt-{f.name}-{opt.value}", + ) + ) + if opt.value == f.default or opt.default: + highlight_idx = i + + list_view = ListView( + *items, + id=f"form-select-{f.name}", + classes="form-select", + ) + field_container.compose_add_child(list_view) + + # Highlight default after mount + _idx = highlight_idx + def _make_highlight(lv=list_view, idx=_idx): + def _set(): + lv.index = idx + return _set + self.call_after_refresh(_make_highlight()) + + elif f.field_type == "multi_checkbox": + self._form_checkbox_values[f.name] = list(f.default) if isinstance(f.default, list) else [] + for opt in f.options: + is_checked = opt.value in self._form_checkbox_values[f.name] + toggle_text = "[x]" if is_checked else "[ ]" + toggle_cls = "form-checkbox-toggle -checked" if is_checked else "form-checkbox-toggle" + row = Horizontal( + Button(toggle_text, id=f"fchk-{f.name}-{opt.value}", classes=toggle_cls), + Static(f" {opt.label}", classes="form-checkbox-label"), + classes="form-checkbox-row", + ) + field_container.compose_add_child(row) + + scroll.compose_add_child(field_container) + + container.mount(scroll) + + # Focus the first text input if any + def _focus_first(): + for f in fields: + if f.field_type == "text": + widget = self.query(f"#form-{f.name}") + if widget: + widget.first().focus() + break + self.call_after_refresh(_focus_first) + + def _get_form_value(self) -> Dict[str, Any]: + """Extract all values from the form fields.""" + result: Dict[str, Any] = {} + for f in self._form_fields: + if f.field_type == "text": + widget = self.query(f"#form-{f.name}") + result[f.name] = widget.first().value.strip() if widget else f.default + + elif f.field_type == "select": + widget = self.query(f"#form-select-{f.name}") + if widget: + lv = widget.first() + if lv and lv.highlighted_child: + item_id = lv.highlighted_child.id + prefix = f"fopt-{f.name}-" + if item_id and item_id.startswith(prefix): + result[f.name] = item_id[len(prefix):] + continue + result[f.name] = f.default + + elif f.field_type == "multi_checkbox": + result[f.name] = list(self._form_checkbox_values.get(f.name, [])) + + else: + result[f.name] = f.default + return result + + def on_button_pressed(self, event: Button.Pressed) -> None: + """Handle button presses (for multi-select toggles and form checkboxes).""" + button_id = event.button.id + + if button_id and button_id.startswith("toggle-"): + value = button_id[7:] # Remove "toggle-" prefix + self._toggle_multi_select(value, event.button) + elif button_id and button_id.startswith("fchk-"): + # Form checkbox toggle: "fchk-{field_name}-{value}" + parts = button_id[5:] # Remove "fchk-" + dash_idx = parts.index("-") + field_name = parts[:dash_idx] + value = parts[dash_idx + 1:] + self._toggle_form_checkbox(field_name, value, event.button) + + def on_list_view_selected(self, event: ListView.Selected) -> None: + """Handle list view selection.""" + list_id = event.list_view.id + + # Handle navigation actions + if list_id == "nav-actions": + if event.item.id == "nav-next": + self._go_next() + elif event.item.id == "nav-skip": + self._skip_step() + elif event.item.id == "nav-back": + # Check if back is enabled (not on first step) + if self._current_step > 0: + self._go_back() + + # Check if it's an option list (IDs are now like "option-list-provider") + elif list_id and list_id.startswith("option-list-"): + # Don't auto-advance on selection, wait for next action + pass + + def on_input_submitted(self, event: Input.Submitted) -> None: + """Handle Enter key in input field.""" + self._go_next() + + def _toggle_multi_select(self, value: str, button: Button) -> None: + """Toggle a multi-select option.""" + if value in self._multi_select_values: + self._multi_select_values.remove(value) + button.label = "[-]" + button.remove_class("-selected") + else: + self._multi_select_values.append(value) + button.label = "[+]" + button.add_class("-selected") + + def _toggle_form_checkbox(self, field_name: str, value: str, button: Button) -> None: + """Toggle a form checkbox option.""" + values = self._form_checkbox_values.setdefault(field_name, []) + if value in values: + values.remove(value) + button.label = "[ ]" + button.remove_class("-checked") + else: + values.append(value) + button.label = "[x]" + button.add_class("-checked") + + def _get_current_value(self) -> Any: + """Get the current value from the active step widget.""" + step = self._handler.get_step(self._current_step) + + # Form step returns a dict + if self._form_fields: + return self._get_form_value() + + if step.name == "skills": + return self._multi_select_values + + if step.name == "integrations": + # Panel step has no submittable value + return "" + + # Check for option list (IDs are now like "option-list-provider") + option_list = self.query(f"#option-list-{step.name}") + if option_list: + list_view = option_list.first() + if list_view and list_view.highlighted_child: + # Extract value from id (e.g., "opt-provider-openai" -> "openai") + item_id = list_view.highlighted_child.id + prefix = f"opt-{step.name}-" + if item_id and item_id.startswith(prefix): + return item_id[len(prefix):] + + # Check for text input (IDs are now like "step-input-user_name") + input_widget = self.query(f"#step-input-{step.name}") + if input_widget: + return input_widget.first().value + + return step.get_default() + + def _go_back(self) -> None: + """Go to the previous step.""" + if self._current_step > 0: + self._show_step(self._current_step - 1) + + def _skip_step(self) -> None: + """Skip the current optional step.""" + step = self._handler.get_step(self._current_step) + # Store default/empty value + self._handler.set_step_data(step.name, step.get_default()) + self._advance() + + def _go_next(self) -> None: + """Validate and advance to the next step.""" + step = self._handler.get_step(self._current_step) + value = self._get_current_value() + + # Validate + is_valid, error = step.validate(value) + if not is_valid: + self.query_one("#step-error", Static).update(error or "Invalid input") + return + + # Store value + self._handler.set_step_data(step.name, value) + + self._advance() + + def _advance(self) -> None: + """Advance to the next step or complete.""" + if self._current_step < self._handler.get_step_count() - 1: + self._show_step(self._current_step + 1) + else: + self._complete() + + def _complete(self) -> None: + """Complete the wizard and return to the app.""" + self._handler.on_complete(cancelled=False) + self.app.pop_screen() + + def action_skip_step(self) -> None: + """Skip the current optional step (Ctrl+S).""" + step = self._handler.get_step(self._current_step) + if not step.required: + self._skip_step() + + def action_cancel(self) -> None: + """Handle Escape key to cancel wizard.""" + self._handler.on_complete(cancelled=True) + self.app.pop_screen() + + def action_focus_nav(self) -> None: + """Focus the navigation bar (Tab).""" + nav = self.query_one("#nav-actions") + if hasattr(nav, 'focus'): + nav.focus() diff --git a/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.module.css b/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.module.css index 31b30911..ae7aac15 100644 --- a/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.module.css +++ b/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.module.css @@ -144,22 +144,41 @@ padding-right: var(--space-2); } +/* Embedded IntegrationsSettings panel — the panel ships its own inner + max-height scroll for the integrations list, but inside the onboarding + card the available height is smaller, so we let the whole panel scroll + here and disable the inner scroll (uses a global selector to reach into + the foreign CSS-module class). */ +.integrationsPanel { + overflow-y: auto; + padding-right: var(--space-2); +} + +.integrationsPanel :global([class*="integrationsList"]) { + max-height: none; + overflow: visible; +} + /* Scrollbar styling */ -.optionsList::-webkit-scrollbar { +.optionsList::-webkit-scrollbar, +.integrationsPanel::-webkit-scrollbar { width: 6px; } -.optionsList::-webkit-scrollbar-track { +.optionsList::-webkit-scrollbar-track, +.integrationsPanel::-webkit-scrollbar-track { background: var(--bg-tertiary); border-radius: var(--radius-full); } -.optionsList::-webkit-scrollbar-thumb { +.optionsList::-webkit-scrollbar-thumb, +.integrationsPanel::-webkit-scrollbar-thumb { background: var(--border-secondary); border-radius: var(--radius-full); } -.optionsList::-webkit-scrollbar-thumb:hover { +.optionsList::-webkit-scrollbar-thumb:hover, +.integrationsPanel::-webkit-scrollbar-thumb:hover { background: var(--border-hover); } diff --git a/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.tsx b/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.tsx index 7217f376..0b5383df 100644 --- a/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.tsx +++ b/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.tsx @@ -6,7 +6,7 @@ import { ChevronLeft, ChevronRight, SkipForward, - // Icons for MCP servers and Skills + // Icons for Integrations and Skills Folder, Search, Github, @@ -33,6 +33,7 @@ import { } from 'lucide-react' import { Button } from '../../components/ui' import { useWebSocket } from '../../contexts/WebSocketContext' +import { IntegrationsSettings } from '../Settings/IntegrationsSettings' import type { OnboardingStep, OnboardingStepOption, OnboardingFormField } from '../../types' import styles from './OnboardingPage.module.css' @@ -55,7 +56,7 @@ const ICON_MAP: Record = { Sheet, } -const STEP_NAMES = ['Provider', 'API Key', 'Agent Name', 'User Profile', 'MCP Servers', 'Skills'] +const STEP_NAMES = ['Provider', 'API Key', 'Agent Name', 'User Profile', 'Skills', 'Integrations'] // ── Ollama local-setup component ───────────────────────────────────────────── @@ -407,7 +408,7 @@ export function OnboardingPage() { } return defaults }) - } else if (onboardingStep.name === 'mcp' || onboardingStep.name === 'skills') { + } else if (onboardingStep.name === 'skills') { setSelectedValue(Array.isArray(onboardingStep.default) ? onboardingStep.default : []) } else if (onboardingStep.options.length > 0) { const defaultOption = onboardingStep.options.find(opt => opt.default) @@ -479,7 +480,7 @@ export function OnboardingPage() { const handleOptionSelect = useCallback((value: string) => { if (!onboardingStep) return - if (onboardingStep.name === 'mcp' || onboardingStep.name === 'skills') { + if (onboardingStep.name === 'skills') { setSelectedValue(prev => { const arr = Array.isArray(prev) ? prev : [] return arr.includes(value) ? arr.filter(v => v !== value) : [...arr, value] @@ -499,6 +500,10 @@ export function OnboardingPage() { submitOnboardingStep(ollamaUrl) } else if (isProxiedStep) { submitOnboardingStep({ api_key: textValue, via: proxiedVia, or_model: proxiedVia === 'openrouter' ? orModel : '' }) + } else if (onboardingStep.name === 'integrations') { + // Panel step — the embedded IntegrationsSettings handles its own + // connect flows. Just advance. + submitOnboardingStep('') } else if (onboardingStep.form_fields && onboardingStep.form_fields.length > 0) { submitOnboardingStep(formValues) } else if (onboardingStep.options.length > 0) { @@ -526,9 +531,10 @@ export function OnboardingPage() { const handleBack = useCallback(() => goBackOnboardingStep(), [goBackOnboardingStep]) - const isMultiSelect = onboardingStep?.name === 'mcp' || onboardingStep?.name === 'skills' + const isMultiSelect = onboardingStep?.name === 'skills' + const isIntegrationsStep = onboardingStep?.name === 'integrations' const isFormStep = !!(onboardingStep?.form_fields && onboardingStep.form_fields.length > 0) - const isWideStep = isMultiSelect || isFormStep + const isWideStep = isMultiSelect || isFormStep || isIntegrationsStep const isLastStep = onboardingStep ? onboardingStep.index === onboardingStep.total - 1 : false const isOllamaStep = @@ -540,6 +546,7 @@ export function OnboardingPage() { if (isOllamaStep) { return ollamaConnected || (localLLM.phase === 'connected' && !!localLLM.testResult?.success) } + if (isIntegrationsStep) return true // Connection is optional — Next always works if (isFormStep) return true // All form fields are optional if (onboardingStep.options.length > 0) { return isMultiSelect ? true : !!selectedValue @@ -579,6 +586,16 @@ export function OnboardingPage() { ) } + // External app integrations — embed the full Settings → Integrations + // panel so the user can connect any integration in place. + if (isIntegrationsStep) { + return ( +
+ +
+ ) + } + // Agent Identity step — compact side-by-side layout (avatar + name) if ( onboardingStep.name === 'agent_name' && diff --git a/app/ui_layer/onboarding/controller.py b/app/ui_layer/onboarding/controller.py index 1fdd49a4..18c48bc7 100644 --- a/app/ui_layer/onboarding/controller.py +++ b/app/ui_layer/onboarding/controller.py @@ -10,13 +10,13 @@ ApiKeyStep, AgentNameStep, UserProfileStep, - MCPStep, + IntegrationStep, SkillsStep, HardOnboardingStep, StepOption, ) from app.onboarding import onboarding_manager -from app.ui_layer.settings.provider_settings import save_settings_to_json +from app.tui.settings import save_settings_to_json if TYPE_CHECKING: from app.ui_layer.controller.ui_controller import UIController @@ -46,7 +46,7 @@ class OnboardingFlowController: Interfaces implement the presentation layer and call this controller for the business logic. This ensures consistent onboarding behavior - across CLI and Browser interfaces. + across CLI, TUI, and Browser interfaces. Example: controller = OnboardingFlowController(ui_controller) @@ -69,8 +69,8 @@ class OnboardingFlowController: ApiKeyStep, AgentNameStep, UserProfileStep, - MCPStep, SkillsStep, + IntegrationStep, ] def __init__(self, controller: Optional["UIController"] = None) -> None: @@ -261,45 +261,30 @@ def _complete(self) -> None: agent_name = agent_name_data.get("agent_name") or "Agent" else: agent_name = agent_name_data or "Agent" - selected_mcp_servers = self._state.collected_data.get("mcp", []) + # The integrations step is informational — selected integrations are + # surfaced for awareness, but OAuth/token connection happens in + # Settings → Integrations after onboarding. selected_skills = self._state.collected_data.get("skills", []) # Save provider configuration to settings.json from app.onboarding.interfaces.steps import ApiKeyStep - if provider == "remote": # api_key holds the Ollama base URL for the remote provider remote_url = api_key or "http://localhost:11434" - from app.ui_layer.settings.provider_settings import save_remote_endpoint - + from app.tui.settings import save_remote_endpoint save_remote_endpoint(remote_url) - elif provider == "bedrock": - # Bedrock has no api_key at the onboarding step (boto3 credential - # chain). Still set the active provider so the agent reinitializes - # against bedrock — explicit creds (if needed) are entered later - # under Settings → Model → AWS Bedrock. - save_settings_to_json("bedrock", "") elif provider in ApiKeyStep.OPENROUTER_PROXIED and api_key: if proxied_via == "openrouter": # User chose to go via OpenRouter — save key as openrouter and set model slug. if submitted_or_model: or_model = submitted_or_model else: - from agent_core.core.models.factory import ( - _to_openrouter_slug, - _OR_MODEL_MAP, - ) + from agent_core.core.models.factory import _to_openrouter_slug, _OR_MODEL_MAP from app.models import MODEL_REGISTRY, InterfaceType - - native_model = MODEL_REGISTRY.get(provider, {}).get( - InterfaceType.LLM, "" - ) - or_model = _OR_MODEL_MAP.get(provider, {}).get( - native_model - ) or _to_openrouter_slug(provider, native_model) + native_model = MODEL_REGISTRY.get(provider, {}).get(InterfaceType.LLM, "") + or_model = _OR_MODEL_MAP.get(provider, {}).get(native_model) or _to_openrouter_slug(provider, native_model) save_settings_to_json("openrouter", api_key) from app.ui_layer.settings.model_settings import update_model_settings - update_model_settings(llm_model=or_model, vlm_model=or_model) provider = "openrouter" else: @@ -315,36 +300,21 @@ def _complete(self) -> None: success = self._controller.agent.reinitialize_llm(provider) if success: from agent_core.utils.logger import logger - - logger.info( - f"[ONBOARDING] Reinitialized LLM with provider: {provider}" - ) + logger.info(f"[ONBOARDING] Reinitialized LLM with provider: {provider}") else: from agent_core.utils.logger import logger - - logger.warning( - f"[ONBOARDING] Failed to reinitialize LLM with provider: {provider}" - ) + logger.warning(f"[ONBOARDING] Failed to reinitialize LLM with provider: {provider}") except Exception as e: from agent_core.utils.logger import logger - logger.warning(f"[ONBOARDING] Error reinitializing LLM: {e}") # Update controller state if available if self._controller: self._controller.state_store.dispatch("SET_PROVIDER", provider) - # Apply MCP server selections - if selected_mcp_servers: - from app.ui_layer.settings.mcp_settings import enable_mcp_server - - for server_name in selected_mcp_servers: - enable_mcp_server(server_name) - # Apply skill selections if selected_skills: - from app.ui_layer.settings.skill_settings import enable_skill - + from app.tui.skill_settings import enable_skill for skill_name in selected_skills: enable_skill(skill_name) @@ -352,7 +322,6 @@ def _complete(self) -> None: user_profile = self._state.collected_data.get("user_profile", {}) if user_profile: from app.onboarding.profile_writer import write_profile_to_user_md - write_profile_to_user_md(user_profile) else: # Fallback: initialize language from OS locale if profile step was skipped @@ -369,7 +338,6 @@ def _complete(self) -> None: ) if not success: from agent_core.utils.logger import logger - logger.error( "[ONBOARDING] Failed to persist hard onboarding state — " "onboarding will re-trigger on next launch. " @@ -381,7 +349,6 @@ def _complete(self) -> None: # before interface starts (and thus before hard onboarding completes) if onboarding_manager.needs_soft_onboarding and self._controller: import asyncio - asyncio.create_task(self._trigger_soft_onboarding_async()) async def _trigger_soft_onboarding_async(self) -> None: @@ -398,10 +365,7 @@ async def _trigger_soft_onboarding_async(self) -> None: task_id = await agent.trigger_soft_onboarding() if task_id: from agent_core.utils.logger import logger - - logger.info( - f"[ONBOARDING] Soft onboarding triggered after hard onboarding: {task_id}" - ) + logger.info(f"[ONBOARDING] Soft onboarding triggered after hard onboarding: {task_id}") def _initialize_user_language(self) -> None: """ @@ -424,15 +388,15 @@ def _initialize_user_language(self) -> None: # Replace the Language field value # Pattern: - **Language**: updated_content = re.sub( - r"(\*\*Language\*\*:\s*)\S+", f"\\1{os_lang}", content + r'(\*\*Language\*\*:\s*)\S+', + f'\\1{os_lang}', + content ) user_md_path.write_text(updated_content, encoding="utf-8") from agent_core.utils.logger import logger - logger.info(f"[ONBOARDING] Initialized USER.md language to: {os_lang}") except Exception as e: from agent_core.utils.logger import logger - logger.warning(f"[ONBOARDING] Failed to update USER.md language: {e}") def get_progress_text(self) -> str: @@ -465,7 +429,7 @@ def get_step_info(self) -> Dict[str, Any]: } # Include form fields if the step has them (e.g., UserProfileStep) - form_fields = getattr(step, "get_form_fields", lambda: [])() + form_fields = getattr(step, 'get_form_fields', lambda: [])() if form_fields: info["form_fields"] = [ { @@ -473,12 +437,7 @@ def get_step_info(self) -> Dict[str, Any]: "label": f.label, "field_type": f.field_type, "options": [ - { - "value": o.value, - "label": o.label, - "description": o.description, - "default": o.default, - } + {"value": o.value, "label": o.label, "description": o.description, "default": o.default} for o in f.options ], "default": f.default, diff --git a/craftos_integrations/integrations/discord/__init__.py b/craftos_integrations/integrations/discord/__init__.py index 1a1d6b1f..f9d6c49a 100644 --- a/craftos_integrations/integrations/discord/__init__.py +++ b/craftos_integrations/integrations/discord/__init__.py @@ -962,3 +962,617 @@ async def get_voice_status(self, guild_id: str) -> Result: return {"error": f"Voice dependencies not installed: {e}"} except Exception as e: return {"error": str(e)} + + # ================================================================== + # Messages (extended): bulk delete, crosspost, pins, reactions + # ================================================================== + + def bulk_delete_messages(self, channel_id: str, + message_ids: List[str]) -> Result: + """Delete 2-100 messages, all <14 days old. Returns 204.""" + return http_request( + "POST", f"{DISCORD_API_BASE}/channels/{channel_id}/messages/bulk-delete", + headers=self._bot_headers(), + json={"messages": message_ids}, expected=(204,), + transform=lambda _: {"deleted": len(message_ids)}, + ) + + def crosspost_message(self, channel_id: str, message_id: str) -> Result: + """Publish a message from an announcement channel to following channels.""" + return http_request( + "POST", f"{DISCORD_API_BASE}/channels/{channel_id}/messages/{message_id}/crosspost", + headers=self._bot_headers(), expected=(200,), + transform=lambda d: {"id": d.get("id"), "flags": d.get("flags")}, + ) + + def pin_message(self, channel_id: str, message_id: str) -> Result: + return http_request( + "PUT", f"{DISCORD_API_BASE}/channels/{channel_id}/pins/{message_id}", + headers=self._bot_headers(), expected=(204,), + transform=lambda _: {"pinned": True, "message_id": message_id}, + ) + + def unpin_message(self, channel_id: str, message_id: str) -> Result: + return http_request( + "DELETE", f"{DISCORD_API_BASE}/channels/{channel_id}/pins/{message_id}", + headers=self._bot_headers(), expected=(204,), + transform=lambda _: {"unpinned": True, "message_id": message_id}, + ) + + def list_pinned_messages(self, channel_id: str) -> Result: + return http_request( + "GET", f"{DISCORD_API_BASE}/channels/{channel_id}/pins", + headers=self._bot_headers(), expected=(200,), + transform=lambda messages: {"messages": messages, "count": len(messages)}, + ) + + def remove_user_reaction(self, channel_id: str, message_id: str, + emoji: str, user_id: str) -> Result: + encoded = _url_quote(emoji, safe="") + return http_request( + "DELETE", + f"{DISCORD_API_BASE}/channels/{channel_id}/messages/{message_id}/reactions/{encoded}/{user_id}", + headers=self._bot_headers(), expected=(204,), + transform=lambda _: {"removed": True, "emoji": emoji, "user_id": user_id}, + ) + + def remove_own_reaction(self, channel_id: str, message_id: str, + emoji: str) -> Result: + encoded = _url_quote(emoji, safe="") + return http_request( + "DELETE", + f"{DISCORD_API_BASE}/channels/{channel_id}/messages/{message_id}/reactions/{encoded}/@me", + headers=self._bot_headers(), expected=(204,), + transform=lambda _: {"removed": True, "emoji": emoji}, + ) + + def list_reaction_users(self, channel_id: str, message_id: str, + emoji: str, limit: int = 100) -> Result: + encoded = _url_quote(emoji, safe="") + return http_request( + "GET", + f"{DISCORD_API_BASE}/channels/{channel_id}/messages/{message_id}/reactions/{encoded}", + headers=self._bot_headers(), + params={"limit": min(limit, 100)}, expected=(200,), + transform=lambda users: {"users": users, "count": len(users)}, + ) + + def clear_reactions(self, channel_id: str, message_id: str, + emoji: Optional[str] = None) -> Result: + """Clear all reactions, or just one emoji's reactions.""" + if emoji: + encoded = _url_quote(emoji, safe="") + url = f"{DISCORD_API_BASE}/channels/{channel_id}/messages/{message_id}/reactions/{encoded}" + else: + url = f"{DISCORD_API_BASE}/channels/{channel_id}/messages/{message_id}/reactions" + return http_request( + "DELETE", url, headers=self._bot_headers(), expected=(204,), + transform=lambda _: {"cleared": True, "emoji": emoji or "*"}, + ) + + # ================================================================== + # Threads + # ================================================================== + + def create_thread_from_message(self, channel_id: str, message_id: str, + name: str, + auto_archive_duration: int = 1440) -> Result: + """auto_archive_duration in minutes: 60, 1440, 4320, 10080.""" + return http_request( + "POST", f"{DISCORD_API_BASE}/channels/{channel_id}/messages/{message_id}/threads", + headers=self._bot_headers(), + json={"name": name, "auto_archive_duration": auto_archive_duration}, + expected=(201,), + transform=lambda d: {"thread_id": d.get("id"), "name": d.get("name"), + "parent_id": d.get("parent_id")}, + ) + + def create_thread(self, channel_id: str, name: str, + thread_type: int = 11, + auto_archive_duration: int = 1440, + invitable: bool = True, + rate_limit_per_user: Optional[int] = None) -> Result: + """thread_type: 10=announcement, 11=public, 12=private. Default 11 (public).""" + payload: Dict[str, Any] = { + "name": name, + "type": thread_type, + "auto_archive_duration": auto_archive_duration, + "invitable": invitable, + } + if rate_limit_per_user is not None: + payload["rate_limit_per_user"] = rate_limit_per_user + return http_request( + "POST", f"{DISCORD_API_BASE}/channels/{channel_id}/threads", + headers=self._bot_headers(), json=payload, expected=(201,), + transform=lambda d: {"thread_id": d.get("id"), "name": d.get("name"), + "type": d.get("type"), "parent_id": d.get("parent_id")}, + ) + + def join_thread(self, thread_id: str) -> Result: + return http_request( + "PUT", f"{DISCORD_API_BASE}/channels/{thread_id}/thread-members/@me", + headers=self._bot_headers(), expected=(204,), + transform=lambda _: {"joined": True, "thread_id": thread_id}, + ) + + def leave_thread(self, thread_id: str) -> Result: + return http_request( + "DELETE", f"{DISCORD_API_BASE}/channels/{thread_id}/thread-members/@me", + headers=self._bot_headers(), expected=(204,), + transform=lambda _: {"left": True, "thread_id": thread_id}, + ) + + def add_thread_member(self, thread_id: str, user_id: str) -> Result: + return http_request( + "PUT", f"{DISCORD_API_BASE}/channels/{thread_id}/thread-members/{user_id}", + headers=self._bot_headers(), expected=(204,), + transform=lambda _: {"added": True, "user_id": user_id}, + ) + + def remove_thread_member(self, thread_id: str, user_id: str) -> Result: + return http_request( + "DELETE", f"{DISCORD_API_BASE}/channels/{thread_id}/thread-members/{user_id}", + headers=self._bot_headers(), expected=(204,), + transform=lambda _: {"removed": True, "user_id": user_id}, + ) + + def list_thread_members(self, thread_id: str) -> Result: + return http_request( + "GET", f"{DISCORD_API_BASE}/channels/{thread_id}/thread-members", + headers=self._bot_headers(), expected=(200,), + transform=lambda m: {"members": m, "count": len(m)}, + ) + + def list_active_threads(self, guild_id: str) -> Result: + return http_request( + "GET", f"{DISCORD_API_BASE}/guilds/{guild_id}/threads/active", + headers=self._bot_headers(), expected=(200,), + transform=lambda d: {"threads": d.get("threads", []), + "members": d.get("members", [])}, + ) + + def archive_thread(self, thread_id: str) -> Result: + """Archive by PATCHing the thread with archived=true.""" + return http_request( + "PATCH", f"{DISCORD_API_BASE}/channels/{thread_id}", + headers=self._bot_headers(), + json={"archived": True}, expected=(200,), + transform=lambda d: {"archived": True, "thread_id": d.get("id")}, + ) + + def unarchive_thread(self, thread_id: str) -> Result: + return http_request( + "PATCH", f"{DISCORD_API_BASE}/channels/{thread_id}", + headers=self._bot_headers(), + json={"archived": False}, expected=(200,), + transform=lambda d: {"archived": False, "thread_id": d.get("id")}, + ) + + # ================================================================== + # Channels (CRUD + invites + permission overwrites) + # ================================================================== + + def create_guild_channel(self, guild_id: str, name: str, + channel_type: int = 0, + topic: Optional[str] = None, + parent_id: Optional[str] = None, + nsfw: bool = False, + rate_limit_per_user: Optional[int] = None, + position: Optional[int] = None, + permission_overwrites: Optional[List[Dict[str, Any]]] = None, + bitrate: Optional[int] = None, + user_limit: Optional[int] = None) -> Result: + """channel_type: 0=text, 2=voice, 4=category, 5=announcement, 13=stage, 15=forum.""" + payload: Dict[str, Any] = {"name": name, "type": channel_type, "nsfw": nsfw} + if topic is not None: payload["topic"] = topic + if parent_id: payload["parent_id"] = parent_id + if rate_limit_per_user is not None: payload["rate_limit_per_user"] = rate_limit_per_user + if position is not None: payload["position"] = position + if permission_overwrites is not None: payload["permission_overwrites"] = permission_overwrites + if bitrate is not None: payload["bitrate"] = bitrate + if user_limit is not None: payload["user_limit"] = user_limit + return http_request( + "POST", f"{DISCORD_API_BASE}/guilds/{guild_id}/channels", + headers=self._bot_headers(), json=payload, expected=(201,), + transform=lambda d: {"channel_id": d.get("id"), "name": d.get("name"), + "type": d.get("type")}, + ) + + def modify_channel(self, channel_id: str, name: Optional[str] = None, + topic: Optional[str] = None, + nsfw: Optional[bool] = None, + rate_limit_per_user: Optional[int] = None, + parent_id: Optional[str] = None, + position: Optional[int] = None, + bitrate: Optional[int] = None, + user_limit: Optional[int] = None, + archived: Optional[bool] = None, + locked: Optional[bool] = None) -> Result: + payload: Dict[str, Any] = {} + if name is not None: payload["name"] = name + if topic is not None: payload["topic"] = topic + if nsfw is not None: payload["nsfw"] = nsfw + if rate_limit_per_user is not None: payload["rate_limit_per_user"] = rate_limit_per_user + if parent_id is not None: payload["parent_id"] = parent_id + if position is not None: payload["position"] = position + if bitrate is not None: payload["bitrate"] = bitrate + if user_limit is not None: payload["user_limit"] = user_limit + if archived is not None: payload["archived"] = archived + if locked is not None: payload["locked"] = locked + return http_request( + "PATCH", f"{DISCORD_API_BASE}/channels/{channel_id}", + headers=self._bot_headers(), json=payload, expected=(200,), + transform=lambda d: {"channel_id": d.get("id"), "name": d.get("name"), + "topic": d.get("topic")}, + ) + + def delete_channel(self, channel_id: str) -> Result: + return http_request( + "DELETE", f"{DISCORD_API_BASE}/channels/{channel_id}", + headers=self._bot_headers(), expected=(200,), + transform=lambda d: {"deleted": True, "channel_id": d.get("id")}, + ) + + def edit_channel_permissions(self, channel_id: str, overwrite_id: str, + allow: str = "0", deny: str = "0", + type: int = 0) -> Result: + """type: 0=role, 1=member. allow/deny are bitfields as decimal strings.""" + return http_request( + "PUT", f"{DISCORD_API_BASE}/channels/{channel_id}/permissions/{overwrite_id}", + headers=self._bot_headers(), + json={"allow": allow, "deny": deny, "type": type}, expected=(204,), + transform=lambda _: {"updated": True, "overwrite_id": overwrite_id}, + ) + + def delete_channel_permission(self, channel_id: str, overwrite_id: str) -> Result: + return http_request( + "DELETE", f"{DISCORD_API_BASE}/channels/{channel_id}/permissions/{overwrite_id}", + headers=self._bot_headers(), expected=(204,), + transform=lambda _: {"deleted": True, "overwrite_id": overwrite_id}, + ) + + def list_channel_invites(self, channel_id: str) -> Result: + return http_request( + "GET", f"{DISCORD_API_BASE}/channels/{channel_id}/invites", + headers=self._bot_headers(), expected=(200,), + transform=lambda invites: {"invites": invites, "count": len(invites)}, + ) + + def create_channel_invite(self, channel_id: str, + max_age: int = 86400, max_uses: int = 0, + temporary: bool = False, unique: bool = False) -> Result: + return http_request( + "POST", f"{DISCORD_API_BASE}/channels/{channel_id}/invites", + headers=self._bot_headers(), + json={"max_age": max_age, "max_uses": max_uses, + "temporary": temporary, "unique": unique}, + expected=(200, 201), + transform=lambda d: {"code": d.get("code"), "url": f"https://discord.gg/{d.get('code')}", + "max_age": d.get("max_age"), "max_uses": d.get("max_uses")}, + ) + + def delete_invite(self, invite_code: str) -> Result: + return http_request( + "DELETE", f"{DISCORD_API_BASE}/invites/{invite_code}", + headers=self._bot_headers(), expected=(200,), + transform=lambda d: {"deleted": True, "code": d.get("code")}, + ) + + # ================================================================== + # Webhooks + # ================================================================== + + def list_channel_webhooks(self, channel_id: str) -> Result: + return http_request( + "GET", f"{DISCORD_API_BASE}/channels/{channel_id}/webhooks", + headers=self._bot_headers(), expected=(200,), + transform=lambda webhooks: {"webhooks": webhooks, "count": len(webhooks)}, + ) + + def create_webhook(self, channel_id: str, name: str, + avatar: Optional[str] = None) -> Result: + """avatar is a data-URI string (data:image/png;base64,...).""" + payload: Dict[str, Any] = {"name": name} + if avatar: payload["avatar"] = avatar + return http_request( + "POST", f"{DISCORD_API_BASE}/channels/{channel_id}/webhooks", + headers=self._bot_headers(), json=payload, expected=(200,), + transform=lambda d: {"id": d.get("id"), "token": d.get("token"), + "url": d.get("url"), "name": d.get("name")}, + ) + + def get_webhook(self, webhook_id: str) -> Result: + return http_request( + "GET", f"{DISCORD_API_BASE}/webhooks/{webhook_id}", + headers=self._bot_headers(), expected=(200,), + ) + + def modify_webhook(self, webhook_id: str, name: Optional[str] = None, + avatar: Optional[str] = None, + channel_id: Optional[str] = None) -> Result: + payload: Dict[str, Any] = {} + if name is not None: payload["name"] = name + if avatar is not None: payload["avatar"] = avatar + if channel_id is not None: payload["channel_id"] = channel_id + return http_request( + "PATCH", f"{DISCORD_API_BASE}/webhooks/{webhook_id}", + headers=self._bot_headers(), json=payload, expected=(200,), + transform=lambda d: {"id": d.get("id"), "name": d.get("name")}, + ) + + def delete_webhook(self, webhook_id: str) -> Result: + return http_request( + "DELETE", f"{DISCORD_API_BASE}/webhooks/{webhook_id}", + headers=self._bot_headers(), expected=(204,), + transform=lambda _: {"deleted": True, "webhook_id": webhook_id}, + ) + + def execute_webhook(self, webhook_id: str, webhook_token: str, + content: Optional[str] = None, + username: Optional[str] = None, + avatar_url: Optional[str] = None, + embeds: Optional[List[Dict[str, Any]]] = None, + wait: bool = False) -> Result: + """Post via webhook URL (no bot token needed for execute).""" + payload: Dict[str, Any] = {} + if content: payload["content"] = content + if username: payload["username"] = username + if avatar_url: payload["avatar_url"] = avatar_url + if embeds: payload["embeds"] = embeds + params: Dict[str, Any] = {} + if wait: params["wait"] = "true" + return http_request( + "POST", f"{DISCORD_API_BASE}/webhooks/{webhook_id}/{webhook_token}", + headers={"Content-Type": "application/json"}, + json=payload, params=params, expected=(200, 204), + transform=lambda d: {"sent": True, "message_id": (d or {}).get("id")}, + ) + + # ================================================================== + # Members (moderation: nickname, roles, kick, ban, timeout) + # ================================================================== + + def modify_guild_member(self, guild_id: str, user_id: str, + nick: Optional[str] = None, + roles: Optional[List[str]] = None, + mute: Optional[bool] = None, + deaf: Optional[bool] = None, + channel_id: Optional[str] = None, + communication_disabled_until: Optional[str] = None) -> Result: + """Modify a guild member. communication_disabled_until is an ISO 8601 timestamp for timeout (max 28 days).""" + payload: Dict[str, Any] = {} + if nick is not None: payload["nick"] = nick + if roles is not None: payload["roles"] = roles + if mute is not None: payload["mute"] = mute + if deaf is not None: payload["deaf"] = deaf + if channel_id is not None: payload["channel_id"] = channel_id + if communication_disabled_until is not None: + payload["communication_disabled_until"] = communication_disabled_until + return http_request( + "PATCH", f"{DISCORD_API_BASE}/guilds/{guild_id}/members/{user_id}", + headers=self._bot_headers(), json=payload, expected=(200,), + transform=lambda d: {"nick": d.get("nick"), + "roles": d.get("roles", []), + "communication_disabled_until": d.get("communication_disabled_until")}, + ) + + def modify_current_member_nick(self, guild_id: str, + nick: Optional[str]) -> Result: + """Set the bot's own nickname in a guild.""" + return http_request( + "PATCH", f"{DISCORD_API_BASE}/guilds/{guild_id}/members/@me", + headers=self._bot_headers(), json={"nick": nick}, expected=(200,), + transform=lambda d: {"nick": d.get("nick")}, + ) + + def add_guild_member_role(self, guild_id: str, user_id: str, + role_id: str) -> Result: + return http_request( + "PUT", f"{DISCORD_API_BASE}/guilds/{guild_id}/members/{user_id}/roles/{role_id}", + headers=self._bot_headers(), expected=(204,), + transform=lambda _: {"added": True, "role_id": role_id}, + ) + + def remove_guild_member_role(self, guild_id: str, user_id: str, + role_id: str) -> Result: + return http_request( + "DELETE", f"{DISCORD_API_BASE}/guilds/{guild_id}/members/{user_id}/roles/{role_id}", + headers=self._bot_headers(), expected=(204,), + transform=lambda _: {"removed": True, "role_id": role_id}, + ) + + def kick_guild_member(self, guild_id: str, user_id: str) -> Result: + return http_request( + "DELETE", f"{DISCORD_API_BASE}/guilds/{guild_id}/members/{user_id}", + headers=self._bot_headers(), expected=(204,), + transform=lambda _: {"kicked": True, "user_id": user_id}, + ) + + def ban_guild_member(self, guild_id: str, user_id: str, + delete_message_seconds: int = 0) -> Result: + """delete_message_seconds: 0..604800 (7 days).""" + return http_request( + "PUT", f"{DISCORD_API_BASE}/guilds/{guild_id}/bans/{user_id}", + headers=self._bot_headers(), + json={"delete_message_seconds": delete_message_seconds}, + expected=(204,), + transform=lambda _: {"banned": True, "user_id": user_id}, + ) + + def unban_guild_member(self, guild_id: str, user_id: str) -> Result: + return http_request( + "DELETE", f"{DISCORD_API_BASE}/guilds/{guild_id}/bans/{user_id}", + headers=self._bot_headers(), expected=(204,), + transform=lambda _: {"unbanned": True, "user_id": user_id}, + ) + + def list_guild_bans(self, guild_id: str, limit: int = 100) -> Result: + return http_request( + "GET", f"{DISCORD_API_BASE}/guilds/{guild_id}/bans", + headers=self._bot_headers(), + params={"limit": min(limit, 1000)}, expected=(200,), + transform=lambda bans: {"bans": bans, "count": len(bans)}, + ) + + def search_guild_members(self, guild_id: str, query: str, + limit: int = 10) -> Result: + return http_request( + "GET", f"{DISCORD_API_BASE}/guilds/{guild_id}/members/search", + headers=self._bot_headers(), + params={"query": query, "limit": min(limit, 1000)}, expected=(200,), + transform=lambda members: {"members": members, "count": len(members)}, + ) + + # ================================================================== + # Guild: roles + emojis + stickers + scheduled events + audit log + invites + # ================================================================== + + def get_guild(self, guild_id: str) -> Result: + return http_request( + "GET", f"{DISCORD_API_BASE}/guilds/{guild_id}", + headers=self._bot_headers(), expected=(200,), + ) + + def create_guild_role(self, guild_id: str, name: str, + permissions: Optional[str] = None, + color: Optional[int] = None, + hoist: bool = False, + mentionable: bool = False) -> Result: + payload: Dict[str, Any] = {"name": name, "hoist": hoist, "mentionable": mentionable} + if permissions is not None: payload["permissions"] = permissions + if color is not None: payload["color"] = color + return http_request( + "POST", f"{DISCORD_API_BASE}/guilds/{guild_id}/roles", + headers=self._bot_headers(), json=payload, expected=(200,), + transform=lambda d: {"role_id": d.get("id"), "name": d.get("name"), + "color": d.get("color")}, + ) + + def modify_guild_role(self, guild_id: str, role_id: str, + name: Optional[str] = None, + permissions: Optional[str] = None, + color: Optional[int] = None, + hoist: Optional[bool] = None, + mentionable: Optional[bool] = None) -> Result: + payload: Dict[str, Any] = {} + if name is not None: payload["name"] = name + if permissions is not None: payload["permissions"] = permissions + if color is not None: payload["color"] = color + if hoist is not None: payload["hoist"] = hoist + if mentionable is not None: payload["mentionable"] = mentionable + return http_request( + "PATCH", f"{DISCORD_API_BASE}/guilds/{guild_id}/roles/{role_id}", + headers=self._bot_headers(), json=payload, expected=(200,), + ) + + def delete_guild_role(self, guild_id: str, role_id: str) -> Result: + return http_request( + "DELETE", f"{DISCORD_API_BASE}/guilds/{guild_id}/roles/{role_id}", + headers=self._bot_headers(), expected=(204,), + transform=lambda _: {"deleted": True, "role_id": role_id}, + ) + + def list_guild_emojis(self, guild_id: str) -> Result: + return http_request( + "GET", f"{DISCORD_API_BASE}/guilds/{guild_id}/emojis", + headers=self._bot_headers(), expected=(200,), + transform=lambda emojis: {"emojis": emojis, "count": len(emojis)}, + ) + + def create_guild_emoji(self, guild_id: str, name: str, + image: str, roles: Optional[List[str]] = None) -> Result: + """image is a data-URI: 'data:image/png;base64,'.""" + payload: Dict[str, Any] = {"name": name, "image": image} + if roles: payload["roles"] = roles + return http_request( + "POST", f"{DISCORD_API_BASE}/guilds/{guild_id}/emojis", + headers=self._bot_headers(), json=payload, expected=(201,), + transform=lambda d: {"id": d.get("id"), "name": d.get("name")}, + ) + + def delete_guild_emoji(self, guild_id: str, emoji_id: str) -> Result: + return http_request( + "DELETE", f"{DISCORD_API_BASE}/guilds/{guild_id}/emojis/{emoji_id}", + headers=self._bot_headers(), expected=(204,), + transform=lambda _: {"deleted": True, "emoji_id": emoji_id}, + ) + + def list_guild_stickers(self, guild_id: str) -> Result: + return http_request( + "GET", f"{DISCORD_API_BASE}/guilds/{guild_id}/stickers", + headers=self._bot_headers(), expected=(200,), + transform=lambda s: {"stickers": s, "count": len(s)}, + ) + + def list_scheduled_events(self, guild_id: str, + with_user_count: bool = False) -> Result: + return http_request( + "GET", f"{DISCORD_API_BASE}/guilds/{guild_id}/scheduled-events", + headers=self._bot_headers(), + params={"with_user_count": str(with_user_count).lower()}, + expected=(200,), + transform=lambda events: {"events": events, "count": len(events)}, + ) + + def create_scheduled_event(self, guild_id: str, name: str, + scheduled_start_time: str, + entity_type: int, + privacy_level: int = 2, + scheduled_end_time: Optional[str] = None, + channel_id: Optional[str] = None, + entity_metadata: Optional[Dict[str, Any]] = None, + description: Optional[str] = None) -> Result: + """entity_type: 1=stage_instance, 2=voice, 3=external. privacy_level: 2=guild_only.""" + payload: Dict[str, Any] = { + "name": name, + "scheduled_start_time": scheduled_start_time, + "entity_type": entity_type, + "privacy_level": privacy_level, + } + if scheduled_end_time is not None: payload["scheduled_end_time"] = scheduled_end_time + if channel_id is not None: payload["channel_id"] = channel_id + if entity_metadata is not None: payload["entity_metadata"] = entity_metadata + if description is not None: payload["description"] = description + return http_request( + "POST", f"{DISCORD_API_BASE}/guilds/{guild_id}/scheduled-events", + headers=self._bot_headers(), json=payload, expected=(200,), + transform=lambda d: {"event_id": d.get("id"), "name": d.get("name")}, + ) + + def modify_scheduled_event(self, guild_id: str, event_id: str, + **fields) -> Result: + return http_request( + "PATCH", f"{DISCORD_API_BASE}/guilds/{guild_id}/scheduled-events/{event_id}", + headers=self._bot_headers(), json=fields, expected=(200,), + ) + + def delete_scheduled_event(self, guild_id: str, event_id: str) -> Result: + return http_request( + "DELETE", f"{DISCORD_API_BASE}/guilds/{guild_id}/scheduled-events/{event_id}", + headers=self._bot_headers(), expected=(204,), + transform=lambda _: {"deleted": True, "event_id": event_id}, + ) + + def get_audit_log(self, guild_id: str, + user_id: Optional[str] = None, + action_type: Optional[int] = None, + before: Optional[str] = None, + limit: int = 50) -> Result: + params: Dict[str, Any] = {"limit": min(limit, 100)} + if user_id: params["user_id"] = user_id + if action_type is not None: params["action_type"] = action_type + if before: params["before"] = before + return http_request( + "GET", f"{DISCORD_API_BASE}/guilds/{guild_id}/audit-logs", + headers=self._bot_headers(), params=params, expected=(200,), + transform=lambda d: {"audit_log_entries": d.get("audit_log_entries", []), + "users": d.get("users", []), + "webhooks": d.get("webhooks", [])}, + ) + + def list_guild_invites(self, guild_id: str) -> Result: + return http_request( + "GET", f"{DISCORD_API_BASE}/guilds/{guild_id}/invites", + headers=self._bot_headers(), expected=(200,), + transform=lambda invites: {"invites": invites, "count": len(invites)}, + ) diff --git a/craftos_integrations/integrations/gmail/__init__.py b/craftos_integrations/integrations/gmail/__init__.py index 4073516d..1c8d6260 100644 --- a/craftos_integrations/integrations/gmail/__init__.py +++ b/craftos_integrations/integrations/gmail/__init__.py @@ -438,3 +438,468 @@ def read_top_emails(self, n: int = 5, full_body: bool = False) -> Result: detail.get("result", detail) if "error" not in detail else detail ) return {"ok": True, "result": emails} + + # ----- Messages: search / modify / trash / untrash / delete / batch ----- + + def search_messages(self, query: str, max_results: int = 25, + label_ids: Optional[List[str]] = None, + include_spam_trash: bool = False) -> Result: + """Search messages by Gmail's q syntax (e.g. 'from:alice subject:invoice newer_than:7d').""" + params: Dict[str, Any] = { + "q": query, + "maxResults": max_results, + "includeSpamTrash": str(include_spam_trash).lower(), + } + if label_ids: + params["labelIds"] = label_ids + return http_request( + "GET", f"{GMAIL_API_BASE}/users/me/messages", + headers=self._auth_header(), params=params, expected=(200,), + transform=lambda d: {"messages": d.get("messages", []), + "resultSizeEstimate": d.get("resultSizeEstimate", 0)}, + ) + + def modify_message_labels(self, message_id: str, + add_label_ids: Optional[List[str]] = None, + remove_label_ids: Optional[List[str]] = None) -> Result: + payload: Dict[str, Any] = {} + if add_label_ids: payload["addLabelIds"] = add_label_ids + if remove_label_ids: payload["removeLabelIds"] = remove_label_ids + return http_request( + "POST", f"{GMAIL_API_BASE}/users/me/messages/{message_id}/modify", + headers=self._headers(), json=payload, expected=(200,), + transform=lambda d: {"id": d.get("id"), "labelIds": d.get("labelIds", [])}, + ) + + def trash_message(self, message_id: str) -> Result: + return http_request( + "POST", f"{GMAIL_API_BASE}/users/me/messages/{message_id}/trash", + headers=self._auth_header(), expected=(200,), + transform=lambda d: {"id": d.get("id"), "trashed": True}, + ) + + def untrash_message(self, message_id: str) -> Result: + return http_request( + "POST", f"{GMAIL_API_BASE}/users/me/messages/{message_id}/untrash", + headers=self._auth_header(), expected=(200,), + transform=lambda d: {"id": d.get("id"), "trashed": False}, + ) + + def delete_message(self, message_id: str) -> Result: + """Permanently delete. Use trash_message for soft delete.""" + return http_request( + "DELETE", f"{GMAIL_API_BASE}/users/me/messages/{message_id}", + headers=self._auth_header(), expected=(204,), + transform=lambda _d: {"deleted": True, "message_id": message_id}, + ) + + def batch_modify_messages(self, message_ids: List[str], + add_label_ids: Optional[List[str]] = None, + remove_label_ids: Optional[List[str]] = None) -> Result: + payload: Dict[str, Any] = {"ids": message_ids} + if add_label_ids: payload["addLabelIds"] = add_label_ids + if remove_label_ids: payload["removeLabelIds"] = remove_label_ids + return http_request( + "POST", f"{GMAIL_API_BASE}/users/me/messages/batchModify", + headers=self._headers(), json=payload, expected=(204,), + transform=lambda _d: {"modified": len(message_ids)}, + ) + + def batch_delete_messages(self, message_ids: List[str]) -> Result: + return http_request( + "POST", f"{GMAIL_API_BASE}/users/me/messages/batchDelete", + headers=self._headers(), json={"ids": message_ids}, expected=(204,), + transform=lambda _d: {"deleted": len(message_ids)}, + ) + + # ----- Reply / forward (build proper RFC 2822 message and send via threadId) ----- + + def _fetch_reply_headers(self, message_id: str) -> Dict[str, str]: + """Fetch original message metadata + Message-ID/Subject/From headers.""" + result = http_request( + "GET", f"{GMAIL_API_BASE}/users/me/messages/{message_id}", + headers=self._auth_header(), + params=[("format", "metadata"), + ("metadataHeaders", "From"), + ("metadataHeaders", "To"), + ("metadataHeaders", "Cc"), + ("metadataHeaders", "Subject"), + ("metadataHeaders", "Message-ID"), + ("metadataHeaders", "References")], + expected=(200,), + ) + if "error" in result: + return {"_error": result["error"], "_thread_id": ""} + data = result["result"] + headers = {h["name"]: h["value"] for h in data.get("payload", {}).get("headers", [])} + headers["_thread_id"] = data.get("threadId", "") + return headers + + def reply_to_message(self, message_id: str, body: str, + reply_all: bool = False, + attachments: Optional[List[str]] = None) -> Result: + info = self._fetch_reply_headers(message_id) + if info.get("_error"): + return {"error": info["_error"]} + cred = self._load() + + orig_subject = info.get("Subject", "") + reply_subject = orig_subject if orig_subject.lower().startswith("re:") else f"Re: {orig_subject}" + msg_id_hdr = info.get("Message-ID") or info.get("Message-Id", "") + references = info.get("References", "") + thread_id = info["_thread_id"] + + # Default: reply to sender. If reply_all, also CC the original To/Cc minus self. + from_addr = info.get("From", "") + cc_addrs: List[str] = [] + if reply_all: + for hdr in ("To", "Cc"): + if info.get(hdr): + cc_addrs.extend([a.strip() for a in info[hdr].split(",")]) + self_email = (cred.email or "").lower() + cc_addrs = [a for a in cc_addrs if a and self_email not in a.lower()] + + msg = MIMEMultipart() + msg["to"] = from_addr + msg["from"] = cred.email + msg["subject"] = reply_subject + if cc_addrs: + msg["cc"] = ", ".join(cc_addrs) + if msg_id_hdr: + msg["In-Reply-To"] = msg_id_hdr + msg["References"] = (references + " " + msg_id_hdr).strip() if references else msg_id_hdr + msg.attach(MIMEText(body, "plain")) + + if attachments: + for file_path in attachments: + if not os.path.isfile(file_path): + continue + mime_type, _ = mimetypes.guess_type(file_path) + if mime_type is None: + mime_type = "application/octet-stream" + maintype, subtype = mime_type.split("/", 1) + with open(file_path, "rb") as f: + part = MIMEBase(maintype, subtype) + part.set_payload(f.read()) + encoders.encode_base64(part) + part.add_header("Content-Disposition", + f'attachment; filename="{os.path.basename(file_path)}"') + msg.attach(part) + + raw = base64.urlsafe_b64encode(msg.as_bytes()).decode() + payload: Dict[str, Any] = {"raw": raw} + if thread_id: + payload["threadId"] = thread_id + return http_request( + "POST", f"{GMAIL_API_BASE}/users/me/messages/send", + headers=self._headers(), json=payload, expected=(200,), + transform=lambda d: {"id": d.get("id"), "threadId": d.get("threadId"), + "replied_to": message_id}, + ) + + def forward_message(self, message_id: str, to: str, body: str = "", + attachments: Optional[List[str]] = None) -> Result: + info = self._fetch_reply_headers(message_id) + if info.get("_error"): + return {"error": info["_error"]} + cred = self._load() + + orig_subject = info.get("Subject", "") + fwd_subject = orig_subject if orig_subject.lower().startswith("fwd:") else f"Fwd: {orig_subject}" + thread_id = info["_thread_id"] + + msg = MIMEMultipart() + msg["to"] = to + msg["from"] = cred.email + msg["subject"] = fwd_subject + msg.attach(MIMEText(body, "plain")) + + if attachments: + for file_path in attachments: + if not os.path.isfile(file_path): + continue + mime_type, _ = mimetypes.guess_type(file_path) + if mime_type is None: + mime_type = "application/octet-stream" + maintype, subtype = mime_type.split("/", 1) + with open(file_path, "rb") as f: + part = MIMEBase(maintype, subtype) + part.set_payload(f.read()) + encoders.encode_base64(part) + part.add_header("Content-Disposition", + f'attachment; filename="{os.path.basename(file_path)}"') + msg.attach(part) + + raw = base64.urlsafe_b64encode(msg.as_bytes()).decode() + payload: Dict[str, Any] = {"raw": raw} + if thread_id: + payload["threadId"] = thread_id + return http_request( + "POST", f"{GMAIL_API_BASE}/users/me/messages/send", + headers=self._headers(), json=payload, expected=(200,), + transform=lambda d: {"id": d.get("id"), "threadId": d.get("threadId"), + "forwarded": message_id, "to": to}, + ) + + # ----- Threads ----- + + def list_threads(self, query: Optional[str] = None, + label_ids: Optional[List[str]] = None, + max_results: int = 25) -> Result: + params: Dict[str, Any] = {"maxResults": max_results} + if query: params["q"] = query + if label_ids: params["labelIds"] = label_ids + return http_request( + "GET", f"{GMAIL_API_BASE}/users/me/threads", + headers=self._auth_header(), params=params, expected=(200,), + transform=lambda d: {"threads": d.get("threads", []), + "resultSizeEstimate": d.get("resultSizeEstimate", 0)}, + ) + + def get_thread(self, thread_id: str, fmt: str = "metadata") -> Result: + return http_request( + "GET", f"{GMAIL_API_BASE}/users/me/threads/{thread_id}", + headers=self._auth_header(), + params={"format": fmt}, expected=(200,), + ) + + def modify_thread_labels(self, thread_id: str, + add_label_ids: Optional[List[str]] = None, + remove_label_ids: Optional[List[str]] = None) -> Result: + payload: Dict[str, Any] = {} + if add_label_ids: payload["addLabelIds"] = add_label_ids + if remove_label_ids: payload["removeLabelIds"] = remove_label_ids + return http_request( + "POST", f"{GMAIL_API_BASE}/users/me/threads/{thread_id}/modify", + headers=self._headers(), json=payload, expected=(200,), + transform=lambda d: {"id": d.get("id"), "messages": len(d.get("messages", []))}, + ) + + def trash_thread(self, thread_id: str) -> Result: + return http_request( + "POST", f"{GMAIL_API_BASE}/users/me/threads/{thread_id}/trash", + headers=self._auth_header(), expected=(200,), + transform=lambda d: {"id": d.get("id"), "trashed": True}, + ) + + def untrash_thread(self, thread_id: str) -> Result: + return http_request( + "POST", f"{GMAIL_API_BASE}/users/me/threads/{thread_id}/untrash", + headers=self._auth_header(), expected=(200,), + transform=lambda d: {"id": d.get("id"), "trashed": False}, + ) + + def delete_thread(self, thread_id: str) -> Result: + return http_request( + "DELETE", f"{GMAIL_API_BASE}/users/me/threads/{thread_id}", + headers=self._auth_header(), expected=(204,), + transform=lambda _d: {"deleted": True, "thread_id": thread_id}, + ) + + # ----- Drafts ----- + + def list_drafts(self, max_results: int = 25, query: Optional[str] = None) -> Result: + params: Dict[str, Any] = {"maxResults": max_results} + if query: params["q"] = query + return http_request( + "GET", f"{GMAIL_API_BASE}/users/me/drafts", + headers=self._auth_header(), params=params, expected=(200,), + transform=lambda d: {"drafts": d.get("drafts", [])}, + ) + + def get_draft(self, draft_id: str, fmt: str = "metadata") -> Result: + return http_request( + "GET", f"{GMAIL_API_BASE}/users/me/drafts/{draft_id}", + headers=self._auth_header(), params={"format": fmt}, expected=(200,), + ) + + def create_draft(self, to: str, subject: str, body: str, + cc: Optional[str] = None, bcc: Optional[str] = None, + attachments: Optional[List[str]] = None) -> Result: + cred = self._load() + msg = MIMEMultipart() + msg["to"] = to + msg["from"] = cred.email + msg["subject"] = subject + if cc: msg["cc"] = cc + if bcc: msg["bcc"] = bcc + msg.attach(MIMEText(body, "plain")) + + if attachments: + for file_path in attachments: + if not os.path.isfile(file_path): + continue + mime_type, _ = mimetypes.guess_type(file_path) + if mime_type is None: + mime_type = "application/octet-stream" + maintype, subtype = mime_type.split("/", 1) + with open(file_path, "rb") as f: + part = MIMEBase(maintype, subtype) + part.set_payload(f.read()) + encoders.encode_base64(part) + part.add_header("Content-Disposition", + f'attachment; filename="{os.path.basename(file_path)}"') + msg.attach(part) + + raw = base64.urlsafe_b64encode(msg.as_bytes()).decode() + return http_request( + "POST", f"{GMAIL_API_BASE}/users/me/drafts", + headers=self._headers(), + json={"message": {"raw": raw}}, expected=(200,), + transform=lambda d: {"id": d.get("id"), + "message_id": d.get("message", {}).get("id")}, + ) + + def update_draft(self, draft_id: str, to: str, subject: str, body: str, + cc: Optional[str] = None, bcc: Optional[str] = None, + attachments: Optional[List[str]] = None) -> Result: + """Replaces the draft content (PUT).""" + cred = self._load() + msg = MIMEMultipart() + msg["to"] = to + msg["from"] = cred.email + msg["subject"] = subject + if cc: msg["cc"] = cc + if bcc: msg["bcc"] = bcc + msg.attach(MIMEText(body, "plain")) + + if attachments: + for file_path in attachments: + if not os.path.isfile(file_path): + continue + mime_type, _ = mimetypes.guess_type(file_path) + if mime_type is None: + mime_type = "application/octet-stream" + maintype, subtype = mime_type.split("/", 1) + with open(file_path, "rb") as f: + part = MIMEBase(maintype, subtype) + part.set_payload(f.read()) + encoders.encode_base64(part) + part.add_header("Content-Disposition", + f'attachment; filename="{os.path.basename(file_path)}"') + msg.attach(part) + + raw = base64.urlsafe_b64encode(msg.as_bytes()).decode() + return http_request( + "PUT", f"{GMAIL_API_BASE}/users/me/drafts/{draft_id}", + headers=self._headers(), + json={"message": {"raw": raw}}, expected=(200,), + transform=lambda d: {"id": d.get("id")}, + ) + + def send_draft(self, draft_id: str) -> Result: + return http_request( + "POST", f"{GMAIL_API_BASE}/users/me/drafts/send", + headers=self._headers(), json={"id": draft_id}, expected=(200,), + transform=lambda d: {"sent": True, "message_id": d.get("id"), "draft_id": draft_id}, + ) + + def delete_draft(self, draft_id: str) -> Result: + return http_request( + "DELETE", f"{GMAIL_API_BASE}/users/me/drafts/{draft_id}", + headers=self._auth_header(), expected=(204,), + transform=lambda _d: {"deleted": True, "draft_id": draft_id}, + ) + + # ----- Labels ----- + + def list_labels(self) -> Result: + return http_request( + "GET", f"{GMAIL_API_BASE}/users/me/labels", + headers=self._auth_header(), expected=(200,), + transform=lambda d: {"labels": [ + {"id": l.get("id"), "name": l.get("name"), "type": l.get("type"), + "messageListVisibility": l.get("messageListVisibility"), + "labelListVisibility": l.get("labelListVisibility")} + for l in d.get("labels", []) + ]}, + ) + + def get_label(self, label_id: str) -> Result: + return http_request( + "GET", f"{GMAIL_API_BASE}/users/me/labels/{label_id}", + headers=self._auth_header(), expected=(200,), + ) + + def create_label(self, name: str, + label_list_visibility: str = "labelShow", + message_list_visibility: str = "show", + background_color: Optional[str] = None, + text_color: Optional[str] = None) -> Result: + payload: Dict[str, Any] = { + "name": name, + "labelListVisibility": label_list_visibility, + "messageListVisibility": message_list_visibility, + } + if background_color and text_color: + payload["color"] = {"backgroundColor": background_color, "textColor": text_color} + return http_request( + "POST", f"{GMAIL_API_BASE}/users/me/labels", + headers=self._headers(), json=payload, expected=(200,), + transform=lambda d: {"id": d.get("id"), "name": d.get("name")}, + ) + + def update_label(self, label_id: str, name: Optional[str] = None, + label_list_visibility: Optional[str] = None, + message_list_visibility: Optional[str] = None, + background_color: Optional[str] = None, + text_color: Optional[str] = None) -> Result: + payload: Dict[str, Any] = {} + if name is not None: payload["name"] = name + if label_list_visibility is not None: payload["labelListVisibility"] = label_list_visibility + if message_list_visibility is not None: payload["messageListVisibility"] = message_list_visibility + if background_color and text_color: + payload["color"] = {"backgroundColor": background_color, "textColor": text_color} + return http_request( + "PATCH", f"{GMAIL_API_BASE}/users/me/labels/{label_id}", + headers=self._headers(), json=payload, expected=(200,), + transform=lambda d: {"id": d.get("id"), "name": d.get("name")}, + ) + + def delete_label(self, label_id: str) -> Result: + return http_request( + "DELETE", f"{GMAIL_API_BASE}/users/me/labels/{label_id}", + headers=self._auth_header(), expected=(204,), + transform=lambda _d: {"deleted": True, "label_id": label_id}, + ) + + # ----- Attachments ----- + + def download_attachment(self, message_id: str, attachment_id: str, + save_to: str) -> Result: + """Download an attachment to a local path. Decodes Gmail's urlsafe base64 data.""" + import os as _os + + result = http_request( + "GET", f"{GMAIL_API_BASE}/users/me/messages/{message_id}/attachments/{attachment_id}", + headers=self._auth_header(), expected=(200,), + ) + if "error" in result: + return result + data_b64 = result["result"].get("data", "") + if not data_b64: + return {"error": "Attachment had no data field"} + try: + save_to = _os.path.abspath(save_to) + parent = _os.path.dirname(save_to) + if parent: + _os.makedirs(parent, exist_ok=True) + with open(save_to, "wb") as f: + f.write(base64.urlsafe_b64decode(data_b64.encode("ascii"))) + return {"ok": True, "result": {"saved_to": save_to, "size": _os.path.getsize(save_to)}} + except Exception as e: + return {"error": str(e)} + + # ----- Profile ----- + + def get_profile(self) -> Result: + return http_request( + "GET", f"{GMAIL_API_BASE}/users/me/profile", + headers=self._auth_header(), expected=(200,), + transform=lambda d: { + "emailAddress": d.get("emailAddress"), + "messagesTotal": d.get("messagesTotal"), + "threadsTotal": d.get("threadsTotal"), + "historyId": d.get("historyId"), + }, + ) diff --git a/craftos_integrations/integrations/google_calendar/__init__.py b/craftos_integrations/integrations/google_calendar/__init__.py index dd85d7ee..cead61ad 100644 --- a/craftos_integrations/integrations/google_calendar/__init__.py +++ b/craftos_integrations/integrations/google_calendar/__init__.py @@ -180,3 +180,265 @@ def list_calendars(self) -> Result: expected=(200,), transform=lambda d: d.get("items", []), ) + + # ----- Events ----- + + def insert_event(self, calendar_id: str, event_data: Dict[str, Any], + send_updates: str = "none", + supports_attachments: bool = False, + conference_data_version: int = 0) -> Result: + params: Dict[str, Any] = {"sendUpdates": send_updates} + if supports_attachments: + params["supportsAttachments"] = "true" + if conference_data_version: + params["conferenceDataVersion"] = conference_data_version + return http_request( + "POST", f"{CALENDAR_API_BASE}/calendars/{calendar_id}/events", + headers=self._headers(), params=params, json=event_data, + expected=(200,), + ) + + def update_event(self, calendar_id: str, event_id: str, + event_data: Dict[str, Any], + send_updates: str = "none") -> Result: + return http_request( + "PUT", f"{CALENDAR_API_BASE}/calendars/{calendar_id}/events/{event_id}", + headers=self._headers(), params={"sendUpdates": send_updates}, + json=event_data, expected=(200,), + ) + + def patch_event(self, calendar_id: str, event_id: str, + event_data: Dict[str, Any], + send_updates: str = "none") -> Result: + return http_request( + "PATCH", f"{CALENDAR_API_BASE}/calendars/{calendar_id}/events/{event_id}", + headers=self._headers(), params={"sendUpdates": send_updates}, + json=event_data, expected=(200,), + ) + + def move_event(self, calendar_id: str, event_id: str, + destination_calendar_id: str, + send_updates: str = "none") -> Result: + return http_request( + "POST", f"{CALENDAR_API_BASE}/calendars/{calendar_id}/events/{event_id}/move", + headers=self._auth_header(), + params={"destination": destination_calendar_id, "sendUpdates": send_updates}, + expected=(200,), + ) + + def quick_add_event(self, calendar_id: str, text: str, + send_updates: str = "none") -> Result: + return http_request( + "POST", f"{CALENDAR_API_BASE}/calendars/{calendar_id}/events/quickAdd", + headers=self._auth_header(), + params={"text": text, "sendUpdates": send_updates}, + expected=(200,), + ) + + def list_event_instances(self, calendar_id: str, event_id: str, + time_min: Optional[str] = None, + time_max: Optional[str] = None, + max_results: int = 50) -> Result: + params: Dict[str, Any] = {"maxResults": max_results} + if time_min: params["timeMin"] = time_min + if time_max: params["timeMax"] = time_max + return http_request( + "GET", f"{CALENDAR_API_BASE}/calendars/{calendar_id}/events/{event_id}/instances", + headers=self._auth_header(), params=params, expected=(200,), + transform=lambda d: {"instances": d.get("items", [])}, + ) + + def import_event(self, calendar_id: str, event_data: Dict[str, Any]) -> Result: + return http_request( + "POST", f"{CALENDAR_API_BASE}/calendars/{calendar_id}/events/import", + headers=self._headers(), json=event_data, expected=(200,), + ) + + # ----- Calendars (the resource itself) ----- + + def get_calendar(self, calendar_id: str = "primary") -> Result: + return http_request( + "GET", f"{CALENDAR_API_BASE}/calendars/{calendar_id}", + headers=self._auth_header(), expected=(200,), + ) + + def create_calendar(self, summary: str, description: Optional[str] = None, + time_zone: Optional[str] = None, + location: Optional[str] = None) -> Result: + payload: Dict[str, Any] = {"summary": summary} + if description: payload["description"] = description + if time_zone: payload["timeZone"] = time_zone + if location: payload["location"] = location + return http_request( + "POST", f"{CALENDAR_API_BASE}/calendars", + headers=self._headers(), json=payload, expected=(200,), + transform=lambda d: {"id": d.get("id"), "summary": d.get("summary"), "timeZone": d.get("timeZone")}, + ) + + def update_calendar(self, calendar_id: str, summary: Optional[str] = None, + description: Optional[str] = None, + time_zone: Optional[str] = None, + location: Optional[str] = None) -> Result: + payload: Dict[str, Any] = {} + if summary is not None: payload["summary"] = summary + if description is not None: payload["description"] = description + if time_zone is not None: payload["timeZone"] = time_zone + if location is not None: payload["location"] = location + return http_request( + "PUT", f"{CALENDAR_API_BASE}/calendars/{calendar_id}", + headers=self._headers(), json=payload, expected=(200,), + ) + + def patch_calendar(self, calendar_id: str, summary: Optional[str] = None, + description: Optional[str] = None, + time_zone: Optional[str] = None, + location: Optional[str] = None) -> Result: + payload: Dict[str, Any] = {} + if summary is not None: payload["summary"] = summary + if description is not None: payload["description"] = description + if time_zone is not None: payload["timeZone"] = time_zone + if location is not None: payload["location"] = location + return http_request( + "PATCH", f"{CALENDAR_API_BASE}/calendars/{calendar_id}", + headers=self._headers(), json=payload, expected=(200,), + ) + + def delete_calendar(self, calendar_id: str) -> Result: + return http_request( + "DELETE", f"{CALENDAR_API_BASE}/calendars/{calendar_id}", + headers=self._auth_header(), expected=(204,), + transform=lambda _d: {"deleted": True, "calendar_id": calendar_id}, + ) + + def clear_calendar(self, calendar_id: str = "primary") -> Result: + """Clears all events on the PRIMARY calendar. No-op on secondary.""" + return http_request( + "POST", f"{CALENDAR_API_BASE}/calendars/{calendar_id}/clear", + headers=self._auth_header(), expected=(204,), + transform=lambda _d: {"cleared": True, "calendar_id": calendar_id}, + ) + + # ----- CalendarList (the user's view: subscriptions, colors, visibility) ----- + + def get_calendar_list_entry(self, calendar_id: str) -> Result: + return http_request( + "GET", f"{CALENDAR_API_BASE}/users/me/calendarList/{calendar_id}", + headers=self._auth_header(), expected=(200,), + ) + + def subscribe_calendar(self, calendar_id: str, color_id: Optional[str] = None, + summary_override: Optional[str] = None, + selected: Optional[bool] = None, + hidden: Optional[bool] = None) -> Result: + payload: Dict[str, Any] = {"id": calendar_id} + if color_id is not None: payload["colorId"] = color_id + if summary_override is not None: payload["summaryOverride"] = summary_override + if selected is not None: payload["selected"] = selected + if hidden is not None: payload["hidden"] = hidden + return http_request( + "POST", f"{CALENDAR_API_BASE}/users/me/calendarList", + headers=self._headers(), json=payload, expected=(200,), + transform=lambda d: {"id": d.get("id"), "summary": d.get("summary")}, + ) + + def update_calendar_list_entry(self, calendar_id: str, + color_id: Optional[str] = None, + summary_override: Optional[str] = None, + selected: Optional[bool] = None, + hidden: Optional[bool] = None) -> Result: + payload: Dict[str, Any] = {} + if color_id is not None: payload["colorId"] = color_id + if summary_override is not None: payload["summaryOverride"] = summary_override + if selected is not None: payload["selected"] = selected + if hidden is not None: payload["hidden"] = hidden + return http_request( + "PATCH", f"{CALENDAR_API_BASE}/users/me/calendarList/{calendar_id}", + headers=self._headers(), json=payload, expected=(200,), + ) + + def unsubscribe_calendar(self, calendar_id: str) -> Result: + return http_request( + "DELETE", f"{CALENDAR_API_BASE}/users/me/calendarList/{calendar_id}", + headers=self._auth_header(), expected=(204,), + transform=lambda _d: {"unsubscribed": True, "calendar_id": calendar_id}, + ) + + # ----- ACL (per-calendar sharing) ----- + + def list_calendar_acl(self, calendar_id: str = "primary") -> Result: + return http_request( + "GET", f"{CALENDAR_API_BASE}/calendars/{calendar_id}/acl", + headers=self._auth_header(), expected=(200,), + transform=lambda d: {"acl": [ + {"id": r.get("id"), "role": r.get("role"), + "scope_type": r.get("scope", {}).get("type"), + "scope_value": r.get("scope", {}).get("value")} + for r in d.get("items", []) + ]}, + ) + + def get_calendar_acl_rule(self, calendar_id: str, rule_id: str) -> Result: + return http_request( + "GET", f"{CALENDAR_API_BASE}/calendars/{calendar_id}/acl/{rule_id}", + headers=self._auth_header(), expected=(200,), + ) + + def add_calendar_acl_rule(self, calendar_id: str, scope_type: str, + scope_value: str, role: str, + send_notifications: bool = True) -> Result: + """scope_type: user|group|domain|default. role: none|freeBusyReader|reader|writer|owner.""" + payload = {"role": role, "scope": {"type": scope_type, "value": scope_value}} + return http_request( + "POST", f"{CALENDAR_API_BASE}/calendars/{calendar_id}/acl", + headers=self._headers(), json=payload, + params={"sendNotifications": str(send_notifications).lower()}, + expected=(200,), + transform=lambda d: {"id": d.get("id"), "role": d.get("role")}, + ) + + def update_calendar_acl_rule(self, calendar_id: str, rule_id: str, role: str, + scope_type: Optional[str] = None, + scope_value: Optional[str] = None, + send_notifications: bool = True) -> Result: + payload: Dict[str, Any] = {"role": role} + if scope_type or scope_value: + payload["scope"] = {} + if scope_type: payload["scope"]["type"] = scope_type + if scope_value: payload["scope"]["value"] = scope_value + return http_request( + "PUT", f"{CALENDAR_API_BASE}/calendars/{calendar_id}/acl/{rule_id}", + headers=self._headers(), json=payload, + params={"sendNotifications": str(send_notifications).lower()}, + expected=(200,), + ) + + def delete_calendar_acl_rule(self, calendar_id: str, rule_id: str) -> Result: + return http_request( + "DELETE", f"{CALENDAR_API_BASE}/calendars/{calendar_id}/acl/{rule_id}", + headers=self._auth_header(), expected=(204,), + transform=lambda _d: {"deleted": True, "rule_id": rule_id}, + ) + + # ----- Settings & colors ----- + + def list_calendar_settings(self) -> Result: + return http_request( + "GET", f"{CALENDAR_API_BASE}/users/me/settings", + headers=self._auth_header(), expected=(200,), + transform=lambda d: {"settings": {s.get("id"): s.get("value") for s in d.get("items", [])}}, + ) + + def get_calendar_setting(self, setting_id: str) -> Result: + """setting_id examples: timezone, locale, autoAddHangouts, weekStart.""" + return http_request( + "GET", f"{CALENDAR_API_BASE}/users/me/settings/{setting_id}", + headers=self._auth_header(), expected=(200,), + transform=lambda d: {"id": d.get("id"), "value": d.get("value")}, + ) + + def get_calendar_colors(self) -> Result: + return http_request( + "GET", f"{CALENDAR_API_BASE}/colors", + headers=self._auth_header(), expected=(200,), + transform=lambda d: {"calendar": d.get("calendar", {}), "event": d.get("event", {})}, + ) diff --git a/craftos_integrations/integrations/google_docs/__init__.py b/craftos_integrations/integrations/google_docs/__init__.py index 0a4474d6..f02b360c 100644 --- a/craftos_integrations/integrations/google_docs/__init__.py +++ b/craftos_integrations/integrations/google_docs/__init__.py @@ -270,3 +270,374 @@ def delete_document(self, document_id: str) -> Result: expected=(204,), transform=lambda _d: {"deleted": True, "document_id": document_id}, ) + + # ----- batchUpdate helper ----- + + def _batch_update(self, document_id: str, requests: List[Dict[str, Any]], + transform=None) -> Result: + return http_request( + "POST", f"{DOCS_API_BASE}/documents/{document_id}:batchUpdate", + headers=self._headers(), + json={"requests": requests}, + expected=(200,), + transform=transform or (lambda d: {"document_id": document_id, "replies": d.get("replies", [])}), + ) + + # ----- Content: insert / delete ----- + + def insert_text(self, document_id: str, text: str, index: int) -> Result: + """Insert text at a specific UTF-16 index. Index 1 is the start of the body.""" + return self._batch_update(document_id, [ + {"insertText": {"location": {"index": index}, "text": text}} + ], transform=lambda _d: {"inserted": True, "document_id": document_id, "index": index}) + + def delete_content_range(self, document_id: str, start_index: int, end_index: int) -> Result: + return self._batch_update(document_id, [ + {"deleteContentRange": {"range": {"startIndex": start_index, "endIndex": end_index}}} + ], transform=lambda _d: {"deleted": True, "document_id": document_id}) + + # ----- Styling: text + paragraph ----- + + def update_text_style(self, document_id: str, start_index: int, end_index: int, + bold: Optional[bool] = None, italic: Optional[bool] = None, + underline: Optional[bool] = None, strikethrough: Optional[bool] = None, + font_size_pt: Optional[float] = None, font_family: Optional[str] = None, + foreground_color_hex: Optional[str] = None, + background_color_hex: Optional[str] = None, + link_url: Optional[str] = None) -> Result: + """Apply text-level styling to a range. ``*_hex`` are '#RRGGBB' strings. + + Only supplied parameters are applied; the rest stay untouched. + """ + text_style: Dict[str, Any] = {} + fields: List[str] = [] + if bold is not None: + text_style["bold"] = bold; fields.append("bold") + if italic is not None: + text_style["italic"] = italic; fields.append("italic") + if underline is not None: + text_style["underline"] = underline; fields.append("underline") + if strikethrough is not None: + text_style["strikethrough"] = strikethrough; fields.append("strikethrough") + if font_size_pt is not None: + text_style["fontSize"] = {"magnitude": font_size_pt, "unit": "PT"} + fields.append("fontSize") + if font_family is not None: + text_style["weightedFontFamily"] = {"fontFamily": font_family} + fields.append("weightedFontFamily") + if foreground_color_hex is not None: + text_style["foregroundColor"] = {"color": {"rgbColor": _hex_to_rgb(foreground_color_hex)}} + fields.append("foregroundColor") + if background_color_hex is not None: + text_style["backgroundColor"] = {"color": {"rgbColor": _hex_to_rgb(background_color_hex)}} + fields.append("backgroundColor") + if link_url is not None: + text_style["link"] = {"url": link_url}; fields.append("link") + if not fields: + return {"error": "no_style_fields"} + return self._batch_update(document_id, [ + {"updateTextStyle": { + "range": {"startIndex": start_index, "endIndex": end_index}, + "textStyle": text_style, + "fields": ",".join(fields), + }} + ], transform=lambda _d: {"styled": True, "document_id": document_id}) + + def update_paragraph_style(self, document_id: str, start_index: int, end_index: int, + named_style_type: Optional[str] = None, + alignment: Optional[str] = None, + line_spacing: Optional[float] = None, + keep_with_next: Optional[bool] = None) -> Result: + """Apply paragraph-level styling to a range. + + ``named_style_type``: NORMAL_TEXT, TITLE, SUBTITLE, HEADING_1..HEADING_6. + ``alignment``: START, CENTER, END, JUSTIFIED. + ``line_spacing`` is a percentage (100 = single spacing). + """ + para_style: Dict[str, Any] = {} + fields: List[str] = [] + if named_style_type is not None: + para_style["namedStyleType"] = named_style_type; fields.append("namedStyleType") + if alignment is not None: + para_style["alignment"] = alignment; fields.append("alignment") + if line_spacing is not None: + para_style["lineSpacing"] = line_spacing; fields.append("lineSpacing") + if keep_with_next is not None: + para_style["keepWithNext"] = keep_with_next; fields.append("keepWithNext") + if not fields: + return {"error": "no_style_fields"} + return self._batch_update(document_id, [ + {"updateParagraphStyle": { + "range": {"startIndex": start_index, "endIndex": end_index}, + "paragraphStyle": para_style, + "fields": ",".join(fields), + }} + ], transform=lambda _d: {"styled": True, "document_id": document_id}) + + # ----- Lists ----- + + def create_paragraph_bullets(self, document_id: str, start_index: int, end_index: int, + bullet_preset: str = "BULLET_DISC_CIRCLE_SQUARE") -> Result: + """Turn paragraphs in a range into a bulleted/numbered list. + + ``bullet_preset`` examples: BULLET_DISC_CIRCLE_SQUARE, + BULLET_ARROW_DIAMOND_DISC, NUMBERED_DECIMAL_NESTED, NUMBERED_DECIMAL_ALPHA_ROMAN, + BULLET_CHECKBOX. + """ + return self._batch_update(document_id, [ + {"createParagraphBullets": { + "range": {"startIndex": start_index, "endIndex": end_index}, + "bulletPreset": bullet_preset, + }} + ], transform=lambda _d: {"bullets_created": True, "document_id": document_id}) + + def delete_paragraph_bullets(self, document_id: str, start_index: int, end_index: int) -> Result: + return self._batch_update(document_id, [ + {"deleteParagraphBullets": { + "range": {"startIndex": start_index, "endIndex": end_index}, + }} + ], transform=lambda _d: {"bullets_removed": True, "document_id": document_id}) + + # ----- Tables ----- + + def insert_table(self, document_id: str, rows: int, columns: int, index: int) -> Result: + return self._batch_update(document_id, [ + {"insertTable": { + "rows": rows, + "columns": columns, + "location": {"index": index}, + }} + ], transform=lambda _d: {"table_inserted": True, "document_id": document_id, "rows": rows, "columns": columns}) + + def insert_table_row(self, document_id: str, table_start_index: int, + row_index: int, column_index: int, insert_below: bool = True) -> Result: + return self._batch_update(document_id, [ + {"insertTableRow": { + "tableCellLocation": { + "tableStartLocation": {"index": table_start_index}, + "rowIndex": row_index, + "columnIndex": column_index, + }, + "insertBelow": insert_below, + }} + ], transform=lambda _d: {"row_inserted": True, "document_id": document_id}) + + def insert_table_column(self, document_id: str, table_start_index: int, + row_index: int, column_index: int, insert_right: bool = True) -> Result: + return self._batch_update(document_id, [ + {"insertTableColumn": { + "tableCellLocation": { + "tableStartLocation": {"index": table_start_index}, + "rowIndex": row_index, + "columnIndex": column_index, + }, + "insertRight": insert_right, + }} + ], transform=lambda _d: {"column_inserted": True, "document_id": document_id}) + + def delete_table_row(self, document_id: str, table_start_index: int, + row_index: int, column_index: int) -> Result: + return self._batch_update(document_id, [ + {"deleteTableRow": { + "tableCellLocation": { + "tableStartLocation": {"index": table_start_index}, + "rowIndex": row_index, + "columnIndex": column_index, + }, + }} + ], transform=lambda _d: {"row_deleted": True, "document_id": document_id}) + + def delete_table_column(self, document_id: str, table_start_index: int, + row_index: int, column_index: int) -> Result: + return self._batch_update(document_id, [ + {"deleteTableColumn": { + "tableCellLocation": { + "tableStartLocation": {"index": table_start_index}, + "rowIndex": row_index, + "columnIndex": column_index, + }, + }} + ], transform=lambda _d: {"column_deleted": True, "document_id": document_id}) + + def merge_table_cells(self, document_id: str, table_start_index: int, + row_index: int, column_index: int, + row_span: int, column_span: int) -> Result: + return self._batch_update(document_id, [ + {"mergeTableCells": {"tableRange": { + "tableCellLocation": { + "tableStartLocation": {"index": table_start_index}, + "rowIndex": row_index, + "columnIndex": column_index, + }, + "rowSpan": row_span, + "columnSpan": column_span, + }}} + ], transform=lambda _d: {"merged": True, "document_id": document_id}) + + def unmerge_table_cells(self, document_id: str, table_start_index: int, + row_index: int, column_index: int, + row_span: int, column_span: int) -> Result: + return self._batch_update(document_id, [ + {"unmergeTableCells": {"tableRange": { + "tableCellLocation": { + "tableStartLocation": {"index": table_start_index}, + "rowIndex": row_index, + "columnIndex": column_index, + }, + "rowSpan": row_span, + "columnSpan": column_span, + }}} + ], transform=lambda _d: {"unmerged": True, "document_id": document_id}) + + # ----- Images ----- + + def insert_inline_image(self, document_id: str, image_uri: str, index: int, + width_pt: Optional[float] = None, + height_pt: Optional[float] = None) -> Result: + req: Dict[str, Any] = { + "uri": image_uri, + "location": {"index": index}, + } + if width_pt is not None and height_pt is not None: + req["objectSize"] = { + "width": {"magnitude": width_pt, "unit": "PT"}, + "height": {"magnitude": height_pt, "unit": "PT"}, + } + return self._batch_update(document_id, [ + {"insertInlineImage": req} + ], transform=lambda d: { + "document_id": document_id, + "image_object_id": (d.get("replies") or [{}])[0].get("insertInlineImage", {}).get("objectId"), + }) + + def replace_image(self, document_id: str, image_object_id: str, image_uri: str) -> Result: + return self._batch_update(document_id, [ + {"replaceImage": {"imageObjectId": image_object_id, "uri": image_uri}} + ], transform=lambda _d: {"replaced": True, "document_id": document_id}) + + # ----- Structure: page/section breaks, headers/footers, named ranges ----- + + def insert_page_break(self, document_id: str, index: int) -> Result: + return self._batch_update(document_id, [ + {"insertPageBreak": {"location": {"index": index}}} + ], transform=lambda _d: {"page_break_inserted": True, "document_id": document_id}) + + def insert_section_break(self, document_id: str, index: int, + section_type: str = "NEXT_PAGE") -> Result: + """``section_type``: CONTINUOUS or NEXT_PAGE.""" + return self._batch_update(document_id, [ + {"insertSectionBreak": { + "location": {"index": index}, + "sectionType": section_type, + }} + ], transform=lambda _d: {"section_break_inserted": True, "document_id": document_id}) + + def create_header(self, document_id: str, header_type: str = "DEFAULT") -> Result: + return self._batch_update(document_id, [ + {"createHeader": {"type": header_type}} + ], transform=lambda d: { + "document_id": document_id, + "header_id": (d.get("replies") or [{}])[0].get("createHeader", {}).get("headerId"), + }) + + def create_footer(self, document_id: str, footer_type: str = "DEFAULT") -> Result: + return self._batch_update(document_id, [ + {"createFooter": {"type": footer_type}} + ], transform=lambda d: { + "document_id": document_id, + "footer_id": (d.get("replies") or [{}])[0].get("createFooter", {}).get("footerId"), + }) + + def delete_header(self, document_id: str, header_id: str) -> Result: + return self._batch_update(document_id, [ + {"deleteHeader": {"headerId": header_id}} + ], transform=lambda _d: {"deleted": True, "document_id": document_id}) + + def delete_footer(self, document_id: str, footer_id: str) -> Result: + return self._batch_update(document_id, [ + {"deleteFooter": {"footerId": footer_id}} + ], transform=lambda _d: {"deleted": True, "document_id": document_id}) + + def create_named_range(self, document_id: str, name: str, + start_index: int, end_index: int) -> Result: + return self._batch_update(document_id, [ + {"createNamedRange": { + "name": name, + "range": {"startIndex": start_index, "endIndex": end_index}, + }} + ], transform=lambda d: { + "document_id": document_id, + "named_range_id": (d.get("replies") or [{}])[0].get("createNamedRange", {}).get("namedRangeId"), + }) + + def delete_named_range(self, document_id: str, name: Optional[str] = None, + named_range_id: Optional[str] = None) -> Result: + if name: + req = {"deleteNamedRange": {"name": name}} + elif named_range_id: + req = {"deleteNamedRange": {"namedRangeId": named_range_id}} + else: + return {"error": "name_or_id_required"} + return self._batch_update(document_id, [req], + transform=lambda _d: {"deleted": True, "document_id": document_id}) + + # ----- File-level (Drive) ops ----- + + def copy_document(self, document_id: str, new_title: str) -> Result: + """Make a copy of an existing doc with a new title.""" + return http_request( + "POST", f"{DRIVE_API_BASE}/files/{document_id}/copy", + headers=self._headers(), + json={"name": new_title}, + expected=(200,), + transform=lambda d: { + "document_id": d.get("id"), + "title": d.get("name"), + "url": f"https://docs.google.com/document/d/{d.get('id')}/edit", + }, + ) + + def export_document(self, document_id: str, mime_type: str, dest_path: str) -> Result: + """Export a doc as PDF / DOCX / ODT / etc. and save to ``dest_path``. + + Common ``mime_type`` values: + - application/pdf + - application/vnd.openxmlformats-officedocument.wordprocessingml.document (DOCX) + - application/vnd.oasis.opendocument.text (ODT) + - text/plain + - text/html + """ + import httpx + try: + with httpx.stream( + "GET", + f"{DRIVE_API_BASE}/files/{document_id}/export", + headers=self._auth_header(), + params={"mimeType": mime_type}, + follow_redirects=True, + timeout=60.0, + ) as r: + if r.status_code != 200: + return {"error": f"http_{r.status_code}", "details": r.read().decode("utf-8", "replace")[:300]} + with open(dest_path, "wb") as fh: + for chunk in r.iter_bytes(): + fh.write(chunk) + return {"ok": True, "result": {"saved_to": dest_path, "document_id": document_id, "mime_type": mime_type}} + except Exception as e: + return {"error": "export_failed", "details": str(e)} + + +# ----------------------------------------------------------------- +# Helpers +# ----------------------------------------------------------------- + +def _hex_to_rgb(hex_color: str) -> Dict[str, float]: + """Convert '#RRGGBB' / 'RRGGBB' to Google API rgbColor dict (0-1 floats).""" + h = hex_color.strip().lstrip("#") + if len(h) != 6: + raise ValueError(f"Invalid hex color: {hex_color}") + return { + "red": int(h[0:2], 16) / 255.0, + "green": int(h[2:4], 16) / 255.0, + "blue": int(h[4:6], 16) / 255.0, + } diff --git a/craftos_integrations/integrations/google_drive/__init__.py b/craftos_integrations/integrations/google_drive/__init__.py index 55e81220..4076d01f 100644 --- a/craftos_integrations/integrations/google_drive/__init__.py +++ b/craftos_integrations/integrations/google_drive/__init__.py @@ -1,4 +1,4 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8 -*- """Google Drive - granular Google integration. Connect just Drive (without granting Gmail/Calendar/YouTube scopes) by @@ -9,7 +9,6 @@ only file-level differences are the scope, the API base URL, and the REST surface. """ - from __future__ import annotations from typing import Any, Dict, List, Optional, Tuple @@ -50,7 +49,6 @@ # Handler - auth flow only # ----------------------------------------------------------------- - @register_handler(GDRIVE.name) class GoogleDriveHandler(IntegrationHandler): spec = GDRIVE @@ -76,7 +74,6 @@ async def status(self) -> Tuple[bool, str]: # Client - Drive REST methods (no listener; Drive isn't push-based) # ----------------------------------------------------------------- - @register_client class GoogleDriveClient(GoogleApiClientMixin, BasePlatformClient): spec = GDRIVE @@ -101,9 +98,7 @@ def supports_listening(self) -> bool: def list_drive_files(self, folder_id: str, fields: Optional[str] = None) -> Result: return http_request( - "GET", - f"{DRIVE_API_BASE}/files", - headers=self._auth_header(), + "GET", f"{DRIVE_API_BASE}/files", headers=self._auth_header(), params={ "q": f"'{folder_id}' in parents and trashed = false", "fields": fields or "files(id,name,mimeType,parents)", @@ -112,14 +107,11 @@ def list_drive_files(self, folder_id: str, fields: Optional[str] = None) -> Resu transform=lambda d: d.get("files", []), ) - def search_drive( - self, query: str, max_results: int = 50, fields: Optional[str] = None - ) -> Result: + def search_drive(self, query: str, max_results: int = 50, + fields: Optional[str] = None) -> Result: """Free-form search across all of Drive - uses Drive's q-query syntax.""" return http_request( - "GET", - f"{DRIVE_API_BASE}/files", - headers=self._auth_header(), + "GET", f"{DRIVE_API_BASE}/files", headers=self._auth_header(), params={ "q": query, "pageSize": max_results, @@ -129,50 +121,34 @@ def search_drive( transform=lambda d: d.get("files", []), ) - def create_drive_folder( - self, name: str, parent_folder_id: Optional[str] = None - ) -> Result: - payload: Dict[str, Any] = { - "name": name, - "mimeType": "application/vnd.google-apps.folder", - } + def create_drive_folder(self, name: str, parent_folder_id: Optional[str] = None) -> Result: + payload: Dict[str, Any] = {"name": name, "mimeType": "application/vnd.google-apps.folder"} if parent_folder_id: payload["parents"] = [parent_folder_id] return http_request( - "POST", - f"{DRIVE_API_BASE}/files", - headers=self._headers(), + "POST", f"{DRIVE_API_BASE}/files", headers=self._headers(), json=payload, ) def get_drive_file(self, file_id: str, fields: Optional[str] = None) -> Result: return http_request( - "GET", - f"{DRIVE_API_BASE}/files/{file_id}", + "GET", f"{DRIVE_API_BASE}/files/{file_id}", headers=self._auth_header(), - params={ - "fields": fields or "id,name,mimeType,parents,modifiedTime,webViewLink" - }, + params={"fields": fields or "id,name,mimeType,parents,modifiedTime,webViewLink"}, expected=(200,), ) - def move_drive_file( - self, file_id: str, add_parents: str, remove_parents: str - ) -> Result: + def move_drive_file(self, file_id: str, add_parents: str, remove_parents: str) -> Result: params: Dict[str, str] = {"addParents": add_parents, "fields": "id,parents"} if remove_parents: params["removeParents"] = remove_parents return http_request( - "PATCH", - f"{DRIVE_API_BASE}/files/{file_id}", - headers=self._auth_header(), - params=params, - expected=(200,), + "PATCH", f"{DRIVE_API_BASE}/files/{file_id}", + headers=self._auth_header(), params=params, expected=(200,), ) - def find_drive_folder_by_name( - self, name: str, parent_folder_id: Optional[str] = None - ) -> Result: + def find_drive_folder_by_name(self, name: str, + parent_folder_id: Optional[str] = None) -> Result: q_parts = [ f"name = '{name}'", "mimeType = 'application/vnd.google-apps.folder'", @@ -181,9 +157,7 @@ def find_drive_folder_by_name( if parent_folder_id: q_parts.append(f"'{parent_folder_id}' in parents") return http_request( - "GET", - f"{DRIVE_API_BASE}/files", - headers=self._auth_header(), + "GET", f"{DRIVE_API_BASE}/files", headers=self._auth_header(), params={"q": " and ".join(q_parts), "fields": "files(id,name)"}, expected=(200,), transform=lambda d: (d.get("files") or [None])[0], @@ -191,20 +165,435 @@ def find_drive_folder_by_name( def delete_drive_file(self, file_id: str) -> Result: return http_request( - "DELETE", - f"{DRIVE_API_BASE}/files/{file_id}", - headers=self._auth_header(), - expected=(204,), + "DELETE", f"{DRIVE_API_BASE}/files/{file_id}", + headers=self._auth_header(), expected=(204,), transform=lambda _d: {"deleted": True, "file_id": file_id}, ) - def share_drive_file( - self, file_id: str, email: str, role: str = "reader" - ) -> Result: - """Grant a Drive permission. Roles: reader, commenter, writer, owner.""" + def share_drive_file(self, file_id: str, email: str, + role: str = "reader") -> Result: + """Grant a Drive permission. Roles: reader, commenter, writer, owner. + + Kept for backwards compat — new code should use create_drive_permission + which supports more types (group, domain, anyone) and notification opts. + """ return http_request( - "POST", - f"{DRIVE_API_BASE}/files/{file_id}/permissions", + "POST", f"{DRIVE_API_BASE}/files/{file_id}/permissions", headers=self._headers(), json={"type": "user", "role": role, "emailAddress": email}, ) + + # ----- Files (extended) ----- + + def update_drive_file_metadata(self, file_id: str, name: Optional[str] = None, + description: Optional[str] = None, + starred: Optional[bool] = None, + trashed: Optional[bool] = None) -> Result: + payload: Dict[str, Any] = {} + if name is not None: payload["name"] = name + if description is not None: payload["description"] = description + if starred is not None: payload["starred"] = starred + if trashed is not None: payload["trashed"] = trashed + return http_request( + "PATCH", f"{DRIVE_API_BASE}/files/{file_id}", + headers=self._headers(), json=payload, expected=(200,), + transform=lambda d: {"id": d.get("id"), "name": d.get("name"), "trashed": d.get("trashed")}, + ) + + def copy_drive_file(self, file_id: str, name: Optional[str] = None, + parent_folder_id: Optional[str] = None) -> Result: + payload: Dict[str, Any] = {} + if name: payload["name"] = name + if parent_folder_id: payload["parents"] = [parent_folder_id] + return http_request( + "POST", f"{DRIVE_API_BASE}/files/{file_id}/copy", + headers=self._headers(), json=payload, expected=(200,), + transform=lambda d: {"id": d.get("id"), "name": d.get("name"), "webViewLink": d.get("webViewLink")}, + ) + + def empty_drive_trash(self) -> Result: + # Drive returns 200 with empty JSON {} for this endpoint, not 204. + return http_request( + "DELETE", f"{DRIVE_API_BASE}/files/trash", + headers=self._auth_header(), expected=(200,), + transform=lambda _d: {"emptied": True}, + ) + + def get_drive_about(self) -> Result: + return http_request( + "GET", f"{DRIVE_API_BASE}/about", + headers=self._auth_header(), + params={"fields": "user,storageQuota,maxUploadSize,exportFormats,importFormats,canCreateDrives"}, + expected=(200,), + ) + + def upload_drive_file(self, file_path: str, name: Optional[str] = None, + mime_type: Optional[str] = None, + parent_folder_id: Optional[str] = None) -> Result: + """Upload a local file to Drive. 2-step: create metadata, then PATCH content. + + Avoids multipart/related construction; works with the standard helper + for the metadata step and uses httpx directly for the binary upload. + """ + import os + import mimetypes + import httpx + + file_path = os.path.abspath(file_path) + if not os.path.isfile(file_path): + return {"error": f"File not found: {file_path}"} + if not name: + name = os.path.basename(file_path) + if not mime_type: + mime_type, _ = mimetypes.guess_type(file_path) + if not mime_type: + mime_type = "application/octet-stream" + + metadata: Dict[str, Any] = {"name": name} + if parent_folder_id: + metadata["parents"] = [parent_folder_id] + + create_result = http_request( + "POST", f"{DRIVE_API_BASE}/files", + headers=self._headers(), json=metadata, expected=(200,), + ) + if "error" in create_result: + return create_result + file_id = create_result["result"]["id"] + + try: + with open(file_path, "rb") as f: + content = f.read() + r = httpx.patch( + f"https://www.googleapis.com/upload/drive/v3/files/{file_id}?uploadType=media", + headers={ + "Authorization": f"Bearer {self._ensure_token()}", + "Content-Type": mime_type, + }, + content=content, timeout=300.0, + ) + if r.status_code != 200: + return {"error": f"Upload error: {r.status_code}", "details": r.text} + data = r.json() + return {"ok": True, "result": { + "id": data.get("id"), "name": data.get("name"), + "mimeType": data.get("mimeType"), "size": data.get("size"), + "webViewLink": data.get("webViewLink"), + }} + except Exception as e: + return {"error": str(e)} + + def update_drive_file_content(self, file_id: str, file_path: str, + mime_type: Optional[str] = None) -> Result: + """Replace a file's content with a local file.""" + import os + import mimetypes + import httpx + + file_path = os.path.abspath(file_path) + if not os.path.isfile(file_path): + return {"error": f"File not found: {file_path}"} + if not mime_type: + mime_type, _ = mimetypes.guess_type(file_path) + if not mime_type: + mime_type = "application/octet-stream" + + try: + with open(file_path, "rb") as f: + content = f.read() + r = httpx.patch( + f"https://www.googleapis.com/upload/drive/v3/files/{file_id}?uploadType=media", + headers={ + "Authorization": f"Bearer {self._ensure_token()}", + "Content-Type": mime_type, + }, + content=content, timeout=300.0, + ) + if r.status_code != 200: + return {"error": f"Upload error: {r.status_code}", "details": r.text} + data = r.json() + return {"ok": True, "result": { + "id": data.get("id"), "name": data.get("name"), + "modifiedTime": data.get("modifiedTime"), + }} + except Exception as e: + return {"error": str(e)} + + def download_drive_file(self, file_id: str, save_to: str) -> Result: + """Download a regular (non-Google-native) file to a local path.""" + import os + import httpx + + try: + r = httpx.get( + f"{DRIVE_API_BASE}/files/{file_id}", + headers=self._auth_header(), + params={"alt": "media"}, + timeout=300.0, + ) + if r.status_code != 200: + return {"error": f"Download error: {r.status_code}", "details": r.text[:500]} + save_to = os.path.abspath(save_to) + parent = os.path.dirname(save_to) + if parent: + os.makedirs(parent, exist_ok=True) + with open(save_to, "wb") as f: + f.write(r.content) + return {"ok": True, "result": {"saved_to": save_to, "size": len(r.content)}} + except Exception as e: + return {"error": str(e)} + + def export_drive_file(self, file_id: str, save_to: str, mime_type: str) -> Result: + """Export a Google-native file (Doc/Sheet/Slide) to a local path as the target format. + + Common mime types: + - application/pdf + - application/vnd.openxmlformats-officedocument.wordprocessingml.document (.docx) + - application/vnd.openxmlformats-officedocument.spreadsheetml.sheet (.xlsx) + - application/vnd.openxmlformats-officedocument.presentationml.presentation (.pptx) + - text/plain, text/csv, text/html + """ + import os + import httpx + + try: + r = httpx.get( + f"{DRIVE_API_BASE}/files/{file_id}/export", + headers=self._auth_header(), + params={"mimeType": mime_type}, + timeout=300.0, + ) + if r.status_code != 200: + return {"error": f"Export error: {r.status_code}", "details": r.text[:500]} + save_to = os.path.abspath(save_to) + parent = os.path.dirname(save_to) + if parent: + os.makedirs(parent, exist_ok=True) + with open(save_to, "wb") as f: + f.write(r.content) + return {"ok": True, "result": {"saved_to": save_to, "mimeType": mime_type, "size": len(r.content)}} + except Exception as e: + return {"error": str(e)} + + # ----- Permissions (sharing) ----- + + def list_drive_permissions(self, file_id: str) -> Result: + return http_request( + "GET", f"{DRIVE_API_BASE}/files/{file_id}/permissions", + headers=self._auth_header(), + params={"fields": "permissions(id,type,emailAddress,role,domain,displayName)"}, + expected=(200,), + transform=lambda d: {"permissions": d.get("permissions", [])}, + ) + + def get_drive_permission(self, file_id: str, permission_id: str) -> Result: + return http_request( + "GET", f"{DRIVE_API_BASE}/files/{file_id}/permissions/{permission_id}", + headers=self._auth_header(), expected=(200,), + ) + + def create_drive_permission(self, file_id: str, role: str, perm_type: str = "user", + email_address: Optional[str] = None, + domain: Optional[str] = None, + send_notification: bool = True, + email_message: Optional[str] = None) -> Result: + """perm_type: user|group|domain|anyone. role: reader|commenter|writer|owner.""" + payload: Dict[str, Any] = {"role": role, "type": perm_type} + if email_address: payload["emailAddress"] = email_address + if domain: payload["domain"] = domain + params: Dict[str, Any] = {"sendNotificationEmail": str(send_notification).lower()} + if email_message: + params["emailMessage"] = email_message + return http_request( + "POST", f"{DRIVE_API_BASE}/files/{file_id}/permissions", + headers=self._headers(), json=payload, params=params, expected=(200,), + transform=lambda d: {"id": d.get("id"), "role": d.get("role"), "type": d.get("type")}, + ) + + def update_drive_permission(self, file_id: str, permission_id: str, role: str) -> Result: + return http_request( + "PATCH", f"{DRIVE_API_BASE}/files/{file_id}/permissions/{permission_id}", + headers=self._headers(), json={"role": role}, expected=(200,), + transform=lambda d: {"id": d.get("id"), "role": d.get("role")}, + ) + + def delete_drive_permission(self, file_id: str, permission_id: str) -> Result: + return http_request( + "DELETE", f"{DRIVE_API_BASE}/files/{file_id}/permissions/{permission_id}", + headers=self._auth_header(), expected=(204,), + transform=lambda _d: {"deleted": True, "permission_id": permission_id}, + ) + + # ----- Comments ----- + + def list_drive_comments(self, file_id: str, include_deleted: bool = False) -> Result: + return http_request( + "GET", f"{DRIVE_API_BASE}/files/{file_id}/comments", + headers=self._auth_header(), + params={ + "fields": "comments(id,content,createdTime,modifiedTime,author,resolved,deleted,quotedFileContent)", + "includeDeleted": str(include_deleted).lower(), + }, + expected=(200,), + transform=lambda d: {"comments": d.get("comments", [])}, + ) + + def get_drive_comment(self, file_id: str, comment_id: str) -> Result: + return http_request( + "GET", f"{DRIVE_API_BASE}/files/{file_id}/comments/{comment_id}", + headers=self._auth_header(), + params={"fields": "id,content,createdTime,modifiedTime,author,resolved,replies"}, + expected=(200,), + ) + + def create_drive_comment(self, file_id: str, content: str, + anchor: Optional[str] = None) -> Result: + payload: Dict[str, Any] = {"content": content} + if anchor: payload["anchor"] = anchor + return http_request( + "POST", f"{DRIVE_API_BASE}/files/{file_id}/comments", + headers=self._headers(), json=payload, + params={"fields": "id,content,createdTime"}, + expected=(200,), + transform=lambda d: {"id": d.get("id"), "content": d.get("content")}, + ) + + def update_drive_comment(self, file_id: str, comment_id: str, + content: Optional[str] = None, + resolved: Optional[bool] = None) -> Result: + payload: Dict[str, Any] = {} + if content is not None: payload["content"] = content + if resolved is not None: payload["resolved"] = resolved + return http_request( + "PATCH", f"{DRIVE_API_BASE}/files/{file_id}/comments/{comment_id}", + headers=self._headers(), json=payload, + params={"fields": "id,content,resolved"}, + expected=(200,), + ) + + def delete_drive_comment(self, file_id: str, comment_id: str) -> Result: + return http_request( + "DELETE", f"{DRIVE_API_BASE}/files/{file_id}/comments/{comment_id}", + headers=self._auth_header(), expected=(204,), + transform=lambda _d: {"deleted": True, "comment_id": comment_id}, + ) + + def list_drive_comment_replies(self, file_id: str, comment_id: str) -> Result: + return http_request( + "GET", f"{DRIVE_API_BASE}/files/{file_id}/comments/{comment_id}/replies", + headers=self._auth_header(), + params={"fields": "replies(id,content,author,createdTime,modifiedTime,action,deleted)"}, + expected=(200,), + transform=lambda d: {"replies": d.get("replies", [])}, + ) + + def create_drive_comment_reply(self, file_id: str, comment_id: str, + content: str) -> Result: + return http_request( + "POST", f"{DRIVE_API_BASE}/files/{file_id}/comments/{comment_id}/replies", + headers=self._headers(), json={"content": content}, + params={"fields": "id,content,createdTime"}, + expected=(200,), + transform=lambda d: {"id": d.get("id"), "content": d.get("content")}, + ) + + def update_drive_comment_reply(self, file_id: str, comment_id: str, + reply_id: str, content: str) -> Result: + return http_request( + "PATCH", f"{DRIVE_API_BASE}/files/{file_id}/comments/{comment_id}/replies/{reply_id}", + headers=self._headers(), json={"content": content}, + params={"fields": "id,content"}, + expected=(200,), + ) + + def delete_drive_comment_reply(self, file_id: str, comment_id: str, + reply_id: str) -> Result: + return http_request( + "DELETE", f"{DRIVE_API_BASE}/files/{file_id}/comments/{comment_id}/replies/{reply_id}", + headers=self._auth_header(), expected=(204,), + transform=lambda _d: {"deleted": True, "reply_id": reply_id}, + ) + + # ----- Revisions (version history) ----- + + def list_drive_revisions(self, file_id: str) -> Result: + return http_request( + "GET", f"{DRIVE_API_BASE}/files/{file_id}/revisions", + headers=self._auth_header(), + params={"fields": "revisions(id,modifiedTime,keepForever,published,lastModifyingUser,size)"}, + expected=(200,), + transform=lambda d: {"revisions": d.get("revisions", [])}, + ) + + def get_drive_revision(self, file_id: str, revision_id: str) -> Result: + return http_request( + "GET", f"{DRIVE_API_BASE}/files/{file_id}/revisions/{revision_id}", + headers=self._auth_header(), expected=(200,), + ) + + def update_drive_revision(self, file_id: str, revision_id: str, + keep_forever: Optional[bool] = None, + published: Optional[bool] = None, + publish_auto: Optional[bool] = None) -> Result: + payload: Dict[str, Any] = {} + if keep_forever is not None: payload["keepForever"] = keep_forever + if published is not None: payload["published"] = published + if publish_auto is not None: payload["publishAuto"] = publish_auto + return http_request( + "PATCH", f"{DRIVE_API_BASE}/files/{file_id}/revisions/{revision_id}", + headers=self._headers(), json=payload, expected=(200,), + ) + + def delete_drive_revision(self, file_id: str, revision_id: str) -> Result: + return http_request( + "DELETE", f"{DRIVE_API_BASE}/files/{file_id}/revisions/{revision_id}", + headers=self._auth_header(), expected=(204,), + transform=lambda _d: {"deleted": True, "revision_id": revision_id}, + ) + + # ----- Shared drives ----- + + def list_shared_drives(self, page_size: int = 50, q: Optional[str] = None) -> Result: + params: Dict[str, Any] = { + "pageSize": page_size, + "fields": "drives(id,name,createdTime,colorRgb,hidden)", + } + if q: params["q"] = q + return http_request( + "GET", f"{DRIVE_API_BASE}/drives", + headers=self._auth_header(), params=params, expected=(200,), + transform=lambda d: {"drives": d.get("drives", [])}, + ) + + def get_shared_drive(self, drive_id: str) -> Result: + return http_request( + "GET", f"{DRIVE_API_BASE}/drives/{drive_id}", + headers=self._auth_header(), expected=(200,), + ) + + def create_shared_drive(self, name: str) -> Result: + import uuid + return http_request( + "POST", f"{DRIVE_API_BASE}/drives", + headers=self._headers(), json={"name": name}, + params={"requestId": str(uuid.uuid4())}, + expected=(200,), + transform=lambda d: {"id": d.get("id"), "name": d.get("name")}, + ) + + def update_shared_drive(self, drive_id: str, name: Optional[str] = None, + hidden: Optional[bool] = None) -> Result: + payload: Dict[str, Any] = {} + if name is not None: payload["name"] = name + if hidden is not None: payload["hidden"] = hidden + return http_request( + "PATCH", f"{DRIVE_API_BASE}/drives/{drive_id}", + headers=self._headers(), json=payload, expected=(200,), + ) + + def delete_shared_drive(self, drive_id: str) -> Result: + return http_request( + "DELETE", f"{DRIVE_API_BASE}/drives/{drive_id}", + headers=self._auth_header(), expected=(204,), + transform=lambda _d: {"deleted": True, "drive_id": drive_id}, + ) diff --git a/craftos_integrations/integrations/jira/__init__.py b/craftos_integrations/integrations/jira/__init__.py index 27248e24..7311e6e0 100644 --- a/craftos_integrations/integrations/jira/__init__.py +++ b/craftos_integrations/integrations/jira/__init__.py @@ -281,6 +281,17 @@ def _base_url(self) -> str: return f"{domain}/rest/api/3" raise RuntimeError("No Jira domain or cloud_id configured.") + def _agile_base_url(self) -> str: + cred = self._load() + if cred.cloud_id: + return f"{JIRA_CLOUD_API}/{cred.cloud_id}/rest/agile/1.0" + if cred.domain: + domain = cred.domain.rstrip("/") + if not domain.startswith("http"): + domain = f"https://{domain}" + return f"{domain}/rest/agile/1.0" + raise RuntimeError("No Jira domain or cloud_id configured.") + def _headers(self) -> Dict[str, str]: cred = self._load() headers: Dict[str, str] = { @@ -814,6 +825,576 @@ async def remove_labels(self, issue_key: str, labels: List[str]) -> Result: transform=lambda _d: {"labels_removed": labels, "key": issue_key}, ) + # ----- Issue: delete ----- + + async def delete_issue(self, issue_key: str, delete_subtasks: bool = False) -> Result: + return await arequest( + "DELETE", f"{self._base_url()}/issue/{issue_key}", + headers=self._headers(), + params={"deleteSubtasks": "true" if delete_subtasks else "false"}, + expected=(204,), + transform=lambda _d: {"deleted": True, "key": issue_key}, + ) + + # ----- Comments: edit / delete ----- + + async def update_comment(self, issue_key: str, comment_id: str, body: str) -> Result: + return await arequest( + "PUT", f"{self._base_url()}/issue/{issue_key}/comment/{comment_id}", + headers=self._headers(), + json={"body": _text_to_adf(body)}, + expected=(200,), + transform=lambda d: {"id": d.get("id"), "updated": d.get("updated"), "author": (d.get("author") or {}).get("displayName", "")}, + ) + + async def delete_comment(self, issue_key: str, comment_id: str) -> Result: + return await arequest( + "DELETE", f"{self._base_url()}/issue/{issue_key}/comment/{comment_id}", + headers=self._headers(), + expected=(204,), + transform=lambda _d: {"deleted": True, "comment_id": comment_id}, + ) + + # ----- Watchers ----- + + async def get_watchers(self, issue_key: str) -> Result: + return await arequest( + "GET", f"{self._base_url()}/issue/{issue_key}/watchers", + headers=self._headers(), + expected=(200,), + transform=lambda d: { + "is_watching": d.get("isWatching", False), + "watch_count": d.get("watchCount", 0), + "watchers": [ + {"accountId": w.get("accountId"), "displayName": w.get("displayName"), "active": w.get("active", True)} + for w in d.get("watchers", []) + ], + }, + ) + + async def add_watcher(self, issue_key: str, account_id: str) -> Result: + # API requires accountId sent as JSON string literal (quoted) + headers = self._headers() + return await arequest( + "POST", f"{self._base_url()}/issue/{issue_key}/watchers", + headers=headers, + json=account_id, + expected=(204,), + transform=lambda _d: {"added": True, "account_id": account_id}, + ) + + async def remove_watcher(self, issue_key: str, account_id: str) -> Result: + return await arequest( + "DELETE", f"{self._base_url()}/issue/{issue_key}/watchers", + headers=self._headers(), + params={"accountId": account_id}, + expected=(204,), + transform=lambda _d: {"removed": True, "account_id": account_id}, + ) + + # ----- Attachments ----- + + async def add_attachment(self, issue_key: str, file_path: str, filename: Optional[str] = None) -> Result: + """Upload a file as an attachment. Uses multipart form; sets X-Atlassian-Token: no-check.""" + cred = self._load() + # Build headers without Content-Type so httpx sets multipart boundary + headers: Dict[str, str] = {"Accept": "application/json", "X-Atlassian-Token": "no-check"} + if cred.cloud_id and cred.access_token: + headers["Authorization"] = f"Bearer {cred.access_token}" + elif cred.email and cred.api_token: + raw = f"{cred.email}:{cred.api_token}" + headers["Authorization"] = f"Basic {base64.b64encode(raw.encode()).decode()}" + else: + raise RuntimeError("Incomplete Jira credentials.") + + try: + with open(file_path, "rb") as fh: + file_bytes = fh.read() + except OSError as e: + return {"error": "file_read_failed", "details": str(e)} + + import os + name = filename or os.path.basename(file_path) + + return await arequest( + "POST", f"{self._base_url()}/issue/{issue_key}/attachments", + headers=headers, + files={"file": (name, file_bytes)}, + expected=(200,), + transform=lambda d: {"attachments": [ + {"id": a.get("id"), "filename": a.get("filename"), "size": a.get("size"), "content": a.get("content")} + for a in (d if isinstance(d, list) else []) + ]}, + ) + + async def get_attachment(self, attachment_id: str) -> Result: + return await arequest( + "GET", f"{self._base_url()}/attachment/{attachment_id}", + headers=self._headers(), + expected=(200,), + ) + + async def delete_attachment(self, attachment_id: str) -> Result: + return await arequest( + "DELETE", f"{self._base_url()}/attachment/{attachment_id}", + headers=self._headers(), + expected=(204,), + transform=lambda _d: {"deleted": True, "attachment_id": attachment_id}, + ) + + async def download_attachment(self, attachment_id: str, dest_path: str) -> Result: + """Resolve the attachment's content URL and stream bytes to ``dest_path``.""" + meta = await self.get_attachment(attachment_id) + if "error" in meta: + return meta + content_url = (meta.get("result") or {}).get("content") + if not content_url: + return {"error": "no_content_url"} + try: + async with httpx.AsyncClient(timeout=60.0, follow_redirects=True) as client: + async with client.stream("GET", content_url, headers=self._headers()) as r: + if r.status_code != 200: + return {"error": f"http_{r.status_code}"} + with open(dest_path, "wb") as fh: + async for chunk in r.aiter_bytes(): + fh.write(chunk) + return {"ok": True, "result": {"saved_to": dest_path, "attachment_id": attachment_id}} + except Exception as e: + return {"error": "download_failed", "details": str(e)} + + # ----- Worklogs ----- + + async def add_worklog(self, issue_key: str, time_spent: Optional[str] = None, + time_spent_seconds: Optional[int] = None, comment: Optional[str] = None, + started: Optional[str] = None) -> Result: + payload: Dict[str, Any] = {} + if time_spent: + payload["timeSpent"] = time_spent + if time_spent_seconds is not None: + payload["timeSpentSeconds"] = time_spent_seconds + if comment: + payload["comment"] = _text_to_adf(comment) + if started: + payload["started"] = started + return await arequest( + "POST", f"{self._base_url()}/issue/{issue_key}/worklog", + headers=self._headers(), + json=payload, + expected=(201,), + transform=lambda d: { + "id": d.get("id"), + "timeSpent": d.get("timeSpent"), + "timeSpentSeconds": d.get("timeSpentSeconds"), + "started": d.get("started"), + "author": (d.get("author") or {}).get("displayName", ""), + }, + ) + + async def get_worklogs(self, issue_key: str) -> Result: + return await arequest( + "GET", f"{self._base_url()}/issue/{issue_key}/worklog", + headers=self._headers(), + expected=(200,), + transform=lambda d: {"worklogs": [ + {"id": w.get("id"), "timeSpent": w.get("timeSpent"), "timeSpentSeconds": w.get("timeSpentSeconds"), + "started": w.get("started"), "author": (w.get("author") or {}).get("displayName", ""), + "comment": _extract_adf_text(w.get("comment", {}))} + for w in d.get("worklogs", []) + ], "total": d.get("total", 0)}, + ) + + async def update_worklog(self, issue_key: str, worklog_id: str, + time_spent: Optional[str] = None, + time_spent_seconds: Optional[int] = None, + comment: Optional[str] = None, + started: Optional[str] = None) -> Result: + payload: Dict[str, Any] = {} + if time_spent: + payload["timeSpent"] = time_spent + if time_spent_seconds is not None: + payload["timeSpentSeconds"] = time_spent_seconds + if comment: + payload["comment"] = _text_to_adf(comment) + if started: + payload["started"] = started + return await arequest( + "PUT", f"{self._base_url()}/issue/{issue_key}/worklog/{worklog_id}", + headers=self._headers(), + json=payload, + expected=(200,), + transform=lambda d: {"id": d.get("id"), "timeSpent": d.get("timeSpent"), "updated": d.get("updated")}, + ) + + async def delete_worklog(self, issue_key: str, worklog_id: str) -> Result: + return await arequest( + "DELETE", f"{self._base_url()}/issue/{issue_key}/worklog/{worklog_id}", + headers=self._headers(), + expected=(204,), + transform=lambda _d: {"deleted": True, "worklog_id": worklog_id}, + ) + + # ----- Issue links ----- + + async def create_issue_link(self, link_type: str, inward_issue_key: str, outward_issue_key: str, + comment: Optional[str] = None) -> Result: + payload: Dict[str, Any] = { + "type": {"name": link_type}, + "inwardIssue": {"key": inward_issue_key}, + "outwardIssue": {"key": outward_issue_key}, + } + if comment: + payload["comment"] = {"body": _text_to_adf(comment)} + return await arequest( + "POST", f"{self._base_url()}/issueLink", + headers=self._headers(), + json=payload, + expected=(201,), + transform=lambda _d: {"created": True, "type": link_type, "inward": inward_issue_key, "outward": outward_issue_key}, + ) + + async def get_issue_link(self, link_id: str) -> Result: + return await arequest( + "GET", f"{self._base_url()}/issueLink/{link_id}", + headers=self._headers(), + expected=(200,), + ) + + async def delete_issue_link(self, link_id: str) -> Result: + return await arequest( + "DELETE", f"{self._base_url()}/issueLink/{link_id}", + headers=self._headers(), + expected=(204,), + transform=lambda _d: {"deleted": True, "link_id": link_id}, + ) + + async def list_issue_link_types(self) -> Result: + return await arequest( + "GET", f"{self._base_url()}/issueLinkType", + headers=self._headers(), + expected=(200,), + transform=lambda d: {"types": [ + {"id": t.get("id"), "name": t.get("name"), "inward": t.get("inward"), "outward": t.get("outward")} + for t in d.get("issueLinkTypes", []) + ]}, + ) + + # ----- Versions ----- + + async def list_versions(self, project_key: str) -> Result: + return await arequest( + "GET", f"{self._base_url()}/project/{project_key}/versions", + headers=self._headers(), + expected=(200,), + transform=lambda d: {"versions": [ + {"id": v.get("id"), "name": v.get("name"), "released": v.get("released"), "archived": v.get("archived"), "releaseDate": v.get("releaseDate")} + for v in (d if isinstance(d, list) else []) + ]}, + ) + + async def create_version(self, project_key: str, name: str, + description: Optional[str] = None, + release_date: Optional[str] = None, + start_date: Optional[str] = None, + released: bool = False) -> Result: + # /version requires projectId (not key). Resolve project first. + proj = await self.get_project(project_key) + if "error" in proj: + return proj + project_id = (proj.get("result") or {}).get("id") or (proj.get("result") or {}).get("projectId") + if not project_id: + return {"error": "project_id_not_found"} + payload: Dict[str, Any] = { + "name": name, + "projectId": int(project_id), + "released": released, + } + if description: + payload["description"] = description + if release_date: + payload["releaseDate"] = release_date + if start_date: + payload["startDate"] = start_date + return await arequest( + "POST", f"{self._base_url()}/version", + headers=self._headers(), + json=payload, + transform=lambda d: {"id": d.get("id"), "name": d.get("name"), "released": d.get("released")}, + ) + + async def update_version(self, version_id: str, name: Optional[str] = None, + description: Optional[str] = None, + release_date: Optional[str] = None, + released: Optional[bool] = None, + archived: Optional[bool] = None) -> Result: + payload: Dict[str, Any] = {} + if name is not None: + payload["name"] = name + if description is not None: + payload["description"] = description + if release_date is not None: + payload["releaseDate"] = release_date + if released is not None: + payload["released"] = released + if archived is not None: + payload["archived"] = archived + return await arequest( + "PUT", f"{self._base_url()}/version/{version_id}", + headers=self._headers(), + json=payload, + expected=(200,), + ) + + async def delete_version(self, version_id: str) -> Result: + return await arequest( + "DELETE", f"{self._base_url()}/version/{version_id}", + headers=self._headers(), + expected=(204,), + transform=lambda _d: {"deleted": True, "version_id": version_id}, + ) + + # ----- Components ----- + + async def list_components(self, project_key: str) -> Result: + return await arequest( + "GET", f"{self._base_url()}/project/{project_key}/components", + headers=self._headers(), + expected=(200,), + transform=lambda d: {"components": [ + {"id": c.get("id"), "name": c.get("name"), "description": c.get("description", ""), + "lead": (c.get("lead") or {}).get("displayName", "")} + for c in (d if isinstance(d, list) else []) + ]}, + ) + + async def create_component(self, project_key: str, name: str, + description: Optional[str] = None, + lead_account_id: Optional[str] = None) -> Result: + payload: Dict[str, Any] = {"project": project_key, "name": name} + if description: + payload["description"] = description + if lead_account_id: + payload["leadAccountId"] = lead_account_id + return await arequest( + "POST", f"{self._base_url()}/component", + headers=self._headers(), + json=payload, + transform=lambda d: {"id": d.get("id"), "name": d.get("name")}, + ) + + async def delete_component(self, component_id: str) -> Result: + return await arequest( + "DELETE", f"{self._base_url()}/component/{component_id}", + headers=self._headers(), + expected=(204,), + transform=lambda _d: {"deleted": True, "component_id": component_id}, + ) + + # ----- Project / metadata lookups ----- + + async def get_project(self, project_key: str) -> Result: + return await arequest( + "GET", f"{self._base_url()}/project/{project_key}", + headers=self._headers(), + expected=(200,), + ) + + async def list_priorities(self) -> Result: + return await arequest( + "GET", f"{self._base_url()}/priority", + headers=self._headers(), + expected=(200,), + transform=lambda d: {"priorities": [ + {"id": p.get("id"), "name": p.get("name")} for p in (d if isinstance(d, list) else []) + ]}, + ) + + async def list_issue_types(self) -> Result: + return await arequest( + "GET", f"{self._base_url()}/issuetype", + headers=self._headers(), + expected=(200,), + transform=lambda d: {"issue_types": [ + {"id": t.get("id"), "name": t.get("name"), "description": t.get("description", "")} + for t in (d if isinstance(d, list) else []) + ]}, + ) + + # ----- Agile: boards ----- + + async def list_boards(self, project_key: Optional[str] = None, board_type: Optional[str] = None, + max_results: int = 50) -> Result: + params: Dict[str, Any] = {"maxResults": max_results} + if project_key: + params["projectKeyOrId"] = project_key + if board_type: + params["type"] = board_type + return await arequest( + "GET", f"{self._agile_base_url()}/board", + headers=self._headers(), + params=params, + expected=(200,), + transform=lambda d: {"boards": [ + {"id": b.get("id"), "name": b.get("name"), "type": b.get("type"), + "location": (b.get("location") or {}).get("projectKey", "")} + for b in d.get("values", []) + ], "total": d.get("total", 0)}, + ) + + async def get_board(self, board_id: int) -> Result: + return await arequest( + "GET", f"{self._agile_base_url()}/board/{board_id}", + headers=self._headers(), + expected=(200,), + ) + + async def get_board_issues(self, board_id: int, jql: Optional[str] = None, max_results: int = 50) -> Result: + params: Dict[str, Any] = {"maxResults": max_results} + if jql: + params["jql"] = jql + return await arequest( + "GET", f"{self._agile_base_url()}/board/{board_id}/issue", + headers=self._headers(), + params=params, + expected=(200,), + transform=lambda d: {"issues": d.get("issues", []), "total": d.get("total", 0)}, + ) + + async def get_board_sprints(self, board_id: int, state: Optional[str] = None, max_results: int = 50) -> Result: + params: Dict[str, Any] = {"maxResults": max_results} + if state: + params["state"] = state + return await arequest( + "GET", f"{self._agile_base_url()}/board/{board_id}/sprint", + headers=self._headers(), + params=params, + expected=(200,), + transform=lambda d: {"sprints": [ + {"id": s.get("id"), "name": s.get("name"), "state": s.get("state"), + "startDate": s.get("startDate"), "endDate": s.get("endDate"), "goal": s.get("goal", "")} + for s in d.get("values", []) + ], "total": d.get("total", 0)}, + ) + + async def get_board_backlog(self, board_id: int, max_results: int = 50) -> Result: + return await arequest( + "GET", f"{self._agile_base_url()}/board/{board_id}/backlog", + headers=self._headers(), + params={"maxResults": max_results}, + expected=(200,), + transform=lambda d: {"issues": d.get("issues", []), "total": d.get("total", 0)}, + ) + + # ----- Agile: sprints ----- + + async def get_sprint(self, sprint_id: int) -> Result: + return await arequest( + "GET", f"{self._agile_base_url()}/sprint/{sprint_id}", + headers=self._headers(), + expected=(200,), + ) + + async def get_sprint_issues(self, sprint_id: int, jql: Optional[str] = None, max_results: int = 50) -> Result: + params: Dict[str, Any] = {"maxResults": max_results} + if jql: + params["jql"] = jql + return await arequest( + "GET", f"{self._agile_base_url()}/sprint/{sprint_id}/issue", + headers=self._headers(), + params=params, + expected=(200,), + transform=lambda d: {"issues": d.get("issues", []), "total": d.get("total", 0)}, + ) + + async def create_sprint(self, name: str, board_id: int, goal: Optional[str] = None, + start_date: Optional[str] = None, end_date: Optional[str] = None) -> Result: + payload: Dict[str, Any] = {"name": name, "originBoardId": board_id} + if goal: + payload["goal"] = goal + if start_date: + payload["startDate"] = start_date + if end_date: + payload["endDate"] = end_date + return await arequest( + "POST", f"{self._agile_base_url()}/sprint", + headers=self._headers(), + json=payload, + transform=lambda d: {"id": d.get("id"), "name": d.get("name"), "state": d.get("state")}, + ) + + async def update_sprint(self, sprint_id: int, name: Optional[str] = None, + state: Optional[str] = None, goal: Optional[str] = None, + start_date: Optional[str] = None, end_date: Optional[str] = None) -> Result: + payload: Dict[str, Any] = {} + if name is not None: + payload["name"] = name + if state is not None: + payload["state"] = state + if goal is not None: + payload["goal"] = goal + if start_date is not None: + payload["startDate"] = start_date + if end_date is not None: + payload["endDate"] = end_date + return await arequest( + "POST", f"{self._agile_base_url()}/sprint/{sprint_id}", + headers=self._headers(), + json=payload, + expected=(200,), + ) + + async def delete_sprint(self, sprint_id: int) -> Result: + return await arequest( + "DELETE", f"{self._agile_base_url()}/sprint/{sprint_id}", + headers=self._headers(), + expected=(204,), + transform=lambda _d: {"deleted": True, "sprint_id": sprint_id}, + ) + + async def move_issues_to_sprint(self, sprint_id: int, issue_keys: List[str]) -> Result: + return await arequest( + "POST", f"{self._agile_base_url()}/sprint/{sprint_id}/issue", + headers=self._headers(), + json={"issues": issue_keys}, + expected=(204,), + transform=lambda _d: {"moved": True, "sprint_id": sprint_id, "issues": issue_keys}, + ) + + async def move_issues_to_backlog(self, issue_keys: List[str]) -> Result: + return await arequest( + "POST", f"{self._agile_base_url()}/backlog/issue", + headers=self._headers(), + json={"issues": issue_keys}, + expected=(204,), + transform=lambda _d: {"moved": True, "issues": issue_keys}, + ) + + # ----- Agile: epics ----- + + async def get_epic(self, epic_id_or_key: str) -> Result: + return await arequest( + "GET", f"{self._agile_base_url()}/epic/{epic_id_or_key}", + headers=self._headers(), + expected=(200,), + ) + + async def get_epic_issues(self, epic_id_or_key: str, max_results: int = 50) -> Result: + return await arequest( + "GET", f"{self._agile_base_url()}/epic/{epic_id_or_key}/issue", + headers=self._headers(), + params={"maxResults": max_results}, + expected=(200,), + transform=lambda d: {"issues": d.get("issues", []), "total": d.get("total", 0)}, + ) + + async def move_issues_to_epic(self, epic_id_or_key: str, issue_keys: List[str]) -> Result: + return await arequest( + "POST", f"{self._agile_base_url()}/epic/{epic_id_or_key}/issue", + headers=self._headers(), + json={"issues": issue_keys}, + expected=(204,), + transform=lambda _d: {"moved": True, "epic": epic_id_or_key, "issues": issue_keys}, + ) + # ----------------------------------------------------------------- # ADF helpers diff --git a/craftos_integrations/integrations/lark/__init__.py b/craftos_integrations/integrations/lark/__init__.py index f181dddd..40c2f03c 100644 --- a/craftos_integrations/integrations/lark/__init__.py +++ b/craftos_integrations/integrations/lark/__init__.py @@ -455,3 +455,565 @@ def get_bot_info(self) -> Result: expected=(200,), transform=lambda d: d.get("bot", d), ) + + # ================================================================== + # Messages — extended lifecycle / content / reactions / pins + # ================================================================== + + @staticmethod + def _msg_content(msg_type: str, body: Dict[str, Any]) -> str: + """Lark's content field is always a JSON-encoded STRING (not an object).""" + import json as _json + return _json.dumps(body, ensure_ascii=False) + + def send_message(self, receive_id: str, msg_type: str, + content: Dict[str, Any], + receive_id_type: str = "open_id", + uuid: Optional[str] = None) -> Result: + """Generic send. msg_type: text | post | image | file | audio | media | sticker | interactive | share_chat | share_user. content is the per-type dict (this method JSON-encodes it).""" + import json as _json + payload: Dict[str, Any] = { + "receive_id": receive_id, + "msg_type": msg_type, + "content": _json.dumps(content, ensure_ascii=False), + } + if uuid: payload["uuid"] = uuid + return http_request( + "POST", f"{LARK_API_BASE}/im/v1/messages", + params={"receive_id_type": receive_id_type}, + headers=self._headers(), json=payload, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def send_image_message(self, receive_id: str, image_key: str, + receive_id_type: str = "open_id") -> Result: + return self.send_message(receive_id, "image", {"image_key": image_key}, + receive_id_type=receive_id_type) + + def send_file_message(self, receive_id: str, file_key: str, + receive_id_type: str = "open_id") -> Result: + return self.send_message(receive_id, "file", {"file_key": file_key}, + receive_id_type=receive_id_type) + + def send_card_message(self, receive_id: str, card: Dict[str, Any], + receive_id_type: str = "open_id") -> Result: + """card is a Lark interactive-card JSON schema.""" + return self.send_message(receive_id, "interactive", card, + receive_id_type=receive_id_type) + + def send_post_message(self, receive_id: str, post: Dict[str, Any], + receive_id_type: str = "open_id") -> Result: + """post is Lark's rich-text 'post' format: {zh_cn: {title, content: [[{tag,text/...}]]}}.""" + return self.send_message(receive_id, "post", post, + receive_id_type=receive_id_type) + + def reply_message(self, message_id: str, msg_type: str, + content: Dict[str, Any], + reply_in_thread: bool = False) -> Result: + """Reply to message_id. reply_in_thread starts a thread off the parent.""" + import json as _json + return http_request( + "POST", f"{LARK_API_BASE}/im/v1/messages/{message_id}/reply", + headers=self._headers(), + json={"msg_type": msg_type, + "content": _json.dumps(content, ensure_ascii=False), + "reply_in_thread": reply_in_thread}, + expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def get_message(self, message_id: str) -> Result: + return http_request( + "GET", f"{LARK_API_BASE}/im/v1/messages/{message_id}", + headers=self._headers(), expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def delete_message(self, message_id: str) -> Result: + """Recall a message the bot sent (within Lark's recall window).""" + return http_request( + "DELETE", f"{LARK_API_BASE}/im/v1/messages/{message_id}", + headers=self._headers(), expected=(200,), + transform=lambda d: d.get("data", {"recalled": True, "message_id": message_id}), + ) + + def update_message(self, message_id: str, msg_type: str, + content: Dict[str, Any]) -> Result: + """Edit text/interactive content of a bot-sent message.""" + import json as _json + return http_request( + "PUT", f"{LARK_API_BASE}/im/v1/messages/{message_id}", + headers=self._headers(), + json={"msg_type": msg_type, + "content": _json.dumps(content, ensure_ascii=False)}, + expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def forward_message(self, message_id: str, receive_id: str, + receive_id_type: str = "open_id", + uuid: Optional[str] = None) -> Result: + payload: Dict[str, Any] = {"receive_id": receive_id} + if uuid: payload["uuid"] = uuid + return http_request( + "POST", f"{LARK_API_BASE}/im/v1/messages/{message_id}/forward", + params={"receive_id_type": receive_id_type}, + headers=self._headers(), json=payload, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def list_messages(self, container_id: str, + container_id_type: str = "chat", + start_time: Optional[str] = None, + end_time: Optional[str] = None, + sort_type: str = "ByCreateTimeAsc", + page_size: int = 50, + page_token: str = "") -> Result: + """List a chat's message history. start_time/end_time are unix-seconds strings.""" + params: Dict[str, str] = { + "container_id": container_id, + "container_id_type": container_id_type, + "sort_type": sort_type, + "page_size": str(min(page_size, 50)), + } + if start_time: params["start_time"] = start_time + if end_time: params["end_time"] = end_time + if page_token: params["page_token"] = page_token + return http_request( + "GET", f"{LARK_API_BASE}/im/v1/messages", + headers=self._headers(), params=params, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def list_message_read_users(self, message_id: str, + user_id_type: str = "open_id", + page_size: int = 100, + page_token: str = "") -> Result: + """Who has read a message. Returns user identifiers + read_time.""" + params: Dict[str, str] = { + "user_id_type": user_id_type, + "page_size": str(min(page_size, 100)), + } + if page_token: params["page_token"] = page_token + return http_request( + "GET", f"{LARK_API_BASE}/im/v1/messages/{message_id}/read_users", + headers=self._headers(), params=params, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def add_reaction(self, message_id: str, emoji_type: str) -> Result: + """emoji_type is Lark's emoji code, e.g. 'SMILE' / 'HAPPY' / 'THUMBSUP'. See Lark emoji reference.""" + return http_request( + "POST", f"{LARK_API_BASE}/im/v1/messages/{message_id}/reactions", + headers=self._headers(), + json={"reaction_type": {"emoji_type": emoji_type}}, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def remove_reaction(self, message_id: str, reaction_id: str) -> Result: + return http_request( + "DELETE", + f"{LARK_API_BASE}/im/v1/messages/{message_id}/reactions/{reaction_id}", + headers=self._headers(), expected=(200,), + transform=lambda d: d.get("data", {"removed": True, "reaction_id": reaction_id}), + ) + + def list_reactions(self, message_id: str, + emoji_type: Optional[str] = None, + page_size: int = 100, + page_token: str = "", + user_id_type: str = "open_id") -> Result: + params: Dict[str, str] = { + "user_id_type": user_id_type, + "page_size": str(min(page_size, 100)), + } + if emoji_type: params["reaction_type"] = emoji_type + if page_token: params["page_token"] = page_token + return http_request( + "GET", f"{LARK_API_BASE}/im/v1/messages/{message_id}/reactions", + headers=self._headers(), params=params, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def pin_message(self, message_id: str) -> Result: + return http_request( + "POST", f"{LARK_API_BASE}/im/v1/pins", + headers=self._headers(), + json={"message_id": message_id}, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def unpin_message(self, message_id: str) -> Result: + return http_request( + "DELETE", f"{LARK_API_BASE}/im/v1/pins/{message_id}", + headers=self._headers(), expected=(200,), + transform=lambda d: d.get("data", {"unpinned": True, "message_id": message_id}), + ) + + def list_pinned_messages(self, chat_id: str, + page_size: int = 50, + page_token: str = "") -> Result: + params: Dict[str, str] = { + "chat_id": chat_id, + "page_size": str(min(page_size, 50)), + } + if page_token: params["page_token"] = page_token + return http_request( + "GET", f"{LARK_API_BASE}/im/v1/pins", + headers=self._headers(), params=params, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def send_urgent(self, message_id: str, user_id_list: List[str], + urgent_type: str = "app", + user_id_type: str = "open_id") -> Result: + """urgent_type: app | sms | phone (escalation level). Most useful when a message needs immediate attention.""" + endpoint_map = {"app": "urgent_app", "sms": "urgent_sms", "phone": "urgent_phone"} + sub = endpoint_map.get(urgent_type, "urgent_app") + return http_request( + "PATCH", f"{LARK_API_BASE}/im/v1/messages/{message_id}/{sub}", + headers=self._headers(), + params={"user_id_type": user_id_type}, + json={"user_id_list": user_id_list}, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def batch_send_message(self, msg_type: str, content: Dict[str, Any], + open_ids: Optional[List[str]] = None, + user_ids: Optional[List[str]] = None, + department_ids: Optional[List[str]] = None) -> Result: + """Send the same message to many recipients (departments/users/openids) at once.""" + import json as _json + payload: Dict[str, Any] = { + "msg_type": msg_type, + "content": _json.dumps(content, ensure_ascii=False), + } + if open_ids: payload["open_ids"] = open_ids + if user_ids: payload["user_ids"] = user_ids + if department_ids: payload["department_ids"] = department_ids + return http_request( + "POST", f"{LARK_API_BASE}/message/v4/batch_send/", + headers=self._headers(), json=payload, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + # ----- IM resources (file/image upload + download) ----- + + def upload_image(self, file_path: str, + image_type: str = "message") -> Result: + """image_type: message | avatar. Returns image_key for use in send_image_message.""" + import os + token = self._headers()["Authorization"] + try: + with open(file_path, "rb") as f: + file_data = f.read() + return http_request( + "POST", f"{LARK_API_BASE}/im/v1/images", + headers={"Authorization": token}, + data={"image_type": image_type}, + files={"image": (os.path.basename(file_path), file_data)}, + expected=(200,), + transform=lambda d: d.get("data", d), + ) + except OSError as e: + return {"error": f"Failed to read {file_path}: {e}"} + + def upload_im_file(self, file_path: str, file_type: str = "stream", + file_name: Optional[str] = None, + duration: Optional[int] = None) -> Result: + """file_type: opus | mp4 | pdf | doc | xls | ppt | stream. Returns file_key for send_file_message.""" + import os + if not file_name: + file_name = os.path.basename(file_path) + token = self._headers()["Authorization"] + try: + with open(file_path, "rb") as f: + file_data = f.read() + form: Dict[str, Any] = {"file_type": file_type, "file_name": file_name} + if duration is not None: + form["duration"] = str(duration) + return http_request( + "POST", f"{LARK_API_BASE}/im/v1/files", + headers={"Authorization": token}, + data=form, + files={"file": (file_name, file_data)}, + expected=(200,), + transform=lambda d: d.get("data", d), + ) + except OSError as e: + return {"error": f"Failed to read {file_path}: {e}"} + + def download_message_resource(self, message_id: str, file_key: str, + dest_path: str, + resource_type: str = "file") -> Result: + """Download an attached image/file/audio from a message. resource_type: image | file (covers audio/video).""" + import httpx + token = self._headers()["Authorization"] + try: + with httpx.stream( + "GET", + f"{LARK_API_BASE}/im/v1/messages/{message_id}/resources/{file_key}", + headers={"Authorization": token}, + params={"type": resource_type}, + timeout=120.0, + ) as resp: + if resp.status_code != 200: + return {"error": f"Download failed: HTTP {resp.status_code}", + "details": resp.read().decode("utf-8", errors="replace")[:500]} + bytes_written = 0 + with open(dest_path, "wb") as f: + for chunk in resp.iter_bytes(chunk_size=64 * 1024): + f.write(chunk) + bytes_written += len(chunk) + return {"ok": True, "result": {"path": dest_path, + "bytes_written": bytes_written}} + except (httpx.HTTPError, OSError) as e: + return {"error": f"Download failed: {e}"} + + # ================================================================== + # Chats — CRUD + members + announcement + search + # ================================================================== + + def create_chat(self, name: str, + description: str = "", + owner_id: Optional[str] = None, + user_id_list: Optional[List[str]] = None, + bot_id_list: Optional[List[str]] = None, + chat_mode: str = "group", + chat_type: str = "private", + user_id_type: str = "open_id") -> Result: + """chat_mode: group | topic. chat_type: public | private.""" + payload: Dict[str, Any] = { + "name": name, + "description": description, + "chat_mode": chat_mode, + "chat_type": chat_type, + } + if owner_id: payload["owner_id"] = owner_id + if user_id_list: payload["user_id_list"] = user_id_list + if bot_id_list: payload["bot_id_list"] = bot_id_list + return http_request( + "POST", f"{LARK_API_BASE}/im/v1/chats", + params={"user_id_type": user_id_type}, + headers=self._headers(), json=payload, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def get_chat(self, chat_id: str, + user_id_type: str = "open_id") -> Result: + return http_request( + "GET", f"{LARK_API_BASE}/im/v1/chats/{chat_id}", + params={"user_id_type": user_id_type}, + headers=self._headers(), expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def update_chat(self, chat_id: str, + name: Optional[str] = None, + description: Optional[str] = None, + avatar: Optional[str] = None, + add_member_permission: Optional[str] = None, + share_card_permission: Optional[str] = None, + at_all_permission: Optional[str] = None, + edit_permission: Optional[str] = None, + chat_type: Optional[str] = None) -> Result: + payload: Dict[str, Any] = {} + if name is not None: payload["name"] = name + if description is not None: payload["description"] = description + if avatar is not None: payload["avatar"] = avatar + if add_member_permission is not None: payload["add_member_permission"] = add_member_permission + if share_card_permission is not None: payload["share_card_permission"] = share_card_permission + if at_all_permission is not None: payload["at_all_permission"] = at_all_permission + if edit_permission is not None: payload["edit_permission"] = edit_permission + if chat_type is not None: payload["chat_type"] = chat_type + return http_request( + "PUT", f"{LARK_API_BASE}/im/v1/chats/{chat_id}", + headers=self._headers(), json=payload, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def dissolve_chat(self, chat_id: str) -> Result: + return http_request( + "DELETE", f"{LARK_API_BASE}/im/v1/chats/{chat_id}", + headers=self._headers(), expected=(200,), + transform=lambda d: d.get("data", {"dissolved": True, "chat_id": chat_id}), + ) + + def list_chat_members(self, chat_id: str, + member_id_type: str = "open_id", + page_size: int = 100, + page_token: str = "") -> Result: + params: Dict[str, str] = { + "member_id_type": member_id_type, + "page_size": str(min(page_size, 100)), + } + if page_token: params["page_token"] = page_token + return http_request( + "GET", f"{LARK_API_BASE}/im/v1/chats/{chat_id}/members", + headers=self._headers(), params=params, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def add_chat_members(self, chat_id: str, id_list: List[str], + member_id_type: str = "open_id", + succeed_type: int = 0) -> Result: + """succeed_type: 0 (return error if any fails) | 1 (partial-success allowed) | 2 (return existing-member info).""" + return http_request( + "POST", f"{LARK_API_BASE}/im/v1/chats/{chat_id}/members", + params={"member_id_type": member_id_type, "succeed_type": str(succeed_type)}, + headers=self._headers(), + json={"id_list": id_list}, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def remove_chat_members(self, chat_id: str, id_list: List[str], + member_id_type: str = "open_id") -> Result: + return http_request( + "DELETE", f"{LARK_API_BASE}/im/v1/chats/{chat_id}/members", + params={"member_id_type": member_id_type}, + headers=self._headers(), + json={"id_list": id_list}, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def search_chats(self, query: str, page_size: int = 50, + page_token: str = "") -> Result: + params: Dict[str, str] = { + "query": query, + "page_size": str(min(page_size, 100)), + } + if page_token: params["page_token"] = page_token + return http_request( + "GET", f"{LARK_API_BASE}/im/v1/chats/search", + headers=self._headers(), params=params, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def get_chat_announcement(self, chat_id: str) -> Result: + return http_request( + "GET", f"{LARK_API_BASE}/im/v1/chats/{chat_id}/announcement", + headers=self._headers(), expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def update_chat_announcement(self, chat_id: str, revision: str, + requests: List[Dict[str, Any]]) -> Result: + """requests is a list of Lark block update operations (same shape as Docx).""" + return http_request( + "PATCH", f"{LARK_API_BASE}/im/v1/chats/{chat_id}/announcement", + headers=self._headers(), + json={"revision": revision, "requests": requests}, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def update_chat_moderation(self, chat_id: str, + moderation_setting: str, + user_id_list: Optional[List[str]] = None, + user_id_type: str = "open_id") -> Result: + """moderation_setting: all_members | only_owner | specific_users.""" + payload: Dict[str, Any] = {"moderation_setting": moderation_setting} + if user_id_list is not None: payload["user_id_list"] = user_id_list + return http_request( + "PUT", f"{LARK_API_BASE}/im/v1/chats/{chat_id}/moderation", + params={"user_id_type": user_id_type}, + headers=self._headers(), json=payload, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + # ================================================================== + # Contacts — users / departments + # ================================================================== + + def get_user(self, user_id: str, + user_id_type: str = "open_id", + department_id_type: str = "open_department_id") -> Result: + return http_request( + "GET", f"{LARK_API_BASE}/contact/v3/users/{user_id}", + params={"user_id_type": user_id_type, + "department_id_type": department_id_type}, + headers=self._headers(), expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def batch_get_users(self, user_ids: List[str], + user_id_type: str = "open_id") -> Result: + return http_request( + "GET", f"{LARK_API_BASE}/contact/v3/users/batch", + params=[("user_id_type", user_id_type)] + [("user_ids", uid) for uid in user_ids], + headers=self._headers(), expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def batch_get_user_ids(self, + emails: Optional[List[str]] = None, + mobiles: Optional[List[str]] = None, + user_id_type: str = "open_id") -> Result: + """Resolve a batch of emails/mobiles to user IDs (extension of get_user_by_email).""" + payload: Dict[str, Any] = {} + if emails: payload["emails"] = emails + if mobiles: payload["mobiles"] = mobiles + return http_request( + "POST", f"{LARK_API_BASE}/contact/v3/users/batch_get_id", + params={"user_id_type": user_id_type}, + headers=self._headers(), json=payload, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def list_department_users(self, department_id: str, + user_id_type: str = "open_id", + department_id_type: str = "open_department_id", + page_size: int = 50, + page_token: str = "") -> Result: + params: Dict[str, str] = { + "department_id": department_id, + "user_id_type": user_id_type, + "department_id_type": department_id_type, + "page_size": str(min(page_size, 50)), + } + if page_token: params["page_token"] = page_token + return http_request( + "GET", f"{LARK_API_BASE}/contact/v3/users/find_by_department", + headers=self._headers(), params=params, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def search_users_by_name(self, query: str, + page_size: int = 50, + page_token: str = "") -> Result: + """User search visible to the app (depends on scope grants).""" + params: Dict[str, str] = {"query": query, "page_size": str(min(page_size, 50))} + if page_token: params["page_token"] = page_token + return http_request( + "GET", f"{LARK_API_BASE}/contact/v3/users/search", + headers=self._headers(), params=params, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def get_department(self, department_id: str, + department_id_type: str = "open_department_id", + user_id_type: str = "open_id") -> Result: + return http_request( + "GET", f"{LARK_API_BASE}/contact/v3/departments/{department_id}", + params={"department_id_type": department_id_type, + "user_id_type": user_id_type}, + headers=self._headers(), expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def list_department_children(self, parent_department_id: str, + department_id_type: str = "open_department_id", + fetch_child: bool = False, + page_size: int = 50, + page_token: str = "") -> Result: + params: Dict[str, str] = { + "parent_department_id": parent_department_id, + "department_id_type": department_id_type, + "fetch_child": str(fetch_child).lower(), + "page_size": str(min(page_size, 50)), + } + if page_token: params["page_token"] = page_token + return http_request( + "GET", f"{LARK_API_BASE}/contact/v3/departments/children", + headers=self._headers(), params=params, expected=(200,), + transform=lambda d: d.get("data", d), + ) diff --git a/craftos_integrations/integrations/lark_calendar/__init__.py b/craftos_integrations/integrations/lark_calendar/__init__.py index 3d4ba490..98fb15a3 100644 --- a/craftos_integrations/integrations/lark_calendar/__init__.py +++ b/craftos_integrations/integrations/lark_calendar/__init__.py @@ -191,6 +191,102 @@ def get_primary_calendar(self) -> Result: transform=lambda d: d.get("data", d), ) + def get_calendar(self, calendar_id: str) -> Result: + """Fetch a single calendar's metadata.""" + return http_request( + "GET", f"{LARK_API_BASE}/calendar/v4/calendars/{calendar_id}", + headers=self._headers(), expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def create_calendar(self, summary: str, + description: str = "", + permissions: str = "private", + color: Optional[int] = None, + summary_alias: str = "") -> Result: + """Create a secondary calendar owned by the bot. + + ``permissions``: private | show_only_free_busy | public. + ``color`` is an RGB int32 (Lark's own encoding; -1 = default). + """ + body: Dict[str, Any] = {"summary": summary} + if description: + body["description"] = description + if permissions: + body["permissions"] = permissions + if color is not None: + body["color"] = color + if summary_alias: + body["summary_alias"] = summary_alias + return http_request( + "POST", f"{LARK_API_BASE}/calendar/v4/calendars", + headers=self._headers(), json=body, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def update_calendar(self, calendar_id: str, + summary: Optional[str] = None, + description: Optional[str] = None, + permissions: Optional[str] = None, + color: Optional[int] = None, + summary_alias: Optional[str] = None) -> Result: + """Patch a calendar. Only fields with non-None values are sent.""" + body: Dict[str, Any] = {} + if summary is not None: + body["summary"] = summary + if description is not None: + body["description"] = description + if permissions is not None: + body["permissions"] = permissions + if color is not None: + body["color"] = color + if summary_alias is not None: + body["summary_alias"] = summary_alias + if not body: + return {"error": "No fields provided to update"} + return http_request( + "PATCH", f"{LARK_API_BASE}/calendar/v4/calendars/{calendar_id}", + headers=self._headers(), json=body, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def delete_calendar(self, calendar_id: str) -> Result: + """Delete a calendar the bot owns.""" + return http_request( + "DELETE", f"{LARK_API_BASE}/calendar/v4/calendars/{calendar_id}", + headers=self._headers(), expected=(200,), + transform=lambda d: d.get("data", d) or {"deleted": True, "calendar_id": calendar_id}, + ) + + def search_calendars(self, query: str, page_size: int = 20, + page_token: str = "") -> Result: + """Search across calendars the bot can see by name.""" + params: Dict[str, str] = {"page_size": str(min(page_size, 100))} + if page_token: + params["page_token"] = page_token + return http_request( + "POST", f"{LARK_API_BASE}/calendar/v4/calendars/search", + params=params, headers=self._headers(), + json={"query": query}, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def subscribe_calendar(self, calendar_id: str) -> Result: + """Subscribe to (follow) a shared calendar so it shows up in list_calendars.""" + return http_request( + "POST", f"{LARK_API_BASE}/calendar/v4/calendars/{calendar_id}/subscribe", + headers=self._headers(), expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def unsubscribe_calendar(self, calendar_id: str) -> Result: + """Unsubscribe from a shared calendar.""" + return http_request( + "POST", f"{LARK_API_BASE}/calendar/v4/calendars/{calendar_id}/unsubscribe", + headers=self._headers(), expected=(200,), + transform=lambda d: d.get("data", d), + ) + # ----- Events ----- def list_events( @@ -395,3 +491,128 @@ def check_free_busy( expected=(200,), transform=lambda d: d.get("data", d), ) + + # ----- Event RSVP ----- + + def reply_event(self, calendar_id: str, event_id: str, + rsvp_status: str) -> Result: + """Reply to an event invitation. ``rsvp_status``: accept | decline | tentative.""" + return http_request( + "POST", f"{LARK_API_BASE}/calendar/v4/calendars/{calendar_id}/events/{event_id}/reply", + headers=self._headers(), + json={"rsvp_status": rsvp_status}, + expected=(200,), + transform=lambda d: d.get("data", d) or {"replied": True, "rsvp_status": rsvp_status}, + ) + + # ----- Event attendees: list / batch delete / chat members ----- + + def list_event_attendees(self, calendar_id: str, event_id: str, + page_size: int = 100, page_token: str = "") -> Result: + """List attendees on an event.""" + params: Dict[str, str] = {"page_size": str(min(page_size, 200))} + if page_token: + params["page_token"] = page_token + return http_request( + "GET", f"{LARK_API_BASE}/calendar/v4/calendars/{calendar_id}/events/{event_id}/attendees", + params=params, headers=self._headers(), expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def batch_delete_event_attendees(self, calendar_id: str, event_id: str, + attendee_ids: List[str], + need_notification: bool = True) -> Result: + """Remove attendees by attendee_id from an event in one call.""" + return http_request( + "POST", f"{LARK_API_BASE}/calendar/v4/calendars/{calendar_id}/events/{event_id}/attendees/batch_delete", + headers=self._headers(), + json={ + "attendee_ids": attendee_ids, + "need_notification": need_notification, + }, + expected=(200,), + transform=lambda d: d.get("data", d) or {"removed": attendee_ids}, + ) + + def list_event_attendee_chat_members(self, calendar_id: str, event_id: str, + attendee_id: str, + page_size: int = 100, page_token: str = "") -> Result: + """List the chat members behind a chat-type attendee.""" + params: Dict[str, str] = {"page_size": str(min(page_size, 200))} + if page_token: + params["page_token"] = page_token + return http_request( + "GET", f"{LARK_API_BASE}/calendar/v4/calendars/{calendar_id}/events/{event_id}/attendees/{attendee_id}/chat_members", + params=params, headers=self._headers(), expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def add_meeting_room_to_event(self, calendar_id: str, event_id: str, + meeting_room_id: str, + need_notification: bool = True) -> Result: + """Book a meeting room by attaching it as a resource-type attendee.""" + return http_request( + "POST", f"{LARK_API_BASE}/calendar/v4/calendars/{calendar_id}/events/{event_id}/attendees", + headers=self._headers(), + json={ + "attendees": [{"type": "resource", "room_id": meeting_room_id}], + "need_notification": need_notification, + }, + expected=(200,), + transform=lambda d: d.get("data", d), + ) + + # ----- Recurring event instances ----- + + def list_event_instances(self, calendar_id: str, event_id: str, + start_time: int, end_time: int, + page_size: int = 50, page_token: str = "") -> Result: + """List concrete instances of a recurring event in a window.""" + params: Dict[str, str] = { + "start_time": str(start_time), + "end_time": str(end_time), + "page_size": str(min(page_size, 100)), + } + if page_token: + params["page_token"] = page_token + return http_request( + "GET", f"{LARK_API_BASE}/calendar/v4/calendars/{calendar_id}/events/{event_id}/instances", + params=params, headers=self._headers(), expected=(200,), + transform=lambda d: d.get("data", d), + ) + + # ----- Calendar ACL (sharing) ----- + + def list_calendar_acls(self, calendar_id: str) -> Result: + """List the access-control entries (sharing permissions) on a calendar.""" + return http_request( + "GET", f"{LARK_API_BASE}/calendar/v4/calendars/{calendar_id}/acls", + headers=self._headers(), expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def create_calendar_acl(self, calendar_id: str, user_id: str, + role: str = "reader") -> Result: + """Share a calendar with a user. + + ``role``: owner | reader | writer | free_busy_reader. + ``user_id`` is the Lark open_id (ou_...). + """ + return http_request( + "POST", f"{LARK_API_BASE}/calendar/v4/calendars/{calendar_id}/acls", + headers=self._headers(), + json={ + "role": role, + "scope": {"type": "user", "user_id": user_id}, + }, + expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def delete_calendar_acl(self, calendar_id: str, acl_id: str) -> Result: + """Revoke a sharing permission.""" + return http_request( + "DELETE", f"{LARK_API_BASE}/calendar/v4/calendars/{calendar_id}/acls/{acl_id}", + headers=self._headers(), expected=(200,), + transform=lambda d: d.get("data", d) or {"deleted": True, "acl_id": acl_id}, + ) diff --git a/craftos_integrations/integrations/lark_drive/__init__.py b/craftos_integrations/integrations/lark_drive/__init__.py index b86d07f4..2b8b2484 100644 --- a/craftos_integrations/integrations/lark_drive/__init__.py +++ b/craftos_integrations/integrations/lark_drive/__init__.py @@ -326,3 +326,910 @@ def search_files(self, search_key: str, count: int = 20) -> Result: expected=(200,), transform=lambda d: d.get("data", d), ) + + # ------------------------------------------------------------------ + # Drive: copy / move / versions / shortcuts / stats + # ------------------------------------------------------------------ + + def copy_file(self, file_token: str, name: str, + folder_token: str, + copy_type: str = "file") -> Result: + """Copy a file to a folder. copy_type: file | folder | doc | docx | sheet | bitable | mindnote | slides.""" + return http_request( + "POST", f"{LARK_API_BASE}/drive/v1/files/{file_token}/copy", + headers=self._headers(), + json={"name": name, "type": copy_type, "folder_token": folder_token}, + expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def move_file(self, file_token: str, target_folder_token: str, + file_type: str = "file") -> Result: + return http_request( + "POST", f"{LARK_API_BASE}/drive/v1/files/{file_token}/move", + headers=self._headers(), + json={"type": file_type, "folder_token": target_folder_token}, + expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def list_file_versions(self, file_token: str, file_type: str = "docx", + page_size: int = 50, + page_token: str = "") -> Result: + """List version history. file_type: docx | doc | sheet.""" + params: Dict[str, str] = {"obj_type": file_type, "page_size": str(min(page_size, 50))} + if page_token: params["page_token"] = page_token + return http_request( + "GET", f"{LARK_API_BASE}/drive/v1/files/{file_token}/versions", + headers=self._headers(), params=params, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def file_statistics(self, file_token: str, file_type: str = "docx") -> Result: + """View/like/comment stats. file_type: docx | doc | sheet | bitable | file.""" + return http_request( + "GET", f"{LARK_API_BASE}/drive/v1/files/{file_token}/statistics", + headers=self._headers(), + params={"file_type": file_type}, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + # ------------------------------------------------------------------ + # Drive Permissions (sharing) + # ------------------------------------------------------------------ + + def list_permission_members(self, file_token: str, + file_type: str = "docx") -> Result: + """List who has access. file_type: doc | docx | sheet | bitable | file | folder | mindnote | slides.""" + return http_request( + "GET", f"{LARK_API_BASE}/drive/v1/permissions/{file_token}/members", + headers=self._headers(), + params={"type": file_type}, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def add_permission_member(self, file_token: str, + member_type: str, member_id: str, + perm: str, file_type: str = "docx", + perm_type: str = "container", + notify_lark: bool = False) -> Result: + """Grant access. member_type: email|openid|userid|unionid|chatid|departmentid|openchat|opendepartment|userid|groupid. perm: view|edit|full_access. perm_type: container|single_page.""" + return http_request( + "POST", f"{LARK_API_BASE}/drive/v1/permissions/{file_token}/members", + headers=self._headers(), + params={"type": file_type, "need_notification": str(notify_lark).lower()}, + json={"member_type": member_type, "member_id": member_id, + "perm": perm, "perm_type": perm_type}, + expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def update_permission_member(self, file_token: str, member_id: str, + member_type: str, perm: str, + file_type: str = "docx", + perm_type: str = "container", + notify_lark: bool = False) -> Result: + return http_request( + "PUT", f"{LARK_API_BASE}/drive/v1/permissions/{file_token}/members/{member_id}", + headers=self._headers(), + params={"type": file_type, "need_notification": str(notify_lark).lower()}, + json={"member_type": member_type, "perm": perm, "perm_type": perm_type}, + expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def delete_permission_member(self, file_token: str, member_id: str, + member_type: str, + file_type: str = "docx") -> Result: + return http_request( + "DELETE", f"{LARK_API_BASE}/drive/v1/permissions/{file_token}/members/{member_id}", + headers=self._headers(), + params={"type": file_type, "member_type": member_type}, + expected=(200,), + transform=lambda d: d.get("data", {"removed": True, "member_id": member_id}), + ) + + def get_public_permission(self, file_token: str, + file_type: str = "docx") -> Result: + return http_request( + "GET", f"{LARK_API_BASE}/drive/v2/permissions/{file_token}/public", + headers=self._headers(), + params={"type": file_type}, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def update_public_permission(self, file_token: str, + file_type: str = "docx", + external_access_entity: Optional[str] = None, + security_entity: Optional[str] = None, + comment_entity: Optional[str] = None, + share_entity: Optional[str] = None, + link_share_entity: Optional[str] = None, + invite_external: Optional[bool] = None) -> Result: + """Update public-link settings. Values like 'tenant_readable', 'anyone_readable', 'anyone_editable', 'closed', etc. — see Lark docs for the exact enum per field.""" + payload: Dict[str, Any] = {} + if external_access_entity is not None: payload["external_access_entity"] = external_access_entity + if security_entity is not None: payload["security_entity"] = security_entity + if comment_entity is not None: payload["comment_entity"] = comment_entity + if share_entity is not None: payload["share_entity"] = share_entity + if link_share_entity is not None: payload["link_share_entity"] = link_share_entity + if invite_external is not None: payload["invite_external"] = invite_external + return http_request( + "PATCH", f"{LARK_API_BASE}/drive/v2/permissions/{file_token}/public", + headers=self._headers(), + params={"type": file_type}, + json=payload, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def transfer_owner(self, file_token: str, member_type: str, member_id: str, + file_type: str = "docx", + remove_old_owner: bool = False) -> Result: + return http_request( + "POST", f"{LARK_API_BASE}/drive/v1/permissions/{file_token}/members/transfer_owner", + headers=self._headers(), + params={"type": file_type, + "need_notification": "true", + "remove_old_owner": str(remove_old_owner).lower()}, + json={"member_type": member_type, "member_id": member_id}, + expected=(200,), + transform=lambda d: d.get("data", d), + ) + + # ------------------------------------------------------------------ + # Drive Comments (and replies) + # ------------------------------------------------------------------ + + def list_comments(self, file_token: str, file_type: str = "docx", + is_whole: bool = True, + page_size: int = 100, + page_token: str = "") -> Result: + """is_whole=True returns whole-document comments; False returns anchored ones.""" + params: Dict[str, str] = { + "file_type": file_type, + "is_whole": str(is_whole).lower(), + "page_size": str(min(page_size, 100)), + } + if page_token: params["page_token"] = page_token + return http_request( + "GET", f"{LARK_API_BASE}/drive/v1/files/{file_token}/comments", + headers=self._headers(), params=params, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def create_comment(self, file_token: str, content_elements: List[Dict[str, Any]], + file_type: str = "docx") -> Result: + """content_elements is a list of rich-text element dicts (text_run, mention, link, etc.).""" + return http_request( + "POST", f"{LARK_API_BASE}/drive/v1/files/{file_token}/comments", + headers=self._headers(), + params={"file_type": file_type}, + json={"reply_list": {"replies": [{"content": {"elements": content_elements}}]}}, + expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def get_comment(self, file_token: str, comment_id: str, + file_type: str = "docx") -> Result: + return http_request( + "GET", f"{LARK_API_BASE}/drive/v1/files/{file_token}/comments/{comment_id}", + headers=self._headers(), + params={"file_type": file_type}, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def resolve_comment(self, file_token: str, comment_id: str, + file_type: str = "docx", + is_solved: bool = True) -> Result: + return http_request( + "PATCH", f"{LARK_API_BASE}/drive/v1/files/{file_token}/comments/{comment_id}", + headers=self._headers(), + params={"file_type": file_type}, + json={"is_solved": is_solved}, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def list_comment_replies(self, file_token: str, comment_id: str, + file_type: str = "docx", + page_size: int = 100) -> Result: + return http_request( + "GET", f"{LARK_API_BASE}/drive/v1/files/{file_token}/comments/{comment_id}/replies", + headers=self._headers(), + params={"file_type": file_type, "page_size": str(min(page_size, 100))}, + expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def update_comment_reply(self, file_token: str, comment_id: str, reply_id: str, + content_elements: List[Dict[str, Any]], + file_type: str = "docx") -> Result: + return http_request( + "PUT", + f"{LARK_API_BASE}/drive/v1/files/{file_token}/comments/{comment_id}/replies/{reply_id}", + headers=self._headers(), + params={"file_type": file_type}, + json={"content": {"elements": content_elements}}, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def delete_comment_reply(self, file_token: str, comment_id: str, reply_id: str, + file_type: str = "docx") -> Result: + return http_request( + "DELETE", + f"{LARK_API_BASE}/drive/v1/files/{file_token}/comments/{comment_id}/replies/{reply_id}", + headers=self._headers(), + params={"file_type": file_type}, expected=(200,), + transform=lambda d: d.get("data", {"deleted": True, "reply_id": reply_id}), + ) + + # ------------------------------------------------------------------ + # Drive Import / Export tasks + # ------------------------------------------------------------------ + + def create_import_task(self, file_extension: str, file_name: str, + file_token: str, file_type: str, + point_type: str = "ccm_import_open_platform", + folder_token: str = "") -> Result: + """Convert a regular file token into a Doc/Sheet/Bitable. + + file_extension: docx | pdf | csv | xlsx ... ; file_type: docx | sheet | bitable + """ + payload: Dict[str, Any] = { + "file_extension": file_extension, + "file_name": file_name, + "file_token": file_token, + "type": file_type, + "point": {"mount_type": 1, "mount_key": folder_token}, + } + return http_request( + "POST", f"{LARK_API_BASE}/drive/v1/import_tasks", + headers=self._headers(), json=payload, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def get_import_task(self, ticket: str) -> Result: + """Poll a previously-created import task. Returns job_status + result_token when done.""" + return http_request( + "GET", f"{LARK_API_BASE}/drive/v1/import_tasks/{ticket}", + headers=self._headers(), expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def create_export_task(self, file_extension: str, file_token: str, + file_type: str, + sub_id: str = "") -> Result: + """Convert a Doc/Sheet/Bitable into a regular file. Returns a ticket. + + file_extension: docx | pdf | csv | xlsx + file_type: docx | sheet | bitable + """ + payload: Dict[str, Any] = { + "file_extension": file_extension, + "token": file_token, + "type": file_type, + } + if sub_id: payload["sub_id"] = sub_id + return http_request( + "POST", f"{LARK_API_BASE}/drive/v1/export_tasks", + headers=self._headers(), json=payload, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def get_export_task(self, ticket: str, file_token: str) -> Result: + """Poll export task. When done, response contains file_token of the result blob.""" + return http_request( + "GET", f"{LARK_API_BASE}/drive/v1/export_tasks/{ticket}", + headers=self._headers(), + params={"token": file_token}, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def download_export(self, result_file_token: str, dest_path: str) -> Result: + """Download the file blob produced by a finished export task.""" + token = ensure_token(self._load(), self.spec.cred_file) + import httpx + try: + with httpx.stream( + "GET", f"{LARK_API_BASE}/drive/v1/export_tasks/file/{result_file_token}/download", + headers={"Authorization": f"Bearer {token}"}, + timeout=120.0, + ) as resp: + if resp.status_code != 200: + return {"error": f"Download failed: HTTP {resp.status_code}", + "details": resp.read().decode("utf-8", errors="replace")[:500]} + bytes_written = 0 + with open(dest_path, "wb") as f: + for chunk in resp.iter_bytes(chunk_size=64 * 1024): + f.write(chunk) + bytes_written += len(chunk) + return {"ok": True, "result": {"path": dest_path, + "bytes_written": bytes_written}} + except (httpx.HTTPError, OSError) as e: + return {"error": f"Download failed: {e}"} + + # ================================================================== + # Docx (new Docs) — documents + blocks + # ================================================================== + + def create_document(self, title: str = "", + folder_token: str = "") -> Result: + payload: Dict[str, Any] = {} + if title: payload["title"] = title + if folder_token: payload["folder_token"] = folder_token + return http_request( + "POST", f"{LARK_API_BASE}/docx/v1/documents", + headers=self._headers(), json=payload, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def get_document(self, document_id: str) -> Result: + return http_request( + "GET", f"{LARK_API_BASE}/docx/v1/documents/{document_id}", + headers=self._headers(), expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def get_document_raw_content(self, document_id: str, lang: int = 0) -> Result: + """Returns the doc's plain-text representation. lang: 0=default, 1=en, 2=zh, 3=ja.""" + return http_request( + "GET", f"{LARK_API_BASE}/docx/v1/documents/{document_id}/raw_content", + headers=self._headers(), + params={"lang": lang}, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def list_document_blocks(self, document_id: str, + page_size: int = 500, + page_token: str = "", + document_revision_id: int = -1) -> Result: + params: Dict[str, str] = { + "page_size": str(min(page_size, 500)), + "document_revision_id": str(document_revision_id), + } + if page_token: params["page_token"] = page_token + return http_request( + "GET", f"{LARK_API_BASE}/docx/v1/documents/{document_id}/blocks", + headers=self._headers(), params=params, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def get_document_block(self, document_id: str, block_id: str, + document_revision_id: int = -1) -> Result: + return http_request( + "GET", f"{LARK_API_BASE}/docx/v1/documents/{document_id}/blocks/{block_id}", + headers=self._headers(), + params={"document_revision_id": str(document_revision_id)}, + expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def create_document_block_children(self, document_id: str, block_id: str, + children: List[Dict[str, Any]], + index: int = -1, + document_revision_id: int = -1) -> Result: + """Insert children blocks under a parent. block_id is the parent; use the document_id for top-level inserts.""" + return http_request( + "POST", + f"{LARK_API_BASE}/docx/v1/documents/{document_id}/blocks/{block_id}/children", + headers=self._headers(), + params={"document_revision_id": str(document_revision_id)}, + json={"children": children, "index": index}, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def update_document_block(self, document_id: str, block_id: str, + update_payload: Dict[str, Any], + document_revision_id: int = -1) -> Result: + """update_payload uses Docx's update structures: {update_text_elements: {...}} / {update_table_property: {...}} / etc.""" + return http_request( + "PATCH", + f"{LARK_API_BASE}/docx/v1/documents/{document_id}/blocks/{block_id}", + headers=self._headers(), + params={"document_revision_id": str(document_revision_id)}, + json=update_payload, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def batch_update_document_blocks(self, document_id: str, + requests: List[Dict[str, Any]], + document_revision_id: int = -1) -> Result: + """One round-trip multi-block update. requests is a list of {block_id, ...update_fields}.""" + return http_request( + "PATCH", + f"{LARK_API_BASE}/docx/v1/documents/{document_id}/blocks/batch_update", + headers=self._headers(), + params={"document_revision_id": str(document_revision_id)}, + json={"requests": requests}, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def delete_document_blocks(self, document_id: str, block_id: str, + start_index: int, end_index: int, + document_revision_id: int = -1) -> Result: + """Delete a contiguous range of children of block_id (half-open [start_index, end_index)).""" + return http_request( + "DELETE", + f"{LARK_API_BASE}/docx/v1/documents/{document_id}/blocks/{block_id}/children/batch_delete", + headers=self._headers(), + params={"document_revision_id": str(document_revision_id)}, + json={"start_index": start_index, "end_index": end_index}, + expected=(200,), + transform=lambda d: d.get("data", d), + ) + + # ================================================================== + # Sheets (Spreadsheets + values) + # ================================================================== + + def create_spreadsheet(self, title: str = "", + folder_token: str = "") -> Result: + payload: Dict[str, Any] = {} + if title: payload["title"] = title + if folder_token: payload["folder_token"] = folder_token + return http_request( + "POST", f"{LARK_API_BASE}/sheets/v3/spreadsheets", + headers=self._headers(), json=payload, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def get_spreadsheet(self, spreadsheet_token: str) -> Result: + return http_request( + "GET", f"{LARK_API_BASE}/sheets/v3/spreadsheets/{spreadsheet_token}", + headers=self._headers(), expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def update_spreadsheet_title(self, spreadsheet_token: str, + title: str) -> Result: + return http_request( + "PATCH", f"{LARK_API_BASE}/sheets/v3/spreadsheets/{spreadsheet_token}", + headers=self._headers(), + json={"title": title}, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def list_spreadsheet_sheets(self, spreadsheet_token: str) -> Result: + return http_request( + "GET", + f"{LARK_API_BASE}/sheets/v3/spreadsheets/{spreadsheet_token}/sheets/query", + headers=self._headers(), expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def get_spreadsheet_sheet(self, spreadsheet_token: str, + sheet_id: str) -> Result: + return http_request( + "GET", + f"{LARK_API_BASE}/sheets/v3/spreadsheets/{spreadsheet_token}/sheets/{sheet_id}", + headers=self._headers(), expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def get_sheet_values(self, spreadsheet_token: str, range_: str, + value_render_option: str = "ToString", + date_time_render_option: str = "FormattedString") -> Result: + """range_ format: '!A1:D10'.""" + return http_request( + "GET", + f"{LARK_API_BASE}/sheets/v2/spreadsheets/{spreadsheet_token}/values/{range_}", + headers=self._headers(), + params={ + "valueRenderOption": value_render_option, + "dateTimeRenderOption": date_time_render_option, + }, + expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def batch_get_sheet_values(self, spreadsheet_token: str, + ranges: List[str], + value_render_option: str = "ToString") -> Result: + return http_request( + "GET", + f"{LARK_API_BASE}/sheets/v2/spreadsheets/{spreadsheet_token}/values_batch_get", + headers=self._headers(), + params={"ranges": ",".join(ranges), + "valueRenderOption": value_render_option}, + expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def update_sheet_values(self, spreadsheet_token: str, range_: str, + values: List[List[Any]]) -> Result: + """Write a 2D values array into range_ (overwriting). values e.g. [['A1','B1'],['A2','B2']].""" + return http_request( + "PUT", + f"{LARK_API_BASE}/sheets/v2/spreadsheets/{spreadsheet_token}/values", + headers=self._headers(), + json={"valueRange": {"range": range_, "values": values}}, + expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def append_sheet_values(self, spreadsheet_token: str, range_: str, + values: List[List[Any]], + insert_data_option: str = "OVERWRITE") -> Result: + """Append rows after the last filled row in range_. insert_data_option: OVERWRITE | INSERT_ROWS.""" + return http_request( + "POST", + f"{LARK_API_BASE}/sheets/v2/spreadsheets/{spreadsheet_token}/values_append", + headers=self._headers(), + params={"insertDataOption": insert_data_option}, + json={"valueRange": {"range": range_, "values": values}}, + expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def batch_update_sheet_values(self, spreadsheet_token: str, + value_ranges: List[Dict[str, Any]]) -> Result: + """Multiple writes in one call. value_ranges: [{range, values}, ...].""" + return http_request( + "POST", + f"{LARK_API_BASE}/sheets/v2/spreadsheets/{spreadsheet_token}/values_batch_update", + headers=self._headers(), + json={"valueRanges": value_ranges}, + expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def find_in_sheet(self, spreadsheet_token: str, sheet_id: str, + find_text: str, range_: str, + match_case: bool = False, + match_entire_cell: bool = False, + search_by_regex: bool = False, + include_formulas: bool = False) -> Result: + return http_request( + "POST", + f"{LARK_API_BASE}/sheets/v3/spreadsheets/{spreadsheet_token}/sheets/{sheet_id}/find", + headers=self._headers(), + json={ + "find_condition": { + "range": range_, + "match_case": match_case, + "match_entire_cell": match_entire_cell, + "search_by_regex": search_by_regex, + "include_formulas": include_formulas, + }, + "find": find_text, + }, + expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def replace_in_sheet(self, spreadsheet_token: str, sheet_id: str, + find_text: str, replacement: str, range_: str, + match_case: bool = False, + match_entire_cell: bool = False, + search_by_regex: bool = False, + include_formulas: bool = False) -> Result: + return http_request( + "POST", + f"{LARK_API_BASE}/sheets/v3/spreadsheets/{spreadsheet_token}/sheets/{sheet_id}/replace", + headers=self._headers(), + json={ + "find_condition": { + "range": range_, + "match_case": match_case, + "match_entire_cell": match_entire_cell, + "search_by_regex": search_by_regex, + "include_formulas": include_formulas, + }, + "find": find_text, + "replacement": replacement, + }, + expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def insert_sheet_dimension_range(self, spreadsheet_token: str, sheet_id: str, + major_dimension: str, + start_index: int, end_index: int, + inherit_style: str = "BEFORE") -> Result: + """Insert rows/columns. major_dimension: ROWS | COLUMNS. inherit_style: BEFORE | AFTER.""" + return http_request( + "POST", + f"{LARK_API_BASE}/sheets/v2/spreadsheets/{spreadsheet_token}/insert_dimension_range", + headers=self._headers(), + json={ + "dimension": { + "sheetId": sheet_id, + "majorDimension": major_dimension, + "startIndex": start_index, + "endIndex": end_index, + }, + "inheritStyle": inherit_style, + }, + expected=(200,), + transform=lambda d: d.get("data", d), + ) + + # ================================================================== + # Bitable (Base) — apps + tables + records + fields + # ================================================================== + + def create_bitable_app(self, name: str = "", + folder_token: str = "", + time_zone: str = "Asia/Shanghai") -> Result: + payload: Dict[str, Any] = {"time_zone": time_zone} + if name: payload["name"] = name + if folder_token: payload["folder_token"] = folder_token + return http_request( + "POST", f"{LARK_API_BASE}/bitable/v1/apps", + headers=self._headers(), json=payload, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def get_bitable_app(self, app_token: str) -> Result: + return http_request( + "GET", f"{LARK_API_BASE}/bitable/v1/apps/{app_token}", + headers=self._headers(), expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def update_bitable_app(self, app_token: str, + name: Optional[str] = None, + is_advanced: Optional[bool] = None) -> Result: + payload: Dict[str, Any] = {} + if name is not None: payload["name"] = name + if is_advanced is not None: payload["is_advanced"] = is_advanced + return http_request( + "PUT", f"{LARK_API_BASE}/bitable/v1/apps/{app_token}", + headers=self._headers(), json=payload, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def list_bitable_tables(self, app_token: str, page_size: int = 100, + page_token: str = "") -> Result: + params: Dict[str, str] = {"page_size": str(min(page_size, 100))} + if page_token: params["page_token"] = page_token + return http_request( + "GET", f"{LARK_API_BASE}/bitable/v1/apps/{app_token}/tables", + headers=self._headers(), params=params, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def create_bitable_table(self, app_token: str, name: str, + default_view_name: Optional[str] = None, + fields: Optional[List[Dict[str, Any]]] = None) -> Result: + payload_table: Dict[str, Any] = {"name": name} + if default_view_name: payload_table["default_view_name"] = default_view_name + if fields: payload_table["fields"] = fields + return http_request( + "POST", f"{LARK_API_BASE}/bitable/v1/apps/{app_token}/tables", + headers=self._headers(), + json={"table": payload_table}, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def delete_bitable_table(self, app_token: str, table_id: str) -> Result: + return http_request( + "DELETE", + f"{LARK_API_BASE}/bitable/v1/apps/{app_token}/tables/{table_id}", + headers=self._headers(), expected=(200,), + transform=lambda d: d.get("data", {"deleted": True, "table_id": table_id}), + ) + + def list_bitable_records(self, app_token: str, table_id: str, + view_id: str = "", + page_size: int = 100, + page_token: str = "", + field_names: Optional[List[str]] = None) -> Result: + params: Dict[str, str] = {"page_size": str(min(page_size, 500))} + if view_id: params["view_id"] = view_id + if page_token: params["page_token"] = page_token + if field_names: + params["field_names"] = ",".join(f'"{n}"' for n in field_names) + return http_request( + "GET", + f"{LARK_API_BASE}/bitable/v1/apps/{app_token}/tables/{table_id}/records", + headers=self._headers(), params=params, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def get_bitable_record(self, app_token: str, table_id: str, + record_id: str) -> Result: + return http_request( + "GET", + f"{LARK_API_BASE}/bitable/v1/apps/{app_token}/tables/{table_id}/records/{record_id}", + headers=self._headers(), expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def create_bitable_record(self, app_token: str, table_id: str, + fields: Dict[str, Any]) -> Result: + return http_request( + "POST", + f"{LARK_API_BASE}/bitable/v1/apps/{app_token}/tables/{table_id}/records", + headers=self._headers(), + json={"fields": fields}, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def update_bitable_record(self, app_token: str, table_id: str, + record_id: str, + fields: Dict[str, Any]) -> Result: + return http_request( + "PUT", + f"{LARK_API_BASE}/bitable/v1/apps/{app_token}/tables/{table_id}/records/{record_id}", + headers=self._headers(), + json={"fields": fields}, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def delete_bitable_record(self, app_token: str, table_id: str, + record_id: str) -> Result: + return http_request( + "DELETE", + f"{LARK_API_BASE}/bitable/v1/apps/{app_token}/tables/{table_id}/records/{record_id}", + headers=self._headers(), expected=(200,), + transform=lambda d: d.get("data", {"deleted": True, "record_id": record_id}), + ) + + def batch_create_bitable_records(self, app_token: str, table_id: str, + records: List[Dict[str, Any]]) -> Result: + """records: [{fields: {...}}, ...].""" + return http_request( + "POST", + f"{LARK_API_BASE}/bitable/v1/apps/{app_token}/tables/{table_id}/records/batch_create", + headers=self._headers(), + json={"records": records}, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def batch_update_bitable_records(self, app_token: str, table_id: str, + records: List[Dict[str, Any]]) -> Result: + """records: [{record_id, fields}, ...].""" + return http_request( + "POST", + f"{LARK_API_BASE}/bitable/v1/apps/{app_token}/tables/{table_id}/records/batch_update", + headers=self._headers(), + json={"records": records}, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def batch_delete_bitable_records(self, app_token: str, table_id: str, + record_ids: List[str]) -> Result: + return http_request( + "POST", + f"{LARK_API_BASE}/bitable/v1/apps/{app_token}/tables/{table_id}/records/batch_delete", + headers=self._headers(), + json={"records": record_ids}, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def search_bitable_records(self, app_token: str, table_id: str, + filter_obj: Optional[Dict[str, Any]] = None, + sort: Optional[List[Dict[str, Any]]] = None, + field_names: Optional[List[str]] = None, + view_id: str = "", + page_size: int = 100, + page_token: str = "") -> Result: + """Filtered/sorted record search. filter_obj uses Bitable's conjunction/conditions syntax.""" + payload: Dict[str, Any] = {} + if filter_obj is not None: payload["filter"] = filter_obj + if sort is not None: payload["sort"] = sort + if field_names is not None: payload["field_names"] = field_names + if view_id: payload["view_id"] = view_id + params: Dict[str, str] = {"page_size": str(min(page_size, 500))} + if page_token: params["page_token"] = page_token + return http_request( + "POST", + f"{LARK_API_BASE}/bitable/v1/apps/{app_token}/tables/{table_id}/records/search", + headers=self._headers(), + params=params, json=payload, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def list_bitable_fields(self, app_token: str, table_id: str, + view_id: str = "", + page_size: int = 100, + page_token: str = "") -> Result: + params: Dict[str, str] = {"page_size": str(min(page_size, 100))} + if view_id: params["view_id"] = view_id + if page_token: params["page_token"] = page_token + return http_request( + "GET", + f"{LARK_API_BASE}/bitable/v1/apps/{app_token}/tables/{table_id}/fields", + headers=self._headers(), params=params, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def create_bitable_field(self, app_token: str, table_id: str, + field_name: str, field_type: int, + property: Optional[Dict[str, Any]] = None, + description: Optional[Dict[str, Any]] = None) -> Result: + """field_type: 1=Text, 2=Number, 3=SingleSelect, 4=MultiSelect, 5=DateTime, 7=Checkbox, 11=User, 13=Phone, 15=URL, 17=Attachment, 18=Link, 19=Lookup, 20=Formula, 21=DuplicateLookup, 22=Location, 23=Group, 1001=CreatedTime, 1002=ModifiedTime, 1003=CreatedUser, 1004=ModifiedUser, 1005=AutoNumber.""" + payload: Dict[str, Any] = { + "field_name": field_name, + "type": field_type, + } + if property is not None: payload["property"] = property + if description is not None: payload["description"] = description + return http_request( + "POST", + f"{LARK_API_BASE}/bitable/v1/apps/{app_token}/tables/{table_id}/fields", + headers=self._headers(), json=payload, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def list_bitable_views(self, app_token: str, table_id: str, + page_size: int = 100) -> Result: + return http_request( + "GET", + f"{LARK_API_BASE}/bitable/v1/apps/{app_token}/tables/{table_id}/views", + headers=self._headers(), + params={"page_size": str(min(page_size, 100))}, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + # ================================================================== + # Wiki spaces + nodes + # ================================================================== + + def list_wiki_spaces(self, page_size: int = 50, + page_token: str = "") -> Result: + params: Dict[str, str] = {"page_size": str(min(page_size, 50))} + if page_token: params["page_token"] = page_token + return http_request( + "GET", f"{LARK_API_BASE}/wiki/v2/spaces", + headers=self._headers(), params=params, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def get_wiki_space(self, space_id: str) -> Result: + return http_request( + "GET", f"{LARK_API_BASE}/wiki/v2/spaces/{space_id}", + headers=self._headers(), expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def list_wiki_nodes(self, space_id: str, + parent_node_token: str = "", + page_size: int = 50, + page_token: str = "") -> Result: + params: Dict[str, str] = {"page_size": str(min(page_size, 50))} + if parent_node_token: params["parent_node_token"] = parent_node_token + if page_token: params["page_token"] = page_token + return http_request( + "GET", f"{LARK_API_BASE}/wiki/v2/spaces/{space_id}/nodes", + headers=self._headers(), params=params, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def get_wiki_node(self, token: str, obj_type: str = "wiki") -> Result: + """Resolve a wiki URL token to its underlying obj_token + obj_type (docx/sheet/bitable/...).""" + return http_request( + "GET", f"{LARK_API_BASE}/wiki/v2/spaces/get_node", + headers=self._headers(), + params={"token": token, "obj_type": obj_type}, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def create_wiki_node(self, space_id: str, + obj_type: str, node_type: str = "origin", + parent_node_token: str = "", + origin_node_token: str = "", + title: str = "") -> Result: + """obj_type: doc | docx | sheet | bitable | mindnote | file | slides. node_type: origin (create new) | shortcut (reference).""" + payload: Dict[str, Any] = {"obj_type": obj_type, "node_type": node_type} + if parent_node_token: payload["parent_node_token"] = parent_node_token + if origin_node_token: payload["origin_node_token"] = origin_node_token + if title: payload["title"] = title + return http_request( + "POST", f"{LARK_API_BASE}/wiki/v2/spaces/{space_id}/nodes", + headers=self._headers(), json=payload, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + def move_wiki_node(self, space_id: str, node_token: str, + target_parent_token: str = "", + target_space_id: str = "") -> Result: + payload: Dict[str, Any] = {} + if target_parent_token: payload["target_parent_token"] = target_parent_token + if target_space_id: payload["target_space_id"] = target_space_id + return http_request( + "POST", + f"{LARK_API_BASE}/wiki/v2/spaces/{space_id}/nodes/{node_token}/move", + headers=self._headers(), json=payload, expected=(200,), + transform=lambda d: d.get("data", d), + ) diff --git a/craftos_integrations/integrations/line/__init__.py b/craftos_integrations/integrations/line/__init__.py index f1475b26..5925535a 100644 --- a/craftos_integrations/integrations/line/__init__.py +++ b/craftos_integrations/integrations/line/__init__.py @@ -39,6 +39,8 @@ logger = get_logger(__name__) LINE_API_BASE = "https://api.line.me/v2/bot" +LINE_DATA_API_BASE = "https://api-data.line.me/v2/bot" +LINE_OAUTH_API_BASE = "https://api.line.me/oauth2/v3" @dataclass @@ -334,3 +336,521 @@ def get_quota(self) -> Result: headers={"Authorization": f"Bearer {self._load().channel_access_token}"}, expected=(200,), ) + + # ================================================================== + # Generic message sending (any LINE message object — up to 5 per call) + # ================================================================== + + def push_messages(self, to: str, messages: List[Dict[str, Any]], + notification_disabled: Optional[bool] = None) -> Result: + """Send up to 5 LINE message objects to a user/group/room. + + messages is a list of LINE-formatted message dicts, e.g. + [{"type":"text","text":"Hi"}, {"type":"image","originalContentUrl":"...","previewImageUrl":"..."}]. + """ + cfg = self._config() + nd = cfg.notification_disabled if notification_disabled is None else notification_disabled + payload: Dict[str, Any] = {"to": to, "messages": messages} + if nd: + payload["notificationDisabled"] = True + return http_request( + "POST", f"{LINE_API_BASE}/message/push", + headers=self._headers(), json=payload, expected=(200,), + ) + + def reply_messages(self, reply_token: str, messages: List[Dict[str, Any]], + notification_disabled: Optional[bool] = None) -> Result: + """Reply with up to 5 LINE message objects.""" + cfg = self._config() + nd = cfg.notification_disabled if notification_disabled is None else notification_disabled + payload: Dict[str, Any] = {"replyToken": reply_token, "messages": messages} + if nd: + payload["notificationDisabled"] = True + return http_request( + "POST", f"{LINE_API_BASE}/message/reply", + headers=self._headers(), json=payload, expected=(200,), + ) + + def multicast_messages(self, to: List[str], messages: List[Dict[str, Any]], + notification_disabled: Optional[bool] = None) -> Result: + cfg = self._config() + nd = cfg.notification_disabled if notification_disabled is None else notification_disabled + payload: Dict[str, Any] = {"to": to, "messages": messages} + if nd: + payload["notificationDisabled"] = True + return http_request( + "POST", f"{LINE_API_BASE}/message/multicast", + headers=self._headers(), json=payload, expected=(200,), + ) + + def broadcast_messages(self, messages: List[Dict[str, Any]], + notification_disabled: Optional[bool] = None) -> Result: + cfg = self._config() + nd = cfg.notification_disabled if notification_disabled is None else notification_disabled + payload: Dict[str, Any] = {"messages": messages} + if nd: + payload["notificationDisabled"] = True + return http_request( + "POST", f"{LINE_API_BASE}/message/broadcast", + headers=self._headers(), json=payload, expected=(200,), + ) + + # ----- Convenience builders for common message types ----- + + def push_image(self, to: str, original_content_url: str, + preview_image_url: Optional[str] = None) -> Result: + msg: Dict[str, Any] = {"type": "image", + "originalContentUrl": original_content_url, + "previewImageUrl": preview_image_url or original_content_url} + return self.push_messages(to, [msg]) + + def push_video(self, to: str, original_content_url: str, + preview_image_url: str) -> Result: + msg: Dict[str, Any] = {"type": "video", + "originalContentUrl": original_content_url, + "previewImageUrl": preview_image_url} + return self.push_messages(to, [msg]) + + def push_audio(self, to: str, original_content_url: str, + duration_ms: int) -> Result: + msg: Dict[str, Any] = {"type": "audio", + "originalContentUrl": original_content_url, + "duration": duration_ms} + return self.push_messages(to, [msg]) + + def push_location(self, to: str, title: str, address: str, + latitude: float, longitude: float) -> Result: + msg: Dict[str, Any] = {"type": "location", "title": title, + "address": address, + "latitude": latitude, "longitude": longitude} + return self.push_messages(to, [msg]) + + def push_sticker(self, to: str, package_id: str, sticker_id: str) -> Result: + msg: Dict[str, Any] = {"type": "sticker", + "packageId": package_id, "stickerId": sticker_id} + return self.push_messages(to, [msg]) + + def push_flex(self, to: str, alt_text: str, contents: Dict[str, Any]) -> Result: + """Send a Flex Message. contents is the Flex JSON structure.""" + msg: Dict[str, Any] = {"type": "flex", "altText": alt_text, "contents": contents} + return self.push_messages(to, [msg]) + + def push_template(self, to: str, alt_text: str, template: Dict[str, Any]) -> Result: + """Send a template message (buttons/confirm/carousel/image_carousel).""" + msg: Dict[str, Any] = {"type": "template", "altText": alt_text, "template": template} + return self.push_messages(to, [msg]) + + def push_imagemap(self, to: str, base_url: str, alt_text: str, + base_width: int, base_height: int, + actions: List[Dict[str, Any]]) -> Result: + msg: Dict[str, Any] = { + "type": "imagemap", "baseUrl": base_url, "altText": alt_text, + "baseSize": {"width": base_width, "height": base_height}, + "actions": actions, + } + return self.push_messages(to, [msg]) + + # ================================================================== + # Content retrieval (data plane) + # ================================================================== + + def get_message_content(self, message_id: str, dest_path: str) -> Result: + """Download the binary content of a user-sent image/video/audio/file message.""" + import httpx + try: + with httpx.stream( + "GET", f"{LINE_DATA_API_BASE}/message/{message_id}/content", + headers={"Authorization": f"Bearer {self._load().channel_access_token}"}, + timeout=120.0, + ) as resp: + if resp.status_code != 200: + return {"error": f"Download failed: HTTP {resp.status_code}", + "details": resp.read().decode("utf-8", errors="replace")[:500]} + bytes_written = 0 + with open(dest_path, "wb") as f: + for chunk in resp.iter_bytes(chunk_size=64 * 1024): + f.write(chunk) + bytes_written += len(chunk) + return {"ok": True, "result": { + "path": dest_path, "bytes_written": bytes_written, + "mimetype": resp.headers.get("content-type", ""), + }} + except (httpx.HTTPError, OSError) as e: + return {"error": f"Download failed: {e}"} + + # ================================================================== + # Group / room operations + # ================================================================== + + def get_group_summary(self, group_id: str) -> Result: + return http_request( + "GET", f"{LINE_API_BASE}/group/{group_id}/summary", + headers={"Authorization": f"Bearer {self._load().channel_access_token}"}, + expected=(200,), + ) + + def get_group_member_count(self, group_id: str) -> Result: + return http_request( + "GET", f"{LINE_API_BASE}/group/{group_id}/members/count", + headers={"Authorization": f"Bearer {self._load().channel_access_token}"}, + expected=(200,), + ) + + def list_group_member_user_ids(self, group_id: str, + start: Optional[str] = None) -> Result: + params: Dict[str, Any] = {} + if start: + params["start"] = start + return http_request( + "GET", f"{LINE_API_BASE}/group/{group_id}/members/ids", + headers={"Authorization": f"Bearer {self._load().channel_access_token}"}, + params=params, expected=(200,), + ) + + def get_group_member_profile(self, group_id: str, user_id: str) -> Result: + return http_request( + "GET", f"{LINE_API_BASE}/group/{group_id}/member/{user_id}", + headers={"Authorization": f"Bearer {self._load().channel_access_token}"}, + expected=(200,), + ) + + def leave_group(self, group_id: str) -> Result: + return http_request( + "POST", f"{LINE_API_BASE}/group/{group_id}/leave", + headers=self._headers(), expected=(200,), + transform=lambda _d: {"left": True, "group_id": group_id}, + ) + + def get_room_member_count(self, room_id: str) -> Result: + return http_request( + "GET", f"{LINE_API_BASE}/room/{room_id}/members/count", + headers={"Authorization": f"Bearer {self._load().channel_access_token}"}, + expected=(200,), + ) + + def list_room_member_user_ids(self, room_id: str, + start: Optional[str] = None) -> Result: + params: Dict[str, Any] = {} + if start: + params["start"] = start + return http_request( + "GET", f"{LINE_API_BASE}/room/{room_id}/members/ids", + headers={"Authorization": f"Bearer {self._load().channel_access_token}"}, + params=params, expected=(200,), + ) + + def get_room_member_profile(self, room_id: str, user_id: str) -> Result: + return http_request( + "GET", f"{LINE_API_BASE}/room/{room_id}/member/{user_id}", + headers={"Authorization": f"Bearer {self._load().channel_access_token}"}, + expected=(200,), + ) + + def leave_room(self, room_id: str) -> Result: + return http_request( + "POST", f"{LINE_API_BASE}/room/{room_id}/leave", + headers=self._headers(), expected=(200,), + transform=lambda _d: {"left": True, "room_id": room_id}, + ) + + # ================================================================== + # Rich menus + # ================================================================== + + def create_rich_menu(self, rich_menu: Dict[str, Any]) -> Result: + """rich_menu is a RichMenu object: {size, selected, name, chatBarText, areas: [...]}.""" + return http_request( + "POST", f"{LINE_API_BASE}/richmenu", + headers=self._headers(), json=rich_menu, expected=(200,), + ) + + def get_rich_menu(self, rich_menu_id: str) -> Result: + return http_request( + "GET", f"{LINE_API_BASE}/richmenu/{rich_menu_id}", + headers={"Authorization": f"Bearer {self._load().channel_access_token}"}, + expected=(200,), + ) + + def list_rich_menus(self) -> Result: + return http_request( + "GET", f"{LINE_API_BASE}/richmenu/list", + headers={"Authorization": f"Bearer {self._load().channel_access_token}"}, + expected=(200,), + ) + + def delete_rich_menu(self, rich_menu_id: str) -> Result: + return http_request( + "DELETE", f"{LINE_API_BASE}/richmenu/{rich_menu_id}", + headers={"Authorization": f"Bearer {self._load().channel_access_token}"}, + expected=(200,), + transform=lambda _d: {"deleted": True, "rich_menu_id": rich_menu_id}, + ) + + def upload_rich_menu_image(self, rich_menu_id: str, file_path: str) -> Result: + """Upload PNG/JPEG image for a rich menu (data plane). Image must match the rich menu's size.""" + import os + import mimetypes + import httpx + + if not os.path.isfile(file_path): + return {"error": f"File not found: {file_path}"} + mime, _ = mimetypes.guess_type(file_path) + if mime not in ("image/png", "image/jpeg"): + return {"error": f"Unsupported image type {mime}; rich menu images must be PNG or JPEG"} + try: + with open(file_path, "rb") as f: + data = f.read() + r = httpx.post( + f"{LINE_DATA_API_BASE}/richmenu/{rich_menu_id}/content", + headers={ + "Authorization": f"Bearer {self._load().channel_access_token}", + "Content-Type": mime, + }, + content=data, timeout=120.0, + ) + if r.status_code != 200: + return {"error": f"Upload failed: HTTP {r.status_code}", "details": r.text[:500]} + return {"ok": True, "result": {"rich_menu_id": rich_menu_id, "size": len(data)}} + except (httpx.HTTPError, OSError) as e: + return {"error": str(e)} + + def set_default_rich_menu(self, rich_menu_id: str) -> Result: + return http_request( + "POST", f"{LINE_API_BASE}/user/all/richmenu/{rich_menu_id}", + headers={"Authorization": f"Bearer {self._load().channel_access_token}"}, + expected=(200,), + transform=lambda _d: {"set_default": True, "rich_menu_id": rich_menu_id}, + ) + + def get_default_rich_menu(self) -> Result: + return http_request( + "GET", f"{LINE_API_BASE}/user/all/richmenu", + headers={"Authorization": f"Bearer {self._load().channel_access_token}"}, + expected=(200,), + ) + + def cancel_default_rich_menu(self) -> Result: + return http_request( + "DELETE", f"{LINE_API_BASE}/user/all/richmenu", + headers={"Authorization": f"Bearer {self._load().channel_access_token}"}, + expected=(200,), + transform=lambda _d: {"cancelled": True}, + ) + + def link_rich_menu_to_user(self, user_id: str, rich_menu_id: str) -> Result: + return http_request( + "POST", f"{LINE_API_BASE}/user/{user_id}/richmenu/{rich_menu_id}", + headers={"Authorization": f"Bearer {self._load().channel_access_token}"}, + expected=(200,), + transform=lambda _d: {"linked": True, "user_id": user_id, "rich_menu_id": rich_menu_id}, + ) + + def unlink_rich_menu_from_user(self, user_id: str) -> Result: + return http_request( + "DELETE", f"{LINE_API_BASE}/user/{user_id}/richmenu", + headers={"Authorization": f"Bearer {self._load().channel_access_token}"}, + expected=(200,), + transform=lambda _d: {"unlinked": True, "user_id": user_id}, + ) + + def get_user_rich_menu(self, user_id: str) -> Result: + return http_request( + "GET", f"{LINE_API_BASE}/user/{user_id}/richmenu", + headers={"Authorization": f"Bearer {self._load().channel_access_token}"}, + expected=(200,), + ) + + def bulk_link_rich_menu(self, rich_menu_id: str, + user_ids: List[str]) -> Result: + """Link 1+ users (max 500) to a rich menu in one call.""" + return http_request( + "POST", f"{LINE_API_BASE}/richmenu/bulk/link", + headers=self._headers(), + json={"richMenuId": rich_menu_id, "userIds": user_ids}, + expected=(202,), + transform=lambda _d: {"queued": True, "count": len(user_ids)}, + ) + + def bulk_unlink_rich_menu(self, user_ids: List[str]) -> Result: + return http_request( + "POST", f"{LINE_API_BASE}/richmenu/bulk/unlink", + headers=self._headers(), + json={"userIds": user_ids}, + expected=(202,), + transform=lambda _d: {"queued": True, "count": len(user_ids)}, + ) + + # ================================================================== + # Narrowcast + Audiences + # ================================================================== + + def send_narrowcast(self, messages: List[Dict[str, Any]], + recipient: Optional[Dict[str, Any]] = None, + demographic: Optional[Dict[str, Any]] = None, + limit: Optional[Dict[str, Any]] = None, + notification_disabled: Optional[bool] = None) -> Result: + """Send a message to a filtered subset of friends. Returns a request ID; poll with get_narrowcast_progress.""" + cfg = self._config() + nd = cfg.notification_disabled if notification_disabled is None else notification_disabled + payload: Dict[str, Any] = {"messages": messages} + if recipient is not None: payload["recipient"] = recipient + if demographic is not None: payload["filter"] = {"demographic": demographic} + if limit is not None: payload["limit"] = limit + if nd: payload["notificationDisabled"] = True + return http_request( + "POST", f"{LINE_API_BASE}/message/narrowcast", + headers=self._headers(), json=payload, expected=(202,), + transform=lambda d: {"request_id": d.get("requestId") if d else None, + "queued": True}, + ) + + def get_narrowcast_progress(self, request_id: str) -> Result: + return http_request( + "GET", f"{LINE_API_BASE}/message/progress/narrowcast", + headers={"Authorization": f"Bearer {self._load().channel_access_token}"}, + params={"requestId": request_id}, expected=(200,), + ) + + def create_user_id_audience(self, description: str, + audiences: Optional[List[Dict[str, str]]] = None, + is_ifa_audience: bool = False) -> Result: + """Create an audience group from explicit user IDs. audiences: [{id: ''}, ...].""" + payload: Dict[str, Any] = { + "description": description, + "isIfaAudience": is_ifa_audience, + } + if audiences: + payload["audiences"] = audiences + return http_request( + "POST", "https://api.line.me/v2/bot/audienceGroup/upload", + headers=self._headers(), json=payload, expected=(200,), + ) + + def update_audience_description(self, audience_group_id: int, + description: str) -> Result: + return http_request( + "PUT", f"{LINE_API_BASE}/audienceGroup/{audience_group_id}/updateDescription", + headers=self._headers(), + json={"description": description}, expected=(200,), + transform=lambda _d: {"updated": True, "audience_group_id": audience_group_id}, + ) + + def delete_audience(self, audience_group_id: int) -> Result: + return http_request( + "DELETE", f"{LINE_API_BASE}/audienceGroup/{audience_group_id}", + headers={"Authorization": f"Bearer {self._load().channel_access_token}"}, + expected=(200,), + transform=lambda _d: {"deleted": True, "audience_group_id": audience_group_id}, + ) + + def get_audience(self, audience_group_id: int) -> Result: + return http_request( + "GET", f"{LINE_API_BASE}/audienceGroup/{audience_group_id}", + headers={"Authorization": f"Bearer {self._load().channel_access_token}"}, + expected=(200,), + ) + + def list_audiences(self, page: int = 1, size: int = 20, + description: Optional[str] = None, + status: Optional[str] = None) -> Result: + params: Dict[str, Any] = {"page": page, "size": min(size, 40)} + if description: params["description"] = description + if status: params["status"] = status + return http_request( + "GET", f"{LINE_API_BASE}/audienceGroup/list", + headers={"Authorization": f"Bearer {self._load().channel_access_token}"}, + params=params, expected=(200,), + ) + + # ================================================================== + # Insights + # ================================================================== + + def get_number_of_followers(self, date: str) -> Result: + """date: YYYYMMDD.""" + return http_request( + "GET", f"{LINE_API_BASE}/insight/followers", + headers={"Authorization": f"Bearer {self._load().channel_access_token}"}, + params={"date": date}, expected=(200,), + ) + + def get_friend_demographics(self) -> Result: + return http_request( + "GET", f"{LINE_API_BASE}/insight/demographic", + headers={"Authorization": f"Bearer {self._load().channel_access_token}"}, + expected=(200,), + ) + + def get_message_delivery_stats(self, date: str) -> Result: + """Number of pushes/multicasts/broadcasts sent on a date (YYYYMMDD).""" + return http_request( + "GET", f"{LINE_API_BASE}/insight/message/delivery", + headers={"Authorization": f"Bearer {self._load().channel_access_token}"}, + params={"date": date}, expected=(200,), + ) + + def get_message_event_stats(self, request_id: str) -> Result: + """Per-narrowcast/broadcast/multicast click/impression/open stats.""" + return http_request( + "GET", f"{LINE_API_BASE}/insight/message/event", + headers={"Authorization": f"Bearer {self._load().channel_access_token}"}, + params={"requestId": request_id}, expected=(200,), + ) + + def get_user_interaction_stats(self, request_id: str) -> Result: + return http_request( + "GET", f"{LINE_API_BASE}/insight/message/event/aggregation", + headers={"Authorization": f"Bearer {self._load().channel_access_token}"}, + params={"customAggregationUnit": request_id}, expected=(200,), + ) + + # ================================================================== + # Webhook + channel-token admin + # ================================================================== + + def set_webhook_endpoint(self, endpoint: str) -> Result: + return http_request( + "PUT", f"{LINE_API_BASE}/channel/webhook/endpoint", + headers=self._headers(), + json={"endpoint": endpoint}, expected=(200,), + transform=lambda _d: {"endpoint": endpoint, "updated": True}, + ) + + def get_webhook_endpoint(self) -> Result: + return http_request( + "GET", f"{LINE_API_BASE}/channel/webhook/endpoint", + headers={"Authorization": f"Bearer {self._load().channel_access_token}"}, + expected=(200,), + ) + + def test_webhook_endpoint(self, endpoint: Optional[str] = None) -> Result: + payload: Dict[str, Any] = {} + if endpoint: payload["endpoint"] = endpoint + return http_request( + "POST", f"{LINE_API_BASE}/channel/webhook/test", + headers=self._headers(), json=payload, expected=(200,), + ) + + def issue_channel_access_token(self, channel_id: str, + channel_secret: str) -> Result: + """Issue a short-lived access token (v2.1) — useful for rotating credentials.""" + return http_request( + "POST", f"{LINE_OAUTH_API_BASE}/token", + data={"grant_type": "client_credentials", + "client_id": channel_id, "client_secret": channel_secret}, + expected=(200,), + ) + + def revoke_channel_access_token(self, access_token: str) -> Result: + return http_request( + "POST", f"{LINE_OAUTH_API_BASE}/revoke", + data={"access_token": access_token}, + expected=(200,), + transform=lambda _d: {"revoked": True}, + ) + + def verify_access_token(self, access_token: str) -> Result: + return http_request( + "GET", f"{LINE_OAUTH_API_BASE}/verify", + params={"access_token": access_token}, expected=(200,), + ) diff --git a/craftos_integrations/integrations/notion/__init__.py b/craftos_integrations/integrations/notion/__init__.py index 03264cbd..18fe2e89 100644 --- a/craftos_integrations/integrations/notion/__init__.py +++ b/craftos_integrations/integrations/notion/__init__.py @@ -267,3 +267,239 @@ def delete_block(self, block_id: str) -> Dict[str, Any]: def get_user(self, user_id: str = "me") -> Dict[str, Any]: return _notion_call("GET", f"/users/{user_id}", self._headers()) + + # ----- Pages (extended) ----- + + def archive_page(self, page_id: str) -> Dict[str, Any]: + return _notion_call( + "PATCH", f"/pages/{page_id}", + self._headers(), json={"archived": True}, + ) + + def restore_page(self, page_id: str) -> Dict[str, Any]: + return _notion_call( + "PATCH", f"/pages/{page_id}", + self._headers(), json={"archived": False}, + ) + + def get_page_property(self, page_id: str, property_id: str, + page_size: int = 100) -> Dict[str, Any]: + return _notion_call( + "GET", f"/pages/{page_id}/properties/{property_id}", + self._headers(), params={"page_size": page_size}, + ) + + # ----- Databases (extended) ----- + + def create_database(self, parent_page_id: str, + title: Optional[List[Dict[str, Any]]] = None, + description: Optional[List[Dict[str, Any]]] = None, + properties: Optional[Dict[str, Any]] = None, + is_inline: bool = False, + icon: Optional[Dict[str, Any]] = None, + cover: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """Create a database under a parent page. + + Note: per the 2025 update, the database schema moved under + ``initial_data_source.properties``. We accept ``properties`` at the + method surface and wrap it for the agent. + """ + payload: Dict[str, Any] = { + "parent": {"page_id": parent_page_id}, + "is_inline": is_inline, + } + if title is not None: payload["title"] = title + if description is not None: payload["description"] = description + if properties is not None: + payload["initial_data_source"] = {"properties": properties} + if icon is not None: payload["icon"] = icon + if cover is not None: payload["cover"] = cover + return _notion_call("POST", "/databases", self._headers(), json=payload) + + def update_database(self, database_id: str, + title: Optional[List[Dict[str, Any]]] = None, + description: Optional[List[Dict[str, Any]]] = None, + properties: Optional[Dict[str, Any]] = None, + is_inline: Optional[bool] = None, + archived: Optional[bool] = None) -> Dict[str, Any]: + payload: Dict[str, Any] = {} + if title is not None: payload["title"] = title + if description is not None: payload["description"] = description + if properties is not None: payload["properties"] = properties + if is_inline is not None: payload["is_inline"] = is_inline + if archived is not None: payload["archived"] = archived + return _notion_call("PATCH", f"/databases/{database_id}", + self._headers(), json=payload) + + def archive_database(self, database_id: str) -> Dict[str, Any]: + return self.update_database(database_id, archived=True) + + def restore_database(self, database_id: str) -> Dict[str, Any]: + return self.update_database(database_id, archived=False) + + # ----- Blocks (extended) ----- + + def get_block(self, block_id: str) -> Dict[str, Any]: + return _notion_call("GET", f"/blocks/{block_id}", self._headers()) + + def update_block(self, block_id: str, block_update: Dict[str, Any]) -> Dict[str, Any]: + """Update a block. block_update has per-block-type keys, e.g. + for a to_do block: {"to_do": {"rich_text": [...], "checked": true}}. + Pass {"in_trash": true} to soft-delete (or use delete_block). + """ + return _notion_call("PATCH", f"/blocks/{block_id}", + self._headers(), json=block_update) + + # ----- Comments ----- + + def list_comments(self, block_id: str, page_size: int = 100, + start_cursor: Optional[str] = None) -> Dict[str, Any]: + params: Dict[str, Any] = {"block_id": block_id, "page_size": page_size} + if start_cursor: + params["start_cursor"] = start_cursor + return _notion_call("GET", "/comments", self._headers(), params=params) + + def create_comment(self, rich_text: List[Dict[str, Any]], + parent_page_id: Optional[str] = None, + parent_block_id: Optional[str] = None, + discussion_id: Optional[str] = None, + display_name: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """Create a top-level comment on a page/block, or a reply in a discussion. + + Provide exactly one of: parent_page_id, parent_block_id, discussion_id. + """ + payload: Dict[str, Any] = {"rich_text": rich_text} + targets = [t for t in (parent_page_id, parent_block_id, discussion_id) if t] + if len(targets) != 1: + return {"error": {"validation": "Provide exactly one of parent_page_id, parent_block_id, discussion_id"}} + if parent_page_id: + payload["parent"] = {"page_id": parent_page_id} + elif parent_block_id: + payload["parent"] = {"block_id": parent_block_id} + else: + payload["discussion_id"] = discussion_id + if display_name is not None: + payload["display_name"] = display_name + return _notion_call("POST", "/comments", self._headers(), json=payload) + + # ----- Users (extended) ----- + + def list_users(self, page_size: int = 100, + start_cursor: Optional[str] = None) -> Dict[str, Any]: + params: Dict[str, Any] = {"page_size": page_size} + if start_cursor: + params["start_cursor"] = start_cursor + return _notion_call("GET", "/users", self._headers(), params=params) + + def get_bot_info(self) -> Dict[str, Any]: + """Returns the bot user including workspace_name + owner info.""" + return _notion_call("GET", "/users/me", self._headers()) + + # ----- File uploads ----- + + def create_file_upload(self, mode: str = "single_part", + filename: Optional[str] = None, + content_type: Optional[str] = None, + number_of_parts: Optional[int] = None, + external_url: Optional[str] = None) -> Dict[str, Any]: + """Initialise a file upload. Returns id + upload_url (for pending uploads). + + mode: single_part | multi_part | external_url + filename: required when mode=multi_part + number_of_parts: required when mode=multi_part + external_url: required when mode=external_url + """ + payload: Dict[str, Any] = {"mode": mode} + if filename is not None: payload["filename"] = filename + if content_type is not None: payload["content_type"] = content_type + if number_of_parts is not None: payload["number_of_parts"] = number_of_parts + if external_url is not None: payload["external_url"] = external_url + return _notion_call("POST", "/file_uploads", self._headers(), json=payload) + + def send_file_upload(self, file_upload_id: str, file_path: str, + part_number: Optional[int] = None) -> Dict[str, Any]: + """Send a single-part or one part of a multi-part upload. + + Uses multipart/form-data — bypasses the JSON helper. + """ + import os + import httpx + + file_path = os.path.abspath(file_path) + if not os.path.isfile(file_path): + return {"error": {"validation": f"File not found: {file_path}"}} + + cred = self._load() + headers = { + "Authorization": f"Bearer {cred.token}", + "Notion-Version": NOTION_VERSION, + } + try: + with open(file_path, "rb") as f: + files = {"file": (os.path.basename(file_path), f)} + data: Dict[str, Any] = {} + if part_number is not None: + data["part_number"] = str(part_number) + r = httpx.post( + f"{NOTION_API_BASE}/file_uploads/{file_upload_id}/send", + headers=headers, files=files, data=data, timeout=300.0, + ) + if r.status_code != 200: + try: + return {"error": r.json()} + except Exception: + return {"error": {"http": r.status_code, "details": r.text[:500]}} + return r.json() + except Exception as e: + return {"error": {"exception": str(e)}} + + def complete_file_upload(self, file_upload_id: str) -> Dict[str, Any]: + """Finalize a multi-part upload.""" + return _notion_call( + "POST", f"/file_uploads/{file_upload_id}/complete", + self._headers(), + ) + + def get_file_upload(self, file_upload_id: str) -> Dict[str, Any]: + return _notion_call( + "GET", f"/file_uploads/{file_upload_id}", self._headers(), + ) + + def list_file_uploads(self, status: Optional[str] = None, + page_size: int = 100, + start_cursor: Optional[str] = None) -> Dict[str, Any]: + params: Dict[str, Any] = {"page_size": page_size} + if status: params["status"] = status + if start_cursor: params["start_cursor"] = start_cursor + return _notion_call("GET", "/file_uploads", self._headers(), params=params) + + def upload_local_file(self, file_path: str, + content_type: Optional[str] = None) -> Dict[str, Any]: + """High-level helper: single-part upload of a local file in one call. + + Returns the final file_upload object (with id + status='uploaded') that + can be attached to a block via {"type":"file_upload","file_upload":{"id":...}}. + For files >20 MB use multi-part directly. + """ + import os + import mimetypes + + file_path = os.path.abspath(file_path) + if not os.path.isfile(file_path): + return {"error": {"validation": f"File not found: {file_path}"}} + if not content_type: + content_type, _ = mimetypes.guess_type(file_path) + if not content_type: + content_type = "application/octet-stream" + + created = self.create_file_upload( + mode="single_part", + filename=os.path.basename(file_path), + content_type=content_type, + ) + if "error" in created: + return created + upload_id = created.get("id") + if not upload_id: + return {"error": {"validation": "create_file_upload returned no id"}} + return self.send_file_upload(upload_id, file_path) diff --git a/craftos_integrations/integrations/outlook/__init__.py b/craftos_integrations/integrations/outlook/__init__.py index 09e5d7da..75f2aee0 100644 --- a/craftos_integrations/integrations/outlook/__init__.py +++ b/craftos_integrations/integrations/outlook/__init__.py @@ -457,3 +457,449 @@ def read_top_emails(self, n: int = 5, full_body: bool = False) -> Result: detail.get("result", e_info) if "error" not in detail else e_info ) return {"ok": True, "result": detailed} + + # ----- Helper: build a Recipient list payload ----- + + @staticmethod + def _recipients(addresses: Optional[List[str]]) -> List[Dict[str, Any]]: + if not addresses: + return [] + return [{"emailAddress": {"address": a.strip()}} for a in addresses if a and a.strip()] + + # ----- Message lifecycle: reply / forward / move / copy / delete / flag ----- + + def reply_to_message(self, message_id: str, comment: str, + to_recipients: Optional[List[str]] = None) -> Result: + """Send a reply to the sender immediately. Returns 202.""" + payload: Dict[str, Any] = {"comment": comment} + if to_recipients: + payload["message"] = {"toRecipients": self._recipients(to_recipients)} + return http_request( + "POST", f"{GRAPH_API_BASE}/me/messages/{message_id}/reply", + headers=self._headers(), json=payload, expected=(202,), + transform=lambda _d: {"replied": True, "message_id": message_id}, + ) + + def reply_all_to_message(self, message_id: str, comment: str) -> Result: + return http_request( + "POST", f"{GRAPH_API_BASE}/me/messages/{message_id}/replyAll", + headers=self._headers(), json={"comment": comment}, expected=(202,), + transform=lambda _d: {"replied_all": True, "message_id": message_id}, + ) + + def forward_message(self, message_id: str, to_recipients: List[str], + comment: str = "") -> Result: + return http_request( + "POST", f"{GRAPH_API_BASE}/me/messages/{message_id}/forward", + headers=self._headers(), + json={"comment": comment, "toRecipients": self._recipients(to_recipients)}, + expected=(202,), + transform=lambda _d: {"forwarded": True, "message_id": message_id, "to": to_recipients}, + ) + + def create_reply_draft(self, message_id: str, comment: str = "") -> Result: + """Create a draft pre-populated as a reply; returns the draft so it can be edited then sent.""" + payload: Dict[str, Any] = {} + if comment: + payload["comment"] = comment + return http_request( + "POST", f"{GRAPH_API_BASE}/me/messages/{message_id}/createReply", + headers=self._headers(), json=payload, expected=(201,), + transform=lambda d: {"draft_id": d.get("id"), "conversationId": d.get("conversationId")}, + ) + + def create_forward_draft(self, message_id: str, to_recipients: List[str], + comment: str = "") -> Result: + payload: Dict[str, Any] = { + "comment": comment, + "toRecipients": self._recipients(to_recipients), + } + return http_request( + "POST", f"{GRAPH_API_BASE}/me/messages/{message_id}/createForward", + headers=self._headers(), json=payload, expected=(201,), + transform=lambda d: {"draft_id": d.get("id"), "conversationId": d.get("conversationId")}, + ) + + def create_draft(self, subject: str, body: str, to: Optional[List[str]] = None, + cc: Optional[List[str]] = None, bcc: Optional[List[str]] = None, + html: bool = False) -> Result: + """Create a draft message. POST /me/messages returns 201 + draft resource.""" + message: Dict[str, Any] = { + "subject": subject, + "body": {"contentType": "HTML" if html else "Text", "content": body}, + } + if to: message["toRecipients"] = self._recipients(to) + if cc: message["ccRecipients"] = self._recipients(cc) + if bcc: message["bccRecipients"] = self._recipients(bcc) + return http_request( + "POST", f"{GRAPH_API_BASE}/me/messages", + headers=self._headers(), json=message, expected=(201,), + transform=lambda d: {"draft_id": d.get("id"), "subject": d.get("subject"), + "conversationId": d.get("conversationId")}, + ) + + def update_draft(self, message_id: str, subject: Optional[str] = None, + body: Optional[str] = None, html: bool = False, + to: Optional[List[str]] = None, cc: Optional[List[str]] = None, + bcc: Optional[List[str]] = None) -> Result: + payload: Dict[str, Any] = {} + if subject is not None: payload["subject"] = subject + if body is not None: + payload["body"] = {"contentType": "HTML" if html else "Text", "content": body} + if to is not None: payload["toRecipients"] = self._recipients(to) + if cc is not None: payload["ccRecipients"] = self._recipients(cc) + if bcc is not None: payload["bccRecipients"] = self._recipients(bcc) + return http_request( + "PATCH", f"{GRAPH_API_BASE}/me/messages/{message_id}", + headers=self._headers(), json=payload, expected=(200,), + transform=lambda d: {"id": d.get("id"), "subject": d.get("subject")}, + ) + + def send_draft(self, message_id: str) -> Result: + """Send an existing draft. Returns 202.""" + return http_request( + "POST", f"{GRAPH_API_BASE}/me/messages/{message_id}/send", + headers=self._headers(), expected=(202,), + transform=lambda _d: {"sent": True, "message_id": message_id}, + ) + + def delete_message(self, message_id: str) -> Result: + return http_request( + "DELETE", f"{GRAPH_API_BASE}/me/messages/{message_id}", + headers=self._auth_header(), expected=(204,), + transform=lambda _d: {"deleted": True, "message_id": message_id}, + ) + + def move_message(self, message_id: str, destination_folder_id: str) -> Result: + """Move a message to a folder. destination_folder_id can be a well-known name (inbox, drafts, sentitems, deleteditems, archive, junkemail) or a custom folder ID. Returns 201.""" + return http_request( + "POST", f"{GRAPH_API_BASE}/me/messages/{message_id}/move", + headers=self._headers(), json={"destinationId": destination_folder_id}, + expected=(201,), + transform=lambda d: {"moved": True, "new_id": d.get("id"), "parent_folder_id": d.get("parentFolderId")}, + ) + + def copy_message(self, message_id: str, destination_folder_id: str) -> Result: + return http_request( + "POST", f"{GRAPH_API_BASE}/me/messages/{message_id}/copy", + headers=self._headers(), json={"destinationId": destination_folder_id}, + expected=(201,), + transform=lambda d: {"copied": True, "new_id": d.get("id")}, + ) + + def mark_as_unread(self, message_id: str) -> Result: + return http_request( + "PATCH", f"{GRAPH_API_BASE}/me/messages/{message_id}", + headers=self._headers(), json={"isRead": False}, expected=(200,), + transform=lambda _d: {"marked_unread": True, "message_id": message_id}, + ) + + def flag_message(self, message_id: str, flag_status: str = "flagged") -> Result: + """flag_status: notFlagged | complete | flagged.""" + return http_request( + "PATCH", f"{GRAPH_API_BASE}/me/messages/{message_id}", + headers=self._headers(), + json={"flag": {"flagStatus": flag_status}}, expected=(200,), + transform=lambda _d: {"flag_status": flag_status, "message_id": message_id}, + ) + + def set_message_categories(self, message_id: str, categories: List[str]) -> Result: + return http_request( + "PATCH", f"{GRAPH_API_BASE}/me/messages/{message_id}", + headers=self._headers(), + json={"categories": categories}, expected=(200,), + transform=lambda _d: {"categories": categories, "message_id": message_id}, + ) + + def search_messages(self, query: str, top: int = 25, + folder: Optional[str] = None) -> Result: + """OData $search across messages (subject, body, attachments). Sorted by relevance.""" + url = (f"{GRAPH_API_BASE}/me/mailFolders/{folder}/messages" + if folder else f"{GRAPH_API_BASE}/me/messages") + return http_request( + "GET", url, + headers=self._auth_header(), + params={ + "$search": f'"{query}"', + "$top": top, + "$select": "id,from,subject,bodyPreview,receivedDateTime,isRead", + }, + expected=(200,), + transform=lambda d: {"results": [ + { + "id": m.get("id"), + "from": (m.get("from") or {}).get("emailAddress", {}).get("address", ""), + "subject": m.get("subject", ""), + "received": m.get("receivedDateTime", ""), + "preview": m.get("bodyPreview", ""), + "is_read": m.get("isRead", False), + } + for m in d.get("value", []) + ]}, + ) + + # ----- Attachments ----- + + def list_attachments(self, message_id: str) -> Result: + return http_request( + "GET", f"{GRAPH_API_BASE}/me/messages/{message_id}/attachments", + headers=self._auth_header(), + params={"$select": "id,name,contentType,size,isInline"}, + expected=(200,), + transform=lambda d: {"attachments": [ + {"id": a.get("id"), "name": a.get("name"), + "contentType": a.get("contentType"), "size": a.get("size"), + "is_inline": a.get("isInline", False)} + for a in d.get("value", []) + ]}, + ) + + def get_attachment(self, message_id: str, attachment_id: str) -> Result: + return http_request( + "GET", f"{GRAPH_API_BASE}/me/messages/{message_id}/attachments/{attachment_id}", + headers=self._auth_header(), expected=(200,), + ) + + def download_attachment(self, message_id: str, attachment_id: str, + save_to: str) -> Result: + """Download an attachment to a local path. Decodes contentBytes (base64).""" + import os + import base64 + + meta = self.get_attachment(message_id, attachment_id) + if "error" in meta: + return meta + data = meta["result"] + content_b64 = data.get("contentBytes") + if not content_b64: + return {"error": "Attachment has no contentBytes (may be itemAttachment or referenceAttachment, not fileAttachment)"} + try: + save_to = os.path.abspath(save_to) + parent = os.path.dirname(save_to) + if parent: + os.makedirs(parent, exist_ok=True) + with open(save_to, "wb") as f: + f.write(base64.b64decode(content_b64)) + return {"ok": True, "result": {"saved_to": save_to, "size": os.path.getsize(save_to)}} + except Exception as e: + return {"error": str(e)} + + def add_attachment(self, message_id: str, file_path: str, + content_type: Optional[str] = None) -> Result: + """Attach a local file to a DRAFT message (under 3 MB; large files need session upload).""" + import os + import base64 + import mimetypes + + file_path = os.path.abspath(file_path) + if not os.path.isfile(file_path): + return {"error": f"File not found: {file_path}"} + if not content_type: + content_type, _ = mimetypes.guess_type(file_path) + if not content_type: + content_type = "application/octet-stream" + + with open(file_path, "rb") as f: + content = base64.b64encode(f.read()).decode("ascii") + + payload = { + "@odata.type": "#microsoft.graph.fileAttachment", + "name": os.path.basename(file_path), + "contentType": content_type, + "contentBytes": content, + } + return http_request( + "POST", f"{GRAPH_API_BASE}/me/messages/{message_id}/attachments", + headers=self._headers(), json=payload, expected=(201,), + transform=lambda d: {"id": d.get("id"), "name": d.get("name"), "size": d.get("size")}, + ) + + def delete_attachment(self, message_id: str, attachment_id: str) -> Result: + return http_request( + "DELETE", f"{GRAPH_API_BASE}/me/messages/{message_id}/attachments/{attachment_id}", + headers=self._auth_header(), expected=(204,), + transform=lambda _d: {"deleted": True, "attachment_id": attachment_id}, + ) + + # ----- Folders (MailFolder CRUD + traversal) ----- + + def get_folder(self, folder_id: str) -> Result: + return http_request( + "GET", f"{GRAPH_API_BASE}/me/mailFolders/{folder_id}", + headers=self._auth_header(), expected=(200,), + transform=lambda d: { + "id": d.get("id"), "name": d.get("displayName"), + "parentFolderId": d.get("parentFolderId"), + "total": d.get("totalItemCount"), "unread": d.get("unreadItemCount"), + }, + ) + + def create_folder(self, display_name: str, + parent_folder_id: str = "msgfolderroot") -> Result: + return http_request( + "POST", f"{GRAPH_API_BASE}/me/mailFolders/{parent_folder_id}/childFolders", + headers=self._headers(), + json={"displayName": display_name}, expected=(201,), + transform=lambda d: {"id": d.get("id"), "name": d.get("displayName")}, + ) + + def update_folder(self, folder_id: str, display_name: str) -> Result: + return http_request( + "PATCH", f"{GRAPH_API_BASE}/me/mailFolders/{folder_id}", + headers=self._headers(), + json={"displayName": display_name}, expected=(200,), + transform=lambda d: {"id": d.get("id"), "name": d.get("displayName")}, + ) + + def delete_folder(self, folder_id: str) -> Result: + return http_request( + "DELETE", f"{GRAPH_API_BASE}/me/mailFolders/{folder_id}", + headers=self._auth_header(), expected=(204,), + transform=lambda _d: {"deleted": True, "folder_id": folder_id}, + ) + + def list_child_folders(self, folder_id: str = "msgfolderroot") -> Result: + return http_request( + "GET", f"{GRAPH_API_BASE}/me/mailFolders/{folder_id}/childFolders", + headers=self._auth_header(), + params={"$select": "id,displayName,totalItemCount,unreadItemCount"}, + expected=(200,), + transform=lambda d: {"folders": [ + {"id": f.get("id"), "name": f.get("displayName"), + "total": f.get("totalItemCount"), "unread": f.get("unreadItemCount")} + for f in d.get("value", []) + ]}, + ) + + def list_folder_messages(self, folder_id: str, n: int = 25, + unread_only: bool = False) -> Result: + params: Dict[str, Any] = { + "$top": n, "$orderby": "receivedDateTime desc", + "$select": "id,from,subject,receivedDateTime,isRead,bodyPreview", + } + if unread_only: + params["$filter"] = "isRead eq false" + return http_request( + "GET", f"{GRAPH_API_BASE}/me/mailFolders/{folder_id}/messages", + headers=self._auth_header(), params=params, expected=(200,), + transform=lambda d: {"messages": [ + { + "id": m.get("id"), + "from": (m.get("from") or {}).get("emailAddress", {}).get("address", ""), + "subject": m.get("subject", ""), + "received": m.get("receivedDateTime", ""), + "is_read": m.get("isRead", False), + "preview": m.get("bodyPreview", ""), + } + for m in d.get("value", []) + ]}, + ) + + # ----- Mailbox settings (out-of-office, timezone, locale) ----- + + def get_mailbox_settings(self) -> Result: + return http_request( + "GET", f"{GRAPH_API_BASE}/me/mailboxSettings", + headers=self._auth_header(), expected=(200,), + ) + + def update_mailbox_settings(self, settings: Dict[str, Any]) -> Result: + return http_request( + "PATCH", f"{GRAPH_API_BASE}/me/mailboxSettings", + headers=self._headers(), json=settings, expected=(200,), + ) + + def get_automatic_replies(self) -> Result: + return http_request( + "GET", f"{GRAPH_API_BASE}/me/mailboxSettings/automaticRepliesSetting", + headers=self._auth_header(), expected=(200,), + ) + + def update_automatic_replies(self, status: str, + internal_reply: Optional[str] = None, + external_reply: Optional[str] = None, + external_audience: str = "all", + scheduled_start: Optional[str] = None, + scheduled_end: Optional[str] = None) -> Result: + """status: disabled | alwaysEnabled | scheduled. external_audience: none|contactsOnly|all.""" + payload: Dict[str, Any] = { + "automaticRepliesSetting": { + "status": status, + "externalAudience": external_audience, + } + } + ars = payload["automaticRepliesSetting"] + if internal_reply is not None: ars["internalReplyMessage"] = internal_reply + if external_reply is not None: ars["externalReplyMessage"] = external_reply + if scheduled_start and scheduled_end: + ars["scheduledStartDateTime"] = {"dateTime": scheduled_start, "timeZone": "UTC"} + ars["scheduledEndDateTime"] = {"dateTime": scheduled_end, "timeZone": "UTC"} + return http_request( + "PATCH", f"{GRAPH_API_BASE}/me/mailboxSettings", + headers=self._headers(), json=payload, expected=(200,), + transform=lambda d: {"status": d.get("automaticRepliesSetting", {}).get("status")}, + ) + + # ----- Inbox rules ----- + + def list_inbox_rules(self) -> Result: + return http_request( + "GET", f"{GRAPH_API_BASE}/me/mailFolders/inbox/messageRules", + headers=self._auth_header(), expected=(200,), + transform=lambda d: {"rules": [ + {"id": r.get("id"), "name": r.get("displayName"), + "sequence": r.get("sequence"), "enabled": r.get("isEnabled")} + for r in d.get("value", []) + ]}, + ) + + def create_inbox_rule(self, display_name: str, conditions: Dict[str, Any], + actions: Dict[str, Any], sequence: int = 1, + is_enabled: bool = True) -> Result: + payload = { + "displayName": display_name, + "sequence": sequence, + "isEnabled": is_enabled, + "conditions": conditions, + "actions": actions, + } + return http_request( + "POST", f"{GRAPH_API_BASE}/me/mailFolders/inbox/messageRules", + headers=self._headers(), json=payload, expected=(201,), + transform=lambda d: {"id": d.get("id"), "name": d.get("displayName")}, + ) + + def delete_inbox_rule(self, rule_id: str) -> Result: + return http_request( + "DELETE", f"{GRAPH_API_BASE}/me/mailFolders/inbox/messageRules/{rule_id}", + headers=self._auth_header(), expected=(204,), + transform=lambda _d: {"deleted": True, "rule_id": rule_id}, + ) + + # ----- Categories (Outlook master categories) ----- + + def list_categories(self) -> Result: + return http_request( + "GET", f"{GRAPH_API_BASE}/me/outlook/masterCategories", + headers=self._auth_header(), expected=(200,), + transform=lambda d: {"categories": [ + {"id": c.get("id"), "displayName": c.get("displayName"), "color": c.get("color")} + for c in d.get("value", []) + ]}, + ) + + def create_category(self, display_name: str, color: str = "preset0") -> Result: + """color: preset0..preset24 (see Microsoft Graph categoryColor enum).""" + return http_request( + "POST", f"{GRAPH_API_BASE}/me/outlook/masterCategories", + headers=self._headers(), + json={"displayName": display_name, "color": color}, expected=(201,), + transform=lambda d: {"id": d.get("id"), "displayName": d.get("displayName"), "color": d.get("color")}, + ) + + def delete_category(self, category_id: str) -> Result: + return http_request( + "DELETE", f"{GRAPH_API_BASE}/me/outlook/masterCategories/{category_id}", + headers=self._auth_header(), expected=(204,), + transform=lambda _d: {"deleted": True, "category_id": category_id}, + ) diff --git a/craftos_integrations/integrations/slack/__init__.py b/craftos_integrations/integrations/slack/__init__.py index 952423e6..8db8c9b9 100644 --- a/craftos_integrations/integrations/slack/__init__.py +++ b/craftos_integrations/integrations/slack/__init__.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8 -*- """Slack integration - handler (token + OAuth invite) + client (poll listener).""" - from __future__ import annotations import asyncio @@ -48,32 +47,16 @@ def _shape_slack(result: Dict[str, Any]) -> Dict[str, Any]: return body -def _slack_call( - method: str, path: str, headers: Dict[str, str], **kw -) -> Dict[str, Any]: - return _shape_slack( - http_request( - method, - f"{SLACK_API_BASE}/{path}", - headers=headers, - expected=(200,), - **kw, - ) - ) +def _slack_call(method: str, path: str, headers: Dict[str, str], **kw) -> Dict[str, Any]: + return _shape_slack(http_request( + method, f"{SLACK_API_BASE}/{path}", headers=headers, expected=(200,), **kw, + )) -async def _slack_acall( - method: str, path: str, headers: Dict[str, str], **kw -) -> Dict[str, Any]: - return _shape_slack( - await arequest( - method, - f"{SLACK_API_BASE}/{path}", - headers=headers, - expected=(200,), - **kw, - ) - ) +async def _slack_acall(method: str, path: str, headers: Dict[str, str], **kw) -> Dict[str, Any]: + return _shape_slack(await arequest( + method, f"{SLACK_API_BASE}/{path}", headers=headers, expected=(200,), **kw, + )) @dataclass @@ -95,7 +78,6 @@ class SlackCredential: # Handler # ----------------------------------------------------------------- - @register_handler(SLACK.name) class SlackHandler(IntegrationHandler): spec = SLACK @@ -111,19 +93,8 @@ class SlackHandler(IntegrationHandler): "Click 'Install to Workspace' at the top, then copy the 'Bot User OAuth Token' (xoxb-...)", ] fields = [ - { - "key": "bot_token", - "label": "Bot Token", - "placeholder": "xoxb-...", - "password": True, - }, - { - "key": "workspace_name", - "label": "Workspace Name (optional)", - "placeholder": "My Workspace", - "password": False, - "optional": True, - }, + {"key": "bot_token", "label": "Bot Token", "placeholder": "xoxb-...", "password": True}, + {"key": "workspace_name", "label": "Workspace Name (optional)", "placeholder": "My Workspace", "password": False, "optional": True}, ] oauth = OAuthFlow( @@ -154,14 +125,9 @@ async def invite(self, args: List[str]) -> Tuple[bool, str]: team_id = team.get("id", "") team_name = team.get("name", team_id) - save_credential( - self.spec.cred_file, - SlackCredential( - bot_token=bot_token, - workspace_id=team_id, - team_name=team_name, - ), - ) + save_credential(self.spec.cred_file, SlackCredential( + bot_token=bot_token, workspace_id=team_id, team_name=team_name, + )) return True, f"Slack connected via CraftOS app: {team_name} ({team_id})" async def login(self, args: List[str]) -> Tuple[bool, str]: @@ -171,22 +137,15 @@ async def login(self, args: List[str]) -> Tuple[bool, str]: if not bot_token.startswith(("xoxb-", "xoxp-")): return False, "Invalid token. Expected xoxb-... or xoxp-..." - result = _slack_call( - "POST", "auth.test", {"Authorization": f"Bearer {bot_token}"} - ) + result = _slack_call("POST", "auth.test", {"Authorization": f"Bearer {bot_token}"}) if "error" in result: return False, f"Slack auth failed: {result['error']}" team_id = result.get("team_id", "") workspace_name = args[1] if len(args) > 1 else result.get("team", team_id) - save_credential( - self.spec.cred_file, - SlackCredential( - bot_token=bot_token, - workspace_id=team_id, - team_name=workspace_name, - ), - ) + save_credential(self.spec.cred_file, SlackCredential( + bot_token=bot_token, workspace_id=team_id, team_name=workspace_name, + )) return True, f"Slack connected: {workspace_name} ({team_id})" async def logout(self, args: List[str]) -> Tuple[bool, str]: @@ -207,7 +166,6 @@ async def status(self) -> Tuple[bool, str]: # Client # ----------------------------------------------------------------- - @register_client class SlackClient(BasePlatformClient): spec = SLACK @@ -233,10 +191,7 @@ def _load(self) -> SlackCredential: def _headers(self) -> Dict[str, str]: cred = self._load() - return { - "Authorization": f"Bearer {cred.bot_token}", - "Content-Type": "application/json", - } + return {"Authorization": f"Bearer {cred.bot_token}", "Content-Type": "application/json"} async def connect(self) -> None: self._load() @@ -252,9 +207,7 @@ async def start_listening(self, callback) -> None: self._message_callback = callback cred = self._load() - data = await _slack_acall( - "POST", "auth.test", {"Authorization": f"Bearer {cred.bot_token}"} - ) + data = await _slack_acall("POST", "auth.test", {"Authorization": f"Bearer {cred.bot_token}"}) if "error" in data: raise RuntimeError(f"Invalid Slack token: {data['error']}") self._bot_user_id = data.get("user_id") @@ -300,16 +253,10 @@ async def _get_joined_channels(self) -> List[Dict[str, Any]]: for ch_type in ("public_channel,private_channel", "mpim,im"): cursor = None while True: - params: Dict[str, Any] = { - "types": ch_type, - "exclude_archived": True, - "limit": 200, - } + params: Dict[str, Any] = {"types": ch_type, "exclude_archived": True, "limit": 200} if cursor: params["cursor"] = cursor - data = await _slack_acall( - "GET", "conversations.list", self._headers(), params=params - ) + data = await _slack_acall("GET", "conversations.list", self._headers(), params=params) if "error" in data: break for ch in data.get("channels", []): @@ -339,9 +286,7 @@ async def _poll_channels(self) -> None: for ch_id, oldest_ts in list(self._last_timestamps.items()): try: data = await _slack_acall( - "GET", - "conversations.history", - self._headers(), + "GET", "conversations.history", self._headers(), params={"channel": ch_id, "oldest": oldest_ts, "limit": 50}, ) if "error" in data: @@ -374,30 +319,24 @@ async def _process_message(self, msg: Dict[str, Any], channel_id: str) -> None: info = self.get_user_info(user_id) if info.get("ok"): profile = info.get("user", {}).get("profile", {}) - sender_name = ( - profile.get("display_name") or profile.get("real_name") or user_id - ) + sender_name = profile.get("display_name") or profile.get("real_name") or user_id except Exception: pass ts_float = float(msg.get("ts", "0")) - timestamp = ( - datetime.fromtimestamp(ts_float, tz=timezone.utc) if ts_float else None - ) + timestamp = datetime.fromtimestamp(ts_float, tz=timezone.utc) if ts_float else None if self._message_callback: - await self._message_callback( - PlatformMessage( - platform=self.spec.platform_id, - sender_id=user_id, - sender_name=sender_name, - text=text, - channel_id=channel_id, - message_id=msg.get("ts", ""), - timestamp=timestamp, - raw=msg, - ) - ) + await self._message_callback(PlatformMessage( + platform=self.spec.platform_id, + sender_id=user_id, + sender_name=sender_name, + text=text, + channel_id=channel_id, + message_id=msg.get("ts", ""), + timestamp=timestamp, + raw=msg, + )) # ----- API ----- async def send_message(self, recipient: str, text: str, **kwargs) -> Dict[str, Any]: @@ -408,101 +347,51 @@ async def send_message(self, recipient: str, text: str, **kwargs) -> Dict[str, A payload["blocks"] = kwargs["blocks"] return _slack_call("POST", "chat.postMessage", self._headers(), json=payload) - def list_channels( - self, - types: str = "public_channel,private_channel", - limit: int = 100, - exclude_archived: bool = True, - ) -> Dict[str, Any]: - return _slack_call( - "GET", - "conversations.list", - self._headers(), - params={ - "types": types, - "limit": limit, - "exclude_archived": exclude_archived, - }, - ) + def list_channels(self, types: str = "public_channel,private_channel", + limit: int = 100, exclude_archived: bool = True) -> Dict[str, Any]: + return _slack_call("GET", "conversations.list", self._headers(), + params={"types": types, "limit": limit, "exclude_archived": exclude_archived}) def get_channel_info(self, channel: str) -> Dict[str, Any]: - return _slack_call( - "GET", "conversations.info", self._headers(), params={"channel": channel} - ) + return _slack_call("GET", "conversations.info", self._headers(), params={"channel": channel}) - def get_channel_history( - self, - channel: str, - limit: int = 100, - oldest: Optional[str] = None, - latest: Optional[str] = None, - ) -> Dict[str, Any]: + def get_channel_history(self, channel: str, limit: int = 100, + oldest: Optional[str] = None, latest: Optional[str] = None) -> Dict[str, Any]: params: Dict[str, Any] = {"channel": channel, "limit": limit} if oldest: params["oldest"] = oldest if latest: params["latest"] = latest - return _slack_call( - "GET", "conversations.history", self._headers(), params=params - ) + return _slack_call("GET", "conversations.history", self._headers(), params=params) def create_channel(self, name: str, is_private: bool = False) -> Dict[str, Any]: - return _slack_call( - "POST", - "conversations.create", - self._headers(), - json={"name": name, "is_private": is_private}, - ) + return _slack_call("POST", "conversations.create", self._headers(), + json={"name": name, "is_private": is_private}) def invite_to_channel(self, channel: str, users: List[str]) -> Dict[str, Any]: - return _slack_call( - "POST", - "conversations.invite", - self._headers(), - json={"channel": channel, "users": ",".join(users)}, - ) + return _slack_call("POST", "conversations.invite", self._headers(), + json={"channel": channel, "users": ",".join(users)}) def list_users(self, limit: int = 100) -> Dict[str, Any]: - return _slack_call( - "GET", "users.list", self._headers(), params={"limit": limit} - ) + return _slack_call("GET", "users.list", self._headers(), params={"limit": limit}) def get_user_info(self, user_id: str) -> Dict[str, Any]: - return _slack_call( - "GET", "users.info", self._headers(), params={"user": user_id} - ) + return _slack_call("GET", "users.info", self._headers(), params={"user": user_id}) def open_dm(self, users: List[str]) -> Dict[str, Any]: - return _slack_call( - "POST", - "conversations.open", - self._headers(), - json={"users": ",".join(users)}, - ) - - def search_messages( - self, - query: str, - count: int = 20, - sort: str = "timestamp", - sort_dir: str = "desc", - ) -> Dict[str, Any]: - return _slack_call( - "GET", - "search.messages", - self._headers(), - params={"query": query, "count": count, "sort": sort, "sort_dir": sort_dir}, - ) - - def upload_file( - self, - channels: List[str], - content: Optional[str] = None, - file_path: Optional[str] = None, - filename: Optional[str] = None, - title: Optional[str] = None, - initial_comment: Optional[str] = None, - ) -> Dict[str, Any]: + return _slack_call("POST", "conversations.open", self._headers(), + json={"users": ",".join(users)}) + + def search_messages(self, query: str, count: int = 20, sort: str = "timestamp", + sort_dir: str = "desc") -> Dict[str, Any]: + return _slack_call("GET", "search.messages", self._headers(), + params={"query": query, "count": count, "sort": sort, "sort_dir": sort_dir}) + + def upload_file(self, channels: List[str], content: Optional[str] = None, + file_path: Optional[str] = None, filename: Optional[str] = None, + title: Optional[str] = None, initial_comment: Optional[str] = None) -> Dict[str, Any]: + """Legacy files.upload — kept for backwards compat. New code should use upload_file_v2, + which uses the modern 3-step files.getUploadURLExternal flow.""" cred = self._load() form_data: Dict[str, Any] = {"channels": ",".join(channels)} if filename: @@ -517,13 +406,360 @@ def upload_file( elif content: form_data["content"] = content try: - return _slack_call( - "POST", - "files.upload", - {"Authorization": f"Bearer {cred.bot_token}"}, - data=form_data, - files=files, - ) + return _slack_call("POST", "files.upload", + {"Authorization": f"Bearer {cred.bot_token}"}, + data=form_data, files=files) finally: if files: files["file"].close() + + # ------------------------------------------------------------------ + # Messages: edit / delete / ephemeral / schedule / permalink / threads + # ------------------------------------------------------------------ + + def update_message(self, channel: str, ts: str, text: Optional[str] = None, + blocks: Optional[List[Dict[str, Any]]] = None, + attachments: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]: + payload: Dict[str, Any] = {"channel": channel, "ts": ts} + if text is not None: payload["text"] = text + if blocks is not None: payload["blocks"] = blocks + if attachments is not None: payload["attachments"] = attachments + return _slack_call("POST", "chat.update", self._headers(), json=payload) + + def delete_message(self, channel: str, ts: str) -> Dict[str, Any]: + return _slack_call("POST", "chat.delete", self._headers(), + json={"channel": channel, "ts": ts}) + + def post_ephemeral(self, channel: str, user: str, text: str, + blocks: Optional[List[Dict[str, Any]]] = None, + thread_ts: Optional[str] = None) -> Dict[str, Any]: + payload: Dict[str, Any] = {"channel": channel, "user": user, "text": text} + if blocks is not None: payload["blocks"] = blocks + if thread_ts: payload["thread_ts"] = thread_ts + return _slack_call("POST", "chat.postEphemeral", self._headers(), json=payload) + + def schedule_message(self, channel: str, post_at: int, text: str, + blocks: Optional[List[Dict[str, Any]]] = None, + thread_ts: Optional[str] = None) -> Dict[str, Any]: + """post_at is a unix timestamp.""" + payload: Dict[str, Any] = {"channel": channel, "post_at": post_at, "text": text} + if blocks is not None: payload["blocks"] = blocks + if thread_ts: payload["thread_ts"] = thread_ts + return _slack_call("POST", "chat.scheduleMessage", self._headers(), json=payload) + + def delete_scheduled_message(self, channel: str, + scheduled_message_id: str) -> Dict[str, Any]: + return _slack_call("POST", "chat.deleteScheduledMessage", self._headers(), + json={"channel": channel, "scheduled_message_id": scheduled_message_id}) + + def list_scheduled_messages(self, channel: Optional[str] = None, + limit: int = 100) -> Dict[str, Any]: + payload: Dict[str, Any] = {"limit": limit} + if channel: payload["channel"] = channel + return _slack_call("POST", "chat.scheduledMessages.list", self._headers(), json=payload) + + def get_permalink(self, channel: str, message_ts: str) -> Dict[str, Any]: + return _slack_call("GET", "chat.getPermalink", self._headers(), + params={"channel": channel, "message_ts": message_ts}) + + def get_thread_replies(self, channel: str, ts: str, limit: int = 100, + cursor: Optional[str] = None) -> Dict[str, Any]: + params: Dict[str, Any] = {"channel": channel, "ts": ts, "limit": limit} + if cursor: params["cursor"] = cursor + return _slack_call("GET", "conversations.replies", self._headers(), params=params) + + # ----- Reactions ----- + + def add_reaction(self, channel: str, timestamp: str, name: str) -> Dict[str, Any]: + """name is the emoji name without colons (e.g. 'thumbsup').""" + return _slack_call("POST", "reactions.add", self._headers(), + json={"channel": channel, "timestamp": timestamp, "name": name}) + + def remove_reaction(self, channel: str, timestamp: str, name: str) -> Dict[str, Any]: + return _slack_call("POST", "reactions.remove", self._headers(), + json={"channel": channel, "timestamp": timestamp, "name": name}) + + def get_reactions(self, channel: str, timestamp: str, + full: bool = True) -> Dict[str, Any]: + return _slack_call("GET", "reactions.get", self._headers(), + params={"channel": channel, "timestamp": timestamp, + "full": str(full).lower()}) + + def list_user_reactions(self, user: Optional[str] = None, + count: int = 100) -> Dict[str, Any]: + params: Dict[str, Any] = {"count": count, "full": "true"} + if user: params["user"] = user + return _slack_call("GET", "reactions.list", self._headers(), params=params) + + # ----- Pins ----- + + def pin_message(self, channel: str, timestamp: str) -> Dict[str, Any]: + return _slack_call("POST", "pins.add", self._headers(), + json={"channel": channel, "timestamp": timestamp}) + + def unpin_message(self, channel: str, timestamp: str) -> Dict[str, Any]: + return _slack_call("POST", "pins.remove", self._headers(), + json={"channel": channel, "timestamp": timestamp}) + + def list_pins(self, channel: str) -> Dict[str, Any]: + return _slack_call("GET", "pins.list", self._headers(), + params={"channel": channel}) + + # ------------------------------------------------------------------ + # Conversations: archive / rename / topic / purpose / join / leave / kick / members + # ------------------------------------------------------------------ + + def archive_channel(self, channel: str) -> Dict[str, Any]: + return _slack_call("POST", "conversations.archive", self._headers(), + json={"channel": channel}) + + def unarchive_channel(self, channel: str) -> Dict[str, Any]: + return _slack_call("POST", "conversations.unarchive", self._headers(), + json={"channel": channel}) + + def rename_channel(self, channel: str, name: str) -> Dict[str, Any]: + return _slack_call("POST", "conversations.rename", self._headers(), + json={"channel": channel, "name": name}) + + def set_channel_topic(self, channel: str, topic: str) -> Dict[str, Any]: + return _slack_call("POST", "conversations.setTopic", self._headers(), + json={"channel": channel, "topic": topic}) + + def set_channel_purpose(self, channel: str, purpose: str) -> Dict[str, Any]: + return _slack_call("POST", "conversations.setPurpose", self._headers(), + json={"channel": channel, "purpose": purpose}) + + def join_channel(self, channel: str) -> Dict[str, Any]: + return _slack_call("POST", "conversations.join", self._headers(), + json={"channel": channel}) + + def leave_channel(self, channel: str) -> Dict[str, Any]: + return _slack_call("POST", "conversations.leave", self._headers(), + json={"channel": channel}) + + def kick_user(self, channel: str, user: str) -> Dict[str, Any]: + return _slack_call("POST", "conversations.kick", self._headers(), + json={"channel": channel, "user": user}) + + def close_conversation(self, channel: str) -> Dict[str, Any]: + """Close a DM / MPDM / private channel (per Slack's `conversations.close`).""" + return _slack_call("POST", "conversations.close", self._headers(), + json={"channel": channel}) + + def list_channel_members(self, channel: str, limit: int = 100, + cursor: Optional[str] = None) -> Dict[str, Any]: + params: Dict[str, Any] = {"channel": channel, "limit": limit} + if cursor: params["cursor"] = cursor + return _slack_call("GET", "conversations.members", self._headers(), params=params) + + # ------------------------------------------------------------------ + # Files (modern 3-step upload + list / info / delete) + # ------------------------------------------------------------------ + + def list_files(self, channel: Optional[str] = None, user: Optional[str] = None, + types: Optional[str] = None, count: int = 100, + page: int = 1) -> Dict[str, Any]: + params: Dict[str, Any] = {"count": count, "page": page} + if channel: params["channel"] = channel + if user: params["user"] = user + if types: params["types"] = types + return _slack_call("GET", "files.list", self._headers(), params=params) + + def get_file_info(self, file_id: str) -> Dict[str, Any]: + return _slack_call("GET", "files.info", self._headers(), + params={"file": file_id}) + + def delete_file(self, file_id: str) -> Dict[str, Any]: + return _slack_call("POST", "files.delete", self._headers(), + json={"file": file_id}) + + def get_upload_url_external(self, filename: str, length: int, + snippet_type: Optional[str] = None, + alt_txt: Optional[str] = None) -> Dict[str, Any]: + """Step 1 of the modern upload flow. Returns upload_url + file_id.""" + params: Dict[str, Any] = {"filename": filename, "length": length} + if snippet_type: params["snippet_type"] = snippet_type + if alt_txt: params["alt_txt"] = alt_txt + return _slack_call("GET", "files.getUploadURLExternal", self._headers(), + params=params) + + def complete_upload_external(self, files: List[Dict[str, Any]], + channel_id: Optional[str] = None, + initial_comment: Optional[str] = None, + thread_ts: Optional[str] = None) -> Dict[str, Any]: + """Step 3 of the modern upload flow. files is [{id, title?, alt_txt?}, ...].""" + payload: Dict[str, Any] = {"files": files} + if channel_id: payload["channel_id"] = channel_id + if initial_comment: payload["initial_comment"] = initial_comment + if thread_ts: payload["thread_ts"] = thread_ts + return _slack_call("POST", "files.completeUploadExternal", self._headers(), + json=payload) + + def upload_file_v2(self, file_path: str, channel_id: Optional[str] = None, + initial_comment: Optional[str] = None, + title: Optional[str] = None, + thread_ts: Optional[str] = None, + filename: Optional[str] = None) -> Dict[str, Any]: + """High-level: full 3-step modern upload of a local file in one call.""" + import os + import httpx + + file_path = os.path.abspath(file_path) + if not os.path.isfile(file_path): + return {"error": f"File not found: {file_path}"} + if not filename: + filename = os.path.basename(file_path) + file_size = os.path.getsize(file_path) + + step1 = self.get_upload_url_external(filename, file_size) + if "error" in step1: + return step1 + upload_url = step1.get("upload_url") + file_id = step1.get("file_id") + if not upload_url or not file_id: + return {"error": "files.getUploadURLExternal returned no upload_url"} + + try: + with open(file_path, "rb") as f: + r = httpx.post(upload_url, content=f.read(), timeout=300.0) + if r.status_code != 200: + return {"error": f"Upload to signed URL failed: {r.status_code}", + "details": r.text[:500]} + except Exception as e: + return {"error": str(e)} + + files_arr: List[Dict[str, Any]] = [{"id": file_id}] + if title: + files_arr[0]["title"] = title + return self.complete_upload_external( + files_arr, channel_id=channel_id, + initial_comment=initial_comment, thread_ts=thread_ts, + ) + + # ------------------------------------------------------------------ + # Users: presence + usergroups + # ------------------------------------------------------------------ + + def get_user_presence(self, user: str) -> Dict[str, Any]: + return _slack_call("GET", "users.getPresence", self._headers(), + params={"user": user}) + + def set_user_presence(self, presence: str) -> Dict[str, Any]: + """Only works with user tokens (xoxp-), not bot tokens. presence: auto | away.""" + return _slack_call("POST", "users.setPresence", self._headers(), + json={"presence": presence}) + + def lookup_user_by_email(self, email: str) -> Dict[str, Any]: + return _slack_call("GET", "users.lookupByEmail", self._headers(), + params={"email": email}) + + def list_usergroups(self, include_disabled: bool = False, + include_count: bool = False, + include_users: bool = False) -> Dict[str, Any]: + return _slack_call("GET", "usergroups.list", self._headers(), + params={ + "include_disabled": str(include_disabled).lower(), + "include_count": str(include_count).lower(), + "include_users": str(include_users).lower(), + }) + + def create_usergroup(self, name: str, handle: Optional[str] = None, + description: Optional[str] = None, + channels: Optional[List[str]] = None) -> Dict[str, Any]: + payload: Dict[str, Any] = {"name": name} + if handle: payload["handle"] = handle + if description: payload["description"] = description + if channels: payload["channels"] = ",".join(channels) + return _slack_call("POST", "usergroups.create", self._headers(), json=payload) + + def update_usergroup(self, usergroup: str, name: Optional[str] = None, + handle: Optional[str] = None, + description: Optional[str] = None, + channels: Optional[List[str]] = None) -> Dict[str, Any]: + payload: Dict[str, Any] = {"usergroup": usergroup} + if name is not None: payload["name"] = name + if handle is not None: payload["handle"] = handle + if description is not None: payload["description"] = description + if channels is not None: payload["channels"] = ",".join(channels) + return _slack_call("POST", "usergroups.update", self._headers(), json=payload) + + def enable_usergroup(self, usergroup: str) -> Dict[str, Any]: + return _slack_call("POST", "usergroups.enable", self._headers(), + json={"usergroup": usergroup}) + + def disable_usergroup(self, usergroup: str) -> Dict[str, Any]: + return _slack_call("POST", "usergroups.disable", self._headers(), + json={"usergroup": usergroup}) + + def list_usergroup_users(self, usergroup: str, + include_disabled: bool = False) -> Dict[str, Any]: + return _slack_call("GET", "usergroups.users.list", self._headers(), + params={"usergroup": usergroup, + "include_disabled": str(include_disabled).lower()}) + + def update_usergroup_users(self, usergroup: str, + users: List[str]) -> Dict[str, Any]: + return _slack_call("POST", "usergroups.users.update", self._headers(), + json={"usergroup": usergroup, "users": ",".join(users)}) + + # ------------------------------------------------------------------ + # Workspace / team / bookmarks / reminders + # ------------------------------------------------------------------ + + def auth_test(self) -> Dict[str, Any]: + return _slack_call("POST", "auth.test", self._headers()) + + def get_team_info(self, team: Optional[str] = None) -> Dict[str, Any]: + params: Dict[str, Any] = {} + if team: params["team"] = team + return _slack_call("GET", "team.info", self._headers(), params=params) + + def list_bookmarks(self, channel_id: str) -> Dict[str, Any]: + return _slack_call("GET", "bookmarks.list", self._headers(), + params={"channel_id": channel_id}) + + def add_bookmark(self, channel_id: str, title: str, + type: str = "link", link: Optional[str] = None, + emoji: Optional[str] = None, + entity_id: Optional[str] = None) -> Dict[str, Any]: + payload: Dict[str, Any] = {"channel_id": channel_id, "title": title, "type": type} + if link: payload["link"] = link + if emoji: payload["emoji"] = emoji + if entity_id: payload["entity_id"] = entity_id + return _slack_call("POST", "bookmarks.add", self._headers(), json=payload) + + def edit_bookmark(self, channel_id: str, bookmark_id: str, + title: Optional[str] = None, link: Optional[str] = None, + emoji: Optional[str] = None) -> Dict[str, Any]: + payload: Dict[str, Any] = {"channel_id": channel_id, "bookmark_id": bookmark_id} + if title is not None: payload["title"] = title + if link is not None: payload["link"] = link + if emoji is not None: payload["emoji"] = emoji + return _slack_call("POST", "bookmarks.edit", self._headers(), json=payload) + + def remove_bookmark(self, channel_id: str, bookmark_id: str) -> Dict[str, Any]: + return _slack_call("POST", "bookmarks.remove", self._headers(), + json={"channel_id": channel_id, "bookmark_id": bookmark_id}) + + def add_reminder(self, text: str, time: Any, + user: Optional[str] = None) -> Dict[str, Any]: + """time is a unix timestamp OR a natural-language string ("in 15 minutes"). + Requires xoxp- user token + reminders:write scope; bot tokens can't create reminders.""" + payload: Dict[str, Any] = {"text": text, "time": time} + if user: payload["user"] = user + return _slack_call("POST", "reminders.add", self._headers(), json=payload) + + def list_reminders(self) -> Dict[str, Any]: + return _slack_call("POST", "reminders.list", self._headers()) + + def complete_reminder(self, reminder: str) -> Dict[str, Any]: + return _slack_call("POST", "reminders.complete", self._headers(), + json={"reminder": reminder}) + + def delete_reminder(self, reminder: str) -> Dict[str, Any]: + return _slack_call("POST", "reminders.delete", self._headers(), + json={"reminder": reminder}) + + def get_reminder_info(self, reminder: str) -> Dict[str, Any]: + return _slack_call("GET", "reminders.info", self._headers(), + params={"reminder": reminder}) diff --git a/craftos_integrations/integrations/telegram_bot/__init__.py b/craftos_integrations/integrations/telegram_bot/__init__.py index e8674bc8..299b6442 100644 --- a/craftos_integrations/integrations/telegram_bot/__init__.py +++ b/craftos_integrations/integrations/telegram_bot/__init__.py @@ -504,6 +504,548 @@ async def forward_message( payload["disable_notification"] = True return await _telegram_acall(self._api_url("forwardMessage"), json=payload) + # ================================================================== + # Messages: extended send (with reply_markup support) + lifecycle + # ================================================================== + + async def send_text_message(self, chat_id: Union[int, str], text: str, + parse_mode: Optional[str] = None, + reply_to_message_id: Optional[int] = None, + disable_notification: bool = False, + reply_markup: Optional[Dict[str, Any]] = None, + entities: Optional[List[Dict[str, Any]]] = None, + disable_web_page_preview: bool = False, + message_thread_id: Optional[int] = None) -> Dict[str, Any]: + """Full-featured sendMessage with inline-keyboard support via reply_markup.""" + payload: Dict[str, Any] = {"chat_id": chat_id, "text": text} + if parse_mode: payload["parse_mode"] = parse_mode + if reply_to_message_id: payload["reply_to_message_id"] = reply_to_message_id + if disable_notification: payload["disable_notification"] = True + if reply_markup is not None: payload["reply_markup"] = reply_markup + if entities is not None: payload["entities"] = entities + if disable_web_page_preview: payload["disable_web_page_preview"] = True + if message_thread_id is not None: payload["message_thread_id"] = message_thread_id + return await _telegram_acall(self._api_url("sendMessage"), json=payload) + + async def edit_message_text(self, chat_id: Union[int, str], message_id: int, + text: str, + parse_mode: Optional[str] = None, + reply_markup: Optional[Dict[str, Any]] = None, + entities: Optional[List[Dict[str, Any]]] = None, + disable_web_page_preview: bool = False) -> Dict[str, Any]: + payload: Dict[str, Any] = {"chat_id": chat_id, "message_id": message_id, "text": text} + if parse_mode: payload["parse_mode"] = parse_mode + if reply_markup is not None: payload["reply_markup"] = reply_markup + if entities is not None: payload["entities"] = entities + if disable_web_page_preview: payload["disable_web_page_preview"] = True + return await _telegram_acall(self._api_url("editMessageText"), json=payload) + + async def edit_message_caption(self, chat_id: Union[int, str], message_id: int, + caption: Optional[str] = None, + parse_mode: Optional[str] = None, + reply_markup: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + payload: Dict[str, Any] = {"chat_id": chat_id, "message_id": message_id} + if caption is not None: payload["caption"] = caption + if parse_mode: payload["parse_mode"] = parse_mode + if reply_markup is not None: payload["reply_markup"] = reply_markup + return await _telegram_acall(self._api_url("editMessageCaption"), json=payload) + + async def edit_message_reply_markup(self, chat_id: Union[int, str], message_id: int, + reply_markup: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + payload: Dict[str, Any] = {"chat_id": chat_id, "message_id": message_id, + "reply_markup": reply_markup} + return await _telegram_acall(self._api_url("editMessageReplyMarkup"), json=payload) + + async def delete_message(self, chat_id: Union[int, str], message_id: int) -> Dict[str, Any]: + return await _telegram_acall(self._api_url("deleteMessage"), + json={"chat_id": chat_id, "message_id": message_id}) + + async def delete_messages(self, chat_id: Union[int, str], + message_ids: List[int]) -> Dict[str, Any]: + return await _telegram_acall(self._api_url("deleteMessages"), + json={"chat_id": chat_id, "message_ids": message_ids}) + + async def copy_message(self, chat_id: Union[int, str], + from_chat_id: Union[int, str], message_id: int, + caption: Optional[str] = None, + parse_mode: Optional[str] = None, + reply_markup: Optional[Dict[str, Any]] = None, + reply_to_message_id: Optional[int] = None, + disable_notification: bool = False) -> Dict[str, Any]: + payload: Dict[str, Any] = { + "chat_id": chat_id, "from_chat_id": from_chat_id, "message_id": message_id, + } + if caption is not None: payload["caption"] = caption + if parse_mode: payload["parse_mode"] = parse_mode + if reply_markup is not None: payload["reply_markup"] = reply_markup + if reply_to_message_id: payload["reply_to_message_id"] = reply_to_message_id + if disable_notification: payload["disable_notification"] = True + return await _telegram_acall(self._api_url("copyMessage"), json=payload) + + async def forward_messages(self, chat_id: Union[int, str], + from_chat_id: Union[int, str], + message_ids: List[int], + disable_notification: bool = False) -> Dict[str, Any]: + payload: Dict[str, Any] = { + "chat_id": chat_id, "from_chat_id": from_chat_id, "message_ids": message_ids, + } + if disable_notification: payload["disable_notification"] = True + return await _telegram_acall(self._api_url("forwardMessages"), json=payload) + + async def pin_message(self, chat_id: Union[int, str], message_id: int, + disable_notification: bool = True) -> Dict[str, Any]: + return await _telegram_acall(self._api_url("pinChatMessage"), + json={"chat_id": chat_id, "message_id": message_id, + "disable_notification": disable_notification}) + + async def unpin_message(self, chat_id: Union[int, str], + message_id: Optional[int] = None) -> Dict[str, Any]: + payload: Dict[str, Any] = {"chat_id": chat_id} + if message_id is not None: payload["message_id"] = message_id + return await _telegram_acall(self._api_url("unpinChatMessage"), json=payload) + + async def unpin_all_messages(self, chat_id: Union[int, str]) -> Dict[str, Any]: + return await _telegram_acall(self._api_url("unpinAllChatMessages"), + json={"chat_id": chat_id}) + + async def set_message_reaction(self, chat_id: Union[int, str], message_id: int, + reaction: Optional[List[Dict[str, Any]]] = None, + is_big: bool = False) -> Dict[str, Any]: + """reaction: list of ReactionType, e.g. [{type:'emoji',emoji:'👍'}]. Pass [] or None to clear.""" + payload: Dict[str, Any] = {"chat_id": chat_id, "message_id": message_id, + "reaction": reaction or []} + if is_big: payload["is_big"] = True + return await _telegram_acall(self._api_url("setMessageReaction"), json=payload) + + async def send_chat_action(self, chat_id: Union[int, str], action: str) -> Dict[str, Any]: + """action: typing | upload_photo | record_video | upload_video | record_voice | upload_voice | upload_document | choose_sticker | find_location | record_video_note | upload_video_note.""" + return await _telegram_acall(self._api_url("sendChatAction"), + json={"chat_id": chat_id, "action": action}) + + # ================================================================== + # Media — video / audio / voice / video_note / animation / sticker / + # location / venue / contact / dice / poll / media group / files + # ================================================================== + + async def send_video(self, chat_id: Union[int, str], video: str, + caption: Optional[str] = None, + duration: Optional[int] = None, + width: Optional[int] = None, height: Optional[int] = None, + supports_streaming: bool = False, + parse_mode: Optional[str] = None, + reply_markup: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + payload: Dict[str, Any] = {"chat_id": chat_id, "video": video} + if caption: payload["caption"] = caption + if duration is not None: payload["duration"] = duration + if width is not None: payload["width"] = width + if height is not None: payload["height"] = height + if supports_streaming: payload["supports_streaming"] = True + if parse_mode: payload["parse_mode"] = parse_mode + if reply_markup is not None: payload["reply_markup"] = reply_markup + return await _telegram_acall(self._api_url("sendVideo"), json=payload, timeout=60.0) + + async def send_audio(self, chat_id: Union[int, str], audio: str, + caption: Optional[str] = None, + duration: Optional[int] = None, + performer: Optional[str] = None, title: Optional[str] = None, + parse_mode: Optional[str] = None, + reply_markup: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + payload: Dict[str, Any] = {"chat_id": chat_id, "audio": audio} + if caption: payload["caption"] = caption + if duration is not None: payload["duration"] = duration + if performer: payload["performer"] = performer + if title: payload["title"] = title + if parse_mode: payload["parse_mode"] = parse_mode + if reply_markup is not None: payload["reply_markup"] = reply_markup + return await _telegram_acall(self._api_url("sendAudio"), json=payload, timeout=60.0) + + async def send_voice(self, chat_id: Union[int, str], voice: str, + caption: Optional[str] = None, + duration: Optional[int] = None) -> Dict[str, Any]: + payload: Dict[str, Any] = {"chat_id": chat_id, "voice": voice} + if caption: payload["caption"] = caption + if duration is not None: payload["duration"] = duration + return await _telegram_acall(self._api_url("sendVoice"), json=payload, timeout=60.0) + + async def send_video_note(self, chat_id: Union[int, str], video_note: str, + duration: Optional[int] = None, + length: Optional[int] = None) -> Dict[str, Any]: + payload: Dict[str, Any] = {"chat_id": chat_id, "video_note": video_note} + if duration is not None: payload["duration"] = duration + if length is not None: payload["length"] = length + return await _telegram_acall(self._api_url("sendVideoNote"), json=payload, timeout=60.0) + + async def send_animation(self, chat_id: Union[int, str], animation: str, + caption: Optional[str] = None, + duration: Optional[int] = None, + width: Optional[int] = None, height: Optional[int] = None, + parse_mode: Optional[str] = None) -> Dict[str, Any]: + payload: Dict[str, Any] = {"chat_id": chat_id, "animation": animation} + if caption: payload["caption"] = caption + if duration is not None: payload["duration"] = duration + if width is not None: payload["width"] = width + if height is not None: payload["height"] = height + if parse_mode: payload["parse_mode"] = parse_mode + return await _telegram_acall(self._api_url("sendAnimation"), json=payload, timeout=60.0) + + async def send_sticker(self, chat_id: Union[int, str], sticker: str, + emoji: Optional[str] = None) -> Dict[str, Any]: + payload: Dict[str, Any] = {"chat_id": chat_id, "sticker": sticker} + if emoji: payload["emoji"] = emoji + return await _telegram_acall(self._api_url("sendSticker"), json=payload) + + async def send_location(self, chat_id: Union[int, str], + latitude: float, longitude: float, + live_period: Optional[int] = None, + heading: Optional[int] = None) -> Dict[str, Any]: + payload: Dict[str, Any] = {"chat_id": chat_id, + "latitude": latitude, "longitude": longitude} + if live_period is not None: payload["live_period"] = live_period + if heading is not None: payload["heading"] = heading + return await _telegram_acall(self._api_url("sendLocation"), json=payload) + + async def send_venue(self, chat_id: Union[int, str], + latitude: float, longitude: float, + title: str, address: str, + foursquare_id: Optional[str] = None) -> Dict[str, Any]: + payload: Dict[str, Any] = {"chat_id": chat_id, + "latitude": latitude, "longitude": longitude, + "title": title, "address": address} + if foursquare_id: payload["foursquare_id"] = foursquare_id + return await _telegram_acall(self._api_url("sendVenue"), json=payload) + + async def send_contact(self, chat_id: Union[int, str], + phone_number: str, first_name: str, + last_name: Optional[str] = None, + vcard: Optional[str] = None) -> Dict[str, Any]: + payload: Dict[str, Any] = {"chat_id": chat_id, + "phone_number": phone_number, + "first_name": first_name} + if last_name: payload["last_name"] = last_name + if vcard: payload["vcard"] = vcard + return await _telegram_acall(self._api_url("sendContact"), json=payload) + + async def send_dice(self, chat_id: Union[int, str], + emoji: str = "🎲") -> Dict[str, Any]: + """emoji: 🎲 (dice) | 🎯 (darts) | 🏀 (basketball) | ⚽ (football) | 🎳 (bowling) | 🎰 (slot machine).""" + return await _telegram_acall(self._api_url("sendDice"), + json={"chat_id": chat_id, "emoji": emoji}) + + async def send_poll(self, chat_id: Union[int, str], question: str, + options: List[str], + is_anonymous: bool = True, + poll_type: str = "regular", + allows_multiple_answers: bool = False, + correct_option_id: Optional[int] = None, + explanation: Optional[str] = None, + open_period: Optional[int] = None, + is_closed: bool = False) -> Dict[str, Any]: + """poll_type: regular | quiz.""" + payload: Dict[str, Any] = { + "chat_id": chat_id, "question": question, "options": options, + "is_anonymous": is_anonymous, "type": poll_type, + "allows_multiple_answers": allows_multiple_answers, + } + if correct_option_id is not None: payload["correct_option_id"] = correct_option_id + if explanation: payload["explanation"] = explanation + if open_period is not None: payload["open_period"] = open_period + if is_closed: payload["is_closed"] = True + return await _telegram_acall(self._api_url("sendPoll"), json=payload) + + async def stop_poll(self, chat_id: Union[int, str], message_id: int, + reply_markup: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + payload: Dict[str, Any] = {"chat_id": chat_id, "message_id": message_id} + if reply_markup is not None: payload["reply_markup"] = reply_markup + return await _telegram_acall(self._api_url("stopPoll"), json=payload) + + async def send_media_group(self, chat_id: Union[int, str], + media: List[Dict[str, Any]]) -> Dict[str, Any]: + """Send 2-10 photos/videos/audios/documents as an album. media: [{type:'photo',media:'url',caption:'...'}, ...].""" + return await _telegram_acall(self._api_url("sendMediaGroup"), + json={"chat_id": chat_id, "media": media}, + timeout=60.0) + + async def get_file(self, file_id: str) -> Dict[str, Any]: + """Resolve a file_id to a downloadable file_path.""" + return await _telegram_acall(self._api_url("getFile"), json={"file_id": file_id}) + + async def download_file(self, file_id: str, dest_path: str) -> Dict[str, Any]: + """Resolve file_id, then download the file to dest_path.""" + import os + info = await self.get_file(file_id) + if "error" in info: + return info + file_path = info.get("result", {}).get("file_path") + if not file_path: + return {"error": "getFile returned no file_path"} + cred = self._load() + url = f"{TELEGRAM_API_BASE}/file/bot{cred.bot_token}/{file_path}" + try: + with httpx.stream("GET", url, timeout=120.0) as resp: + if resp.status_code != 200: + return {"error": f"Download failed: HTTP {resp.status_code}", + "details": resp.read().decode("utf-8", errors="replace")[:500]} + dest_path = os.path.abspath(dest_path) + parent = os.path.dirname(dest_path) + if parent: + os.makedirs(parent, exist_ok=True) + bytes_written = 0 + with open(dest_path, "wb") as f: + for chunk in resp.iter_bytes(chunk_size=64 * 1024): + f.write(chunk) + bytes_written += len(chunk) + return {"ok": True, "result": {"path": dest_path, + "bytes_written": bytes_written, + "file_path": file_path}} + except (httpx.HTTPError, OSError) as e: + return {"error": str(e)} + + # ================================================================== + # Chat admin — ban / restrict / promote / permissions / title / photo / invites + # ================================================================== + + async def ban_chat_member(self, chat_id: Union[int, str], user_id: int, + until_date: Optional[int] = None, + revoke_messages: bool = False) -> Dict[str, Any]: + payload: Dict[str, Any] = {"chat_id": chat_id, "user_id": user_id} + if until_date is not None: payload["until_date"] = until_date + if revoke_messages: payload["revoke_messages"] = True + return await _telegram_acall(self._api_url("banChatMember"), json=payload) + + async def unban_chat_member(self, chat_id: Union[int, str], user_id: int, + only_if_banned: bool = True) -> Dict[str, Any]: + return await _telegram_acall(self._api_url("unbanChatMember"), + json={"chat_id": chat_id, "user_id": user_id, + "only_if_banned": only_if_banned}) + + async def restrict_chat_member(self, chat_id: Union[int, str], user_id: int, + permissions: Dict[str, Any], + until_date: Optional[int] = None) -> Dict[str, Any]: + """permissions: ChatPermissions object (can_send_messages, can_send_media, ...).""" + payload: Dict[str, Any] = {"chat_id": chat_id, "user_id": user_id, + "permissions": permissions} + if until_date is not None: payload["until_date"] = until_date + return await _telegram_acall(self._api_url("restrictChatMember"), json=payload) + + async def promote_chat_member(self, chat_id: Union[int, str], user_id: int, + is_anonymous: Optional[bool] = None, + can_manage_chat: Optional[bool] = None, + can_delete_messages: Optional[bool] = None, + can_manage_video_chats: Optional[bool] = None, + can_restrict_members: Optional[bool] = None, + can_promote_members: Optional[bool] = None, + can_change_info: Optional[bool] = None, + can_invite_users: Optional[bool] = None, + can_post_messages: Optional[bool] = None, + can_edit_messages: Optional[bool] = None, + can_pin_messages: Optional[bool] = None, + can_manage_topics: Optional[bool] = None) -> Dict[str, Any]: + payload: Dict[str, Any] = {"chat_id": chat_id, "user_id": user_id} + for k, v in { + "is_anonymous": is_anonymous, "can_manage_chat": can_manage_chat, + "can_delete_messages": can_delete_messages, + "can_manage_video_chats": can_manage_video_chats, + "can_restrict_members": can_restrict_members, + "can_promote_members": can_promote_members, + "can_change_info": can_change_info, "can_invite_users": can_invite_users, + "can_post_messages": can_post_messages, "can_edit_messages": can_edit_messages, + "can_pin_messages": can_pin_messages, "can_manage_topics": can_manage_topics, + }.items(): + if v is not None: + payload[k] = v + return await _telegram_acall(self._api_url("promoteChatMember"), json=payload) + + async def set_chat_administrator_custom_title(self, chat_id: Union[int, str], + user_id: int, + custom_title: str) -> Dict[str, Any]: + return await _telegram_acall(self._api_url("setChatAdministratorCustomTitle"), + json={"chat_id": chat_id, "user_id": user_id, + "custom_title": custom_title}) + + async def set_chat_permissions(self, chat_id: Union[int, str], + permissions: Dict[str, Any]) -> Dict[str, Any]: + return await _telegram_acall(self._api_url("setChatPermissions"), + json={"chat_id": chat_id, "permissions": permissions}) + + async def set_chat_title(self, chat_id: Union[int, str], title: str) -> Dict[str, Any]: + return await _telegram_acall(self._api_url("setChatTitle"), + json={"chat_id": chat_id, "title": title}) + + async def set_chat_description(self, chat_id: Union[int, str], + description: str) -> Dict[str, Any]: + return await _telegram_acall(self._api_url("setChatDescription"), + json={"chat_id": chat_id, "description": description}) + + async def delete_chat_photo(self, chat_id: Union[int, str]) -> Dict[str, Any]: + return await _telegram_acall(self._api_url("deleteChatPhoto"), + json={"chat_id": chat_id}) + + async def leave_chat(self, chat_id: Union[int, str]) -> Dict[str, Any]: + return await _telegram_acall(self._api_url("leaveChat"), json={"chat_id": chat_id}) + + async def export_chat_invite_link(self, chat_id: Union[int, str]) -> Dict[str, Any]: + """Revoke previous primary invite link and generate a new one.""" + return await _telegram_acall(self._api_url("exportChatInviteLink"), + json={"chat_id": chat_id}) + + async def create_chat_invite_link(self, chat_id: Union[int, str], + name: Optional[str] = None, + expire_date: Optional[int] = None, + member_limit: Optional[int] = None, + creates_join_request: bool = False) -> Dict[str, Any]: + payload: Dict[str, Any] = {"chat_id": chat_id} + if name: payload["name"] = name + if expire_date is not None: payload["expire_date"] = expire_date + if member_limit is not None: payload["member_limit"] = member_limit + if creates_join_request: payload["creates_join_request"] = True + return await _telegram_acall(self._api_url("createChatInviteLink"), json=payload) + + async def edit_chat_invite_link(self, chat_id: Union[int, str], invite_link: str, + name: Optional[str] = None, + expire_date: Optional[int] = None, + member_limit: Optional[int] = None, + creates_join_request: bool = False) -> Dict[str, Any]: + payload: Dict[str, Any] = {"chat_id": chat_id, "invite_link": invite_link} + if name is not None: payload["name"] = name + if expire_date is not None: payload["expire_date"] = expire_date + if member_limit is not None: payload["member_limit"] = member_limit + if creates_join_request: payload["creates_join_request"] = True + return await _telegram_acall(self._api_url("editChatInviteLink"), json=payload) + + async def revoke_chat_invite_link(self, chat_id: Union[int, str], + invite_link: str) -> Dict[str, Any]: + return await _telegram_acall(self._api_url("revokeChatInviteLink"), + json={"chat_id": chat_id, "invite_link": invite_link}) + + async def approve_chat_join_request(self, chat_id: Union[int, str], + user_id: int) -> Dict[str, Any]: + return await _telegram_acall(self._api_url("approveChatJoinRequest"), + json={"chat_id": chat_id, "user_id": user_id}) + + async def decline_chat_join_request(self, chat_id: Union[int, str], + user_id: int) -> Dict[str, Any]: + return await _telegram_acall(self._api_url("declineChatJoinRequest"), + json={"chat_id": chat_id, "user_id": user_id}) + + async def get_chat_administrators(self, chat_id: Union[int, str]) -> Dict[str, Any]: + return await _telegram_acall(self._api_url("getChatAdministrators"), + json={"chat_id": chat_id}) + + # ================================================================== + # Bot config — commands / description / menu button / default admin rights + # ================================================================== + + async def set_my_commands(self, commands: List[Dict[str, str]], + scope: Optional[Dict[str, Any]] = None, + language_code: Optional[str] = None) -> Dict[str, Any]: + """commands: [{command, description}, ...]. scope: BotCommandScope object (optional).""" + payload: Dict[str, Any] = {"commands": commands} + if scope is not None: payload["scope"] = scope + if language_code: payload["language_code"] = language_code + return await _telegram_acall(self._api_url("setMyCommands"), json=payload) + + async def get_my_commands(self, scope: Optional[Dict[str, Any]] = None, + language_code: Optional[str] = None) -> Dict[str, Any]: + payload: Dict[str, Any] = {} + if scope is not None: payload["scope"] = scope + if language_code: payload["language_code"] = language_code + return await _telegram_acall(self._api_url("getMyCommands"), json=payload) + + async def delete_my_commands(self, scope: Optional[Dict[str, Any]] = None, + language_code: Optional[str] = None) -> Dict[str, Any]: + payload: Dict[str, Any] = {} + if scope is not None: payload["scope"] = scope + if language_code: payload["language_code"] = language_code + return await _telegram_acall(self._api_url("deleteMyCommands"), json=payload) + + async def set_my_description(self, description: str, + language_code: Optional[str] = None) -> Dict[str, Any]: + payload: Dict[str, Any] = {"description": description} + if language_code: payload["language_code"] = language_code + return await _telegram_acall(self._api_url("setMyDescription"), json=payload) + + async def get_my_description(self, language_code: Optional[str] = None) -> Dict[str, Any]: + payload: Dict[str, Any] = {} + if language_code: payload["language_code"] = language_code + return await _telegram_acall(self._api_url("getMyDescription"), json=payload) + + async def set_my_short_description(self, short_description: str, + language_code: Optional[str] = None) -> Dict[str, Any]: + payload: Dict[str, Any] = {"short_description": short_description} + if language_code: payload["language_code"] = language_code + return await _telegram_acall(self._api_url("setMyShortDescription"), json=payload) + + async def set_my_name(self, name: str, + language_code: Optional[str] = None) -> Dict[str, Any]: + payload: Dict[str, Any] = {"name": name} + if language_code: payload["language_code"] = language_code + return await _telegram_acall(self._api_url("setMyName"), json=payload) + + async def set_chat_menu_button(self, chat_id: Optional[Union[int, str]] = None, + menu_button: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """menu_button: MenuButton object (commands | web_app | default). chat_id: omit for default.""" + payload: Dict[str, Any] = {} + if chat_id is not None: payload["chat_id"] = chat_id + if menu_button is not None: payload["menu_button"] = menu_button + return await _telegram_acall(self._api_url("setChatMenuButton"), json=payload) + + async def get_chat_menu_button(self, chat_id: Optional[Union[int, str]] = None) -> Dict[str, Any]: + payload: Dict[str, Any] = {} + if chat_id is not None: payload["chat_id"] = chat_id + return await _telegram_acall(self._api_url("getChatMenuButton"), json=payload) + + async def set_my_default_administrator_rights( + self, rights: Optional[Dict[str, Any]] = None, + for_channels: bool = False, + ) -> Dict[str, Any]: + payload: Dict[str, Any] = {"for_channels": for_channels} + if rights is not None: payload["rights"] = rights + return await _telegram_acall(self._api_url("setMyDefaultAdministratorRights"), + json=payload) + + async def get_my_default_administrator_rights(self, for_channels: bool = False) -> Dict[str, Any]: + return await _telegram_acall(self._api_url("getMyDefaultAdministratorRights"), + json={"for_channels": for_channels}) + + # ================================================================== + # Callback queries (for inline-keyboard interactions) + # ================================================================== + + async def answer_callback_query(self, callback_query_id: str, + text: Optional[str] = None, + show_alert: bool = False, + url: Optional[str] = None, + cache_time: int = 0) -> Dict[str, Any]: + payload: Dict[str, Any] = {"callback_query_id": callback_query_id, + "show_alert": show_alert, "cache_time": cache_time} + if text: payload["text"] = text + if url: payload["url"] = url + return await _telegram_acall(self._api_url("answerCallbackQuery"), json=payload) + + # ================================================================== + # Webhook configuration + # ================================================================== + + async def set_webhook(self, url: str, + secret_token: Optional[str] = None, + ip_address: Optional[str] = None, + max_connections: Optional[int] = None, + allowed_updates: Optional[List[str]] = None, + drop_pending_updates: bool = False) -> Dict[str, Any]: + payload: Dict[str, Any] = {"url": url, "drop_pending_updates": drop_pending_updates} + if secret_token: payload["secret_token"] = secret_token + if ip_address: payload["ip_address"] = ip_address + if max_connections is not None: payload["max_connections"] = max_connections + if allowed_updates is not None: payload["allowed_updates"] = allowed_updates + return await _telegram_acall(self._api_url("setWebhook"), json=payload) + + async def delete_webhook(self, drop_pending_updates: bool = False) -> Dict[str, Any]: + return await _telegram_acall(self._api_url("deleteWebhook"), + json={"drop_pending_updates": drop_pending_updates}) + + async def get_webhook_info(self) -> Dict[str, Any]: + return await _telegram_acall(self._api_url("getWebhookInfo")) + async def search_contact(self, name: str) -> Dict[str, Any]: updates_result = await self.get_updates(limit=100) if "error" in updates_result: diff --git a/craftos_integrations/integrations/twitter/__init__.py b/craftos_integrations/integrations/twitter/__init__.py index 6143e734..8f514597 100644 --- a/craftos_integrations/integrations/twitter/__init__.py +++ b/craftos_integrations/integrations/twitter/__init__.py @@ -599,3 +599,430 @@ async def get_user_by_username(self, username: str) -> Result: async def reply_to_tweet(self, tweet_id: str, text: str) -> Result: return await self.post_tweet(text, reply_to=tweet_id) + + # ----- Tweets: lookup, mentions, quote, hide reply, post with media ----- + + async def get_tweet(self, tweet_id: str) -> Result: + url = f"{TWITTER_API}/tweets/{tweet_id}" + params = {"tweet.fields": "created_at,author_id,public_metrics,text,conversation_id"} + return await arequest( + "GET", url, headers=self._auth_header("GET", url, params), + params=params, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + async def lookup_tweets(self, tweet_ids: List[str]) -> Result: + """Batch-lookup multiple tweets by id (up to 100 per call).""" + url = f"{TWITTER_API}/tweets" + params = { + "ids": ",".join(tweet_ids[:100]), + "tweet.fields": "created_at,author_id,public_metrics,text", + } + return await arequest( + "GET", url, headers=self._auth_header("GET", url, params), + params=params, expected=(200,), + ) + + async def get_user_mentions(self, user_id: Optional[str] = None, max_results: int = 10) -> Result: + """Recent mentions of a user (defaults to the authed user).""" + cred = self._load() + uid = user_id or cred.user_id + if not uid: + return {"error": "No user_id available"} + url = f"{TWITTER_API}/users/{uid}/mentions" + params = { + "max_results": str(max_results), + "tweet.fields": "created_at,author_id,text,conversation_id", + "expansions": "author_id", + "user.fields": "username,name", + } + return await arequest( + "GET", url, headers=self._auth_header("GET", url, params), + params=params, expected=(200,), + ) + + async def post_quote_tweet(self, text: str, quoted_tweet_id: str) -> Result: + url = f"{TWITTER_API}/tweets" + payload = {"text": text, "quote_tweet_id": quoted_tweet_id} + return await arequest( + "POST", url, + headers={**self._auth_header("POST", url), "Content-Type": "application/json"}, + json=payload, + transform=lambda d: {"id": d.get("data", {}).get("id"), + "text": d.get("data", {}).get("text")}, + ) + + async def hide_reply(self, reply_tweet_id: str, hidden: bool = True) -> Result: + url = f"{TWITTER_API}/tweets/{reply_tweet_id}/hidden" + return await arequest( + "PUT", url, + headers={**self._auth_header("PUT", url), "Content-Type": "application/json"}, + json={"hidden": hidden}, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + async def post_tweet_with_media(self, text: str, media_ids: List[str], + reply_to: Optional[str] = None) -> Result: + """Post a tweet with up to 4 already-uploaded media_ids attached.""" + url = f"{TWITTER_API}/tweets" + payload: Dict[str, Any] = {"text": text, "media": {"media_ids": media_ids}} + if reply_to: + payload["reply"] = {"in_reply_to_tweet_id": reply_to} + return await arequest( + "POST", url, + headers={**self._auth_header("POST", url), "Content-Type": "application/json"}, + json=payload, + transform=lambda d: {"id": d.get("data", {}).get("id"), + "text": d.get("data", {}).get("text")}, + ) + + # ----- Engagement: unlike, unretweet, bookmarks ----- + + async def unlike_tweet(self, tweet_id: str) -> Result: + cred = self._load() + url = f"{TWITTER_API}/users/{cred.user_id}/likes/{tweet_id}" + return await arequest( + "DELETE", url, headers=self._auth_header("DELETE", url), + expected=(200,), + transform=lambda d: d.get("data", d), + ) + + async def unretweet(self, tweet_id: str) -> Result: + cred = self._load() + url = f"{TWITTER_API}/users/{cred.user_id}/retweets/{tweet_id}" + return await arequest( + "DELETE", url, headers=self._auth_header("DELETE", url), + expected=(200,), + transform=lambda d: d.get("data", d), + ) + + async def add_bookmark(self, tweet_id: str) -> Result: + cred = self._load() + url = f"{TWITTER_API}/users/{cred.user_id}/bookmarks" + return await arequest( + "POST", url, + headers={**self._auth_header("POST", url), "Content-Type": "application/json"}, + json={"tweet_id": tweet_id}, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + async def remove_bookmark(self, tweet_id: str) -> Result: + cred = self._load() + url = f"{TWITTER_API}/users/{cred.user_id}/bookmarks/{tweet_id}" + return await arequest( + "DELETE", url, headers=self._auth_header("DELETE", url), + expected=(200,), + transform=lambda d: d.get("data", d), + ) + + async def list_bookmarks(self, max_results: int = 50) -> Result: + cred = self._load() + url = f"{TWITTER_API}/users/{cred.user_id}/bookmarks" + params = {"max_results": str(max_results), + "tweet.fields": "created_at,author_id,public_metrics,text"} + return await arequest( + "GET", url, headers=self._auth_header("GET", url, params), + params=params, expected=(200,), + ) + + async def list_liking_users(self, tweet_id: str, max_results: int = 50) -> Result: + url = f"{TWITTER_API}/tweets/{tweet_id}/liking_users" + params = {"max_results": str(max_results), "user.fields": "username,name"} + return await arequest( + "GET", url, headers=self._auth_header("GET", url, params), + params=params, expected=(200,), + ) + + async def list_retweeted_by(self, tweet_id: str, max_results: int = 50) -> Result: + url = f"{TWITTER_API}/tweets/{tweet_id}/retweeted_by" + params = {"max_results": str(max_results), "user.fields": "username,name"} + return await arequest( + "GET", url, headers=self._auth_header("GET", url, params), + params=params, expected=(200,), + ) + + # ----- Follows / Block / Mute ----- + + async def follow_user(self, target_user_id: str) -> Result: + cred = self._load() + url = f"{TWITTER_API}/users/{cred.user_id}/following" + return await arequest( + "POST", url, + headers={**self._auth_header("POST", url), "Content-Type": "application/json"}, + json={"target_user_id": target_user_id}, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + async def unfollow_user(self, target_user_id: str) -> Result: + cred = self._load() + url = f"{TWITTER_API}/users/{cred.user_id}/following/{target_user_id}" + return await arequest( + "DELETE", url, headers=self._auth_header("DELETE", url), + expected=(200,), + transform=lambda d: d.get("data", d), + ) + + async def list_following(self, user_id: Optional[str] = None, max_results: int = 100) -> Result: + cred = self._load() + uid = user_id or cred.user_id + url = f"{TWITTER_API}/users/{uid}/following" + params = {"max_results": str(max_results), + "user.fields": "username,name,description,public_metrics"} + return await arequest( + "GET", url, headers=self._auth_header("GET", url, params), + params=params, expected=(200,), + ) + + async def list_followers(self, user_id: Optional[str] = None, max_results: int = 100) -> Result: + cred = self._load() + uid = user_id or cred.user_id + url = f"{TWITTER_API}/users/{uid}/followers" + params = {"max_results": str(max_results), + "user.fields": "username,name,description,public_metrics"} + return await arequest( + "GET", url, headers=self._auth_header("GET", url, params), + params=params, expected=(200,), + ) + + async def block_user(self, target_user_id: str) -> Result: + cred = self._load() + url = f"{TWITTER_API}/users/{cred.user_id}/blocking" + return await arequest( + "POST", url, + headers={**self._auth_header("POST", url), "Content-Type": "application/json"}, + json={"target_user_id": target_user_id}, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + async def unblock_user(self, target_user_id: str) -> Result: + cred = self._load() + url = f"{TWITTER_API}/users/{cred.user_id}/blocking/{target_user_id}" + return await arequest( + "DELETE", url, headers=self._auth_header("DELETE", url), + expected=(200,), + transform=lambda d: d.get("data", d), + ) + + async def mute_user(self, target_user_id: str) -> Result: + cred = self._load() + url = f"{TWITTER_API}/users/{cred.user_id}/muting" + return await arequest( + "POST", url, + headers={**self._auth_header("POST", url), "Content-Type": "application/json"}, + json={"target_user_id": target_user_id}, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + async def unmute_user(self, target_user_id: str) -> Result: + cred = self._load() + url = f"{TWITTER_API}/users/{cred.user_id}/muting/{target_user_id}" + return await arequest( + "DELETE", url, headers=self._auth_header("DELETE", url), + expected=(200,), + transform=lambda d: d.get("data", d), + ) + + # ----- Lists ----- + + async def create_list(self, name: str, description: str = "", + private: bool = False) -> Result: + url = f"{TWITTER_API}/lists" + payload = {"name": name, "private": private} + if description: + payload["description"] = description + return await arequest( + "POST", url, + headers={**self._auth_header("POST", url), "Content-Type": "application/json"}, + json=payload, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + async def get_list(self, list_id: str) -> Result: + url = f"{TWITTER_API}/lists/{list_id}" + params = {"list.fields": "name,description,member_count,follower_count,private"} + return await arequest( + "GET", url, headers=self._auth_header("GET", url, params), + params=params, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + async def update_list(self, list_id: str, + name: Optional[str] = None, + description: Optional[str] = None, + private: Optional[bool] = None) -> Result: + url = f"{TWITTER_API}/lists/{list_id}" + payload: Dict[str, Any] = {} + if name is not None: + payload["name"] = name + if description is not None: + payload["description"] = description + if private is not None: + payload["private"] = private + if not payload: + return {"error": "No fields to update"} + return await arequest( + "PUT", url, + headers={**self._auth_header("PUT", url), "Content-Type": "application/json"}, + json=payload, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + async def delete_list(self, list_id: str) -> Result: + url = f"{TWITTER_API}/lists/{list_id}" + return await arequest( + "DELETE", url, headers=self._auth_header("DELETE", url), + expected=(200,), + transform=lambda _d: {"deleted": True, "list_id": list_id}, + ) + + async def list_owned_lists(self, user_id: Optional[str] = None, + max_results: int = 100) -> Result: + cred = self._load() + uid = user_id or cred.user_id + url = f"{TWITTER_API}/users/{uid}/owned_lists" + params = {"max_results": str(max_results), + "list.fields": "name,description,member_count,follower_count,private"} + return await arequest( + "GET", url, headers=self._auth_header("GET", url, params), + params=params, expected=(200,), + ) + + async def add_list_member(self, list_id: str, user_id: str) -> Result: + url = f"{TWITTER_API}/lists/{list_id}/members" + return await arequest( + "POST", url, + headers={**self._auth_header("POST", url), "Content-Type": "application/json"}, + json={"user_id": user_id}, expected=(200,), + transform=lambda d: d.get("data", d), + ) + + async def remove_list_member(self, list_id: str, user_id: str) -> Result: + url = f"{TWITTER_API}/lists/{list_id}/members/{user_id}" + return await arequest( + "DELETE", url, headers=self._auth_header("DELETE", url), + expected=(200,), + transform=lambda d: d.get("data", d), + ) + + async def list_list_members(self, list_id: str, max_results: int = 100) -> Result: + url = f"{TWITTER_API}/lists/{list_id}/members" + params = {"max_results": str(max_results), + "user.fields": "username,name,description"} + return await arequest( + "GET", url, headers=self._auth_header("GET", url, params), + params=params, expected=(200,), + ) + + async def list_list_tweets(self, list_id: str, max_results: int = 100) -> Result: + url = f"{TWITTER_API}/lists/{list_id}/tweets" + params = {"max_results": str(max_results), + "tweet.fields": "created_at,author_id,public_metrics,text"} + return await arequest( + "GET", url, headers=self._auth_header("GET", url, params), + params=params, expected=(200,), + ) + + # ----- Direct Messages ----- + + async def send_dm_to_user(self, participant_id: str, text: str) -> Result: + """Send a one-on-one DM to a user. Creates the conversation if needed.""" + url = f"{TWITTER_API}/dm_conversations/with/{participant_id}/messages" + return await arequest( + "POST", url, + headers={**self._auth_header("POST", url), "Content-Type": "application/json"}, + json={"text": text}, expected=(201,), + transform=lambda d: d.get("data", d), + ) + + async def send_dm_to_conversation(self, dm_conversation_id: str, text: str) -> Result: + url = f"{TWITTER_API}/dm_conversations/{dm_conversation_id}/messages" + return await arequest( + "POST", url, + headers={**self._auth_header("POST", url), "Content-Type": "application/json"}, + json={"text": text}, expected=(201,), + transform=lambda d: d.get("data", d), + ) + + async def create_group_dm(self, participant_ids: List[str], text: str) -> Result: + """Create a new group DM conversation and send the first message.""" + url = f"{TWITTER_API}/dm_conversations" + payload = { + "conversation_type": "Group", + "participant_ids": participant_ids, + "message": {"text": text}, + } + return await arequest( + "POST", url, + headers={**self._auth_header("POST", url), "Content-Type": "application/json"}, + json=payload, expected=(201,), + transform=lambda d: d.get("data", d), + ) + + async def list_dm_events(self, max_results: int = 100) -> Result: + """List recent DM events across all conversations.""" + url = f"{TWITTER_API}/dm_events" + params = {"max_results": str(max_results), + "dm_event.fields": "id,event_type,text,created_at,sender_id,dm_conversation_id"} + return await arequest( + "GET", url, headers=self._auth_header("GET", url, params), + params=params, expected=(200,), + ) + + async def list_dm_events_with_user(self, participant_id: str, max_results: int = 100) -> Result: + url = f"{TWITTER_API}/dm_conversations/with/{participant_id}/dm_events" + params = {"max_results": str(max_results), + "dm_event.fields": "id,event_type,text,created_at,sender_id"} + return await arequest( + "GET", url, headers=self._auth_header("GET", url, params), + params=params, expected=(200,), + ) + + # ----- Media upload (v1.1 - still the most reliable for OAuth 1.0a) ----- + + async def upload_media(self, file_path: str, + media_category: str = "tweet_image") -> Result: + """Upload an image/video for use in a tweet. Returns ``media_id_string``. + + Uses the v1.1 upload endpoint (``upload.twitter.com``) because OAuth 1.0a + with multipart/form-data is well-supported there and the v2 endpoint + behaves identically. ``media_category``: tweet_image | tweet_gif | + tweet_video | dm_image | dm_video. + """ + import httpx + import os + + cred = self._load() + try: + with open(file_path, "rb") as fh: + file_bytes = fh.read() + except OSError as e: + return {"error": "file_read_failed", "details": str(e)} + + url = "https://upload.twitter.com/1.1/media/upload.json" + # OAuth 1.0a signature must include only the params actually sent in + # the request line (not the multipart body fields). + auth_hdr = _oauth1_header( + "POST", url, {}, + cred.api_key, cred.api_secret, + cred.access_token, cred.access_token_secret, + ) + + name = os.path.basename(file_path) + try: + async with httpx.AsyncClient(timeout=60.0) as client: + r = await client.post( + url, + headers={"Authorization": auth_hdr}, + files={"media": (name, file_bytes)}, + data={"media_category": media_category}, + ) + if r.status_code not in (200, 201): + return {"error": f"http_{r.status_code}", "details": r.text[:500]} + d = r.json() + return {"ok": True, "result": { + "media_id_string": d.get("media_id_string"), + "size": d.get("size"), + "image": d.get("image"), + }} + except Exception as e: + return {"error": "upload_failed", "details": str(e)} diff --git a/craftos_integrations/integrations/whatsapp_web/__init__.py b/craftos_integrations/integrations/whatsapp_web/__init__.py index 13b63819..ecb13d6d 100644 --- a/craftos_integrations/integrations/whatsapp_web/__init__.py +++ b/craftos_integrations/integrations/whatsapp_web/__init__.py @@ -6,7 +6,6 @@ UIs (web settings page, etc.) that need to poll instead of awaiting the QR scan synchronously. """ - from __future__ import annotations import asyncio @@ -47,7 +46,6 @@ class WhatsAppWebCredential: @dataclass class WhatsAppWebConfig: """Runtime knobs persisted to ``whatsapp_web_config.json``.""" - # When True, only forward messages the owner sent to themselves # (self-chat). All other incoming messages — DMs from contacts, group # chats — are dropped before reaching the agent. Useful when the user @@ -73,7 +71,6 @@ def _whatsapp_web_config_file() -> str: # Handler # ════════════════════════════════════════════════════════════════════════ - @register_handler(WHATSAPP_WEB.name) class WhatsAppWebHandler(IntegrationHandler): spec = WHATSAPP_WEB @@ -82,13 +79,9 @@ class WhatsAppWebHandler(IntegrationHandler): auth_type = "interactive" config_class = WhatsAppWebConfig config_fields = [ - { - "key": "self_messages_only", - "label": "Self-messages only", - "type": "checkbox", - "help": "Only forward messages you send to yourself (the WhatsApp self-chat). " - "Drops incoming DMs and group messages before they reach the agent.", - }, + {"key": "self_messages_only", "label": "Self-messages only", "type": "checkbox", + "help": "Only forward messages you send to yourself (the WhatsApp self-chat). " + "Drops incoming DMs and group messages before they reach the agent."}, ] icon = "whatsapp" fields: List = [] @@ -101,10 +94,7 @@ async def login(self, args: List[str]) -> Tuple[bool, str]: try: from ._bridge_client import get_whatsapp_bridge except ImportError: - return ( - False, - "WhatsApp bridge not available. Ensure Node.js >= 18 is installed.", - ) + return False, "WhatsApp bridge not available. Ensure Node.js >= 18 is installed." bridge = get_whatsapp_bridge() if not bridge.is_running: @@ -118,14 +108,9 @@ async def login(self, args: List[str]) -> Tuple[bool, str]: if event_type == "ready": owner_phone = bridge.owner_phone or "" owner_name = bridge.owner_name or "" - save_credential( - self.spec.cred_file, - WhatsAppWebCredential( - session_id="bridge", - owner_phone=owner_phone, - owner_name=owner_name, - ), - ) + save_credential(self.spec.cred_file, WhatsAppWebCredential( + session_id="bridge", owner_phone=owner_phone, owner_name=owner_name, + )) display = owner_phone or owner_name or "connected" return True, f"WhatsApp Web connected: +{display}" @@ -134,19 +119,13 @@ async def login(self, args: List[str]) -> Tuple[bool, str]: if qr_string: try: import qrcode - qr = qrcode.QRCode(border=1) qr.add_data(qr_string) qr.make(fit=True) matrix = qr.get_matrix() - lines = [ - "".join("##" if cell else " " for cell in row) - for row in matrix - ] + lines = ["".join("##" if cell else " " for cell in row) for row in matrix] sys.stderr.write("\n" + "\n".join(lines) + "\n\n") - sys.stderr.write( - "Scan the QR code above with WhatsApp on your phone\n\n" - ) + sys.stderr.write("Scan the QR code above with WhatsApp on your phone\n\n") sys.stderr.flush() except Exception: pass @@ -154,7 +133,6 @@ async def login(self, args: List[str]) -> Tuple[bool, str]: qr_data_url = (event_data or {}).get("qr_data_url") if qr_data_url: import base64 as b64 - qr_b64 = qr_data_url if qr_b64.startswith("data:image"): qr_b64 = qr_b64.split(",", 1)[1] @@ -165,28 +143,17 @@ async def login(self, args: List[str]) -> Tuple[bool, str]: ready = await bridge.wait_for_ready(timeout=120.0) if not ready: - return ( - False, - "Timed out waiting for QR scan. Run /whatsapp_web login again.", - ) + return False, "Timed out waiting for QR scan. Run /whatsapp_web login again." owner_phone = bridge.owner_phone or "" owner_name = bridge.owner_name or "" - save_credential( - self.spec.cred_file, - WhatsAppWebCredential( - session_id="bridge", - owner_phone=owner_phone, - owner_name=owner_name, - ), - ) + save_credential(self.spec.cred_file, WhatsAppWebCredential( + session_id="bridge", owner_phone=owner_phone, owner_name=owner_name, + )) display = owner_phone or owner_name or "connected" return True, f"WhatsApp Web connected: +{display}" - return ( - False, - "Timed out waiting for WhatsApp bridge. Run /whatsapp_web login again.", - ) + return False, "Timed out waiting for WhatsApp bridge. Run /whatsapp_web login again." async def logout(self, args: List[str]) -> Tuple[bool, str]: if not has_credential(self.spec.cred_file): @@ -194,7 +161,6 @@ async def logout(self, args: List[str]) -> Tuple[bool, str]: remove_credential(self.spec.cred_file) try: from ._bridge_client import get_whatsapp_bridge - bridge = get_whatsapp_bridge() # ``logout()`` (not ``stop()``) — calls wwebjs's ``client.logout()`` # which invalidates the session server-side and wipes the LocalAuth @@ -209,15 +175,11 @@ async def logout(self, args: List[str]) -> Tuple[bool, str]: import shutil from pathlib import Path from ...config import ConfigStore - shutil.rmtree( - Path(ConfigStore.project_root) - / ".credentials" - / "whatsapp_wwebjs_auth", + Path(ConfigStore.project_root) / ".credentials" / "whatsapp_wwebjs_auth", ignore_errors=True, ) from ...manager import get_external_comms_manager - manager = get_external_comms_manager() if manager: await manager.stop_platform(self.spec.platform_id) @@ -241,7 +203,6 @@ async def status(self) -> Tuple[bool, str]: # Client # ════════════════════════════════════════════════════════════════════════ - @register_client class WhatsAppWebClient(BasePlatformClient): spec = WHATSAPP_WEB @@ -269,9 +230,7 @@ def _load(self) -> WhatsAppWebCredential: if self._cred is None: self._cred = load_credential(self.spec.cred_file, WhatsAppWebCredential) if self._cred is None: - raise RuntimeError( - "No WhatsApp Web credentials found. Please log in first." - ) + raise RuntimeError("No WhatsApp Web credentials found. Please log in first.") return self._cred @property @@ -281,7 +240,6 @@ def owner_phone(self) -> str: def _get_bridge(self): if self._bridge is None: from ._bridge_client import get_whatsapp_bridge - self._bridge = get_whatsapp_bridge() return self._bridge @@ -292,9 +250,7 @@ async def connect(self) -> None: if not bridge.is_ready: ready = await bridge.wait_for_ready(timeout=120.0) if not ready: - raise RuntimeError( - "WhatsApp bridge did not become ready within timeout" - ) + raise RuntimeError("WhatsApp bridge did not become ready within timeout") self._connected = True async def disconnect(self) -> None: @@ -322,21 +278,279 @@ async def send_message(self, recipient: str, text: str, **kwargs) -> Dict[str, A self._agent_sent_ids.add(msg_id) return {"status": "success" if result.get("success") else "error", **result} - async def send_media( - self, recipient: str, media_path: str, caption: Optional[str] = None - ) -> Dict[str, Any]: - if caption: - return await self.send_message( - recipient, f"[Media: {media_path}]\n{caption}" - ) - return { - "status": "error", - "error": "Media sending not yet supported via bridge", - } + async def send_media(self, recipient: str, media_path: str, + caption: Optional[str] = None, + send_as_sticker: bool = False, + send_as_voice: bool = False, + send_as_document: bool = False, + quoted_message_id: Optional[str] = None) -> Dict[str, Any]: + bridge = self._get_bridge() + if not bridge.is_ready: + return {"status": "error", "error": "Bridge not ready"} + resolved = self._resolve_recipient(recipient) + result = await bridge.send_media( + to=resolved, file_path=media_path, caption=caption, + send_as_sticker=send_as_sticker, + send_as_voice=send_as_voice, + send_as_document=send_as_document, + quoted_message_id=quoted_message_id, + ) + msg_id = result.get("message_id") + if msg_id: + self._agent_sent_ids.add(msg_id) + return {"status": "success" if result.get("success") else "error", **result} + + async def send_location(self, recipient: str, latitude: float, longitude: float, + description: str = "") -> Dict[str, Any]: + bridge = self._get_bridge() + if not bridge.is_ready: + return {"status": "error", "error": "Bridge not ready"} + resolved = self._resolve_recipient(recipient) + result = await bridge.send_location(resolved, latitude, longitude, description) + return {"status": "success" if result.get("success") else "error", **result} + + async def send_reply(self, recipient: str, text: str, + quoted_message_id: str) -> Dict[str, Any]: + bridge = self._get_bridge() + if not bridge.is_ready: + return {"status": "error", "error": "Bridge not ready"} + resolved = self._resolve_recipient(recipient) + prefixed = f"{self._agent_prefix}{text}" + result = await bridge.send_reply(resolved, prefixed, quoted_message_id) + msg_id = result.get("message_id") + if msg_id: + self._agent_sent_ids.add(msg_id) + return {"status": "success" if result.get("success") else "error", **result} + + async def edit_message(self, message_id: str, new_body: str) -> Dict[str, Any]: + bridge = self._get_bridge() + if not bridge.is_ready: + return {"status": "error", "error": "Bridge not ready"} + result = await bridge.edit_message(message_id, new_body) + return {"status": "success" if result.get("success") else "error", **result} + + async def delete_message(self, message_id: str, everyone: bool = False) -> Dict[str, Any]: + bridge = self._get_bridge() + if not bridge.is_ready: + return {"status": "error", "error": "Bridge not ready"} + result = await bridge.delete_message(message_id, everyone) + return {"status": "success" if result.get("success") else "error", **result} + + async def forward_message(self, message_id: str, recipient: str) -> Dict[str, Any]: + bridge = self._get_bridge() + if not bridge.is_ready: + return {"status": "error", "error": "Bridge not ready"} + resolved = self._resolve_recipient(recipient) + result = await bridge.forward_message(message_id, resolved) + return {"status": "success" if result.get("success") else "error", **result} + + async def react_message(self, message_id: str, emoji: str) -> Dict[str, Any]: + bridge = self._get_bridge() + if not bridge.is_ready: + return {"status": "error", "error": "Bridge not ready"} + result = await bridge.react_message(message_id, emoji) + return {"status": "success" if result.get("success") else "error", **result} + + async def star_message(self, message_id: str, starred: bool = True) -> Dict[str, Any]: + bridge = self._get_bridge() + if not bridge.is_ready: + return {"status": "error", "error": "Bridge not ready"} + result = await bridge.star_message(message_id, starred) + return {"status": "success" if result.get("success") else "error", **result} + + async def download_message_media(self, message_id: str, dest_path: str) -> Dict[str, Any]: + """Download attached media from a message to a local path.""" + import base64 as _b64 + bridge = self._get_bridge() + if not bridge.is_ready: + return {"status": "error", "error": "Bridge not ready"} + result = await bridge.download_message_media(message_id) + if not result.get("success"): + return {"status": "error", **result} + data_b64 = result.get("data_b64", "") + if not data_b64: + return {"status": "error", "error": "No media data returned"} + try: + dest_path = os.path.abspath(dest_path) + parent = os.path.dirname(dest_path) + if parent: + os.makedirs(parent, exist_ok=True) + with open(dest_path, "wb") as f: + f.write(_b64.b64decode(data_b64)) + return {"status": "success", "saved_to": dest_path, + "mimetype": result.get("mimetype", ""), + "filename": result.get("filename", ""), + "size": os.path.getsize(dest_path)} + except OSError as e: + return {"status": "error", "error": str(e)} + + async def get_quoted_message(self, message_id: str) -> Dict[str, Any]: + bridge = self._get_bridge() + if not bridge.is_ready: + return {"status": "error", "error": "Bridge not ready"} + result = await bridge.get_quoted_message(message_id) + return {"status": "success" if result.get("success") else "error", **result} + + # ----- Chat operations ----- + + async def mark_chat_read(self, chat_id: str) -> Dict[str, Any]: + bridge = self._get_bridge() + if not bridge.is_ready: + return {"status": "error", "error": "Bridge not ready"} + return {"status": "success", **(await bridge.mark_chat_read(chat_id))} + + async def mark_chat_unread(self, chat_id: str) -> Dict[str, Any]: + bridge = self._get_bridge() + if not bridge.is_ready: + return {"status": "error", "error": "Bridge not ready"} + return {"status": "success", **(await bridge.mark_chat_unread(chat_id))} + + async def archive_chat(self, chat_id: str, archive: bool = True) -> Dict[str, Any]: + bridge = self._get_bridge() + if not bridge.is_ready: + return {"status": "error", "error": "Bridge not ready"} + return {"status": "success", **(await bridge.archive_chat(chat_id, archive))} + + async def pin_chat(self, chat_id: str, pin: bool = True) -> Dict[str, Any]: + bridge = self._get_bridge() + if not bridge.is_ready: + return {"status": "error", "error": "Bridge not ready"} + return {"status": "success", **(await bridge.pin_chat(chat_id, pin))} + + async def mute_chat(self, chat_id: str, mute: bool = True, + unmute_date: Optional[int] = None) -> Dict[str, Any]: + bridge = self._get_bridge() + if not bridge.is_ready: + return {"status": "error", "error": "Bridge not ready"} + return {"status": "success", **(await bridge.mute_chat(chat_id, mute, unmute_date))} + + async def clear_chat_messages(self, chat_id: str) -> Dict[str, Any]: + bridge = self._get_bridge() + if not bridge.is_ready: + return {"status": "error", "error": "Bridge not ready"} + return {"status": "success", **(await bridge.clear_chat_messages(chat_id))} + + async def delete_chat(self, chat_id: str) -> Dict[str, Any]: + bridge = self._get_bridge() + if not bridge.is_ready: + return {"status": "error", "error": "Bridge not ready"} + return {"status": "success", **(await bridge.delete_chat(chat_id))} + + async def send_typing_state(self, chat_id: str, state: str = "typing") -> Dict[str, Any]: + bridge = self._get_bridge() + if not bridge.is_ready: + return {"status": "error", "error": "Bridge not ready"} + return {"status": "success", **(await bridge.send_typing_state(chat_id, state))} + + # ----- Groups ----- + + async def create_group(self, name: str, participants: list) -> Dict[str, Any]: + bridge = self._get_bridge() + if not bridge.is_ready: + return {"status": "error", "error": "Bridge not ready"} + result = await bridge.create_group(name, participants) + return {"status": "success" if result.get("success") else "error", **result} + + async def group_add_participants(self, group_id: str, participants: list) -> Dict[str, Any]: + bridge = self._get_bridge() + if not bridge.is_ready: + return {"status": "error", "error": "Bridge not ready"} + return {"status": "success", **(await bridge.group_add_participants(group_id, participants))} + + async def group_remove_participants(self, group_id: str, participants: list) -> Dict[str, Any]: + bridge = self._get_bridge() + if not bridge.is_ready: + return {"status": "error", "error": "Bridge not ready"} + return {"status": "success", **(await bridge.group_remove_participants(group_id, participants))} + + async def group_promote_participants(self, group_id: str, participants: list) -> Dict[str, Any]: + bridge = self._get_bridge() + if not bridge.is_ready: + return {"status": "error", "error": "Bridge not ready"} + return {"status": "success", **(await bridge.group_promote_participants(group_id, participants))} + + async def group_demote_participants(self, group_id: str, participants: list) -> Dict[str, Any]: + bridge = self._get_bridge() + if not bridge.is_ready: + return {"status": "error", "error": "Bridge not ready"} + return {"status": "success", **(await bridge.group_demote_participants(group_id, participants))} + + async def group_set_subject(self, group_id: str, subject: str) -> Dict[str, Any]: + bridge = self._get_bridge() + if not bridge.is_ready: + return {"status": "error", "error": "Bridge not ready"} + return {"status": "success", **(await bridge.group_set_subject(group_id, subject))} + + async def group_set_description(self, group_id: str, description: str) -> Dict[str, Any]: + bridge = self._get_bridge() + if not bridge.is_ready: + return {"status": "error", "error": "Bridge not ready"} + return {"status": "success", **(await bridge.group_set_description(group_id, description))} - async def get_chat_messages( - self, phone_number: str, limit: int = 50 - ) -> Dict[str, Any]: + async def group_get_info(self, group_id: str) -> Dict[str, Any]: + bridge = self._get_bridge() + if not bridge.is_ready: + return {"status": "error", "error": "Bridge not ready"} + return {"status": "success", **(await bridge.group_get_info(group_id))} + + async def group_leave(self, group_id: str) -> Dict[str, Any]: + bridge = self._get_bridge() + if not bridge.is_ready: + return {"status": "error", "error": "Bridge not ready"} + return {"status": "success", **(await bridge.group_leave(group_id))} + + async def group_invite_code(self, group_id: str) -> Dict[str, Any]: + bridge = self._get_bridge() + if not bridge.is_ready: + return {"status": "error", "error": "Bridge not ready"} + return {"status": "success", **(await bridge.group_invite_code(group_id))} + + async def group_revoke_invite(self, group_id: str) -> Dict[str, Any]: + bridge = self._get_bridge() + if not bridge.is_ready: + return {"status": "error", "error": "Bridge not ready"} + return {"status": "success", **(await bridge.group_revoke_invite(group_id))} + + async def accept_group_invite(self, invite_code: str) -> Dict[str, Any]: + bridge = self._get_bridge() + if not bridge.is_ready: + return {"status": "error", "error": "Bridge not ready"} + return {"status": "success", **(await bridge.accept_group_invite(invite_code))} + + # ----- Contacts ----- + + async def block_contact(self, contact_id: str, block: bool = True) -> Dict[str, Any]: + bridge = self._get_bridge() + if not bridge.is_ready: + return {"status": "error", "error": "Bridge not ready"} + return {"status": "success", **(await bridge.block_contact(contact_id, block))} + + async def get_profile_pic_url(self, contact_id: str) -> Dict[str, Any]: + bridge = self._get_bridge() + if not bridge.is_ready: + return {"status": "error", "error": "Bridge not ready"} + return {"status": "success", **(await bridge.get_profile_pic_url(contact_id))} + + async def get_contact(self, contact_id: str) -> Dict[str, Any]: + bridge = self._get_bridge() + if not bridge.is_ready: + return {"status": "error", "error": "Bridge not ready"} + return {"status": "success", **(await bridge.get_contact(contact_id))} + + async def get_all_contacts(self, my_contacts_only: bool = True, + limit: int = 500) -> Dict[str, Any]: + bridge = self._get_bridge() + if not bridge.is_ready: + return {"status": "error", "error": "Bridge not ready"} + return {"status": "success", **(await bridge.get_all_contacts(my_contacts_only, limit))} + + async def check_number_on_whatsapp(self, number: str) -> Dict[str, Any]: + bridge = self._get_bridge() + if not bridge.is_ready: + return {"status": "error", "error": "Bridge not ready"} + return {"status": "success", **(await bridge.check_number_on_whatsapp(number))} + + async def get_chat_messages(self, phone_number: str, limit: int = 50) -> Dict[str, Any]: bridge = self._get_bridge() if not bridge.is_ready: return {"success": False, "error": "Bridge not ready"} @@ -363,10 +577,7 @@ async def get_session_status(self) -> Optional[Dict[str, Any]]: return {"status": "disconnected", "ready": False} try: result = await bridge.get_status() - return { - "status": "connected" if result.get("ready") else "waiting", - **result, - } + return {"status": "connected" if result.get("ready") else "waiting", **result} except Exception: return {"status": "disconnected", "ready": False} @@ -426,10 +637,7 @@ async def start_listening(self, callback) -> None: if bridge.owner_phone or bridge.owner_name: cred = self._load() - if ( - cred.owner_phone != bridge.owner_phone - or cred.owner_name != bridge.owner_name - ): + if cred.owner_phone != bridge.owner_phone or cred.owner_name != bridge.owner_name: updated = WhatsAppWebCredential( session_id=cred.session_id, owner_phone=bridge.owner_phone or cred.owner_phone, @@ -474,10 +682,7 @@ async def _handle_incoming_message(self, data: Dict[str, Any]) -> None: # Self-chat messages arrive via _handle_sent_message (from_me=True), # so when self_messages_only is set we drop everything else here. - cfg = ( - load_config(_whatsapp_web_config_file(), WhatsAppWebConfig) - or WhatsAppWebConfig() - ) + cfg = load_config(_whatsapp_web_config_file(), WhatsAppWebConfig) or WhatsAppWebConfig() if cfg.self_messages_only: return @@ -516,30 +721,23 @@ async def _handle_incoming_message(self, data: Dict[str, Any]) -> None: except Exception: ts = datetime.now(tz=timezone.utc) - await self._message_callback( - PlatformMessage( - platform=self.PLATFORM_ID, - sender_id=sender_id, - sender_name=sender_name, - text=body, - channel_id=chat.get("id", ""), - channel_name=chat_name, - message_id=msg_id, - timestamp=ts, - raw={ - "source": "WhatsApp Web", - "integrationType": "whatsapp_web", - "is_self_message": False, - "is_group": is_group, - "contactId": sender_id, - "contactName": sender_name, - "messageBody": body, - "chatId": chat.get("id", ""), - "chatName": chat_name, - "timestamp": str(timestamp or ""), - }, - ) - ) + await self._message_callback(PlatformMessage( + platform=self.PLATFORM_ID, + sender_id=sender_id, + sender_name=sender_name, + text=body, + channel_id=chat.get("id", ""), + channel_name=chat_name, + message_id=msg_id, + timestamp=ts, + raw={ + "source": "WhatsApp Web", "integrationType": "whatsapp_web", + "is_self_message": False, "is_group": is_group, + "contactId": sender_id, "contactName": sender_name, + "messageBody": body, "chatId": chat.get("id", ""), + "chatName": chat_name, "timestamp": str(timestamp or ""), + }, + )) async def _handle_sent_message(self, data: Dict[str, Any]) -> None: if not self._listening or not self._message_callback: @@ -571,30 +769,23 @@ async def _handle_sent_message(self, data: Dict[str, Any]) -> None: except Exception: ts = datetime.now(tz=timezone.utc) - await self._message_callback( - PlatformMessage( - platform=self.PLATFORM_ID, - sender_id=data.get("from", ""), - sender_name=chat_name or "Self", - text=body, - channel_id=chat.get("id", ""), - channel_name=chat_name, - message_id=msg_id, - timestamp=ts, - raw={ - "source": "WhatsApp Web", - "integrationType": "whatsapp_web", - "is_self_message": True, - "is_group": False, - "contactId": data.get("from", ""), - "contactName": chat_name or "Self", - "messageBody": body, - "chatId": chat.get("id", ""), - "chatName": chat_name, - "timestamp": str(timestamp or ""), - }, - ) - ) + await self._message_callback(PlatformMessage( + platform=self.PLATFORM_ID, + sender_id=data.get("from", ""), + sender_name=chat_name or "Self", + text=body, + channel_id=chat.get("id", ""), + channel_name=chat_name, + message_id=msg_id, + timestamp=ts, + raw={ + "source": "WhatsApp Web", "integrationType": "whatsapp_web", + "is_self_message": True, "is_group": False, + "contactId": data.get("from", ""), "contactName": chat_name or "Self", + "messageBody": body, "chatId": chat.get("id", ""), + "chatName": chat_name, "timestamp": str(timestamp or ""), + }, + )) def _is_mention_for_me(self, text: str) -> bool: if "@" not in text: @@ -628,8 +819,7 @@ async def start_qr_session() -> Dict[str, Any]: from ._bridge_client import get_whatsapp_bridge except ImportError: return { - "success": False, - "status": "error", + "success": False, "status": "error", "message": "WhatsApp bridge not available. Ensure Node.js >= 18 is installed.", } @@ -642,21 +832,13 @@ async def start_qr_session() -> Dict[str, Any]: if event_type == "ready": owner_phone = bridge.owner_phone or "" owner_name = bridge.owner_name or "" - save_credential( - WHATSAPP_WEB.cred_file, - WhatsAppWebCredential( - session_id="bridge", - owner_phone=owner_phone, - owner_name=owner_name, - ), - ) + save_credential(WHATSAPP_WEB.cred_file, WhatsAppWebCredential( + session_id="bridge", owner_phone=owner_phone, owner_name=owner_name, + )) display = owner_phone or owner_name or "connected" return { - "success": True, - "session_id": "bridge", - "qr_code": "", - "status": "connected", - "message": f"WhatsApp already connected: +{display}", + "success": True, "session_id": "bridge", "qr_code": "", + "status": "connected", "message": f"WhatsApp already connected: +{display}", } if event_type == "qr": @@ -665,10 +847,7 @@ async def start_qr_session() -> Dict[str, Any]: qr_string = (event_data or {}).get("qr_string", "") if qr_string: try: - import qrcode - import io - import base64 - + import qrcode, io, base64 qr = qrcode.QRCode(border=1) qr.add_data(qr_string) qr.make(fit=True) @@ -681,37 +860,22 @@ async def start_qr_session() -> Dict[str, Any]: if not qr_data: await bridge.stop() - return { - "success": False, - "status": "error", - "message": "Failed to generate QR code.", - } + return {"success": False, "status": "error", "message": "Failed to generate QR code."} if qr_data and not qr_data.startswith("data:"): qr_data = f"data:image/png;base64,{qr_data}" session_id = "bridge" _qr_sessions[session_id] = bridge return { - "success": True, - "session_id": session_id, - "qr_code": qr_data, - "status": "qr_ready", - "message": "Scan the QR code with your WhatsApp mobile app", + "success": True, "session_id": session_id, "qr_code": qr_data, + "status": "qr_ready", "message": "Scan the QR code with your WhatsApp mobile app", } await bridge.stop() - return { - "success": False, - "status": "error", - "message": "Timed out waiting for WhatsApp bridge.", - } + return {"success": False, "status": "error", "message": "Timed out waiting for WhatsApp bridge."} except Exception as e: logger.error(f"Failed to start WhatsApp QR session: {e}") - return { - "success": False, - "status": "error", - "message": f"Failed to start session: {e}", - } + return {"success": False, "status": "error", "message": f"Failed to start session: {e}"} async def check_qr_session_status(session_id: str) -> Dict[str, Any]: @@ -719,32 +883,22 @@ async def check_qr_session_status(session_id: str) -> Dict[str, Any]: and starts the platform listener if a manager is running.""" bridge = _qr_sessions.get(session_id) if bridge is None: - return { - "success": False, - "status": "error", - "connected": False, - "message": "Session not found. Please start a new session.", - } + return {"success": False, "status": "error", "connected": False, + "message": "Session not found. Please start a new session."} try: if bridge.is_ready: try: owner_phone = bridge.owner_phone or "" owner_name = bridge.owner_name or "" - save_credential( - WHATSAPP_WEB.cred_file, - WhatsAppWebCredential( - session_id="bridge", - owner_phone=owner_phone, - owner_name=owner_name, - ), - ) + save_credential(WHATSAPP_WEB.cred_file, WhatsAppWebCredential( + session_id="bridge", owner_phone=owner_phone, owner_name=owner_name, + )) del _qr_sessions[session_id] # Best-effort: start the listener if a manager is running. try: from ...manager import get_external_comms_manager - manager = get_external_comms_manager() if manager: await manager.start_platform(WHATSAPP_WEB.platform_id) @@ -752,44 +906,24 @@ async def check_qr_session_status(session_id: str) -> Dict[str, Any]: pass display = owner_phone or owner_name or "connected" - return { - "success": True, - "status": "connected", - "connected": True, - "message": f"WhatsApp connected: +{display}", - } + return {"success": True, "status": "connected", "connected": True, + "message": f"WhatsApp connected: +{display}"} except Exception as e: logger.error(f"Failed to store WhatsApp credential: {e}") - return { - "success": False, - "status": "error", - "connected": False, - "message": f"Connected but failed to save: {e}", - } + return {"success": False, "status": "error", "connected": False, + "message": f"Connected but failed to save: {e}"} elif not bridge.is_running: if session_id in _qr_sessions: del _qr_sessions[session_id] - return { - "success": False, - "status": "error", - "connected": False, - "message": "WhatsApp bridge stopped unexpectedly. Please try again.", - } + return {"success": False, "status": "error", "connected": False, + "message": "WhatsApp bridge stopped unexpectedly. Please try again."} else: - return { - "success": True, - "status": "qr_ready", - "connected": False, - "message": "Waiting for QR code scan...", - } + return {"success": True, "status": "qr_ready", "connected": False, + "message": "Waiting for QR code scan..."} except Exception as e: logger.error(f"Failed to check WhatsApp session status: {e}") - return { - "success": False, - "status": "error", - "connected": False, - "message": f"Status check failed: {e}", - } + return {"success": False, "status": "error", "connected": False, + "message": f"Status check failed: {e}"} def cancel_qr_session(session_id: str) -> Dict[str, Any]: diff --git a/craftos_integrations/integrations/whatsapp_web/_bridge_client.py b/craftos_integrations/integrations/whatsapp_web/_bridge_client.py index 669f8cfe..64232325 100644 --- a/craftos_integrations/integrations/whatsapp_web/_bridge_client.py +++ b/craftos_integrations/integrations/whatsapp_web/_bridge_client.py @@ -358,6 +358,167 @@ async def search_contact(self, name: str) -> Dict[str, Any]: async def get_unread_chats(self) -> Dict[str, Any]: return await self.send_command("get_unread_chats") + # ----- Messages: media / location / reply / edit / delete / forward / react / star / download ----- + + async def send_media(self, to: str, file_path: str, + caption: Optional[str] = None, + send_as_sticker: bool = False, + send_as_voice: bool = False, + send_as_document: bool = False, + quoted_message_id: Optional[str] = None, + timeout: float = 120.0) -> Dict[str, Any]: + return await self.send_command("send_media", { + "to": to, "file_path": file_path, "caption": caption, + "send_as_sticker": send_as_sticker, + "send_as_voice": send_as_voice, + "send_as_document": send_as_document, + "quoted_message_id": quoted_message_id, + }, timeout=timeout) + + async def send_location(self, to: str, latitude: float, longitude: float, + description: str = "") -> Dict[str, Any]: + return await self.send_command("send_location", { + "to": to, "latitude": latitude, "longitude": longitude, "description": description, + }) + + async def send_reply(self, to: str, text: str, + quoted_message_id: str) -> Dict[str, Any]: + return await self.send_command("send_reply", { + "to": to, "text": text, "quoted_message_id": quoted_message_id, + }) + + async def edit_message(self, message_id: str, new_body: str) -> Dict[str, Any]: + return await self.send_command("edit_message", { + "message_id": message_id, "new_body": new_body, + }) + + async def delete_message(self, message_id: str, everyone: bool = False) -> Dict[str, Any]: + return await self.send_command("delete_message", { + "message_id": message_id, "everyone": everyone, + }) + + async def forward_message(self, message_id: str, to: str) -> Dict[str, Any]: + return await self.send_command("forward_message", { + "message_id": message_id, "to": to, + }) + + async def react_message(self, message_id: str, emoji: str) -> Dict[str, Any]: + return await self.send_command("react_message", { + "message_id": message_id, "emoji": emoji, + }) + + async def star_message(self, message_id: str, starred: bool = True) -> Dict[str, Any]: + return await self.send_command("star_message", { + "message_id": message_id, "starred": starred, + }) + + async def download_message_media(self, message_id: str, + timeout: float = 120.0) -> Dict[str, Any]: + return await self.send_command("download_message_media", { + "message_id": message_id, + }, timeout=timeout) + + async def get_quoted_message(self, message_id: str) -> Dict[str, Any]: + return await self.send_command("get_quoted_message", { + "message_id": message_id, + }) + + # ----- Chat operations ----- + + async def mark_chat_read(self, chat_id: str) -> Dict[str, Any]: + return await self.send_command("mark_chat_read", {"chat_id": chat_id}) + + async def mark_chat_unread(self, chat_id: str) -> Dict[str, Any]: + return await self.send_command("mark_chat_unread", {"chat_id": chat_id}) + + async def archive_chat(self, chat_id: str, archive: bool = True) -> Dict[str, Any]: + return await self.send_command("archive_chat", {"chat_id": chat_id, "archive": archive}) + + async def pin_chat(self, chat_id: str, pin: bool = True) -> Dict[str, Any]: + return await self.send_command("pin_chat", {"chat_id": chat_id, "pin": pin}) + + async def mute_chat(self, chat_id: str, mute: bool = True, + unmute_date: Optional[int] = None) -> Dict[str, Any]: + args: Dict[str, Any] = {"chat_id": chat_id, "mute": mute} + if unmute_date is not None: + args["unmute_date"] = unmute_date + return await self.send_command("mute_chat", args) + + async def clear_chat_messages(self, chat_id: str) -> Dict[str, Any]: + return await self.send_command("clear_chat_messages", {"chat_id": chat_id}) + + async def delete_chat(self, chat_id: str) -> Dict[str, Any]: + return await self.send_command("delete_chat", {"chat_id": chat_id}) + + async def send_typing_state(self, chat_id: str, + state: str = "typing") -> Dict[str, Any]: + """state: typing | recording | clear.""" + return await self.send_command("send_typing_state", {"chat_id": chat_id, "state": state}) + + # ----- Groups ----- + + async def create_group(self, name: str, participants: list) -> Dict[str, Any]: + return await self.send_command("create_group", {"name": name, "participants": participants}) + + async def group_add_participants(self, group_id: str, participants: list) -> Dict[str, Any]: + return await self.send_command("group_add_participants", + {"group_id": group_id, "participants": participants}) + + async def group_remove_participants(self, group_id: str, participants: list) -> Dict[str, Any]: + return await self.send_command("group_remove_participants", + {"group_id": group_id, "participants": participants}) + + async def group_promote_participants(self, group_id: str, participants: list) -> Dict[str, Any]: + return await self.send_command("group_promote_participants", + {"group_id": group_id, "participants": participants}) + + async def group_demote_participants(self, group_id: str, participants: list) -> Dict[str, Any]: + return await self.send_command("group_demote_participants", + {"group_id": group_id, "participants": participants}) + + async def group_set_subject(self, group_id: str, subject: str) -> Dict[str, Any]: + return await self.send_command("group_set_subject", {"group_id": group_id, "subject": subject}) + + async def group_set_description(self, group_id: str, description: str) -> Dict[str, Any]: + return await self.send_command("group_set_description", + {"group_id": group_id, "description": description}) + + async def group_get_info(self, group_id: str) -> Dict[str, Any]: + return await self.send_command("group_get_info", {"group_id": group_id}) + + async def group_leave(self, group_id: str) -> Dict[str, Any]: + return await self.send_command("group_leave", {"group_id": group_id}) + + async def group_invite_code(self, group_id: str) -> Dict[str, Any]: + return await self.send_command("group_invite_code", {"group_id": group_id}) + + async def group_revoke_invite(self, group_id: str) -> Dict[str, Any]: + return await self.send_command("group_revoke_invite", {"group_id": group_id}) + + async def accept_group_invite(self, invite_code: str) -> Dict[str, Any]: + return await self.send_command("accept_group_invite", {"invite_code": invite_code}) + + # ----- Contacts ----- + + async def block_contact(self, contact_id: str, block: bool = True) -> Dict[str, Any]: + return await self.send_command("block_contact", + {"contact_id": contact_id, "block": block}) + + async def get_profile_pic_url(self, contact_id: str) -> Dict[str, Any]: + return await self.send_command("get_profile_pic_url", {"contact_id": contact_id}) + + async def get_contact(self, contact_id: str) -> Dict[str, Any]: + return await self.send_command("get_contact", {"contact_id": contact_id}) + + async def get_all_contacts(self, my_contacts_only: bool = True, + limit: int = 500) -> Dict[str, Any]: + return await self.send_command("get_all_contacts", + {"my_contacts_only": my_contacts_only, "limit": limit}, + timeout=60.0) + + async def check_number_on_whatsapp(self, number: str) -> Dict[str, Any]: + return await self.send_command("check_number_on_whatsapp", {"number": number}) + async def wait_for_ready(self, timeout: float = 120.0) -> bool: deadline = asyncio.get_event_loop().time() + timeout while asyncio.get_event_loop().time() < deadline: diff --git a/craftos_integrations/integrations/whatsapp_web/bridge.js b/craftos_integrations/integrations/whatsapp_web/bridge.js index 93befa84..460b4719 100644 --- a/craftos_integrations/integrations/whatsapp_web/bridge.js +++ b/craftos_integrations/integrations/whatsapp_web/bridge.js @@ -16,7 +16,7 @@ * Logs go to stderr so they don't interfere with the JSON protocol. */ -const { Client, LocalAuth } = require("whatsapp-web.js"); +const { Client, LocalAuth, MessageMedia, Location, Buttons, List, Poll } = require("whatsapp-web.js"); const qrcode = require("qrcode"); const path = require("path"); const readline = require("readline"); @@ -607,6 +607,439 @@ async function handleCommand(line) { break; } + // ───────────────────────────────────────────────────────────────── + // Resolve a number/JID to a canonical chat ID. Helper, not a command. + // Used by every command that takes a `to` field. + // ───────────────────────────────────────────────────────────────── + + case "send_media": { + if (!isReady) { emitResponse(id, { success: false, error: "Client not ready" }); return; } + let chatId = args.to; + if (!chatId.includes("@")) { + const wid = await client.getNumberId(chatId.replace(/[\s\-\+\(\)]/g, "")); + if (!wid) { emitResponse(id, { success: false, error: `Number ${chatId} not on WhatsApp` }); return; } + chatId = wid._serialized; + } + let media; + try { + media = MessageMedia.fromFilePath(args.file_path); + } catch (e) { + emitResponse(id, { success: false, error: `Cannot read file: ${e.message}` }); + return; + } + const opts = {}; + if (args.caption) opts.caption = args.caption; + if (args.send_as_sticker) opts.sendMediaAsSticker = true; + if (args.send_as_voice) opts.sendAudioAsVoice = true; + if (args.send_as_document) opts.sendMediaAsDocument = true; + if (args.quoted_message_id) opts.quotedMessageId = args.quoted_message_id; + const sent = await client.sendMessage(chatId, media, opts); + if (sent?.id?._serialized) ownSentIds.add(sent.id._serialized); + emitResponse(id, { + success: true, + message_id: sent?.id?._serialized || null, + timestamp: new Date().toISOString(), + }); + break; + } + + case "send_location": { + if (!isReady) { emitResponse(id, { success: false, error: "Client not ready" }); return; } + let chatId = args.to; + if (!chatId.includes("@")) { + const wid = await client.getNumberId(chatId.replace(/[\s\-\+\(\)]/g, "")); + if (!wid) { emitResponse(id, { success: false, error: `Number ${chatId} not on WhatsApp` }); return; } + chatId = wid._serialized; + } + const loc = new Location(args.latitude, args.longitude, args.description || ""); + const sent = await client.sendMessage(chatId, loc); + emitResponse(id, { + success: true, + message_id: sent?.id?._serialized || null, + }); + break; + } + + case "send_reply": { + if (!isReady) { emitResponse(id, { success: false, error: "Client not ready" }); return; } + let chatId = args.to; + if (!chatId.includes("@")) { + const wid = await client.getNumberId(chatId.replace(/[\s\-\+\(\)]/g, "")); + if (!wid) { emitResponse(id, { success: false, error: `Number ${chatId} not on WhatsApp` }); return; } + chatId = wid._serialized; + } + const sent = await client.sendMessage(chatId, args.text, { quotedMessageId: args.quoted_message_id }); + if (sent?.id?._serialized) ownSentIds.add(sent.id._serialized); + emitResponse(id, { success: true, message_id: sent?.id?._serialized || null }); + break; + } + + case "edit_message": { + if (!isReady) { emitResponse(id, { success: false, error: "Client not ready" }); return; } + const msg = await client.getMessageById(args.message_id); + if (!msg) { emitResponse(id, { success: false, error: "Message not found" }); return; } + await msg.edit(args.new_body); + emitResponse(id, { success: true, message_id: args.message_id }); + break; + } + + case "delete_message": { + if (!isReady) { emitResponse(id, { success: false, error: "Client not ready" }); return; } + const msg = await client.getMessageById(args.message_id); + if (!msg) { emitResponse(id, { success: false, error: "Message not found" }); return; } + await msg.delete(args.everyone === true); + emitResponse(id, { success: true, message_id: args.message_id, deleted_for_everyone: args.everyone === true }); + break; + } + + case "forward_message": { + if (!isReady) { emitResponse(id, { success: false, error: "Client not ready" }); return; } + const msg = await client.getMessageById(args.message_id); + if (!msg) { emitResponse(id, { success: false, error: "Message not found" }); return; } + let chatId = args.to; + if (!chatId.includes("@")) { + const wid = await client.getNumberId(chatId.replace(/[\s\-\+\(\)]/g, "")); + if (!wid) { emitResponse(id, { success: false, error: `Number ${chatId} not on WhatsApp` }); return; } + chatId = wid._serialized; + } + const chat = await client.getChatById(chatId); + await msg.forward(chat); + emitResponse(id, { success: true, forwarded_to: chatId }); + break; + } + + case "react_message": { + if (!isReady) { emitResponse(id, { success: false, error: "Client not ready" }); return; } + const msg = await client.getMessageById(args.message_id); + if (!msg) { emitResponse(id, { success: false, error: "Message not found" }); return; } + await msg.react(args.emoji || ""); // empty string removes the reaction + emitResponse(id, { success: true, message_id: args.message_id, emoji: args.emoji }); + break; + } + + case "star_message": { + if (!isReady) { emitResponse(id, { success: false, error: "Client not ready" }); return; } + const msg = await client.getMessageById(args.message_id); + if (!msg) { emitResponse(id, { success: false, error: "Message not found" }); return; } + if (args.starred === false) await msg.unstar(); else await msg.star(); + emitResponse(id, { success: true, message_id: args.message_id, starred: args.starred !== false }); + break; + } + + case "download_message_media": { + if (!isReady) { emitResponse(id, { success: false, error: "Client not ready" }); return; } + const msg = await client.getMessageById(args.message_id); + if (!msg) { emitResponse(id, { success: false, error: "Message not found" }); return; } + if (!msg.hasMedia) { emitResponse(id, { success: false, error: "Message has no media" }); return; } + const media = await msg.downloadMedia(); + if (!media) { emitResponse(id, { success: false, error: "Media download failed" }); return; } + emitResponse(id, { + success: true, + mimetype: media.mimetype, + filename: media.filename || "", + data_b64: media.data, + }); + break; + } + + case "get_quoted_message": { + if (!isReady) { emitResponse(id, { success: false, error: "Client not ready" }); return; } + const msg = await client.getMessageById(args.message_id); + if (!msg) { emitResponse(id, { success: false, error: "Message not found" }); return; } + const quoted = await msg.getQuotedMessage(); + if (!quoted) { emitResponse(id, { success: true, quoted: null }); return; } + emitResponse(id, { success: true, quoted: { + id: quoted.id._serialized, body: quoted.body || "", + from: quoted.from, from_me: quoted.fromMe, timestamp: quoted.timestamp, + }}); + break; + } + + // ───────────────────────────────────────────────────────────────── + // Chat operations + // ───────────────────────────────────────────────────────────────── + + case "mark_chat_read": { + if (!isReady) { emitResponse(id, { success: false, error: "Client not ready" }); return; } + const chat = await client.getChatById(args.chat_id); + await chat.sendSeen(); + emitResponse(id, { success: true, chat_id: args.chat_id }); + break; + } + + case "mark_chat_unread": { + if (!isReady) { emitResponse(id, { success: false, error: "Client not ready" }); return; } + const chat = await client.getChatById(args.chat_id); + await chat.markUnread(); + emitResponse(id, { success: true, chat_id: args.chat_id }); + break; + } + + case "archive_chat": { + if (!isReady) { emitResponse(id, { success: false, error: "Client not ready" }); return; } + const chat = await client.getChatById(args.chat_id); + if (args.archive === false) await chat.unarchive(); else await chat.archive(); + emitResponse(id, { success: true, chat_id: args.chat_id, archived: args.archive !== false }); + break; + } + + case "pin_chat": { + if (!isReady) { emitResponse(id, { success: false, error: "Client not ready" }); return; } + const chat = await client.getChatById(args.chat_id); + if (args.pin === false) await chat.unpin(); else await chat.pin(); + emitResponse(id, { success: true, chat_id: args.chat_id, pinned: args.pin !== false }); + break; + } + + case "mute_chat": { + if (!isReady) { emitResponse(id, { success: false, error: "Client not ready" }); return; } + const chat = await client.getChatById(args.chat_id); + if (args.mute === false) { + await chat.unmute(); + } else { + // unmute_date is unix seconds (optional, otherwise mute forever) + const date = args.unmute_date ? new Date(args.unmute_date * 1000) : null; + await chat.mute(date); + } + emitResponse(id, { success: true, chat_id: args.chat_id, muted: args.mute !== false }); + break; + } + + case "clear_chat_messages": { + if (!isReady) { emitResponse(id, { success: false, error: "Client not ready" }); return; } + const chat = await client.getChatById(args.chat_id); + await chat.clearMessages(); + emitResponse(id, { success: true, chat_id: args.chat_id }); + break; + } + + case "delete_chat": { + if (!isReady) { emitResponse(id, { success: false, error: "Client not ready" }); return; } + const chat = await client.getChatById(args.chat_id); + await chat.delete(); + emitResponse(id, { success: true, chat_id: args.chat_id }); + break; + } + + case "send_typing_state": { + if (!isReady) { emitResponse(id, { success: false, error: "Client not ready" }); return; } + const chat = await client.getChatById(args.chat_id); + const state = args.state || "typing"; // typing | recording | clear + if (state === "recording") await chat.sendStateRecording(); + else if (state === "clear") await chat.clearState(); + else await chat.sendStateTyping(); + emitResponse(id, { success: true, chat_id: args.chat_id, state }); + break; + } + + // ───────────────────────────────────────────────────────────────── + // Groups + // ───────────────────────────────────────────────────────────────── + + case "create_group": { + if (!isReady) { emitResponse(id, { success: false, error: "Client not ready" }); return; } + // Resolve participants: phone numbers → JIDs + const participants = []; + for (const p of (args.participants || [])) { + if (p.includes("@")) { + participants.push(p); + } else { + const wid = await client.getNumberId(p.replace(/[\s\-\+\(\)]/g, "")); + if (wid) participants.push(wid._serialized); + } + } + const result = await client.createGroup(args.name, participants); + emitResponse(id, { + success: true, + group_id: result.gid?._serialized || result.gid || null, + missing_participants: result.missingParticipants || [], + }); + break; + } + + case "group_add_participants": { + if (!isReady) { emitResponse(id, { success: false, error: "Client not ready" }); return; } + const chat = await client.getChatById(args.group_id); + if (!chat.isGroup) { emitResponse(id, { success: false, error: "Not a group" }); return; } + const result = await chat.addParticipants(args.participants); + emitResponse(id, { success: true, result }); + break; + } + + case "group_remove_participants": { + if (!isReady) { emitResponse(id, { success: false, error: "Client not ready" }); return; } + const chat = await client.getChatById(args.group_id); + if (!chat.isGroup) { emitResponse(id, { success: false, error: "Not a group" }); return; } + const result = await chat.removeParticipants(args.participants); + emitResponse(id, { success: true, result }); + break; + } + + case "group_promote_participants": { + if (!isReady) { emitResponse(id, { success: false, error: "Client not ready" }); return; } + const chat = await client.getChatById(args.group_id); + if (!chat.isGroup) { emitResponse(id, { success: false, error: "Not a group" }); return; } + const result = await chat.promoteParticipants(args.participants); + emitResponse(id, { success: true, result }); + break; + } + + case "group_demote_participants": { + if (!isReady) { emitResponse(id, { success: false, error: "Client not ready" }); return; } + const chat = await client.getChatById(args.group_id); + if (!chat.isGroup) { emitResponse(id, { success: false, error: "Not a group" }); return; } + const result = await chat.demoteParticipants(args.participants); + emitResponse(id, { success: true, result }); + break; + } + + case "group_set_subject": { + if (!isReady) { emitResponse(id, { success: false, error: "Client not ready" }); return; } + const chat = await client.getChatById(args.group_id); + if (!chat.isGroup) { emitResponse(id, { success: false, error: "Not a group" }); return; } + await chat.setSubject(args.subject); + emitResponse(id, { success: true, group_id: args.group_id, subject: args.subject }); + break; + } + + case "group_set_description": { + if (!isReady) { emitResponse(id, { success: false, error: "Client not ready" }); return; } + const chat = await client.getChatById(args.group_id); + if (!chat.isGroup) { emitResponse(id, { success: false, error: "Not a group" }); return; } + await chat.setDescription(args.description); + emitResponse(id, { success: true, group_id: args.group_id }); + break; + } + + case "group_get_info": { + if (!isReady) { emitResponse(id, { success: false, error: "Client not ready" }); return; } + const chat = await client.getChatById(args.group_id); + if (!chat.isGroup) { emitResponse(id, { success: false, error: "Not a group" }); return; } + emitResponse(id, { success: true, info: { + id: chat.id._serialized, + name: chat.name, + description: chat.description || "", + owner: chat.owner?._serialized || "", + created_at: chat.createdAt || null, + participants: (chat.participants || []).map(p => ({ + id: p.id._serialized, + is_admin: p.isAdmin, + is_super_admin: p.isSuperAdmin, + })), + }}); + break; + } + + case "group_leave": { + if (!isReady) { emitResponse(id, { success: false, error: "Client not ready" }); return; } + const chat = await client.getChatById(args.group_id); + if (!chat.isGroup) { emitResponse(id, { success: false, error: "Not a group" }); return; } + await chat.leave(); + emitResponse(id, { success: true, group_id: args.group_id }); + break; + } + + case "group_invite_code": { + if (!isReady) { emitResponse(id, { success: false, error: "Client not ready" }); return; } + const chat = await client.getChatById(args.group_id); + if (!chat.isGroup) { emitResponse(id, { success: false, error: "Not a group" }); return; } + const code = await chat.getInviteCode(); + emitResponse(id, { success: true, invite_code: code, invite_url: `https://chat.whatsapp.com/${code}` }); + break; + } + + case "group_revoke_invite": { + if (!isReady) { emitResponse(id, { success: false, error: "Client not ready" }); return; } + const chat = await client.getChatById(args.group_id); + if (!chat.isGroup) { emitResponse(id, { success: false, error: "Not a group" }); return; } + const code = await chat.revokeInvite(); + emitResponse(id, { success: true, new_invite_code: code }); + break; + } + + case "accept_group_invite": { + if (!isReady) { emitResponse(id, { success: false, error: "Client not ready" }); return; } + const code = args.invite_code.replace(/^https?:\/\/chat\.whatsapp\.com\//, ""); + const groupId = await client.acceptInvite(code); + emitResponse(id, { success: true, group_id: groupId }); + break; + } + + // ───────────────────────────────────────────────────────────────── + // Contacts + // ───────────────────────────────────────────────────────────────── + + case "block_contact": { + if (!isReady) { emitResponse(id, { success: false, error: "Client not ready" }); return; } + const contact = await client.getContactById(args.contact_id); + if (args.block === false) await contact.unblock(); else await contact.block(); + emitResponse(id, { success: true, contact_id: args.contact_id, blocked: args.block !== false }); + break; + } + + case "get_profile_pic_url": { + if (!isReady) { emitResponse(id, { success: false, error: "Client not ready" }); return; } + try { + const url = await client.getProfilePicUrl(args.contact_id); + emitResponse(id, { success: true, url: url || "" }); + } catch (e) { + emitResponse(id, { success: true, url: "" }); + } + break; + } + + case "get_contact": { + if (!isReady) { emitResponse(id, { success: false, error: "Client not ready" }); return; } + const contact = await client.getContactById(args.contact_id); + let about = ""; + try { about = await contact.getAbout() || ""; } catch (_) {} + emitResponse(id, { success: true, contact: { + id: contact.id._serialized, + name: contact.name || "", + pushname: contact.pushname || "", + short_name: contact.shortName || "", + number: contact.number || "", + is_business: contact.isBusiness, + is_my_contact: contact.isMyContact, + is_blocked: contact.isBlocked, + is_user: contact.isUser, + is_group: contact.isGroup, + about, + }}); + break; + } + + case "get_all_contacts": { + if (!isReady) { emitResponse(id, { success: false, error: "Client not ready" }); return; } + // getContacts() can be slow on large accounts; filter to "my contacts" by default. + const contacts = await client.getContacts(); + const filtered = args.my_contacts_only === false + ? contacts + : contacts.filter(c => c.isMyContact); + const result = filtered.slice(0, args.limit || 500).map(c => ({ + id: c.id._serialized, + name: c.name || "", + pushname: c.pushname || "", + number: c.number || "", + is_business: c.isBusiness, + is_my_contact: c.isMyContact, + })); + emitResponse(id, { success: true, contacts: result, count: result.length }); + break; + } + + case "check_number_on_whatsapp": { + if (!isReady) { emitResponse(id, { success: false, error: "Client not ready" }); return; } + const clean = args.number.replace(/[\s\-\+\(\)]/g, ""); + const wid = await client.getNumberId(clean); + emitResponse(id, { + success: true, + on_whatsapp: !!wid, + jid: wid?._serialized || "", + }); + break; + } + default: emitResponse(id, { success: false, error: `Unknown command: ${cmd}` }); }