Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,37 @@ on:
- main
tags:
- 'v*.*.*'
pull_request:

permissions:
contents: read
packages: write

jobs:
test:
runs-on: ubuntu-latest

steps:
- name: Checkout repository
uses: actions/checkout@v3

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'

- name: Install dependencies
run: pip install --no-cache-dir -r requirements-dev.txt

- name: Import smoke test (catch circular imports)
run: python -c "import hutbot, hutbot.state, hutbot.actions, hutbot.buttons, hutbot.scheduling, hutbot.routing, hutbot.commands.dispatch, hutbot.webui_backend"

- name: Run tests
run: python -m pytest

build-and-push:
needs: test
if: github.event_name == 'push'
runs-on: ubuntu-latest

steps:
Expand All @@ -36,4 +60,4 @@ jobs:
push: true
tags: |
ghcr.io/mittwald/hutbot:latest
ghcr.io/mittwald/hutbot:${{ github.ref_name }}
ghcr.io/mittwald/hutbot:${{ github.ref_name }}
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ employees.json
.env
.codex
scheduled_replies.json
button_states.json
4 changes: 3 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,6 @@ RUN pip install --no-cache-dir -r requirements.txt

COPY . .

ENTRYPOINT ["python", "bot.py"]
EXPOSE 8080

ENTRYPOINT ["python", "-m", "hutbot"]
204 changes: 201 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,168 @@ Opsgenie alert priority defaults to `P4` and can be configured per channel confi
/hutbot [config] set opsgenie-priority <P1|P2|P3|P4|P5>
```

## Triggers, conditions, and actions

Beyond the classic "reply if a message goes unanswered" behavior, each named config is a **rule**
with a `trigger`, an optional `condition`, and an `action`. The trigger defaults to `message`, so
existing configs keep working unchanged.

- **Triggers** (`/hutbot [config] set trigger <message|schedule|manual>`)
- `message` — the classic behavior (matches channel messages by pattern/teams/hours).
- `schedule` — fires on a cron schedule. Set it with `set cron "<expr>"` (5-field cron, e.g.
`0 9 * * 1-5`) and optionally `set schedule-timezone <IANA tz>`.
- `manual` — never fires on its own; used as the target of a button or a button timeout.

- **Conditions** (`/hutbot [config] set condition <none|outlook>`) gate a `schedule` trigger.
- `outlook` — matches Outlook calendar entries by `set outlook-subject <regex>` /
`set outlook-body <regex>`. Use `enable negate` to fire when *no* matching entry exists.
**Note:** the Outlook integration is currently a stub (events come from the
`HUTBOT_OUTLOOK_STUB_EVENTS` env var); the real implementation lands in a later task.

- **Actions** (`/hutbot [config] set action <reply|dm-user|group-dm|post-channel>`) decide what the
rule does, using `reply_message` (with the usual `{{variables}}`) as the body.
- `reply` — post in the rule's channel.
- `dm-user` — DM a single user. Set the recipient with `set target <@user>`.
- `group-dm` — open one group DM (mpim) with all members of a Slack user group and post once.
Set the group with `set target <@usergroup>` (Slack caps a group DM at 8 members).
- `post-channel` — post to another channel. Set it with `set target <#channel>`.

- **Buttons** attach interactive buttons to the message a rule sends (including the classic
unanswered-message reply). Each button is added incrementally with a typed action:
- `/hutbot [config] add button "<label>" config <config>` — run another config (a `manual` rule).
- `/hutbot [config] add button "<label>" ack [text]` — acknowledge/dismiss: stop the escalation and
optionally post `text`.
- `/hutbot [config] add button "<label>" message <text>` — post a fixed message.
- `/hutbot [config] add button "<label>" delay <minutes>` — delay the escalation by N minutes.
- `/hutbot [config] clear buttons`
- `/hutbot [config] set button-timeout <minutes>` — escalate if no button is pressed in time, then
either `set default-button "<label>"` (auto-press that button) or `set button-timeout-target
<config>` (run that config). Pending escalations survive restarts.
- A bare `add button "<label>" <config>` (no action keyword) is still treated as `config <config>`.
- A button/timeout that runs a config passes the **original message context** to it, so the target's
templates and any OpsGenie alert reference the original message.

