Skip to content
rakizPublic

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Hex – Slack → Google Tasks Bot

Hex is a small Python/Flask bot that:

  • Listens for @Hex mentions in Slack.
  • Parses inline or bullet-form task descriptions with @mentions.
  • Creates one Google Task per assignee, in that assignee's own Google account, grouped into a Google Tasks list per Slack channel.
  • Replies in Slack summarizing who assigned what to whom (only after Google confirms).

This document captures how the project works and how to re-create all the required configuration (Slack, Google, local dev) so Future‑You doesn't have to reverse-engineer it.


1. High‑level architecture

Flow:

  1. In Slack, user posts:

    @Hex tasks
    * @alice do this
    * @alice @bob do that
    

    or inline:

    @Hex tasks @alice @bob do that
    
  2. Slack sends an app_mention event to Hex via a public HTTPS URL (ngrok in dev).

  3. Hex (Flask app) parses each bullet / inline line → one (assignee_id, task_text) pair per @mention.

  4. Hex looks up human‑readable names (users.info) and the channel name (conversations.info).

  5. Hex adds a 👀 reaction to acknowledge receipt, then creates one Google Task per assignee:

    • Each task goes into the assignee's own Google account (looked up by their Slack user ID).
    • title = "[Display Name] {task_text}"
    • notes include the Slack permalink.
    • Tasks go into a Google Tasks list whose title = Slack channel name (created on demand in each assignee's account).
  6. After Google confirms each task, Hex removes the 👀 reaction and replies in the Slack thread with a per-task ✓/✗ summary.

Important: Both the sender and each assignee must be registered with @Hex register. If the sender is not registered, Hex refuses the command outright; if an assignee is not registered, Hex reports a failure for their task and continues with the others.

Registration acts as the app's allowlist, which is why it is required on both sides: without it, any Slack user — including guests and Slack Connect accounts — could create tasks in any registered person's Google account. Registration itself is refused for guest, single-channel-guest, and external accounts, and each sender is capped at 50 tasks per hour.


2. Code structure

hex-bot/
  hex_bot/
    __init__.py
    app.py               # Flask entrypoint (/healthz, /oauth/google/callback, /slack/events if HTTP mode)
    socket_mode.py       # Socket Mode listener (started if SLACK_APP_TOKEN is set)
    worker.py            # Bounded background execution shared by both Slack transports
    membership.py        # Slack account standing (guest/external/deactivated), cached
    config.py            # Central config (reads env vars + .env)
    slack_client.py      # Slack WebClient + signature verification
    dispatcher.py        # Routes app_mention events to subcommands
    db.py                # MongoDB persistence (users, event dedup, OAuth state, task quota, weekly stats)
    account.py           # Full account removal: revoke at Google, evict cache, delete, scrub stats
    google_tasks.py      # Google Tasks client + tasklist helpers
    scheduler.py         # Background thread: weekly usage stats snapshot
    commands/
      __init__.py
      base.py            # Command base class + registry (@register_command decorator)
      tasks.py           # "@Hex tasks" command
      register.py        # "@Hex register" command (starts Google OAuth flow)
      unregister.py      # "@Hex unregister" command
      status.py          # "@Hex status" command
      tasklist.py        # "@Hex tasklist [name] [all] [limit N] [skip N]" command
      config.py          # "@Hex config tasklist <name|default>" command
      help.py            # "@Hex help [command]" command
  tests/
    __init__.py
    conftest.py          # pytest fixtures (MongoDB hex_test database)
    test_db.py           # MongoDB CRUD, dedup, OAuth state, task quota, TLS guard, stats
    test_account.py      # Account removal: revocation, cache eviction, state/quota purge, stats scrub
    test_socket_mode.py  # Socket Mode event deduplication
    test_worker.py       # Bounded pool, backlog shedding, sender notification
    test_membership.py   # Account standing, caching, and what the cache costs
    test_log_hygiene.py  # Asserts task text, profiles and token material never reach logs
    test_parsing.py      # Bullet/inline parsing tests
    test_signature.py    # Slack signature verification tests
    test_slack_client.py # Bot user ID caching tests
    test_oauth.py        # OAuth URL generation / state verification tests
    test_google_tasks.py # Google Tasks client tests
    test_app.py          # Flask endpoint tests (URL verification, OAuth callback)
    test_dispatcher.py   # Command routing tests
    test_commands.py     # register / unregister / status command tests
    test_commands_tasks.py  # tasks command tests (parsing, per-assignee logic, due dates, me)
    test_commands_list_config.py  # tasklist and config command tests
    test_commands_help.py         # help command tests
    test_scheduler.py             # stats scheduler tests
  scripts/
    get_refresh_token.py # One-off helper to obtain a Google refresh token
  Dockerfile             # Production image (python:3.13-slim + gunicorn)
  build.sh               # Build the Docker image locally
  run.sh                 # Start ngrok + Flask locally (--docker flag available)
  test.sh                # Run the pytest suite
  requirements.in        # Production dependencies (edit this one)
  requirements.txt       # Generated lock: fully pinned + SHA-256 hashes
  requirements-dev.in    # Dev/test dependencies (-r requirements.in + pytest)
  requirements-dev.txt   # Generated lock: fully pinned + SHA-256 hashes
  pytest.ini
  .env                   # Local secrets (never committed)

Key modules:

  • app.py – /slack/events endpoint (registered only when SLACK_SIGNING_SECRET is set): verifies the Slack signature before parsing anything, including on url_verification, then event deduplication (MongoDB TTL), then hands app_mention to the shared bounded pool (worker.py) to avoid Slack's 3s timeout. /oauth/google/callback handles the OAuth redirect from Google. /stats serves the dashboard and / redirects to it. Every response carries CSP, HSTS, X-Content-Type-Options, X-Frame-Options and Referrer-Policy; the CSP is default-src 'none', so no script can run on any page the app serves.
  • socket_mode.py – Socket Mode listener: deduplicates events on the envelope's event_id (same MongoDB TTL collection as the HTTP route), then hands the work to worker.py.
  • worker.py – the ceiling on background work, shared by both transports: a fixed thread pool plus a cap on queued work. Past the cap the event is shed and the sender is told, so a dropped command is never mistaken for a silent success. A cap on one transport only is not a cap.
  • membership.py – whether a Slack account is inside the workspace's trust boundary, with a 10-minute cache. Consulted at registration and again at task creation.
  • config.py – reads all env vars; calls load_dotenv() so .env is loaded automatically.
  • db.py – MongoDB persistence: user records with Fernet-encrypted refresh tokens — registration is an insert, never an overwrite, so a stored token can only be replaced by unregistering first — event dedup via TTL collection (10 min window), single-use OAuth state nonces (oauth_states, keyed per user so only one is ever live, stored as a SHA-256 hash, TTL + atomic consume), per-sender task quota (task_quota, TTL), weekly usage stats (stats). Refuses a MONGODB_URI that disables TLS or weakens certificate validation, resolving options through PyMongo's own normaliser.
  • account.py – sequences full account removal, which db.delete_user() alone does not do: read the refresh token, revoke the grant at Google, evict the cached API session, delete the record, drop any pending OAuth state and quota buckets, scrub the user's identifiers from stats. A revocation failure is logged but never blocks local deletion.
  • scheduler.py – daemon thread that runs init_week_stats() on startup then once per week: snapshots registered user count, computes unique sender/assignee counts.
  • slack_client.py – WebClient, verify_slack_signature, get_bot_user_id.
  • dispatcher.py – finds the @Hex <command> line, routes to the matching command.
  • commands/tasks.py – parses bullets/inline/me self-assignment, resolves names, handles due dates, calls Google Tasks per assignee, posts per-task summary. The target list is resolved per assignee: an explicit me:list, else the list that assignee pinned with @Hex config tasklist, else the channel name. The task is created in the assignee's own account, so how their lists are organised is their decision.
  • commands/tasklist.py – lists open tasks from a Google Tasks list (by channel name, configured default, or explicit name); supports all, limit N, skip N.
  • commands/config.py – pins the list every task assigned to this user lands in, which is also the list @Hex tasklist reads by default; default reverts to the channel name.
  • commands/help.py – says what Hex does and how to start, then lists all registered commands (@Hex help) or shows usage and examples for one (@Hex help <cmd>). Documentation is pulled from each command's own class attributes — no centralised help strings.
  • google_tasks.py – OAuth2 refresh-token client, tasklist cache (per token), create_task, list_tasks.

3. Environment variables and secrets

Create a .env file at the project root (never commit it). config.py loads it automatically via python-dotenv.

3.1. Slack

From your Slack app:

  • SLACK_BOT_TOKEN – OAuth & Permissions → Bot User OAuth Token.
  • SLACK_BOT_USER_ID (optional) – value from auth.test()["user_id"]; auto-discovered if not set.

Hex supports two event-delivery modes. You can enable one or both simultaneously — at startup the app logs which modes are active.

HTTP mode (Slack pushes events to your URL):

  • SLACK_SIGNING_SECRET – Basic Information → App Credentials → Signing Secret. When set, the /slack/events endpoint is registered and accepts incoming webhooks from Slack.

Socket Mode (your bot opens a WebSocket to Slack — no public URL required):

  • SLACK_APP_TOKEN – Basic Information → App-Level Tokens → Generate Token (scope: connections:write). When set, a Socket Mode listener starts in a background thread.

Socket Mode is recommended for deployments behind SSO/auth proxies (e.g. Kanopy) where Slack cannot reach the /slack/events URL directly.

3.2. Google Tasks

From your Google Cloud project:

  • GOOGLE_CLIENT_ID
  • GOOGLE_CLIENT_SECRET

3.3. Public URL

  • PUBLIC_BASE_URL (optional, default http://localhost:8080) – base URL used to build the Google OAuth callback URI.
    • In dev: http://localhost:8080 works as-is (Google allows localhost for Desktop OAuth clients).
    • In prod: set to your public HTTPS domain.

3.4. MongoDB

  • MONGODB_URI – e.g. mongodb+srv://user:pass@cluster.mongodb.net/

    TLS is required for any remote host. mongodb+srv:// enables it implicitly and always passes. A plain mongodb:// URI is accepted only for a loopback host (localhost, 127.0.0.1, ::1) or a container-local one (mongo, mongodb), or if it carries an explicit tls=true / ssl=true. Anything else raises at first database access, because credentials and every refresh token we read cross this connection.

  • MONGODB_DB_NAME (optional, default hex)

3.4b. Security and limits (all optional)

  • HEX_DEBUG – set to 1 to enable the Flask debugger in the local dev entrypoint. Off by default, and deliberately hard to combine with a tunnel: the debugger allows arbitrary code execution on any 500 response, so python -m hex_bot.app refuses to start if an ngrok agent is detected, and run.sh refuses to start at all when this is set. Even when enabled the dev server binds to 127.0.0.1 only.
  • HEX_MAX_CONTENT_LENGTH (default 1048576, i.e. 1 MiB) – maximum request body size. Bounds how much an unauthenticated caller can make the app buffer.
  • HEX_TASK_RATE_MAX (default 50) – maximum tasks one sender may create per window.
  • HEX_TASK_RATE_WINDOW (default 3600) – quota window in seconds.
  • HEX_TRUSTED_HOSTS – comma-separated Host header allowlist (Flask TRUSTED_HOSTS). Empty by default, on purpose. An incomplete list makes Flask answer 400, and kubelet sends the pod IP as the Host header on liveness probes rather than the ingress hostname — so enabling this before enumerating every host that legitimately reaches the app will crash-loop the pod.

3.4c. Who Hex will act for

Registration is the allowlist. Both the sender and each assignee must have connected a Google account, so a task can only ever be created by, and land in, an account that opted in.

On top of that, Hex refuses guests, Slack Connect accounts and deactivated accounts (is_restricted, is_ultra_restricted, is_stranger, deleted). This is checked when the account registers and again when a task is created, because Slack accounts change underneath us: a colleague becomes a contractor, someone leaves and their account is converted or deactivated while their Hex record stays behind.

The re-check is cached for 10 minutes (membership.py). That is a deliberate trade, and worth stating plainly: an account that loses its standing keeps it inside Hex for at most 10 minutes, rather than forever. Without the cache every task would cost an extra users.info call on the critical path. If Slack cannot be reached the answer is "unknown" and Hex refuses — refusing on a blip is cheap and retryable; letting a guest through because Slack was briefly unavailable is not.

Accepted risk: no channel co-membership check

Hex does not verify that the sender and the assignee share the channel the command was sent from. Knowing someone's Slack id is enough to assign them a task from anywhere.

This is accepted rather than overlooked:

  • The assignee must be registered with Hex regardless, so this reaches no one who has not opted in.
  • Both parties are full workspace members, re-checked as described above.
  • The worst outcome is an unwanted task from a colleague, visible to them and deletable by them.
  • Enforcing it would cost a paginated conversations.members call per task, and would break legitimate use — assigning tasks from a group DM, or from a channel the assignee is not in.

If this is ever revisited, the check belongs next to the standing check in tasks.py, not in the parser.

3.5. Encryption

  • FERNET_KEY – symmetric key for encrypting Google refresh tokens at rest. Generate once with:

    python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"

    Never change this once tokens are stored — existing tokens become unreadable.

Example .env

SLACK_BOT_TOKEN=xoxb-...
SLACK_BOT_USER_ID=U0ACK1M63S8

# HTTP mode (one or both can be set)
SLACK_SIGNING_SECRET=...

# Socket Mode
SLACK_APP_TOKEN=xapp-...

GOOGLE_CLIENT_ID=...apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=...

# Optional — defaults to http://localhost:8080
# PUBLIC_BASE_URL=https://your-bot-domain.example.com

MONGODB_URI=mongodb+srv://...
MONGODB_DB_NAME=hex

FERNET_KEY=...

# Optional hardening knobs — see 3.4b for why the last one is empty by default.
# HEX_DEBUG=1
# HEX_MAX_CONTENT_LENGTH=1048576
# HEX_TASK_RATE_MAX=50
# HEX_TASK_RATE_WINDOW=3600
# HEX_TRUSTED_HOSTS=

4. Slack app configuration

4.1. Basic creation

  1. Go to https://api.slack.com/apps → Create New App → From scratch.
  2. Name: Hex. Workspace: your target workspace.

4.2. Bot Token Scopes

Under OAuth & Permissions → Bot Token Scopes, add:

  • app_mentions:read – receive app_mention events.
  • chat:write – send messages.
  • channels:read – read public channel names.
  • groups:read – read private channel names.
  • im:read, mpim:read – read IM/MPIM info.
  • users:read – get display/real names via users.info.
  • reactions:write – add/remove emoji reactions to acknowledge messages.
  • im:write – open a DM to send registration confirmation.

Then Reinstall to Workspace to apply new scopes.

4.3. Event Subscriptions

  1. Set Request URL to https://<ngrok-id>.ngrok.io/slack/events (use run.sh to get the ngrok URL).
  2. Under "Subscribe to bot events", add app_mention.
  3. Save.

4.4. Invite the bot

In the target channel: /invite @Hex


5. Google Cloud & OAuth setup

5.1. Create a project and enable the API

  1. Create a project at https://console.cloud.google.com/ (e.g. hex-tasks-dev).
  2. APIs & Services → Library → enable Google Tasks API.

5.2. OAuth consent screen

  1. APIs & Services → OAuth consent screen → External.
  2. Fill app name, emails. Add the target Google account under Test users.
  3. Leave publishing status as Testing.

5.3. Create OAuth Client ID

  1. Credentials → Create Credentials → OAuth client ID → Desktop app.
  2. Under Authorized redirect URIs, add:
    • http://localhost:8080/oauth/google/callback (for local dev)
    • Your production HTTPS URL when deploying.
  3. Note the Client ID and Client secret.

6. Running locally

  1. Create the virtual environment (Python 3.13):

    python3.13 -m venv .venv
    .venv/bin/pip install -r requirements-dev.txt

    requirements.txt contains production dependencies only (used by the Docker image). requirements-dev.txt adds pytest on top — use it for local development and running tests.

    Both .txt files are generated and must not be hand-edited. They are fully pinned lock files carrying a SHA-256 hash for every distribution, so the Docker build can run pip install --require-hashes and reject any package whose contents do not match. Edit the .in files and regenerate:

    uv pip compile requirements.in     --generate-hashes --universal --python-version 3.13 -o requirements.txt
    uv pip compile requirements-dev.in --generate-hashes --universal --python-version 3.13 -o requirements-dev.txt

    --universal keeps the lock valid on both amd64 and arm64 (the CI pipeline builds arm64), so the same file works everywhere. Regenerate both files together whenever you add or bump a dependency, otherwise the Docker build fails with a hash mismatch.

  2. Create .env (see Section 3).

  3. Start the bot (ngrok + Flask):

    ./run.sh

    Or with Docker (builds image first if needed):

    ./run.sh --docker

    To use the Flask debugger, do not use run.sh — it opens a public ngrok tunnel, and the debugger allows arbitrary code execution on any 500 response. Run the app directly instead; it binds to 127.0.0.1 only, and refuses to start if it detects a running ngrok agent:

    HEX_DEBUG=1 .venv/bin/python -m hex_bot.app

    The script prints the ngrok public URL. Set it as the Slack Event Subscription Request URL if it has changed.

  4. Each user who wants tasks created in their Google account must register first:

    @Hex register
    

    Click the link in the ephemeral message, complete the Google OAuth flow. Hex sends a DM when it's done.

  5. Then create tasks:

    @Hex tasks @you inline test
    

    or:

    @Hex tasks
    * @you bullet test
    * @you @someone another bullet
    

For the full list of commands with usage and examples, type @Hex help in Slack, or @Hex help <command> for details on a specific command.


7. Running tests

./test.sh

Requires MONGODB_URI in the environment (or .env). Tests run against a hex_test database that is wiped after each test.

The suite has 313 tests covering: command parsing, per-assignee task creation, due dates, self-assignment (me), pagination, Google Tasks client (with RefreshError handling), single-use OAuth state and its lifecycle (one live state per user, stored hashed, discarded on refusal), refusal to rebind an already-registered account, event deduplication on both transports, bounded background work and shedding, Slack account standing re-checked at task creation, Slack signature verification (including malformed timestamps), request size limits and security headers, log hygiene (task text, profiles and token material must never reach a log record), account removal (Google revocation, cache eviction, stats scrub), per-sender task quota, MongoDB TLS enforcement, encryption/dedup/stats, the stats scheduler, and all seven commands (including help).

tests/conftest.py forces MONGODB_DB_NAME=hex_test for the whole session. The test_db fixture only protects tests that request it, so without that override an unpatched code path reaching the database would write to the real one.


8. Potential improvements

Feature Notes
DM support Currently Hex only responds to app_mention events in channels. Supporting DMs requires: enabling the Messages Tab in App Home (Slack config), adding the im:history scope, subscribing to the message.im event, and updating the dispatcher to skip the @Hex prefix since users are already talking directly to the bot.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages