Hex is a small Python/Flask bot that:
- Listens for
@Hexmentions 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.
Flow:
-
In Slack, user posts:
@Hex tasks * @alice do this * @alice @bob do thator inline:
@Hex tasks @alice @bob do that -
Slack sends an
app_mentionevent to Hex via a public HTTPS URL (ngrok in dev). -
Hex (Flask app) parses each bullet / inline line → one
(assignee_id, task_text)pair per@mention. -
Hex looks up human‑readable names (
users.info) and the channel name (conversations.info). -
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}"notesinclude the Slack permalink.- Tasks go into a Google Tasks list whose title = Slack channel name (created on demand in each assignee's account).
-
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.
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/eventsendpoint (registered only whenSLACK_SIGNING_SECRETis set): verifies the Slack signature before parsing anything, including onurl_verification, then event deduplication (MongoDB TTL), then handsapp_mentionto the shared bounded pool (worker.py) to avoid Slack's 3s timeout./oauth/google/callbackhandles the OAuth redirect from Google./statsserves the dashboard and/redirects to it. Every response carries CSP, HSTS,X-Content-Type-Options,X-Frame-OptionsandReferrer-Policy; the CSP isdefault-src 'none', so no script can run on any page the app serves.socket_mode.py– Socket Mode listener: deduplicates events on the envelope'sevent_id(same MongoDB TTL collection as the HTTP route), then hands the work toworker.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; callsload_dotenv()so.envis 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 aMONGODB_URIthat disables TLS or weakens certificate validation, resolving options through PyMongo's own normaliser.account.py– sequences full account removal, whichdb.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 runsinit_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/meself-assignment, resolves names, handles due dates, calls Google Tasks per assignee, posts per-task summary. The target list is resolved per assignee: an explicitme: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); supportsall,limit N,skip N.commands/config.py– pins the list every task assigned to this user lands in, which is also the list@Hex tasklistreads by default;defaultreverts 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.
Create a .env file at the project root (never commit it). config.py loads it automatically via python-dotenv.
From your Slack app:
SLACK_BOT_TOKEN– OAuth & Permissions → Bot User OAuth Token.SLACK_BOT_USER_ID(optional) – value fromauth.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/eventsendpoint 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/eventsURL directly.
From your Google Cloud project:
GOOGLE_CLIENT_IDGOOGLE_CLIENT_SECRET
PUBLIC_BASE_URL(optional, defaulthttp://localhost:8080) – base URL used to build the Google OAuth callback URI.- In dev:
http://localhost:8080works as-is (Google allows localhost for Desktop OAuth clients). - In prod: set to your public HTTPS domain.
- In dev:
-
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 plainmongodb://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 explicittls=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, defaulthex)
HEX_DEBUG– set to1to 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, sopython -m hex_bot.apprefuses to start if an ngrok agent is detected, andrun.shrefuses to start at all when this is set. Even when enabled the dev server binds to127.0.0.1only.HEX_MAX_CONTENT_LENGTH(default1048576, i.e. 1 MiB) – maximum request body size. Bounds how much an unauthenticated caller can make the app buffer.HEX_TASK_RATE_MAX(default50) – maximum tasks one sender may create per window.HEX_TASK_RATE_WINDOW(default3600) – quota window in seconds.HEX_TRUSTED_HOSTS– comma-separatedHostheader allowlist (FlaskTRUSTED_HOSTS). Empty by default, on purpose. An incomplete list makes Flask answer 400, and kubelet sends the pod IP as theHostheader 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.
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.
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.memberscall 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.
-
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.
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=- Go to https://api.slack.com/apps → Create New App → From scratch.
- Name:
Hex. Workspace: your target workspace.
Under OAuth & Permissions → Bot Token Scopes, add:
app_mentions:read– receiveapp_mentionevents.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 viausers.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.
- Set Request URL to
https://<ngrok-id>.ngrok.io/slack/events(userun.shto get the ngrok URL). - Under "Subscribe to bot events", add
app_mention. - Save.
In the target channel: /invite @Hex
- Create a project at https://console.cloud.google.com/ (e.g.
hex-tasks-dev). - APIs & Services → Library → enable Google Tasks API.
- APIs & Services → OAuth consent screen → External.
- Fill app name, emails. Add the target Google account under Test users.
- Leave publishing status as
Testing.
- Credentials → Create Credentials → OAuth client ID → Desktop app.
- Under Authorized redirect URIs, add:
http://localhost:8080/oauth/google/callback(for local dev)- Your production HTTPS URL when deploying.
- Note the Client ID and Client secret.
-
Create the virtual environment (Python 3.13):
python3.13 -m venv .venv .venv/bin/pip install -r requirements-dev.txt
requirements.txtcontains production dependencies only (used by the Docker image).requirements-dev.txtadds pytest on top — use it for local development and running tests.Both
.txtfiles 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 runpip install --require-hashesand reject any package whose contents do not match. Edit the.infiles 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
--universalkeeps 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. -
Create
.env(see Section 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 to127.0.0.1only, 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.
-
Each user who wants tasks created in their Google account must register first:
@Hex registerClick the link in the ephemeral message, complete the Google OAuth flow. Hex sends a DM when it's done.
-
Then create tasks:
@Hex tasks @you inline testor:
@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.
./test.shRequires 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.
| 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. |