diff --git a/README.md b/README.md index 013254fc..badf9708 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,8 @@ projects for: - Composable templates for interoperability between common services - Demonstrations of advanced system capabilities and features -Go to the [samples](https://github.com/autokitteh/samples) repository to find -more projects that demonstrate foundational system features, integration API -details, and recommended practices. +In addition, the [samples](./samples/) directory contains projects that +demonstrate basic system features, integration APIs, and best practices. | Name | Description | Integrations | | :------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------------- | :------------------------------------------ | diff --git a/google_forms_to_jira/README.md b/google_forms_to_jira/README.md index 47f604f5..7da6819a 100644 --- a/google_forms_to_jira/README.md +++ b/google_forms_to_jira/README.md @@ -63,7 +63,7 @@ Google Forms: > [!TIP] > The exact CLI commands to do so (`ak connection init ...`) will appear in -> the output of the `ak deploy` command from step 3 when you create the +> the output of the `ak deploy` command from step 5 when you create the > project on the server, i.e. when you run that command for the first time. > [!IMPORTANT] diff --git a/samples/README.md b/samples/README.md new file mode 100644 index 00000000..3f7eabcd --- /dev/null +++ b/samples/README.md @@ -0,0 +1,24 @@ +# Sample Projects + +This directory contains [AutoKitteh](https://github.com/autokitteh/autokitteh) +projects that demonstrate basic system features, integration APIs, and best +practices. + +- [Atlassian](./atlassian/) + - Confluence + - [Jira](./jira/) +- [Discord](./discord/) +- [GitHub](./github/) +- [Google](./google/) + - [Calendar](./google/calendar/) + - Drive + - [Forms](./google/forms/) + - [Gmail](./google/gmail/) + - [Sheets](./google/sheets/) +- [gRPC](./grpc/) +- [HTTP](./http/) +- [OpenAPI ChatGPT](./openai_chatgpt/) +- [Runtime AutoKitteh event handling](./runtime_events/) +- [Scheduler](./scheduler/) +- [Slack](./slack/) +- [Twilio](./twilio/) diff --git a/samples/atlassian/README.md b/samples/atlassian/README.md new file mode 100644 index 00000000..50a38ada --- /dev/null +++ b/samples/atlassian/README.md @@ -0,0 +1,23 @@ +# Atlassian Samples + +These [AutoKitteh](https://github.com/autokitteh/autokitteh) projects +demonstrate 2-way integration with Atlassian services and APIs. + +- Confluence +- [Jira](./jira/) + +## Connection Notes + +AutoKitteh supports three connection modes: + +- User impersonation with: + + - [API token](https://id.atlassian.com/manage-profile/security/api-tokens) + (available only in Atlassian Cloud) + + - [Personal Access Token (PAT)](https://confluence.atlassian.com/enterprise/using-personal-access-tokens-1026032365.html) + (available only in on-prem servers) + +- [OAuth 2.0 (3LO) app](https://developer.atlassian.com/cloud/jira/platform/oauth-2-3lo-apps/) + + - [AutoKitteh guide: configuring Atlassian integrations](https://docs.autokitteh.com/integrations/atlassian/config) diff --git a/samples/atlassian/jira/README.md b/samples/atlassian/jira/README.md new file mode 100644 index 00000000..486cabd8 --- /dev/null +++ b/samples/atlassian/jira/README.md @@ -0,0 +1,54 @@ +# Atlassian Jira Sample + +This [AutoKitteh](https://github.com/autokitteh/autokitteh) project +demonstrates 2-way integration with +[Jira](https://www.atlassian.com/software/jira/guides/). + +Jira API documentation: + +- [REST API](https://developer.atlassian.com/cloud/jira/platform/rest/v3/intro/) +- ["Atlassian Python API" Python library](https://atlassian-python-api.readthedocs.io/) +- ["Jira" Python library](https://jira.readthedocs.io/) + +Python code samples: + +- [Atlassian Python API](https://github.com/atlassian-api/atlassian-python-api/tree/master/examples/jira) +- [Jira](https://github.com/pycontribs/jira/tree/main/examples) + +This program isn't meant to cover all available functions and events. It +merely showcases a few illustrative, annotated, reusable examples. + +## Instructions + +1. Deploy the manifest file: + + ```shell + ak deploy --manifest samples/jira/autokitteh.yaml + ``` + +2. Follow the instructions in the `ak` CLI tool's output: + + ``` + Connection created, but requires initialization. + Please run this to complete: + + ak connection init + ``` + +3. Create a new issue in Jira, and check its comments + +## Connection Notes + +AutoKitteh supports three connection modes: + +- User impersonation with: + + - [API token](https://id.atlassian.com/manage-profile/security/api-tokens) + (available only in Atlassian Cloud) + + - [Personal Access Token (PAT)](https://confluence.atlassian.com/enterprise/using-personal-access-tokens-1026032365.html) + (available only in on-prem servers) + +- [OAuth 2.0 (3LO) app](https://developer.atlassian.com/cloud/jira/platform/oauth-2-3lo-apps/) + + - [AutoKitteh guide: configuring Atlassian integrations](https://docs.autokitteh.com/integrations/atlassian/config) diff --git a/samples/atlassian/jira/autokitteh.yaml b/samples/atlassian/jira/autokitteh.yaml new file mode 100644 index 00000000..a4060830 --- /dev/null +++ b/samples/atlassian/jira/autokitteh.yaml @@ -0,0 +1,20 @@ +# This YAML file is a declarative manifest that describes the setup +# of an AutoKitteh project that demonstrates 2-way integration with +# Atlassian Jira (https://www.atlassian.com/software/jira). + +version: v1 + +project: + name: jira_sample + connections: + - name: jira_conn + integration: jira + triggers: + - name: jira_comment_created + connection: jira_conn + event_type: comment_created + call: program.py:on_jira_comment_created + - name: jira_issue_created + connection: jira_conn + event_type: issue_created + call: program.py:on_jira_issue_created diff --git a/samples/atlassian/jira/program.py b/samples/atlassian/jira/program.py new file mode 100644 index 00000000..dcd255c8 --- /dev/null +++ b/samples/atlassian/jira/program.py @@ -0,0 +1,25 @@ +"""This program demonstrates AutoKitteh's 2-way Atlassian Jira integration. + +Atlassian Jira API documentation: +- https://docs.autokitteh.com/integrations/atlassian/jira/python +- https://docs.autokitteh.com/integrations/atlassian/jira/events +""" + +from autokitteh.atlassian import jira_client + + +def on_jira_issue_created(event): + issue_key = event.data.issue.key + user_name = event.data.user.displayName + + jira = jira_client("jira_conn") + jira.issue_add_comment(issue_key, "This issue was created by " + user_name) + + +def on_jira_comment_created(event): + issue_key = event.data.issue.key + comment = event.data.comment + + jira = jira_client("jira_conn") + suffix = "\n\nThis comment was added by " + comment.author.displayName + jira.issue_edit_comment(issue_key, comment.id, comment.body + suffix) diff --git a/samples/discord/README.md b/samples/discord/README.md new file mode 100644 index 00000000..a0e9df06 --- /dev/null +++ b/samples/discord/README.md @@ -0,0 +1,4 @@ +# Discord Samples + +These [AutoKitteh](https://github.com/autokitteh/autokitteh) projects +demonstrate 2-way integration with [Discord](https://discord.com/). diff --git a/samples/discord/discord_client/autokitteh.yaml b/samples/discord/discord_client/autokitteh.yaml new file mode 100644 index 00000000..f7493fff --- /dev/null +++ b/samples/discord/discord_client/autokitteh.yaml @@ -0,0 +1,19 @@ +# This YAML file is a declarative manifest that describes the setup +# of an AutoKitteh project that demonstrates 2-way integration with +# Discord (https://discord.com/). + +version: v1 + +project: + name: discord_client_sample + vars: + - name: CHANNEL_ID + value: + connections: + - name: discord_conn + integration: discord + triggers: + - name: start_event_loop + type: webhook + event_type: get + call: program.py:start_event_loop diff --git a/samples/discord/discord_client/program.py b/samples/discord/discord_client/program.py new file mode 100644 index 00000000..0bdcd758 --- /dev/null +++ b/samples/discord/discord_client/program.py @@ -0,0 +1,70 @@ +""" +This program demonstrates how to use Autokitteh's Discord integration to create a bot that performs basic operations. + +Modules: + - autokitteh.discord: Custom wrapper for the Discord API client. + - discord: The native Discord client API. + +Functions: + - on_ready: An asynchronous function that is triggered when the bot successfully connects to Discord. + It checks for the availability of a specified channel and sends a message if the channel is found. + Handles permission and HTTP-related exceptions when attempting to send the message. + + - on_http_get: An AutoKitteh activity that runs the bot when an HTTP GET request is received. + Retrieves the bot token and initiates the bot connection. + + - client.run(token): Starts the bot and connects it to the Discord gateway. + This function blocks execution until the bot is stopped. While running, the bot listens for and + responds to events, such as `on_ready`. + + The `run` method initializes the event loop for the bot, connecting it to Discord using the specified token. + Once the connection is established, it triggers the `on_ready` event, allowing the bot to perform any + startup actions, such as checking for available channels and sending messages. This combination of + `run` and `on_ready` forms the backbone of the bot's lifecycle: + - `run` starts the bot and maintains the connection to Discord. + - `on_ready` is triggered automatically when the connection to Discord is fully established. + +Notes: + - This bot listens for the `on_ready` event to confirm a successful connection. + - The bot sends a "Meow!" message to the specified channel once connected. +""" + +import os + +import autokitteh +import autokitteh.discord as ak_discord +import discord + + +intents = discord.Intents.default() +intents.message_content = True + +client = ak_discord.discord_client("discord_conn", intents) + + +@autokitteh.activity +def start_event_loop(event): + client.run(ak_discord.bot_token("discord_conn")) + return + + +@client.event +async def on_ready(): + print(f"We have logged in as {client.user}") + channel_id = int(os.getenv("CHANNEL_ID")) + channel = client.get_channel(channel_id) + + if channel is None: + print(f"Channel with ID {channel_id} not found") + print("Available channels:") + for guild in client.guilds: + for channel in guild.channels: + print(f"{channel.name} (ID: {channel.id})") + return + + try: + await channel.send("Meow!") + except discord.Forbidden: + print("The bot does not have permission to send messages in this channel.") + except discord.HTTPException as e: + print(f"Failed to send message: {e}") diff --git a/samples/discord/events/README.md b/samples/discord/events/README.md new file mode 100644 index 00000000..80c6c1f3 --- /dev/null +++ b/samples/discord/events/README.md @@ -0,0 +1,62 @@ +# Discord Events + +This project demonstrates how to utilize AutoKitteh's event system for Discord, offering a simpler alternative to the [discord.py library](../discord_client/) when working with supported [events](https://docs.autokitteh.com/integrations/discord/events). The program listens for message-related events (message creation, update, and deletion) in Discord and logs the corresponding information. This example serves as a foundational guide for integrating AutoKitteh's Discord event handling with other services. + +## Benefits + +- **Simple Integration:** This workflow is designed to easily connect Discord to custom logging or processing systems. +- **Modular Design:** You can extend or modify this program to suit your specific needs, such as adding more event types or integrating with external APIs. +- **Open Source:** Feel free to modify and use this program in your own projects. + +## How It Works + +- **Message Events:** The program listens for three types of message events in Discord: + - `Message Create`: Captures when a message is sent and logs the username and message content. + - `Message Update`: Logs when a message is updated. + - `Message Delete`: Logs when a message is deleted. + +## Installation and Usage + +1. Clone the Repository: + + ```bash + git clone https://github.com/autokitteh/kittehub.git + cd kittehub/discord_message_logger + ``` + +2. Install AutoKitteh: + + Follow the installation guide for AutoKitteh: + [AutoKitteh Installation](https://docs.autokitteh.com/get_started/install) + +3. Configure Discord Integration: + + Set up your Discord bot and connection using the documentation: + [Discord Integration Guide](https://docs.autokitteh.com/integrations/discord/connect) + +4. Run the AutoKitteh Server: + + Run the following command to start the server: + ```bash + ak up --mode dev + ``` + +5. Deploy the Project: + + Apply the manifest and deploy the project: + ```bash + ak deploy --manifest autokitteh.yaml + ``` + + This command will output a connection ID for your Discord integration. + +6. Initialize the Connection: + + Using the connection ID from the previous step, initialize the Discord connection: + ```bash + ak connection init discord_conn + ``` + +7. Start Logging Discord Messages: + + The workflow will be triggered automatically when a message-related event occurs in Discord. diff --git a/samples/discord/events/autokitteh.yaml b/samples/discord/events/autokitteh.yaml new file mode 100644 index 00000000..f8bfc38c --- /dev/null +++ b/samples/discord/events/autokitteh.yaml @@ -0,0 +1,24 @@ +# This YAML file is a declarative manifest that describes the setup +# of an AutoKitteh project that demonstrates 2-way integration with +# Discord (https://discord.com/). + +version: v1 + +project: + name: discord_events_sample + connections: + - name: discord_conn + integration: discord + triggers: + - name: discord_message_create + connection: discord_conn + event_type: message_create + call: program.py:on_discord_message_create + - name: discord_message_update + connection: discord_conn + event_type: message_update + call: program.py:on_discord_message_update + - name: discord_message_delete + connection: discord_conn + event_type: message_delete + call: program.py:on_discord_message_delete diff --git a/samples/discord/events/program.py b/samples/discord/events/program.py new file mode 100644 index 00000000..d46da83a --- /dev/null +++ b/samples/discord/events/program.py @@ -0,0 +1,15 @@ +"""This program listens for message-related events in Discord and logs the corresponding information +using the `autokitteh.discord` client. +""" + + +def on_discord_message_create(event): + print(f'User {event.data["author"]["username"]} sent: {event.data["content"]}') + + +def on_discord_message_update(event): + print(f"Message updated to: {event.data['content']}") + + +def on_discord_message_delete(event): + print(f"Message with ID {event.data['id']} was deleted") diff --git a/samples/github/README.md b/samples/github/README.md new file mode 100644 index 00000000..5b2a3900 --- /dev/null +++ b/samples/github/README.md @@ -0,0 +1,46 @@ +# GitHub Sample + +This [AutoKitteh](https://github.com/autokitteh/autokitteh) project +demonstrates 2-way integration with [GitHub](https://github.com). + +The file [`program.star`](./program.star) implements multiple entry-point +functions that are triggered by various GitHub webhook events, which are +defined in the [`autokitteh.yaml`](./autokitteh.yaml) manifest file. It also +executes various GitHub API calls. + +The file [`workflow.star`](./workflow.star) demonstrates triggering GitHub +Action workflows, and receiving workflow events. + +GitHub API details: + +- [REST API reference](https://docs.github.com/en/rest) +- [Go client API](https://pkg.go.dev/github.com/google/go-github/v57/github) + +It also demonstrates using a custom builtin function (`rand.intn`) to generate +random integer numbers, based on . + +This project isn't meant to cover all available functions and events. It +merely showcases a few illustrative, annotated, reusable examples. + +## Instructions + +1. [Configure your GitHub integration](https://docs.autokitteh.com/integrations/github). + +2. Via the `ak` CLI tool, or the AutoKitteh VS Code extension, deploy the + `autokitteh.yaml` manifest file + +## Connection Notes + +AutoKitteh supports 2 connection modes: + +- Personal Access Token (PAT - either fine-grained or classic) + manually-configured + webhook + + - [Authenticating with a personal access token](https://docs.github.com/en/rest/authentication/authenticating-to-the-rest-api#authenticating-with-a-personal-access-token) + - [Managing your personal access tokens](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) + - [Setting a PAT policy for your organization](https://docs.github.com/en/organizations/managing-programmatic-access-to-your-organization/setting-a-personal-access-token-policy-for-your-organization) + - [Endpoints available for fine-grained PATs](https://docs.github.com/en/rest/authentication/endpoints-available-for-fine-grained-personal-access-tokens) + +- GitHub App (installed and authorized in step 1 above) + + - [About using GitHub Apps](https://docs.github.com/en/apps/using-github-apps/about-using-github-apps) diff --git a/samples/github/autokitteh.yaml b/samples/github/autokitteh.yaml new file mode 100644 index 00000000..78ac724a --- /dev/null +++ b/samples/github/autokitteh.yaml @@ -0,0 +1,31 @@ +# This YAML file is a declarative manifest that describes the setup +# of an AutoKitteh project that demonstrates 2-way integration with +# GitHub (https://github.com). + +version: v1 + +project: + name: github_sample + connections: + - name: github_conn + integration: github + triggers: + - name: github_issue_comment + connection: github_conn + event_type: issue_comment + # Handle only new issue comments in this sample code + # (FYI, the other options are "edited" and "deleted"). + filter: data.action == "created" + call: program.star:on_github_issue_comment + - name: github_workflow_dispatch + connection: github_conn + event_type: workflow_dispatch + call: workflow.star:on_github_workflow_dispatch + - name: github_workflow_job + connection: github_conn + event_type: workflow_job + call: workflow.star:on_github_workflow_job + - name: github_workflow_run + connection: github_conn + event_type: workflow_run + call: workflow.star:on_github_workflow_run diff --git a/samples/github/program.star b/samples/github/program.star new file mode 100644 index 00000000..3d843539 --- /dev/null +++ b/samples/github/program.star @@ -0,0 +1,44 @@ +"""This program demonstrates AutoKitteh's GitHub integration. + +This program implements multiple entry-point functions that are triggered by +various GitHub webhook events, which are defined in the "autokitteh.yaml" +manifest file. It also executes various GitHub API calls. + +API details: +- REST API referene: https://docs.github.com/en/rest +- Go client API: https://pkg.go.dev/github.com/google/go-github/v57/github + +It also demonstrates using a custom builtin function (rand.intn) to generate +random integer numbers (based on https://pkg.go.dev/math/rand#Rand.Intn). + +This program isn't meant to cover all available functions and events. +It merely showcases a few illustrative, annotated, reusable examples. + +Starlark is a dialect of Python (see https://bazel.build/rules/language). +""" + +load("@github", "github_conn") + +# https://docs.github.com/en/rest/reactions/reactions#about-reactions +REACTIONS = ["+1", "-1", "laugh", "confused", "heart", "hooray", "rocket", "eyes"] + +def on_github_issue_comment(data): + """https://docs.github.com/en/rest/overview/github-event-types#issuecommentevent + + Based on the filter in the "autokitteh.yaml" manifest file, + handle only *new* issue comments in this sample code + (FYI, the other options are "edited" and "deleted"). + + Args: + data: GitHub event data. + """ + + # Add to each new issue comment a random reaction emoji. + # rand.intn: https://pkg.go.dev/math/rand#Rand.Intn. + reaction = REACTIONS[rand.intn(len(REACTIONS))] + github_conn.create_reaction_for_issue_comment( + owner = data.repo.owner.login, + repo = data.repo.name, + id = data.comment.id, + content = reaction, + ) diff --git a/samples/github/workflow.star b/samples/github/workflow.star new file mode 100644 index 00000000..6641ebff --- /dev/null +++ b/samples/github/workflow.star @@ -0,0 +1,60 @@ +"""Trigger GitHub Action workflows, and receive workflow events.""" + +load("@github", "github_conn") + +def start_github_action(data): + """Start a GitHub action workflow. + + See the following link for more information: + https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#workflow_dispatch + + This function isn't configured in "autokitteh.yaml" by default as the + entry-point for an AutoKitteh trigger, because it requires a named + workflow YAML file to be present in a relevant GitHub repository. + + Example workflow YAML file (in the repo's ".github/workflows" directory): + + on: workflow_dispatch + jobs: + job-name: + runs-on: ubuntu-latest + steps: + - run: echo "Do stuff" + + Args: + data: GitHub event data (e.g. new pull request). + """ + repo = data.repo + owner = repo.owner.login + ref = data.pull_request.head.ref # Branch name or tag + workflow_file = "dispatch.yml" # .github/workflows/dispatch.yml + + # https://docs.github.com/en/rest/actions/workflows#create-a-workflow-dispatch-event + github_conn.trigger_workflow(owner, repo.name, ref, workflow_file) + +def on_github_workflow_dispatch(data): + """https://docs.github.com/en/webhooks/webhook-events-and-payloads#workflow_dispatch + + Args: + data: GitHub event data. + """ + print("Workflow dispatch: " + data.workflow) + print(data.inputs) + +def on_github_workflow_job(data): + """https://docs.github.com/en/webhooks/webhook-events-and-payloads#workflow_job + + Args: + data: GitHub event data. + """ + print("Workflow job %s: %s" % (data.action, data.workflow_job.name)) + print(data.workflow_job.htmlurl) + +def on_github_workflow_run(data): + """https://docs.github.com/en/webhooks/webhook-events-and-payloads#workflow_run + + Args: + data: GitHub event data. + """ + print("Workflow run %s: %s" % (data.action, data.workflow_run.name)) + print(data.workflow_run.htmlurl) diff --git a/samples/google/README.md b/samples/google/README.md new file mode 100644 index 00000000..93e406ff --- /dev/null +++ b/samples/google/README.md @@ -0,0 +1,32 @@ +# Google Samples + +These [AutoKitteh](https://github.com/autokitteh/autokitteh) projects +demonstrate 2-way integration with Google services and APIs. + +## Google Workspace + +- [Calendar](./calendar/) +- Drive +- [Forms](./forms/) +- [Gmail](./gmail/) +- [Sheets](./sheets/) + +## Google Cloud + +Coming soon, stay tuned! + +## Connection Modes + +AutoKitteh supports two connection modes: + +- User impersonation (OAuth 2.0) + + - [AutoKitteh guide: configuring Google integrations](https://docs.autokitteh.com/integrations/google/config) + - [Learn about authentication and authorization](https://developers.google.com/workspace/guides/auth-overview) + +- GCP service account (JSON key) + + - [GCP service accounts overview](https://cloud.google.com/iam/docs/service-account-overview) + - [Service account credentials](https://cloud.google.com/iam/docs/service-account-creds) + - [Create and delete service account keys](https://cloud.google.com/iam/docs/keys-create-delete) + - [Best practices for managing service account keys](https://cloud.google.com/iam/docs/best-practices-for-managing-service-account-keys) diff --git a/samples/google/calendar/README.md b/samples/google/calendar/README.md new file mode 100644 index 00000000..6db4697a --- /dev/null +++ b/samples/google/calendar/README.md @@ -0,0 +1,77 @@ +# Google Calendar Sample + +This AutoKitteh project demonstrates 2-way integration with +[Google Calendar](https://workspace.google.com/products/calendar/). + +## API Documentation + +- https://docs.autokitteh.com/integrations/google/calendar/python +- https://docs.autokitteh.com/integrations/google/calendar/events + +## Setup Instructions + +1. Install and start a + [self-hosted AutoKitteh server](https://docs.autokitteh.com/get_started/quickstart), + or use AutoKitteh Cloud + +2. Optional for self-hosted servers (preconfigured in AutoKitteh Cloud): \ + [enable Google connections to use OAuth 2.0](https://docs.autokitteh.com/integrations/google/config) + +> [!NOTE] +> No need to configure GCP Cloud Pub/Sub for this sample - only the Gmail and +> Google Forms integrations require it. + +3. Run these commands to deploy this project's manifest file: + + ```shell + git clone https://github.com/autokitteh/kittehub.git + ak deploy --manifest kittehub/samples/google/calendar/autokitteh.yaml + ``` + +4. Look for the following line in the output of the `ak deploy` command, and + copy the URL path for later: + + ``` + [!!!!] trigger "list_events" created, webhook path is "/webhooks/..." + ``` + +> [!TIP] +> If you don't see the output of `ak deploy` anymore, you can run this command +> instead, and use the webhook slug from the output: +> +> ```shell +> ak trigger get list_events --project google_calendar_sample -J +> ``` + +5. Initialize this project's Google Calendar connection, with user + impersonation using OAuth 2.0 (based on step 2), or a GCP service account's + JSON key + +> [!TIP] +> The exact CLI command to do so (`ak connection init ...`) will appear in the +> output of the `ak deploy` command from step 3 when you create the project on +> the server, i.e. when you run that command for the first time. + +> [!IMPORTANT] +> Specify the ID of a calendar that you own (e.g. `primary`), to receive +> notifications about it. + +## Usage Instructions + +Outgoing API calls: + +1. Run this command to start a session that lists the next 10 calendar events + (use the URL path from step 4 above instead of `/webhooks/...`): + + ```shell + curl -i "http://localhost:9980/webhooks/..." + ``` + +2. Check out the resulting session log in the AutoKitteh server + +Incoming events: + +1. Create/edit/delete + [Google Calendar events](https://developers.google.com/calendar/api/guides/event-types) + +2. Check out the resulting session logs in the AutoKitteh server diff --git a/samples/google/calendar/autokitteh.yaml b/samples/google/calendar/autokitteh.yaml new file mode 100644 index 00000000..27338420 --- /dev/null +++ b/samples/google/calendar/autokitteh.yaml @@ -0,0 +1,28 @@ +# This YAML file is a declarative manifest that describes the setup +# of an AutoKitteh project that demonstrates 2-way integration with +# Google Calendar (https://workspace.google.com/products/calendar/). + +version: v1 + +project: + name: google_calendar_sample + connections: + - name: calendar_conn + integration: googlecalendar + triggers: + - name: list_events + type: webhook + event_type: get + call: program.py:list_events + - name: google_calendar_event_created + connection: calendar_conn + event_type: event_created + call: program.py:on_calendar_event_created + - name: google_calendar_event_updated + connection: calendar_conn + event_type: event_updated + call: program.py:on_calendar_event_updated + - name: google_calendar_event_deleted + connection: calendar_conn + event_type: event_deleted + call: program.py:on_calendar_event_deleted diff --git a/samples/google/calendar/program.py b/samples/google/calendar/program.py new file mode 100644 index 00000000..7b5d0d26 --- /dev/null +++ b/samples/google/calendar/program.py @@ -0,0 +1,55 @@ +"""This program demonstrates AutoKitteh's 2-way Google Calendar integration. + +API documentation: +- https://docs.autokitteh.com/integrations/google/calendar/python +- https://docs.autokitteh.com/integrations/google/calendar/events +""" + +from datetime import UTC, datetime + +from autokitteh.google import google_calendar_client +from googleapiclient.errors import HttpError + + +def list_events(event): + """Get the next 10 events from the primary calendar. + + This is the same as Google's quickstart code sample, but simpler: + https://github.com/googleworkspace/python-samples/tree/main/calendar + """ + gcal = google_calendar_client("calendar_conn").events() + print("Getting the next 10 events") + + try: + result = gcal.list( + calendarId="primary", + timeMin=datetime.now(UTC).isoformat(), + maxResults=10, + singleEvents=True, + orderBy="startTime", + ).execute() + except HttpError as e: + print(f"An error occurred: {e.reason}") + return + + events = result.get("items") + if not events: + print("No upcoming events found") + return + + for e in events: + start = e["start"].get("dateTime") or e["start"].get("date") + start = datetime.fromisoformat(start) + print(f"{start} - {e['summary']}") + + +def on_calendar_event_created(event): + print("Event created:", event.data) + + +def on_calendar_event_updated(event): + print("Event updated:", event.data) + + +def on_calendar_event_deleted(event): + print("Event deleted:", event.data) diff --git a/samples/google/forms/README.md b/samples/google/forms/README.md new file mode 100644 index 00000000..a55d7c13 --- /dev/null +++ b/samples/google/forms/README.md @@ -0,0 +1,79 @@ +# Google Forms Sample + +This AutoKitteh project demonstrates 2-way integration with +[Google Forms](https://www.google.com/forms/about/). + +It appends questions to a form, and handles two event types: form changes +(a.k.a. `schema`) and form responses (a.k.a. `responses`). + +## API Documentation + +- https://docs.autokitteh.com/integrations/google/forms/python +- https://docs.autokitteh.com/integrations/google/forms/events + +## Setup Instructions + +1. Install and start a + [self-hosted AutoKitteh server](https://docs.autokitteh.com/get_started/quickstart), + or use AutoKitteh Cloud + +2. Optional for self-hosted servers (preconfigured in AutoKitteh Cloud): \ + [enable Google connections to use OAuth 2.0](https://docs.autokitteh.com/integrations/google/config) + +3. Run these commands to deploy this project's manifest file: + + ```shell + git clone https://github.com/autokitteh/kittehub.git + ak deploy --manifest kittehub/samples/google/forms/autokitteh.yaml + ``` + +4. Look for the following line in the output of the `ak deploy` command, and + copy the URL path for later: + + ``` + [!!!!] trigger "add_question" created, webhook path is "/webhooks/..." + ``` + +> [!TIP] +> If you don't see the output of `ak deploy` anymore, you can run this command +> instead, and use the webhook slug from the output: +> +> ```shell +> ak trigger get add_question --project google_forms_sample -J +> ``` + +5. Initialize this project's Google Forms connection, with user impersonation + using OAuth 2.0 (based on step 2), or a GCP service account's JSON key + +> [!TIP] +> The exact CLI command to do so (`ak connection init ...`) will appear in the +> output of the `ak deploy` command from step 3 when you create the project on +> the server, i.e. when you run that command for the first time. + +> [!IMPORTANT] +> Specify the ID of a form that you own, to receive notifications about it. + +## Usage Instructions + +1. Run this command to start a session that appends a question to the form + watched by the Google Forms connection (use the URL path from step 4 + above instead of `/webhooks/...`): + + + ```shell + curl -i "http://localhost:9980/webhooks/..." + ``` + +2. The session in step 1 will cause Google Forms to send a form-change event + (a.k.a. `schema`) to the AutoKitteh server, which will start another session + +3. Check out both session logs in the AutoKitteh server: + + - The first one, triggered by the HTTP request + - The second one, triggered by the subsequent Google Forms event + +4. Submit a response to the form - this will cause Google Forms to send a + new-response event (a.k.a. `responses`) to the AutoKitteh server, which + will start a third session + +5. Check out the resulting session log in the AutoKitteh server diff --git a/samples/google/forms/autokitteh.yaml b/samples/google/forms/autokitteh.yaml new file mode 100644 index 00000000..0ed6ff96 --- /dev/null +++ b/samples/google/forms/autokitteh.yaml @@ -0,0 +1,24 @@ +# This YAML file is a declarative manifest that describes the setup +# of an AutoKitteh project that demonstrates 2-way integration with +# Google Forms (https://www.google.com/forms/about/). + +version: v1 + +project: + name: google_forms_sample + connections: + - name: forms_conn + integration: googleforms + triggers: + - name: add_question + type: webhook + event_type: get + call: program.py:add_question + - name: google_forms_schema_change + connection: forms_conn + event_type: schema + call: program.py:on_form_change + - name: google_forms_response + connection: forms_conn + event_type: responses + call: program.py:on_form_response diff --git a/samples/google/forms/new_question.json b/samples/google/forms/new_question.json new file mode 100644 index 00000000..ad3e03a7 --- /dev/null +++ b/samples/google/forms/new_question.json @@ -0,0 +1,27 @@ +{ + "requests": [ + { + "createItem": { + "item": { + "title": "In what year did the United States land a mission on the moon?", + "questionItem": { + "question": { + "required": true, + "choiceQuestion": { + "type": "RADIO", + "options": [ + {"value": "1965"}, + {"value": "1967"}, + {"value": "1969"}, + {"value": "1971"} + ], + "shuffle": true + } + } + } + }, + "location": {"index": 0} + } + } + ] +} diff --git a/samples/google/forms/program.py b/samples/google/forms/program.py new file mode 100644 index 00000000..7eb3c137 --- /dev/null +++ b/samples/google/forms/program.py @@ -0,0 +1,42 @@ +"""This program demonstrates AutoKitteh's 2-way Google Forms integration. + +API documentation: +- https://docs.autokitteh.com/integrations/google/forms/python +- https://docs.autokitteh.com/integrations/google/forms/events +""" + +import json +import os +from pathlib import Path + +from autokitteh.google import google_forms_client + + +def add_question(event): + """Add a new question to the form that our connection watches. + + This is the same as Google's quickstart code sample, but simpler: + https://github.com/googleworkspace/python-samples/tree/main/forms + """ + # Get the form that our connection watches. + forms = google_forms_client("forms_conn").forms() + form_id = os.environ.get("forms_conn__FormID") + form = forms.get(formId=form_id).execute() + new_index = len(form["items"]) + + # Add a new question to the form. + body = Path("new_question.json").read_text().replace("0", str(new_index)) + result = forms.batchUpdate(formId=form_id, body=json.loads(body)).execute() + print(result) + + +def on_form_change(event): + title = event.data.form.info.title + form_id = event.data.form_id + revision = event.data.form.revision_id + items = len(event.data.form.get("items", [])) + print(f"Form change: {title} ({form_id}), revision {revision}, {items} items") + + +def on_form_response(event): + print("New form response submitted:", event.data) diff --git a/samples/google/gmail/README.md b/samples/google/gmail/README.md new file mode 100644 index 00000000..1edfd0f3 --- /dev/null +++ b/samples/google/gmail/README.md @@ -0,0 +1,61 @@ +# Gmail Sample + +This AutoKitteh project demonstrates 2-way integration with +[Gmail](https://www.google.com/gmail/about/). + +## API Documentation + +- https://docs.autokitteh.com/integrations/google/gmail/python +- https://docs.autokitteh.com/integrations/google/gmail/events + +## Setup Instructions + +1. Install and start a + [self-hosted AutoKitteh server](https://docs.autokitteh.com/get_started/quickstart), + or use AutoKitteh Cloud + +2. Optional for self-hosted servers (preconfigured in AutoKitteh Cloud): + + - [Enable Google connections to use OAuth 2.0](https://docs.autokitteh.com/integrations/google/config) + - [Enable Slack connections to use an OAuth v2 app](https://docs.autokitteh.com/integrations/slack/config) + +3. Run these commands to deploy this project's manifest file: + + ```shell + git clone https://github.com/autokitteh/kittehub.git + ak deploy --manifest kittehub/samples/google/gmail/autokitteh.yaml + ``` + +4. Initialize this project's connections: + + - Google Sheets: with user impersonation using OAuth 2.0 (based on step 2), + or a GCP service account's JSON key + - Slack: with an OAuth v2 app (based on step 2), or a Socket Mode app + +> [!TIP] +> The exact CLI commands to do so (`ak connection init ...`) will appear in +> the output of the `ak deploy` command from step 3 when you create the +> project on the server, i.e. when you run that command for the first time. + +## Usage Instructions + +1. Run a slash command of the Slack app that you initialized in step 4 above, + with any of these commands as the slash command's text: + + - `gmail get profile` + - `gmail drafts list [optional query]` + - `gmail drafts get ` + - `gmail messages list [optional query]` + - `gmail messages get ` + - `gmail messages send ` + +2. See the Slack app's DM responses to you + +3. Send to yourself an email, using the Slack slash command with this text: + + ``` + gmail messages send + ``` + +4. See the resulting message in the specified Slack channel - which is a + result of handling a mailbox change event from Gmail diff --git a/samples/google/gmail/autokitteh.yaml b/samples/google/gmail/autokitteh.yaml new file mode 100644 index 00000000..4b08a6ff --- /dev/null +++ b/samples/google/gmail/autokitteh.yaml @@ -0,0 +1,22 @@ +# This YAML file is a declarative manifest that describes the setup +# of an AutoKitteh project that demonstrates 2-way integration with +# Gmail (https://www.google.com/gmail/about/). + +version: v1 + +project: + name: gmail_sample + connections: + - name: gmail_conn + integration: gmail + - name: slack_conn + integration: slack + triggers: + - name: slack_slash_command + connection: slack_conn + event_type: slash_command + call: program.py:on_slack_slash_command + - name: gmail_mailbox_change + connection: gmail_conn + event_type: mailbox_change + call: program.py:on_gmail_mailbox_change diff --git a/samples/google/gmail/program.py b/samples/google/gmail/program.py new file mode 100644 index 00000000..dc07e9c4 --- /dev/null +++ b/samples/google/gmail/program.py @@ -0,0 +1,192 @@ +"""This program demonstrates AutoKitteh's 2-way Gmail integration. + +API documentation: +- https://docs.autokitteh.com/integrations/google/gmail/python +- https://docs.autokitteh.com/integrations/google/gmail/events +""" + +import base64 +import json + +from autokitteh.google import gmail_client +from autokitteh.slack import slack_client +from googleapiclient.errors import HttpError + + +gmail = gmail_client("gmail_conn").users() +slack = slack_client("slack_conn") + + +def on_slack_slash_command(event): + """Use a Slack slash command to interact with a Gmail mailbox. + + See: https://api.slack.com/interactivity/slash-commands, and + https://api.slack.com/interactivity/handling#message_responses + + In this sample, we expect the slash command's text to be: + - "gmail get profile" + - "gmail drafts list [optional query]" + - "gmail drafts get " + - "gmail messages list [optional query]" + - "gmail messages get " + - "gmail messages send " + + Args: + event: Slack event data. + """ + for cmd, handler in COMMANDS.items(): + if event.data.text.startswith(cmd): + handler(event.data.user_id, event.data.text[len(cmd) + 1 :]) + return + + +def _get_profile(slack_channel, _): + """https://developers.google.com/resources/api-libraries/documentation/gmail/v1/python/latest/gmail_v1.users.html#getProfile + + Args: + slack_channel: Slack channel name/ID to post debug messages to. + _: Unused suffix of the user's Slack command, if any. + """ + resp = gmail.getProfile(userId="me").execute() + slack.chat_postMessage(channel=slack_channel, text=resp["emailAddress"]) + msg = f"Total no. of messages: `{resp['messagesTotal']}`" + slack.chat_postMessage(channel=slack_channel, text=msg) + msg = f"Total no. of threads: `{resp['threadsTotal']}`" + slack.chat_postMessage(channel=slack_channel, text=msg) + msg = f"Current History record ID: `{resp['historyId']}`" + slack.chat_postMessage(channel=slack_channel, text=msg) + + +def _drafts_get(slack_channel, id): + """https://developers.google.com/resources/api-libraries/documentation/gmail/v1/python/latest/gmail_v1.users.drafts.html#get + + Args: + slack_channel: Slack channel name/ID to post debug messages to. + id: Required ID of the draft to retrieve. + """ + try: + resp = gmail.drafts().get(userId="me", id=id).execute() + except HttpError as e: + slack.chat_postMessage(channel=slack_channel, text=f"Error: `{e.reason}`") + return + + msg = f"```\n{json.dumps(resp, indent=2)}\n```" + slack.chat_postMessage(channel=slack_channel, text=msg) + + +def _drafts_list(slack_channel, query): + """https://developers.google.com/resources/api-libraries/documentation/gmail/v1/python/latest/gmail_v1.users.drafts.html#list + + Args: + slack_channel: Slack channel name/ID to post debug messages to. + query: Optional query, e.g. "is:unread". + """ + try: + resp = gmail.drafts().list(userId="me", q=query, maxResults=10).execute() + except HttpError as e: + slack.chat_postMessage(channel=slack_channel, text=f"Error: `{e.reason}`") + return + + msg = f"Result size estimate: `{resp['resultSizeEstimate']}`" + slack.chat_postMessage(channel=slack_channel, text=msg) + + for i, d in enumerate(resp.get("drafts", []), start=1): + msg = f"{i}\n```\n{json.dumps(d, indent=2)}\n```" + slack.chat_postMessage(channel=slack_channel, text=msg) + + next_page_token = resp.get("nextPageToken") + if next_page_token: + msg = f"Next page token: `{next_page_token}`" + slack.chat_postMessage(channel=slack_channel, text=msg) + + +def _messages_get(slack_channel, id): + """https://developers.google.com/resources/api-libraries/documentation/gmail/v1/python/latest/gmail_v1.users.messages.html#get + + Args: + slack_channel: Slack channel name/ID to post debug messages to. + id: Required ID of the message to retrieve. + """ + try: + resp = gmail.messages().get(userId="me", id=id).execute() + except HttpError as e: + slack.chat_postMessage(channel=slack_channel, text=f"Error: `{e.reason}`") + return + + msg = f"```\n{json.dumps(resp, indent=2)}\n```" + slack.chat_postMessage(channel=slack_channel, text=msg) + + +def _messages_list(slack_channel, query): + """https://developers.google.com/resources/api-libraries/documentation/gmail/v1/python/latest/gmail_v1.users.messages.html#list + + See also: + https://developers.google.com/resources/api-libraries/documentation/gmail/v1/python/latest/gmail_v1.users.messages.html#list_next + + Args: + slack_channel: Slack channel name/ID to post debug messages to. + query: Optional query, e.g. "is:unread". + """ + try: + resp = gmail.messages().list(userId="me", q=query, maxResults=10).execute() + except HttpError as e: + slack.chat_postMessage(channel=slack_channel, text=f"Error: `{e.reason}`") + return + + msg = f"Result size estimate: `{resp['resultSizeEstimate']}`" + slack.chat_postMessage(channel=slack_channel, text=msg) + + for i, m in enumerate(resp.get("messages", []), start=1): + msg = f"{i}\n```\n{json.dumps(m, indent=2)}\n```" + slack.chat_postMessage(channel=slack_channel, text=msg) + + next_page_token = resp.get("nextPageToken") + if next_page_token: + msg = f"Next page token: `{next_page_token}`" + slack.chat_postMessage(channel=slack_channel, text=msg) + + +def _messages_send(slack_channel, text): + """https://developers.google.com/resources/api-libraries/documentation/gmail/v1/python/latest/gmail_v1.users.messages.html#send + + See also: https://developers.google.com/gmail/api/guides/sending + + This is the same as Google's send-message snippet, but simpler: + https://github.com/googleworkspace/python-samples/blob/main/gmail/snippet/send%20mail/send_message.py + + Args: + slack_channel: Slack channel name/ID to post debug messages to. + text: Short message to send to yourself. + """ + profile = gmail.getProfile(userId="me").execute() + + # Raw text compliant with https://datatracker.ietf.org/doc/html/rfc5322. + msg = f"""From: {profile["emailAddress"]} + To: {profile["emailAddress"]} + Subject: Test from AutoKitteh + + {text}""" + + msg = msg.replace("\n", "\r\n").replace(" ", "") + msg = base64.urlsafe_b64encode(msg.encode()).decode() + try: + gmail.messages().send(userId="me", body={"raw": msg}).execute() + except HttpError as e: + slack.chat_postMessage(channel=slack_channel, text=f"Error: `{e.reason}`") + return + + slack.chat_postMessage(channel=slack_channel, text="Sent!") + + +def on_gmail_mailbox_change(event): + pass # TODO(ENG-1524): Implement this function. + + +COMMANDS = { + "gmail get profile": _get_profile, + "gmail drafts get": _drafts_get, + "gmail drafts list": _drafts_list, + "gmail messages get": _messages_get, + "gmail messages list": _messages_list, + "gmail messages send": _messages_send, +} diff --git a/samples/google/sheets/README.md b/samples/google/sheets/README.md new file mode 100644 index 00000000..ad301d81 --- /dev/null +++ b/samples/google/sheets/README.md @@ -0,0 +1,48 @@ +# Google Sheets Sample + +This AutoKitteh project demonstrates 2-way integration with +[Google Sheets](https://workspace.google.com/products/sheets/). + +## API Documentation + +https://docs.autokitteh.com/integrations/google/sheets/python + +## Setup Instructions + +1. Install and start a + [self-hosted AutoKitteh server](https://docs.autokitteh.com/get_started/quickstart), + or use AutoKitteh Cloud + +2. Optional for self-hosted servers (preconfigured in AutoKitteh Cloud): + + - [Enable Google connections to use OAuth 2.0](https://docs.autokitteh.com/integrations/google/config) + - [Enable Slack connections to use an OAuth v2 app](https://docs.autokitteh.com/integrations/slack/config) + +3. Run these commands to deploy this project's manifest file: + + ```shell + git clone https://github.com/autokitteh/kittehub.git + ak deploy --manifest kittehub/samples/google/sheets/autokitteh.yaml + ``` + +4. Initialize this project's connections: + + - Google Sheets: with user impersonation using OAuth 2.0 (based on step 2), + or a GCP service account's JSON key + - Slack: with an OAuth v2 app (based on step 2), or a Socket Mode app + +> [!TIP] +> The exact CLI commands to do so (`ak connection init ...`) will appear in +> the output of the `ak deploy` command from step 3 when you create the +> project on the server, i.e. when you run that command for the first time. + +## Usage Instructions + +Outgoing API calls: + +1. Create a new Google Sheet: https://sheets.new + +2. Run a slash command of the Slack app that you initialized in step 4 above, + with the URL of the new Google Sheet as the slash command's text + +3. See the Google Sheet, and the Slack app's DM responses to you diff --git a/samples/google/sheets/autokitteh.yaml b/samples/google/sheets/autokitteh.yaml new file mode 100644 index 00000000..b2447e2c --- /dev/null +++ b/samples/google/sheets/autokitteh.yaml @@ -0,0 +1,18 @@ +# This YAML file is a declarative manifest that describes the setup +# of an AutoKitteh project that demonstrates 2-way integration with +# Google Sheets (https://workspace.google.com/products/sheets/). + +version: v1 + +project: + name: google_sheets_sample + connections: + - name: sheets_conn + integration: googlesheets + - name: slack_conn + integration: slack + triggers: + - name: slack_slash_command + connection: slack_conn + event_type: slash_command + call: program.py:on_slack_slash_command diff --git a/samples/google/sheets/program.py b/samples/google/sheets/program.py new file mode 100644 index 00000000..d84d1283 --- /dev/null +++ b/samples/google/sheets/program.py @@ -0,0 +1,120 @@ +"""This program demonstrates AutoKitteh's 2-way Google Sheets integration. + +API documentation: +https://docs.autokitteh.com/integrations/google/sheets/python +""" + +import autokitteh +from autokitteh.google import google_id, google_sheets_client +from autokitteh.slack import slack_client + + +sheet = google_sheets_client("sheets_conn").spreadsheets().values() +slack = slack_client("slack_conn") + + +def on_slack_slash_command(event): + """Use a Slack slash command to interact with a Google Sheet. + + See: https://api.slack.com/interactivity/slash-commands, and + https://api.slack.com/interactivity/handling#message_responses + + In this sample, we expect the slash command's text to be either: + - A Google Sheets ID (https://developers.google.com/sheets/api/guides/concepts) + - A full Google Sheets URL (to extract the spreadsheet ID from it) + + Args: + event: Slack event data. + """ + user_id = event.data.user_id + sheet_id = _extract_sheet_id(event.data.text, user_id) + if not sheet_id: + return + + _write_values(sheet_id, user_id) + _read_values(sheet_id, user_id) + _read_formula(sheet_id, user_id) + + +def _extract_sheet_id(text, user_id): + """Extract a Google Sheets ID from a Slack slash command.""" + try: + return google_id(text) + except ValueError: + msg = f"Invalid Google Sheets URL or spreadsheet ID: `{text}`" + slack_client("slack_conn").chat_postMessage(channel=user_id, text=msg) + return None + + +@autokitteh.activity +def _write_values(spreadsheet_id, slack_target): + """Write multiple cell values, with different data types.""" + resp = sheet.update( + spreadsheetId=spreadsheet_id, + # Explanation of the A1 notation for cell ranges: + # https://developers.google.com/sheets/api/guides/concepts#expandable-1 + range="Sheet1!A1:B7", + # Value input options: + # https://developers.google.com/sheets/api/reference/rest/v4/ValueInputOption + valueInputOption="USER_ENTERED", + body={ + "values": [ + ["String", "Hello, world!"], + ["Number", -123.45], + ["Also number", "-123.45"], + ["Percent", "10.12%"], + ["Boolean", True], + ["Date", "2022-12-31"], + ["Formula", "=B2*B3"], + ] + }, + ).execute() + + text = f"Updated: range `{resp['updatedRange']!r}`, `{resp['updatedRows']}` rows, " + text += f"`{resp['updatedColumns']}` columns, `{resp['updatedCells']}` cells" + slack.chat_postMessage(channel=slack_target, text=text) + + +@autokitteh.activity +def _read_values(id, slack_target): + """Read multiple cell values from a Google Sheet, and send them to Slack. + + Value render options: + https://developers.google.com/sheets/api/reference/rest/v4/ValueRenderOption + """ + # Default value render option: "FORMATTED_VALUE". + resp = sheet.get(spreadsheetId=id, range="A1:B6").execute() + col_a, formatted_col_b = list(zip(*resp.get("values", []))) + + ufv = "UNFORMATTED_VALUE" + resp = sheet.get(spreadsheetId=id, range="A1:B6", valueRenderOption=ufv).execute() + unformatted_col_b = [v for _, v in resp.get("values", [])] + + for i, row in enumerate(zip(col_a, formatted_col_b, unformatted_col_b)): + data_type, formatted, unformatted = row + text = "Row {0}: {1} = formatted `{2!r}`, unformatted `{3!r}`" + text = text.format(i + 1, data_type, formatted, unformatted) + slack.chat_postMessage(channel=slack_target, text=text) + + +@autokitteh.activity +def _read_formula(id, slack_target): + """Read a single cell value with a formula, and its evaluated result. + + Value render options: + https://developers.google.com/sheets/api/reference/rest/v4/ValueRenderOption + """ + f = "FORMULA" + resp = sheet.get(spreadsheetId=id, range="B7", valueRenderOption=f).execute() + value = resp.get("values", [["Not found"]])[0][0] + slack.chat_postMessage(channel=slack_target, text=f"Formula: `{value!r}`") + + # Default value render option: "FORMATTED_VALUE". + resp = sheet.get(spreadsheetId=id, range="B7").execute() + value = resp.get("values", [["Not found"]])[0][0] + slack.chat_postMessage(channel=slack_target, text=f"Formatted: `{value!r}`") + + ufv = "UNFORMATTED_VALUE" + resp = sheet.get(spreadsheetId=id, range="B7", valueRenderOption=ufv).execute() + value = resp.get("values", [["Not found"]])[0][0] + slack.chat_postMessage(channel=slack_target, text=f"Unformatted: `{value!r}`") diff --git a/samples/grpc/autokitteh.yaml b/samples/grpc/autokitteh.yaml new file mode 100644 index 00000000..a5e662bf --- /dev/null +++ b/samples/grpc/autokitteh.yaml @@ -0,0 +1,15 @@ +# This YAML file is a declarative manifest that describes the setup +# of an AutoKitteh project that demonstrates 2-way integration with +# gRPC (https://grpc.io/). + +version: v1 + +project: + name: grpc_sample + connections: + - name: grpc_conn + integration: grpc + triggers: + - name: webhook_trigger + type: webhook + call: program.star:on_webhook_trigger diff --git a/samples/grpc/program.star b/samples/grpc/program.star new file mode 100644 index 00000000..7b15b70b --- /dev/null +++ b/samples/grpc/program.star @@ -0,0 +1,23 @@ +"""This program queries a gRPC server. + +An HTTP GET request triggers this program to query a server using gRPC. +In this sample AutoKitteh is querying itself for demonstration purposes, +however this is not required. The host can be any gRPC server. + +Starlark is a dialect of Python (see https://bazel.build/rules/language). + +For detailed information on gRPC naming conventions see the following link: +https://github.com/grpc/grpc/blob/master/doc/naming.md +""" + +load("@grpc", "grpc_conn") + +def on_webhook_trigger(): + # The following is equivalent to running this command in the terminal: + # grpcurl -plaintext localhost:9980 autokitteh.projects.v1.ProjectsService.List + response = grpc_conn.call( + target = "localhost:9980", + service = "autokitteh.projects.v1.ProjectsService", + method = "List", + ) + print(response) diff --git a/samples/http/README.md b/samples/http/README.md new file mode 100644 index 00000000..8e2b6344 --- /dev/null +++ b/samples/http/README.md @@ -0,0 +1,85 @@ +# HTTP Sample + +This AutoKitteh project demonstrates 2-way usage of HTTP, with AutoKitteh +webhooks and the Python [requests](https://requests.readthedocs.io/) library. + +## API Documentation + +- https://docs.autokitteh.com/integrations/http/python +- https://docs.autokitteh.com/integrations/http/events + +## Setup Instructions + +1. Install and start a + [self-hosted AutoKitteh server](https://docs.autokitteh.com/get_started/quickstart), + or use AutoKitteh Cloud + +2. Run these commands to deploy this project's manifest file: + + ```shell + git clone https://github.com/autokitteh/kittehub.git + ak deploy --manifest kittehub/samples/http/autokitteh.yaml + ``` + +3. Look for the following lines in the output of the `ak deploy` command, and + copy the URL paths for later: + + ``` + [!!!!] trigger "..." created, webhook path is "/webhooks/..." + ``` + +> [!TIP] +> If you don't see the output of `ak deploy` anymore, you can run these +> commands instead, and use the webhook slugs from their outputs: +> +> ```shell +> ak trigger get receive_http_get_or_head --project http_sample -J +> ak trigger get receive_http_post_form --project http_sample -J +> ak trigger get receive_http_post_json --project http_sample -J +> ak trigger get send_requests --project http_sample -J +> ``` + +## Usage Instructions + +1. Run these commands to start sessions that receive GET and HEAD requests + (use the **1st** URL path from step 3 above): + + ```shell + curl -i [--get] "http://localhost:9980/webhooks/SLUG1" + curl -i --head "http://localhost:9980/webhooks/SLUG1" + [--url-query "key1=value1" --url-query "key2=value2"] + ``` + +2. Run this command to start a session that parses a URL-encoded form in a + POST request (use the **2nd** URL path from step 3 above): + + ```shell + curl -i [-X POST] "http://localhost:9980/webhooks/SLUG2" \ + --data key1=value1 --data key2=value2 + ``` + +3. Run this command to start a session that parses the JSON body of a POST + request (use the **3rd** URL path from step 3 above): + + ```shell + curl -i [-X POST] "http://localhost:9980/webhooks/SLUG3" \ + --json '{"key1": "value1", "key2": "value2"}' + ``` + +4. Run this command to start a session that sends various requests (use the + **4th** URL path from step 3 above): + + ```shell + curl -i "http://localhost:9980/webhooks/SLUG4" + ``` + + - Unauthenticated requests + - GET: with query parameters, HTML body, JSON body, 404 not found + - POST: URL-encoded form, JSON data + - Requests with (correct and incorrect) + [HTTP basic authentication](https://datatracker.ietf.org/doc/html/rfc7617) + - Requests with an + [OAuth bearer token](https://datatracker.ietf.org/doc/html/rfc6750) + +5. Check out the resulting session logs in the AutoKitteh server for each of + the steps above diff --git a/samples/http/autokitteh.yaml b/samples/http/autokitteh.yaml new file mode 100644 index 00000000..08750b44 --- /dev/null +++ b/samples/http/autokitteh.yaml @@ -0,0 +1,30 @@ +# This YAML file is a declarative manifest that describes the setup +# of an AutoKitteh project that demonstrates 2-way usage of HTTP. + +version: v1 + +project: + name: http_sample + vars: + - name: HTTPBIN_BASE_URL + value: https://httpbin.org/ + triggers: + - name: receive_http_get_or_head + type: webhook + filter: data.method in ["GET", "HEAD"] + call: webhooks.py:on_http_get_or_head + + - name: receive_http_post_form + type: webhook + event_type: post + filter: data.headers["Content-Type"] == "application/x-www-form-urlencoded" + call: webhooks.py:on_http_post_form + - name: receive_http_post_json + type: webhook + event_type: post + filter: data.headers["Content-Type"].startsWith("application/json") + call: webhooks.py:on_http_post_json + + - name: send_requests + type: webhook + call: webhooks.py:send_requests diff --git a/samples/http/basic_auth.py b/samples/http/basic_auth.py new file mode 100644 index 00000000..1a60d785 --- /dev/null +++ b/samples/http/basic_auth.py @@ -0,0 +1,40 @@ +"""This module demonstrates the "requests" library with basic authentication.""" + +import base64 +from urllib.parse import urljoin + +import requests + + +def send_requests(base_url): + """Send HTTP requests with basic authentication (username + password). + + See: https://datatracker.ietf.org/doc/html/rfc7617 + """ + print("\n>>> Sending HTTP requests with basic authentication") + + expected_creds = ("user", "pass") + url = urljoin(base_url, f"basic-auth/{expected_creds[0]}/{expected_creds[1]}") + + print("\n--- Use the expected credentials (authentication success)") + resp = requests.get(url, auth=expected_creds) + _print_response_details(resp) + + print("\n--- Use unexpected credentials (authentication failure)") + # Also, set them directly in the HTTP request headers, instead of + # using the "auth" parameter, just for the sake of demonstration. + unexpected_creds = "someone_else:wrong_password" + headers = { + "Authorization": "Basic " + base64.b64encode(unexpected_creds.encode()).decode() + } + resp = requests.get(url, headers=headers) + _print_response_details(resp) + + +def _print_response_details(resp): + print("Response URL:", resp.url) + print("Response status code:", resp.status_code) + print("Response text:", resp.text) + print("Response headers:") + for key in sorted(resp.headers): + print(f" {key} = {resp.headers[key]}") diff --git a/samples/http/bearer_token.py b/samples/http/bearer_token.py new file mode 100644 index 00000000..8a774aa9 --- /dev/null +++ b/samples/http/bearer_token.py @@ -0,0 +1,28 @@ +"""This module demonstrates the "requests" library with an OAuth bearer token.""" + +from urllib.parse import urljoin + +import requests + + +def send_requests(base_url): + """Send HTTP requests with an OAuth bearer token. + + See: https://datatracker.ietf.org/doc/html/rfc6750 + """ + print("\n>>> Sending HTTP requests with an OAuth bearer token") + + url = urljoin(base_url, "bearer") + token = "my_bearer_token" + headers = {"Authorization": "Bearer " + token} + resp = requests.get(url, headers=headers) + _print_response_details(resp) + + +def _print_response_details(resp): + print("Response URL:", resp.url) + print("Response status code:", resp.status_code) + print("Response text:", resp.text) + print("Response headers:") + for key in sorted(resp.headers): + print(f" {key} = {resp.headers[key]}") diff --git a/samples/http/no_auth.py b/samples/http/no_auth.py new file mode 100644 index 00000000..60de795a --- /dev/null +++ b/samples/http/no_auth.py @@ -0,0 +1,115 @@ +"""This module demonstrates the "requests" library without authentication.""" + +from urllib.parse import urljoin + +import requests + + +def send_requests(base_url): + print(">>> Sending requests without authentication") + + _get_echo_params(base_url) + _get_html(base_url) + _get_json(base_url) + _get_error(base_url) + + url = urljoin(base_url, "post") + _post_echo_form(url) + _post_echo_json(url) + + +def _get_echo_params(base_url): + """https://httpbin.org/#/HTTP_Methods/get_get""" + url = urljoin(base_url, "get") + print(f"\n--- GET {url}") + resp = requests.get(url, params={"key1": "value1", "key2": "value2"}) + # Expected: "Content-Type" header is "application/json". + _print_response_status_and_headers(resp) + + # httpbin echoes back query params (as "args"), headers, and other things + # in the response's JSON body. In this specific case, the "headers", + # "args", "url" keys should be present in the response body. + + # Expected JSON: {"args": {"key1": "value1", ... }, ...} + print(f"Response body (JSON):\n{resp.json()}") + # Expected text: same as JSON, but formatted as multiline text. + print(f"Response body (text):\n{resp.text}") + + +def _get_html(base_url): + """https://httpbin.org/#/Response_formats/get_html""" + url = urljoin(base_url, "html") + print(f"\n--- GET {url}") + resp = requests.get(url) + _print_response_status_and_headers(resp) + + # Expected text: "\u003c!DOCTYPE html\u003e\\n..." + print(f"Response body (text):\n{resp.text}") + # Don't call resp.json(), since HTML is not valid JSON. + + +def _get_json(base_url): + """https://httpbin.org/#/Response_formats/get_json""" + url = urljoin(base_url, "json") + print(f"\n--- GET {url}") + resp = requests.get(url) + _print_response_status_and_headers(resp) + + # Expected text: same as JSON, but formatted as multiline byte text. + print(f"response body (bytes):\n{resp.content}") + # Expected JSON: {"slideshow": {"author": "Yours Truly", ... }} + print(f"response body (json):\n{resp.json()}") + # Expected value inside JSON: "Yours Truly". + slideshow_author = resp.json().get("slideshow", {}).get("author") + print("response_json['slideshow']['author']:", slideshow_author) + + +def _get_error(base_url): + url = urljoin(base_url, "status/404") + print(f"\n--- GET {url}") + resp = requests.get(url) + _print_response_status_and_headers(resp) # Expected status code: 404. + + +def _post_echo_form(url): + """https://httpbin.org/#/HTTP_Methods/post_post""" + print(f"\n--- POST {url} (form)") + resp = requests.post(url, data={"foo": "bar"}) + # Expected: "Content-Type" header is "application/json". + _print_response_status_and_headers(resp) + + # The form we submitted will be echoed back by httpbin under the "form" key. + + # Expected JSON: {..., "form": {"foo": "bar"}, ...} + print(f"Response body (JSON):\n{resp.json()}") + # Expected text: same as JSON, but formatted as multiline text. + print(f"Response body (text):\n{resp.text}") + + +def _post_echo_json(url): + """https://httpbin.org/#/HTTP_Methods/post_post""" + print(f"\n--- POST {url} (JSON)") + + # Option 1: use the "json" param, without specifying content type. + resp = requests.post(url, json={"foo": "bar"}) + + # Option 2: use the "data" param, and specify its content type. + # headers={"Content-Type": "application/json", ...} + # resp = requests.post(url, data={"foo": "bar"}, headers=headers) + + _print_response_status_and_headers(resp) + + # The JSON we sent will be echoed back by httpbin under the "data" key + # (as a string), and the "json" key. + + # Expected JSON: {..., "data": "{...}", "json": {"foo": "bar"}, ...} + print(f"Response body (JSON):\n{resp.json()}") + # Expected text: same as JSON, but formatted as text. + print(f"Response body (text):\n{resp.text}") + + +def _print_response_status_and_headers(resp): + print("Response status code:", resp.status_code) + print("Response headers:") + for key in sorted(resp.headers): + print(f" {key} = {resp.headers[key]}") diff --git a/samples/http/webhooks.py b/samples/http/webhooks.py new file mode 100644 index 00000000..1a5ac738 --- /dev/null +++ b/samples/http/webhooks.py @@ -0,0 +1,80 @@ +"""This module demonstrates the usage of AutoKitteh webhooks.""" + +import os + +import basic_auth +import bearer_token +import no_auth + + +BASE_URL = os.getenv("HTTPBIN_BASE_URL") # Set in "autokitteh.yaml". + + +def on_http_get_or_head(event): + """Handle incoming HTTP GET and HEAD requests. + + - https://www.rfc-editor.org/rfc/rfc9110#name-get + - https://www.rfc-editor.org/rfc/rfc9110#name-head + + Args: + event: Incoming HTTP request details. + """ + _print_request_details(event.data) + print("Query parameters:") + if not event.data.url.query: + print(" none") + for key in sorted(event.data.url.query): + print(f" {key} = {event.data.url.query[key]}") + + +def on_http_post_form(event): + """Handle URL-encoded form submissions in HTTP POST requests. + + - https://www.rfc-editor.org/rfc/rfc9110#name-post + - https://html.spec.whatwg.org/multipage/forms.html + + Args: + event: Incoming HTTP request details. + """ + _print_request_details(event.data) + # TODO(ENG-1518): print(f"Text body: {event.data.body.text()}") + # TODO(ENG-1518): _parse_form_data(event.data.body.form()) + # for key, value in event.data.body.form().items(): + # print(f" {key} = {value}") + print(f"Form body: {event.data.body}") + + +def on_http_post_json(event): + """Handle incoming HTTP POST requests with a JSON body. + + https://www.rfc-editor.org/rfc/rfc9110#name-post + + Args: + event: Incoming HTTP request details. + """ + _print_request_details(event.data) + # TODO(ENG-1518): print(f"Text body: {event.data.body.text()}") + # TODO(ENG-1518): _parse_json_body(event.data.body.json()) + # try: + # j = data.body.json() + # print(f"request body (json): {j}") + # except Exception as err: + # print(f"Error parsing request body as JSON: {err}") + print(f"JSON body: {event.data.body}") + + +def _print_request_details(data): + print(f"Triggered by an HTTP {data.method} request") + # TODO(ENG-1517): print("Full URL:", data.full_url_string) + print("URL path:", data.url.path) + + print("Headers:") + for key in sorted(data.headers): + print(f" {key} = {data.headers[key]}") + + +def send_requests(event): + """Send various HTTP requests with various authentication schemes.""" + no_auth.send_requests(BASE_URL) + basic_auth.send_requests(BASE_URL) + bearer_token.send_requests(BASE_URL) diff --git a/samples/openai_chatgpt/README.md b/samples/openai_chatgpt/README.md new file mode 100644 index 00000000..37077977 --- /dev/null +++ b/samples/openai_chatgpt/README.md @@ -0,0 +1,28 @@ +# OpenAI ChatGPT Sample + +This [AutoKitteh](https://github.com/autokitteh/autokitteh) project +demonstrates integration with [ChatGPT](https://chat.openai.com). + +The file [`program.star`](./program.star) implements a single entry-point +function, which is configured in the [`autokitteh.yaml`](./autokitteh.yaml) +manifest file as the receiver of Slack `slash_command` events. + +It sends a couple of requests to the ChatGPT API, and sends the responses +back to the user over Slack, as well as ChatGPT token usage stats. + +API details: + +- [OpenAI developer platform](https://platform.openai.com/) +- [Go client API](https://pkg.go.dev/github.com/sashabaranov/go-openai) + +This project isn't meant to cover all available functions and events. it +merely showcases a few illustrative, annotated, reusable examples. + +## Instructions + +1. Follow instructions [here](https://platform.openai.com/docs/quickstart) to setup your OpenAI API account. + +2. Via the `ak` CLI tool, or the AutoKitteh WebUI, initialize the OpenAI connection and provide API key generated in step 1. + +3. Via the `ak` CLI tool, or the AutoKitteh VS Code extension, deploy the + `autokitteh.yaml` manifest file diff --git a/samples/openai_chatgpt/autokitteh.yaml b/samples/openai_chatgpt/autokitteh.yaml new file mode 100644 index 00000000..71d6a717 --- /dev/null +++ b/samples/openai_chatgpt/autokitteh.yaml @@ -0,0 +1,18 @@ +# This YAML file is a declarative manifest that describes the setup +# of an AutoKitteh project that demonstrates integration with +# OpenAI ChatGPT (https://chat.openai.com). + +version: v1 + +project: + name: chatgpt_sample + connections: + - name: chatgpt_conn + integration: chatgpt + - name: slack_conn + integration: slack + triggers: + - name: slack_slash_command + connection: slack_conn + event_type: slash_command + call: program.py:on_slack_slash_command diff --git a/samples/openai_chatgpt/program.py b/samples/openai_chatgpt/program.py new file mode 100644 index 00000000..08cc5eac --- /dev/null +++ b/samples/openai_chatgpt/program.py @@ -0,0 +1,63 @@ +"""This program demonstrates AutoKitteh's OpenAI ChatGPT integration. + +This program implements a single entry-point function, which is +configured in the "autokitteh.yaml" manifest file as the receiver +of Slack "slash_command" events. + +It sends a couple of requests to the ChatGPT API, and sends the responses +back to the user over Slack, as well as ChatGPT token usage stats. + +API details: +- OpenAI developer platform: https://platform.openai.com/ +- Go client API: https://pkg.go.dev/github.com/sashabaranov/go-openai + +This program isn't meant to cover all available functions and events. +It merely showcases various illustrative, annotated, reusable examples. + +""" + +from autokitteh import openai, slack + +MODEL = "gpt-4o-mini" + +chatgpt_client = openai.openai_client("chatgpt_conn") +slack_client = slack.slack_client("slack_conn") + + +def on_slack_slash_command(event): + """https://api.slack.com/interactivity/slash-commands + + To use the slash command, simply type `/command-name` in the Slack message input, + where `command-name` is the name you have assigned to the command in your app. + This command does not require any additional text or arguments. + + Args: + event: Slack event data. + """ + + # Example 1: trivial interaction with ChatGPT. + msg = {"role": "user", "content": "Meow!"} + resp = chatgpt_client.chat.completions.create(model=MODEL, messages=[msg]) + + # For educational and debugging purposes, print ChatGPT's response + # in the AutoKitteh session's log. + print(resp) + + # Example 2: more verbose interaction with ChatGPT, + # including the user's text as part of the conversation. + contents = [ + "You are a poetic assistant, skilled in explaining complex engineering concepts.", + "Compose a Shakespearean sonnet about the importance of reliability, scalability, and durability, in distributed workflows.", + ] + msgs = [ + {"role": "system", "content": contents[0]}, + {"role": "user", "content": contents[1]}, + {"role": "user", "content": event.data["text"]}, + ] + + resp = chatgpt_client.chat.completions.create(model=MODEL, messages=msgs) + + id = event.data["user_id"] + for choice in resp.choices: + slack_client.chat_postMessage(channel=id, text=choice.message.content) + slack_client.chat_postMessage(channel=id, text=f"Usage: `{str(resp.usage)}`") diff --git a/samples/runtime_events/README.md b/samples/runtime_events/README.md new file mode 100644 index 00000000..160294dd --- /dev/null +++ b/samples/runtime_events/README.md @@ -0,0 +1,58 @@ +# Runtime Event Handling Sample + +[The workflow](./program.py) is triggered by an HTTP GET request with a URL +path that ends with `/meow`. The trigger is defined in the +[autokitteh.yaml](./autokitteh.yaml) manifest file. + +During runtime, it waits (up to 60 seconds) for a subsequent webhook event +where the URL path ends with `/woof`, using the `subscribe` and `get_event` +AutoKitteh Python SDK functions. + +## Setup Instructions + +1. Install and start a + [self-hosted AutoKitteh server](https://docs.autokitteh.com/get_started/quickstart), + or use AutoKitteh Cloud + +2. Run these commands to deploy this project's manifest file: + + ```shell + git clone https://github.com/autokitteh/kittehub.git + ak deploy --manifest kittehub/samples/runtime_events/autokitteh.yaml + ``` + +3. Look for the following line in the output of the `ak deploy` command, and + copy the URL path for later: + + ``` + [!!!!] trigger "meow_webhook" created, webhook path is "/webhooks/..." + ``` + +> [!TIP] +> If you don't see the output of `ak deploy` anymore, you can run this command +> instead, and use the webhook slug from the output: +> +> ```shell +> ak trigger get meow_webhook --project runtime_events_sample -J +> ``` + +## Usage Instructions + +1. Run this command to start a session (use the URL path from step 3 above + instead of `/webhooks/...`, and append `meow` to it): + + ```shell + curl -i "http://localhost:9980/webhooks/.../meow" + ``` + +2. Run this command to end the session (use the same URL path, but this time + append `woof` to it instead of `meow`): + + ```shell + curl -i "http://localhost:9980/webhooks/.../woof" + ``` + +3. Repeat step 1, but this time wait a minute until the session times out + +4. Check out the resulting session logs in the AutoKitteh server for each of + the steps above diff --git a/samples/runtime_events/autokitteh.yaml b/samples/runtime_events/autokitteh.yaml new file mode 100644 index 00000000..5d73aed7 --- /dev/null +++ b/samples/runtime_events/autokitteh.yaml @@ -0,0 +1,13 @@ +# This YAML file is a declarative manifest that describes the setup +# of an AutoKitteh project that demonstrates runtime event handling. + +version: v1 + +project: + name: runtime_events_sample + triggers: + - name: meow_webhook + type: webhook + event_type: get + filter: data.url.path.endsWith("/meow") + call: program.py:on_http_get_meow diff --git a/samples/runtime_events/program.py b/samples/runtime_events/program.py new file mode 100644 index 00000000..56d43b1b --- /dev/null +++ b/samples/runtime_events/program.py @@ -0,0 +1,19 @@ +"""This program demonstrates AutoKitteh's runtime event handling.""" + +import autokitteh + + +def on_http_get_meow(event): + """This workflow is triggered by a predefined HTTP GET request event.""" + print("Got a meow, waiting for a woof") + + # Wait (up to 60 seconds) for a subsequent webhook + # event where the URL path ends with "woof". + filter = "data.url.path.endsWith('/woof')" + sub = autokitteh.subscribe("meow_webhook", filter) + next = autokitteh.next_event(sub, timeout=60) + + if next: + print("Got a woof: ", next) + else: + print("Timeout!") diff --git a/samples/scheduler/README.md b/samples/scheduler/README.md new file mode 100644 index 00000000..caa2f3c2 --- /dev/null +++ b/samples/scheduler/README.md @@ -0,0 +1,65 @@ +# Scheduler (Cron) Sample + +This project demonstrates +[AutoKitteh](https://github.com/autokitteh/autokitteh)'s +cron-like scheduler. + +It scans a specific GitHub repo on a daily basis to identify stalled PRs. If +any are found, it sends a message about them to a specific Slack channel. + +## API Documentation + +- [Cron expression format](https://pkg.go.dev/github.com/robfig/cron/v3#hdr-CRON_Expression_Format) + ("\* \* \* \* \*") + +- [Cron extended expression format](https://pkg.go.dev/github.com/robfig/cron/v3#hdr-Alternative_Formats) + (includes seconds and minutes) + +- [Predefined schedules and intervals](https://pkg.go.dev/github.com/robfig/cron#hdr-Predefined_schedules) + ("@" format) + + - `@yearly`/`@annually`, `@monthly`, `@weekly`, `@daily`/`@midnight`, `@hourly` + - `@every 1h30m10s` + +- [Crontab.guru - cron schedule expression editor](https://crontab.guru/) + +## Setup Instructions + +1. Install and start a + [self-hosted AutoKitteh server](https://docs.autokitteh.com/get_started/quickstart), + or use AutoKitteh Cloud + +2. Optional for self-hosted servers (preconfigured in AutoKitteh Cloud): + + - [Enable GitHub connections to use a GitHub app](https://docs.autokitteh.com/integrations/github/config) + - [Enable Slack connections to use an OAuth v2 app](https://docs.autokitteh.com/integrations/slack/config) + +3. Run this command to clone the Kittehub repository, which contains this + project: + + ```shell + git clone https://github.com/autokitteh/kittehub.git + ``` + +4. Set these variables in this project's [autokitteh.yaml](./autokitteh.yaml) + manifest file: + + - `GITHUB_OWNER` and `GITHUB_REPO` + - `SLACK_CHANNEL_NAME_OR_ID` + +5. Run this command to deploy this project's manifest file: + + ```shell + ak deploy --manifest kittehub/samples/scheduler/autokitteh.yaml + ``` + +6. Initialize this project's connections: + + - GitHub: with a GitHub app using OAuth 2.0 (based on step 2), or PATs + (fine-grained or classic) and/or manually-configured webhooks + - Slack: with an OAuth v2 app (based on step 2), or a Socket Mode app + +> [!TIP] +> The exact CLI commands to do so (`ak connection init ...`) will appear in +> the output of the `ak deploy` command from step 5 when you create the +> project on the server, i.e. when you run that command for the first time. diff --git a/samples/scheduler/autokitteh.yaml b/samples/scheduler/autokitteh.yaml new file mode 100644 index 00000000..bb1d82d3 --- /dev/null +++ b/samples/scheduler/autokitteh.yaml @@ -0,0 +1,24 @@ +# This YAML file is a declarative manifest that describes the setup +# of an AutoKitteh project that demonstrates integration with a +# cron-like scheduler. + +version: v1 + +project: + name: scheduler_sample + vars: + - name: GITHUB_OWNER + value: + - name: GITHUB_REPO + value: + - name: SLACK_CHANNEL_NAME_OR_ID + value: + connections: + - name: github_conn + integration: github + - name: slack_conn + integration: slack + triggers: + - name: daily + schedule: "@daily" # Same as `@midnight', `0 0 * * *', or `@every 1d'. + call: program.star:on_cron_trigger diff --git a/samples/scheduler/program.star b/samples/scheduler/program.star new file mode 100644 index 00000000..6aaf658a --- /dev/null +++ b/samples/scheduler/program.star @@ -0,0 +1,48 @@ +"""This program demonstrates AutoKitteh's scheduler capabilities. + +This program implements a single entry-point function, which is configured +in the "autokitteh.yaml" file as the receiver of "scheduler" events. + +It also demonstrates using constant values which are set for each +AutoKitteh environment in the "autokitteh.yaml" manifest file. + +Starlark is a dialect of Python (see https://bazel.build/rules/language). +""" + +load("@slack", "slack_conn") +load("@github", "github_conn") + +# Set in ""autokitteh.yaml" +load("env", "SLACK_CHANNEL_NAME_OR_ID", "GITHUB_OWNER", "GITHUB_REPO") + +def on_cron_trigger(): + """An autokitteh cron schedule was triggered.""" + + # fetch all PRs + prs = github_conn.list_pull_requests(owner=GITHUB_OWNER, repo=GITHUB_REPO, state="open") + + # filter: skip DRAFT or WIP PRs + active_prs = [] + for pr in prs: + if pr.draft or any([k in pr.title.lower() for k in ("draft", "wip")]): + continue + active_prs.append(pr) + + now = time.now() + good_updated_at = time.from_timestamp(now.unix - 1 * 60 * 60 * 24) # a day + good_opened_at = time.from_timestamp(now.unix - 1 * 60 * 60 * 24 * 4) # 4 days + + msg = "Daily reminder about opened STALLED PRs:" + for pr in active_prs: + s = "" + # check whether this PR is stalled - either opened or updated a long ago + print(pr) + if pr.created_at > good_opened_at or pr.updated_at > good_updated_at: + s += "opened <%dh> ago, " % (now - pr.created_at).hours + s += "last updated <%dh> ago" % (now - pr.updated_at).hours + + if len(s): + msg += "\nPR: `%s`\n" % pr.title + msg += " %s\n" % pr.url + msg += " %s\n" % s + slack_conn.chat_post_message(SLACK_CHANNEL_NAME_OR_ID, msg) diff --git a/samples/slack/README.md b/samples/slack/README.md new file mode 100644 index 00000000..0b9cc4b2 --- /dev/null +++ b/samples/slack/README.md @@ -0,0 +1,55 @@ +# Slack Sample + +This sample project demonstrates AutoKitteh's 2-way integration with +[Slack](https://slack.com). + +The code file ([`program.py`](./program.py) implements multiple entry-point +functions that are triggered by incoming Slack events, as defined in the +[`autokitteh.yaml`](./autokitteh.yaml) manifest file. These functions also +execute various Slack API calls. + +Slack API documentation: + +- [Web API reference](https://api.slack.com/methods) +- [Events API reference](https://api.slack.com/events?filter=Events) +- [Python client API](https://slack.dev/python-slack-sdk/api-docs/slack_sdk/) + +This project isn't meant to cover all available functions and events. It +merely showcases a few illustrative, annotated, reusable examples. + +## Instructions + +1. Deploy the manifest file: + + ```shell + ak deploy --manifest samples/slack/autokitteh.yaml + ``` + +2. Follow the instructions in the `ak` CLI tool's output: + + ``` + Connection created, but requires initialization. + Please run this to complete: + + ak connection init + ``` + +3. Events that this sample project responds to: + + - Mentions of the Slack app in messages (e.g. `Hi @autokitteh`) + - Slash commands registered by the Slack app + (`/autokitteh `) + - New and edited messages and replies + - New emoji reactions + +## Connection Notes + +AutoKitteh supports 2 connection modes: + +- Slack app that uses + [OAuth v2](https://docs.autokitteh.com/integrations/slack/config) + +- Slack app that uses + [Socket Mode](https://docs.autokitteh.com/integrations/slack/connection) + +In both cases, the user authorizes the Slack app in step 3 above. diff --git a/samples/slack/approval_message.json.txt b/samples/slack/approval_message.json.txt new file mode 100644 index 00000000..2bf01c70 --- /dev/null +++ b/samples/slack/approval_message.json.txt @@ -0,0 +1,50 @@ +[ + { + "type": "header", + "text": { + "type": "plain_text", + "emoji": true, + "text": "Title" + } + }, + { + "type": "divider" + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "Message" + } + }, + { + "type": "divider" + }, + { + "type": "actions", + "elements": [ + { + "type": "button", + "style": "primary", + "text": { + "type": "plain_text", + "emoji": true, + "text": "Approve" + }, + "value": "Approve", + "action_id": "Approve ActionID" + }, + { + "type": "button", + "style": "danger", + "text": { + "type": "plain_text", + "emoji": true, + "text": "Deny" + }, + "value": "Deny", + "action_id": "Deny ActionID" + } + ] + } +] diff --git a/samples/slack/autokitteh.yaml b/samples/slack/autokitteh.yaml new file mode 100644 index 00000000..8061525b --- /dev/null +++ b/samples/slack/autokitteh.yaml @@ -0,0 +1,32 @@ +# This YAML file is a declarative manifest that describes the setup +# of an AutoKitteh project that demonstrates 2-way integration with +# Slack (https://slack.com). + +version: v1 + +project: + name: slack_sample + connections: + - name: slack_conn + integration: slack + triggers: + - name: slack_app_mention + connection: slack_conn + event_type: app_mention + call: program.py:on_slack_app_mention + - name: slack_interaction + connection: slack_conn + event_type: interaction + call: program.py:on_slack_interaction + - name: slack_message + connection: slack_conn + event_type: message + call: program.py:on_slack_message + - name: slack_reaction_added + connection: slack_conn + event_type: reaction_added + call: program.py:on_slack_reaction_added + - name: slack_slash_command + connection: slack_conn + event_type: slash_command + call: program.py:on_slack_slash_command diff --git a/samples/slack/message.json b/samples/slack/message.json new file mode 100644 index 00000000..e8c50156 --- /dev/null +++ b/samples/slack/message.json @@ -0,0 +1,33 @@ +{ + "blocks": [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": "This is a header block", + "emoji": true + } + }, + { + "type": "divider" + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "This is a section block with a button." + }, + "accessory": { + "type": "button", + "text": { + "type": "plain_text", + "text": "Click Me", + "emoji": true + }, + "value": "click_me_123", + "url": "https://google.com", + "action_id": "button-action" + } + } + ] +} diff --git a/samples/slack/program.py b/samples/slack/program.py new file mode 100644 index 00000000..2cfe8237 --- /dev/null +++ b/samples/slack/program.py @@ -0,0 +1,210 @@ +"""This program demonstrates AutoKitteh's 2-way Slack integration. + +This program implements multiple entry-point functions that are triggered +by incoming Slack events, as defined in the "autokitteh-python.yaml" +manifest file. These functions also execute various Slack API calls. + +Events that this program responds to: +- Mentions of the Slack app in messages (e.g. "Hi @autokitteh") +- Slash commands registered by the Slack app (`/autokitteh `) +- New and edited messages and replies +- New emoji reactions + +Slack API documentation: +- Python client API: https://slack.dev/python-slack-sdk/api-docs/slack_sdk/web/client.html +- Events API reference: https://api.slack.com/events?filter=Events + +This program isn't meant to cover all available functions and events. +It merely showcases a few illustrative, annotated, reusable examples. +""" + +from pathlib import Path +import time + +import autokitteh +from autokitteh.slack import slack_client + + +def on_slack_app_mention(event): + """https://api.slack.com/events/app_mention + + Args: + event: Slack event data. + """ + slack = slack_client("slack_conn") + + # Send messages in response to the event: + # - DM to the user who triggered the event (channel ID = user ID) + # - Two messages to the channel "#slack-test" + # See: https://slack.dev/python-slack-sdk/api-docs/slack_sdk/web/client.html#slack_sdk.web.client.WebClient.chat_postMessage + text = f"You mentioned me in <#{event.data.channel}> and wrote: `{event.data.text}`" + slack.chat_postMessage(channel=event.data.user, text=text) + + text = text.replace("You", f"<@{event.data.user}>") + slack.chat_postMessage(channel="#slack-test", text=text) + + text = "Before update :crying_cat_face:" + resp = slack.chat_postMessage(channel="#slack-test", text=text) + + # Encountered an error? Print debugging information + # in the AutoKitteh session's log, and finish. + resp.validate() + + # Update the last sent message, after a few seconds. + # See: https://slack.dev/python-slack-sdk/api-docs/slack_sdk/web/client.html#slack_sdk.web.client.WebClient.chat_update + time.sleep(10) + resp = autokitteh.AttrDict(resp.data) + text = "After update :smiley_cat:" + resp = slack.chat_update(channel=resp.channel, ts=resp.ts, text=text) + resp = autokitteh.AttrDict(resp.data) + + # Reply to the message's thread, after a few seconds. + time.sleep(5) + text = "Reply before update :crying_cat_face:" + resp = slack.chat_postMessage(channel=resp.channel, text=text, thread_ts=resp.ts) + resp = autokitteh.AttrDict(resp.data) + + # Update the threaded reply message, after a few seconds. + time.sleep(5) + text = "Reply after update :smiley_cat:" + slack.chat_update(channel=resp.channel, ts=resp.ts, text=text) + + # Add a reaction to the threaded reply message. + # See: https://slack.dev/python-slack-sdk/api-docs/slack_sdk/web/client.html#slack_sdk.web.client.WebClient.reactions_add + slack.reactions_add(channel=resp.channel, name="blob-clap", timestamp=resp.ts) + + # Retrieve all the replies. + # See: https://slack.dev/python-slack-sdk/api-docs/slack_sdk/web/client.html#slack_sdk.web.client.WebClient.conversations_replies + resp = slack.conversations_replies(channel=resp.channel, ts=resp.ts) + + # For educational purposes, print all the replies in the AutoKitteh session's log. + resp.validate() + for text in resp.get("messages", default=[]): + print(text) + + +def on_slack_message(event): + """https://api.slack.com/events/message + + Args: + event: Slack event data. + """ + slack = slack_client("slack_conn") + + if not event.data.subtype: + user = f"<@{event.data.user}>" + if not event.data.thread_ts: + _on_slack_new_message(slack, event.data, user) + else: + # https://api.slack.com/events/message/message_replied + _on_slack_reply_message(slack, event.data, user) + elif event.data.subtype == "message_changed": + user = f"<@{event.data.message.user}>" # Not the same as above! + _on_slack_message_changed(slack, event.data, user) + + +def _on_slack_new_message(slack, data, user): + """Someone wrote a new message.""" + text = f":point_up: {user} wrote: `{data.text}`" + slack.chat_postMessage(channel=data.channel, text=text) + + +def _on_slack_reply_message(slack, data, user): + """Someone wrote a reply in a thread.""" + text = f":point_up: {user} wrote a reply to <@{data.parent_user_id}>: `{data.text}`" + ts = data.thread_ts + slack.chat_postMessage(channel=data.channel, text=text, thread_ts=ts) + + +def _on_slack_message_changed(slack, data, user): + """Someone edited a message.""" + old, new = data.previous_message.text, data.message.text + text = f":point_up: {user} edited a message from `{old}` to `{new}`" + + # Thread TS may or may not be empty, depending on the edited message. + thread = data.message.thread_ts + + slack.chat_postMessage(channel=data.channel, text=text, thread_ts=thread) + + +def on_slack_reaction_added(event): + """https://api.slack.com/events/reaction_added + + Args: + event: Slack event data. + """ + # For educational purposes, print the event data in the AutoKitteh session's log. + print(event.data.user) + print(event.data.reaction) + print(event.data.item) + + +def on_slack_slash_command(event): + """https://api.slack.com/interactivity/slash-commands + + See also: https://api.slack.com/interactivity/handling#message_responses + + The text after the slash command is expected to be a valid target for a + Slack message (https://api.slack.com/methods/chat.postMessage#channels): + Slack user ID ("U"), user DM ID ("D"), multi-person/group DM ID ("G"), + channel ID ("C"), or channel name (with or without the "#" prefix). + + Note that all targets except "U", "D" and public channels require + the Slack app to be added in advance. + + Args: + event: Slack event data. + """ + slack = slack_client("slack_conn") + + # Retrieve the profile information of the user who triggered this event. + # See: https://slack.dev/python-slack-sdk/api-docs/slack_sdk/web/client.html#slack_sdk.web.client.WebClient.users_info + user_info = slack.users_info(user=event.data.user_id) + + # Encountered an error? Print debugging information + # in the AutoKitteh session's log, and finish. + user_info.validate() + + profile = autokitteh.AttrDict(user_info.data).user.profile + text = f"Slack mention: <@{event.data.user_id}>" + slack.chat_postMessage(channel=event.data.user_id, text=text) + text = "Full name: " + profile.real_name + slack.chat_postMessage(channel=event.data.user_id, text=text) + text = "Email: " + profile.email + slack.chat_postMessage(channel=event.data.user_id, text=text) + + # Treat the text of the user's slash command as a message target (e.g. + # channel or user), and send an interactive message to that target. + blocks = Path("approval_message.json.txt").read_text() + changes = [ + ("Title", "Question From " + profile.real_name), + ("Message", "Please select one of these options... :smiley_cat:"), + ("ActionID", event.data.user_id), + ] + for old, new in changes: + blocks = blocks.replace(old, new) + + slack.chat_postMessage(channel=event.data.text, blocks=blocks) + + +def on_slack_interaction(event): + """https://api.slack.com/reference/interaction-payloads/block-actions + + Args: + event: Slack event data. + """ + # The Slack ID of the user who sent the question + # (we stored this in the buttons' action IDs). + action = autokitteh.AttrDict(event.data.actions[0]) + origin = action.action_id.split()[-1] + + # User selection = action value = button text + # (our convention, not Slack's, alternatives: action style/text). + text = f"<@{event.data.user.id}> clicked the `{action.value}` button" + if action.style == "primary": # Green button. + text += " :+1:" + elif action.style == "danger": # Red button. + text += " :-1:" + + slack = slack_client("slack_conn") + slack.chat_postMessage(channel=origin, text=text) diff --git a/samples/twilio/README.md b/samples/twilio/README.md new file mode 100644 index 00000000..d21bbcc3 --- /dev/null +++ b/samples/twilio/README.md @@ -0,0 +1,31 @@ +# Twilio Sample + +This sample project demonstrates AutoKitteh's integration with +[Twilio](https://www.twilio.com). + +The file [`program.star`](./program.star) implements two entry-point functions +that are triggered by events which are defined in the +[`autokitteh.yaml`](./autokitteh.yaml) manifest file. One is a Slack trigger +to initiate sending Twilio messages, and the other is a webhook receiving +status reports from Twilio. + +API details: + +- [Messaging API overview](https://www.twilio.com/docs/messaging/api) +- [Voice API overview](https://www.twilio.com/docs/voice/api) + +It also demonstrates using constant values which are set for each AutoKitteh +environment in the [`autokitteh.yaml`](./autokitteh.yaml) manifest file. + +## Instructions + +1. Set the `FROM_PHONE_NUMBER` environment value + +2. Via the `ak` CLI tool, or the AutoKitteh VS Code extension, deploy the + `autokitteh.yaml` manifest file + +## Connection Notes + +AutoKitteh supports connecting to Twilio using either an auth token or an +[API key](https://www.twilio.com/docs/glossary/what-is-an-api-key), which can +be configured in the AutoKitteh WebUI. diff --git a/samples/twilio/autokitteh.yaml b/samples/twilio/autokitteh.yaml new file mode 100644 index 00000000..07f48fef --- /dev/null +++ b/samples/twilio/autokitteh.yaml @@ -0,0 +1,21 @@ +# This YAML file is a declarative manifest that describes the setup +# of an AutoKitteh project that demonstrates 2-way integration with +# Twilio (https://www.twilio.com). + +version: v1 + +project: + name: twilio_sample + vars: + - name: FROM_PHONE_NUMBER + value: + connections: + - name: slack_conn + integration: slack + - name: twilio_conn + integration: twilio + triggers: + - name: slack_slash_command + connection: slack_conn + event_type: slash_command + call: program.star:on_slack_slash_command diff --git a/samples/twilio/program.star b/samples/twilio/program.star new file mode 100644 index 00000000..32685b41 --- /dev/null +++ b/samples/twilio/program.star @@ -0,0 +1,48 @@ +"""This program demonstrates AutoKitteh's Twilio integration. + +This program implements two entry-point functions that are triggered by +events which are defined in the "autokitteh.yaml" manifest file. One is +a Slack trigger to initiate sending Twilio messages, and the other is a +webhook receiving status reports from Twilio. + +API details: +- Messaging API overview: https://www.twilio.com/docs/messaging/api +- Voice API overview: https://www.twilio.com/docs/voice/api + +In this sample, we expect the slash command's text to be a valid +phone number to send messages to. + +It also demonstrates using constant values which are set for each +AutoKitteh environment in the "autokitteh.yaml" manifest file. + +Starlark is a dialect of Python (see https://bazel.build/rules/language). +""" + +load("@twilio", "twilio_conn") +load("env", "FROM_PHONE_NUMBER") # Set in "autokitteh.yaml". + +def on_slack_slash_command(data): + """https://api.slack.com/interactivity/slash-commands + + Args: + data: Slack event data. + """ + + # Send SMS text via Twilio to the given phone number ("+12345556789"). + resp = twilio_conn.create_message( + from_number = FROM_PHONE_NUMBER, + to = data.text, + body = "This is an AutoKitteh demo message, meow!", + ) + + # For education and debugging purposes, print Twilio's response + # in the AutoKitteh session's log. + print(resp) + + # Also send a Whatsapp message to the same number. + resp = twilio_conn.create_message( + from_number = "whatsapp:" + FROM_PHONE_NUMBER, + to = "whatsapp:" + data.text, + body = "This is an AutoKitteh demo message, meow!", + ) + print(resp)