# Worked example: a Slack on-call bot

> A slash command you host answers who is on call: it checks Slack's signature, authorizes the workspace and user, makes one supported read and escapes what it sends back.

Canonical page: https://anectico.com/docs/apps/slack-bot/


`/oncall payments` in Slack answers "On call for payments: @dana." Slack sends the command to a server
you host; that server checks the request really came from Slack, decides whether this workspace and
user may ask, makes one read from Anectico and formats the answer.

The runnable example is `scripts/testing/examples/apps/slack_bot.py` in the Anectico repository, with
the shared client `anectico_rest.py`. Python 3.11 or later, standard library only.

## Before you start

| Decision | This example |
| --- | --- |
| Where it runs | Your bot's backend, reachable by Slack over HTTPS. |
| Project | Exactly one, named in every read. |
| Runtime permissions | `oncall:read`, `projects:read`, `members:read`. |
| Builder authority | Your agent's own credential provisions the key and, if needed, creates the schedule through its preview and confirmation; the bot never holds that credential. |
| Secrets | `ANECTICO_API_KEY` and Slack's `SLACK_SIGNING_SECRET`, both from the host's secret store. |
| Expiry and stop | A fixed expiry; `anectico apikey revoke <key-id>`. |
| Contract | `contract-pin.json`; the bot refuses to start against another contract. |

## What it calls

| Operation | Qualification | What it means for this bot |
| --- | --- | --- |
| `GET /api/v1/oncall/schedules/{id}/on-call` | Supported | A bounded lookup for one schedule at one instant. It returns `found` and the member's `user_id`, never contact details. Resolve it per command, or cache it for at most one minute. |

The bot maps schedule names to IDs (`ONCALL_SCHEDULES`) and member IDs to Slack users
(`ONCALL_MEMBERS`) from its own configuration. Anectico does not know your Slack users.

## Check the request before anything else

<!-- example: slack_bot.py#verify -->
```python
def verify_slack(secret: str, timestamp: str, signature: str, body: bytes, now: float) -> bool:
    """Slack's v0 request signature, with a five-minute replay window."""
    if not timestamp.isdigit() or abs(now - int(timestamp)) > 300:
        return False
    expected = "v0=" + hmac.new(secret.encode(), b"v0:" + timestamp.encode() + b":" + body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)
```

The server rejects a request with a wrong signature or a timestamp more than five minutes away with
`401`, before it reads anything. It then allows only the workspaces in `SLACK_ALLOWED_TEAMS` and, when
set, the users in `SLACK_ALLOWED_USERS`. Only then does it read Anectico. Request logs never include
the body, which holds user and workspace data.

## Answer

<!-- example: slack_bot.py#answer -->
```python
def answer(self, form: dict[str, str]) -> dict[str, Any]:
    """The reply to one already-verified, already-authorized command."""
    alias = form.get("text", "").strip().lower()
    schedule_id = self._config.schedules.get(alias)
    if schedule_id is None:
        known = ", ".join(sorted(self._config.schedules))
        return reply(f"Which schedule? Try one of: {escape(known)}.")
    path = Client.path("/api/v1/oncall/schedules/{id}/on-call", id=schedule_id)
    try:
        answer = self._client.get(path, {"project_id": self._config.project_id}, deadline=2.0).body
    except NotFound:
        return reply(f"The {escape(alias)} schedule no longer exists in Anectico.")
    except Busy as busy:
        wait = f" in {busy.retry_after} s" if busy.retry_after else ""
        return reply(f"Anectico is busy. Try again{wait}.")
    except Paused:
        return reply("Anectico reads are paused by a limit an owner set.")
    except (AccessEnded, Forbidden):
        return reply("This bot's access to Anectico has ended. Ask an admin.")
    except AnecticoError:
        return reply("Anectico refused the request.")
    if not answer.get("found"):
        return reply(f"Nobody is on call for {escape(alias)} right now.")
    member = str(answer.get("user_id", ""))
    slack_user = self._config.members.get(member)
    who = f"<@{slack_user}>" if slack_user and SLACK_USER.match(slack_user) else f"member {escape(member)}"
    return reply(f"On call for {escape(alias)}: {who}.")
```

- **Three seconds.** Slack expects an answer within three seconds, so the read has a two-second
  deadline and at most one retry. If Anectico asks for a longer wait, the bot answers "Anectico is busy.
  Try again in 5 s." at once instead of hanging.
- **Escaping.** Anything that came from data is escaped (`&`, `<`, `>`) before it goes into a Slack
  message, so a stored value can never become `@channel`, a mention or a link. Only a mention built
  from the bot's own configuration is sent unescaped.
- **Nobody on call** reads "Nobody is on call for payments right now." It is an answer, not an error.
- **Access ended** reads "This bot's access to Anectico has ended." The bot never switches to another
  key.
- **Ephemeral.** Replies go only to the person who asked.

## Run it

```bash
export ANECTICO_API_URL=https://app.anectico.com ANECTICO_PROJECT_ID=<project-uuid>
export ANECTICO_API_KEY="$(your-secret-store read anectico/oncall-bot)"
export SLACK_SIGNING_SECRET="$(your-secret-store read slack/oncall-bot)" SLACK_ALLOWED_TEAMS=T0123ABCD
export ONCALL_SCHEDULES='payments=<schedule-uuid>' ONCALL_MEMBERS='<member-id>=U0456EFGH'
python3 slack_bot.py
```

Point the slash command's request URL at `https://<your-host>/slack/commands`. Your agent can check the
same answer with its own credential:

```bash
anectico oncall now <schedule-uuid> -o json
```

What the example's checks cover and what they do not: they drive the handler with correctly and
incorrectly signed requests and a real Anectico read. Slack's own delivery, app installation and
workspace configuration are yours to verify in Slack; a passing local check is not evidence that Slack
reaches your server.

## Stop it and clean up

Revoke the bot's key; the next command answers that access has ended. Messages already posted in Slack
stay in Slack, under your Slack workspace's retention.

## Related

- [Polling, retries and refusals](/docs/apps/polling-and-streaming)
- [Incidents and on-call](/docs/respond/incidents-and-on-call)