- **"Need help?" example** — a question that defaults to "Yes" if ignored:
```bash
/hutbot helpcheck set message Need help?
/hutbot helpcheck add button "Yes" message "Here's the help doc: …"
/hutbot helpcheck add button "No" ack
/hutbot helpcheck set button-timeout 5
/hutbot helpcheck set default-button "Yes"
```
Pressing *Yes* posts the help message; *No* dismisses it; if nobody clicks within 5 minutes, Hutbot
auto-presses *Yes* and posts the help message.

- **OpsGenie is just a config property**: any config that runs and has OpsGenie enabled sends an alert
(with `set opsgenie-message "<template>"` to customise the alert text; default is the original
message). So to **button-gate** an alert, put OpsGenie on a *separate* `manual` config and run it
from a button or the button-timeout — don't put OpsGenie on the question config itself (or it would
alert as soon as the question is posted). Example:
```bash
# question — no OpsGenie
/hutbot incident set message Incident — on it?
/hutbot incident add button "I've got it" ack
/hutbot incident set button-timeout 5
/hutbot incident set button-timeout-target page-oncall # escalate if ignored
# alert config — OpsGenie on, runs only when escalated (or via a Yes-style button)
/hutbot page-oncall set trigger manual
/hutbot page-oncall enable opsgenie
/hutbot page-oncall set opsgenie-schedule "Team Primary"
/hutbot page-oncall set opsgenie-message "Unanswered incident in {{channel}}: {{message}}"
```
*I've got it* cancels the escalation (no page); otherwise after 5 minutes `page-oncall` runs and pages
on-call, referencing the original message. A `delay` button pushes the 5-minute timer out.

Use `/hutbot [config] run` to fire a configuration's action immediately (handy for testing).

## Web configuration UI

Hutbot can serve a small web UI from the **same bot process** (no separate service) that lets a
logged-in user view and edit the Hutbot rule configuration for the Slack channels they belong to. It
shows configuration only — there is no message log or analytics. It is **disabled by default**.

The UI is intended to run behind a Keycloak-fronting reverse proxy (e.g. oauth2-proxy). The proxy
authenticates the user and passes their email in a trusted HTTP header. Hutbot resolves that email to
a Slack user (reusing the same employee-list / Slack mapping the bot already uses), determines their
team, and lists the channels that (a) have a Hutbot config **and** (b) the user is a member of.
Editing is gated by channel membership.

> **Security model:** Hutbot **trusts** the configured header. You **must** ensure the bot's HTTP port
> is only reachable through the authenticating proxy (in Kubernetes the Helm chart's NetworkPolicy plus
> a ClusterIP Service + Ingress do this). Anyone able to reach the port directly could spoof the header.

### Enabling it locally

```bash
export HUTBOT_UI_ENABLED=1
# optional overrides:
export HUTBOT_UI_PORT=8080 # default 8080
export HUTBOT_UI_HOST=0.0.0.0 # default 0.0.0.0
export HUTBOT_UI_USER_HEADER=X-Forwarded-Email # default
python -m hutbot
```

Then test with a manual header:

```bash
curl -H "X-Forwarded-Email: you@yourcompany.com" http://localhost:8080/api/me
curl -H "X-Forwarded-Email: you@yourcompany.com" http://localhost:8080/api/channels
```

Open http://localhost:8080/ in a browser (send the header via a browser extension or your local proxy
during development). Without a valid, resolvable header the API returns `403`.

### Header per proxy

Set `HUTBOT_UI_USER_HEADER` to match the header your proxy sends:

- **oauth2-proxy** → `X-Forwarded-Email` (default)
- **Keycloak gatekeeper (louketo)** → `X-Auth-Email`
- **custom nginx `auth_request`** → whatever header you configure

### Endpoints

- `GET /healthz` — unauthenticated, for Kubernetes probes.
- `GET /api/me`, `GET /api/meta`, `GET /api/channels` — require a resolvable user.
- `GET`/`PUT`/`POST`/`DELETE` `/api/channels/{id}/configs[/{name}]` — require a resolvable user, and
channel routes additionally require membership of the channel.

### Kubernetes / Helm

Enable the UI through the chart with the following `values.yaml` keys under `ui:`:

