{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "# Sending PyLabRobot notifications to Slack" ] }, { "cell_type": "markdown", "id": "92e832c7", "metadata": {}, "source": [ "A basic example of sending Slack messages from Python, wired into a PLR protocol. Nothing here is PLR-specific — for the full webhook API (formatting, attachments, threading, Block Kit), see [Slack's Incoming Webhooks docs](https://api.slack.com/messaging/webhooks)." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Prerequisites\n", "\n", "- A Slack [Incoming Webhook URL](https://api.slack.com/messaging/webhooks)" ] }, { "cell_type": "markdown", "id": "45765d87", "metadata": {}, "source": [ "## 1. Store the webhook URL\n", "\n", "Save the URL to `~/.plr_slack_webhook` — outside the repo, mode 600 so only your user can read it:\n", "\n", "```bash\n", "umask 077 && echo \"https://hooks.slack.com/services/...\" > ~/.plr_slack_webhook\n", "```\n", "\n", "The helper below reads from this file. Pass `webhook_url=` to override (e.g. for testing)." ] }, { "cell_type": "markdown", "id": "e759d922", "metadata": {}, "source": [ "## 2. A tiny stdlib notifier\n", "\n", "Slack's incoming-webhook endpoint accepts a JSON POST. The standard library is enough — no `httpx`, `requests`, or `slack_sdk` needed." ] }, { "cell_type": "code", "execution_count": null, "id": "d764ddbd", "metadata": {}, "outputs": [], "source": [ "import json\n", "import urllib.request\n", "import urllib.error\n", "from pathlib import Path\n", "\n", "WEBHOOK_FILE = Path(\"~/.plr_slack_webhook\").expanduser()\n", "\n", "\n", "def _load_webhook() -> str | None:\n", " if WEBHOOK_FILE.exists():\n", " return WEBHOOK_FILE.read_text().strip() or None\n", " return None\n", "\n", "\n", "def slack_notify(text: str, webhook_url: str | None = None, timeout: float = 5.0) -> None:\n", " url = webhook_url or _load_webhook()\n", " if not url:\n", " print(f\"[slack_notify: no webhook configured at {WEBHOOK_FILE}] {text}\")\n", " return\n", "\n", " req = urllib.request.Request(\n", " url,\n", " data=json.dumps({\"text\": text}).encode(\"utf-8\"),\n", " headers={\"Content-Type\": \"application/json\"},\n", " method=\"POST\",\n", " )\n", " try:\n", " with urllib.request.urlopen(req, timeout=timeout) as resp:\n", " resp.read()\n", " except (urllib.error.URLError, TimeoutError) as e:\n", " print(f\"[slack_notify failed: {e}] {text}\")\n", "\n", "\n", "slack_notify(\":wave: Hello from PyLabRobot\")" ] }, { "cell_type": "markdown", "id": "134d34ab", "metadata": {}, "source": [ "## 3. Wrap a PLR run\n", "\n", "Send a message at start, on success, and on failure. The `finally` block guarantees `lh.stop()` runs even if the protocol crashes." ] }, { "cell_type": "code", "execution_count": null, "id": "489da2a0", "metadata": {}, "outputs": [], "source": [ "import traceback\n", "\n", "from pylabrobot.liquid_handling import LiquidHandler\n", "from pylabrobot.liquid_handling.backends.hamilton.STAR_chatterbox import STARChatterboxBackend\n", "from pylabrobot.resources.hamilton import STARLetDeck\n", "\n", "# Using the chatterbox backend so this notebook runs without hardware.\n", "# On a real machine, swap in `STARBackend()` (or whatever your machine uses).\n", "lh = LiquidHandler(backend=STARChatterboxBackend(), deck=STARLetDeck())\n", "\n", "try:\n", " await lh.setup()\n", " slack_notify(\":test_tube: Protocol started\")\n", "\n", " # ... your protocol goes here ...\n", " # await lh.pick_up_tips(...)\n", " # await lh.aspirate(...)\n", " # await lh.dispense(...)\n", " # await lh.drop_tips()\n", "\n", " slack_notify(\":white_check_mark: Protocol finished\")\n", "except Exception as e:\n", " slack_notify(f\":rotating_light: Protocol failed: `{e}`\\n```{traceback.format_exc()}```\")\n", " raise\n", "finally:\n", " await lh.stop()" ] }, { "cell_type": "markdown", "id": "87501478", "metadata": {}, "source": [ "## 4. Richer messages with Block Kit\n", "\n", "`text` is just one of the payload fields. Slack also accepts [Block Kit](https://api.slack.com/block-kit) for headers, sections, fields, and dividers:" ] }, { "cell_type": "code", "execution_count": null, "id": "f7e35d45", "metadata": {}, "outputs": [], "source": [ "def slack_post(payload: dict, webhook_url: str | None = None, timeout: float = 5.0) -> None:\n", " url = webhook_url or _load_webhook()\n", " if not url:\n", " print(f\"[slack_post: no webhook configured at {WEBHOOK_FILE}] {payload}\")\n", " return\n", " req = urllib.request.Request(\n", " url,\n", " data=json.dumps(payload).encode(\"utf-8\"),\n", " headers={\"Content-Type\": \"application/json\"},\n", " method=\"POST\",\n", " )\n", " try:\n", " with urllib.request.urlopen(req, timeout=timeout) as resp:\n", " resp.read()\n", " except (urllib.error.URLError, TimeoutError) as e:\n", " print(f\"[slack_post failed: {e}] {payload}\")\n", "\n", "\n", "slack_post({\n", " \"blocks\": [\n", " {\"type\": \"header\", \"text\": {\"type\": \"plain_text\", \"text\": \"Run complete\"}},\n", " {\"type\": \"section\", \"fields\": [\n", " {\"type\": \"mrkdwn\", \"text\": \"*Plates:*\\n4\"},\n", " {\"type\": \"mrkdwn\", \"text\": \"*Duration:*\\n00:42:13\"},\n", " ]},\n", " ]\n", "})" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Notes\n", "\n", "- The webhook URL lives in `~/.plr_slack_webhook`, outside the repo. Anyone with the URL can post to that channel, so keep it mode 600.\n", "- The helper catches `URLError` and `TimeoutError` so a failed POST doesn't abort the protocol.\n", "- Incoming Webhooks rate-limit to ~1 msg/sec per channel.\n", "- Webhooks are write-only. Slack → robot (e.g. a `/pause` command) needs the Events API." ] } ], "metadata": { "kernelspec": { "display_name": "env", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.12.12" } }, "nbformat": 4, "nbformat_minor": 5 }