```yaml
ui:
enabled: true
port: 8080
userHeader: "X-Forwarded-Email"
service:
type: ClusterIP
ingress:
enabled: true
className: "nginx"
host: "hutbot.internal.example.com"
annotations: {} # put your Keycloak/oauth2-proxy auth annotations here
tls: []
```

When `ui.enabled` is `true` the chart renders a Service, exposes the container port, adds `/healthz`
readiness/liveness probes, and opens the NetworkPolicy ingress for that port; the Ingress is rendered
when `ui.ingress.enabled` is also `true`. The Keycloak integration itself (oauth2-proxy / annotations)
is the operator's responsibility — Hutbot only consumes the resulting header.

> **Security:** the UI trusts the proxy-set email header, so any pod able to reach the UI port can
> spoof it and edit configs. Restrict the NetworkPolicy ingress to your auth proxy / ingress
> controller with `ui.allowedIngress` (a list of NetworkPolicy peers rendered into the rule's `from:`).
> Leaving it empty allows all sources (back-compat) and is not recommended:
> ```yaml
> ui:
> allowedIngress:
> - namespaceSelector:
> matchLabels:
> kubernetes.io/metadata.name: ingress-nginx
> ```

## Step 1: Set Up the Slack App

1. **Create a New Slack App**
Expand Down Expand Up @@ -81,15 +243,22 @@ Opsgenie alert priority defaults to `P4` and can be configured per channel confi
- `message.im`
- `message.mpim`

6. **Add Slash Command**
6. **Enable Interactivity** (required for buttons)

- Go to **"Interactivity & Shortcuts"**.
- Turn on **"Interactivity"**.
- Because the app uses **Socket Mode**, no Request URL is needed — interactive
button presses (`block_actions`) are delivered over the same socket.

7. **Add Slash Command**

- Go to **"Slash Commands"**.
- Click **"Create New Command"**.
- Set the command to `/hutbot`.
- Set the short description to `Configure @Hutbot`.
- Click **"Save"**.

7. **Install the App**
8. **Install the App**

- Go to **"Install App"**.
- Click **"Install App to Workspace"** and authorize the app.
Expand All @@ -101,7 +270,7 @@ Opsgenie alert priority defaults to `P4` and can be configured per channel confi
export SLACK_BOT_TOKEN='xoxb-your-bot-token'
export SLACK_APP_TOKEN='xapp-your-app-level-token'
pip install -r requirements.txt
python bot.py
python -m hutbot
```

- See [Hutbot Slack App](https://api.slack.com/apps/A07RQ54Q5H9)
Expand Down Expand Up @@ -198,3 +367,32 @@ export NETWORKPOLICY_RULES='443:192.168.0.15/32 80:10.0.0.0/24,10.0.1.0/24'
export HOST_ALIASES='lb.mittwald.it=192.168.0.15'
helmfile sync
```

## Development

Hutbot is a Python package (`hutbot/`). The former monolithic `bot.py` has been
split into cohesive modules:

- `hutbot/state.py` — shared in-memory caches and runtime flags
- `hutbot/models.py`, `hutbot/constants.py` — data types and configuration defaults
- `hutbot/textutil.py`, `hutbot/datetimefmt.py` — logging/text helpers and date/time formatting
- `hutbot/persistence.py` — config and cache load/save
- `hutbot/slackcache.py` — Slack user/channel/usergroup lookups
- `hutbot/templating.py`, `hutbot/opsgenie.py` — reply templates and OpsGenie integration
- `hutbot/messaging.py`, `hutbot/actions.py`, `hutbot/buttons.py` — sending, the action engine, and interactive buttons/escalation
- `hutbot/scheduling.py`, `hutbot/routing.py` — scheduled replies / cron triggers and Slack event routing
- `hutbot/commands/` — slash-command parsing and handlers
- `hutbot/webui_backend.py` — the web-UI bridge (the HTTP server lives in `webui.py`)
- `hutbot/__main__.py` — the entry point

Run it locally with `python -m hutbot`.

### Running tests

```bash
pip install -r requirements-dev.txt
pytest
```

Tests live in `tests/` (one file per module) and run automatically in CI before
the Docker image is built.
Loading
Loading