# Hijinks ↔ Mattermost bridge

A ready-to-run bridge that plays Hijinks games in Mattermost over the
[Hijinks Webhook API](https://docs.hijinks.team/). It is one file
of plain Node (20 or later), with no dependencies and no build step, and it
keeps its state in a small JSON file so a restart does not break a game in
progress. A Dockerfile and a systemd unit come with it.

> **Private beta.** Hijinks is not public yet and custom-platform workspaces
> are not open; they arrive with the rest of the product at launch. The
> contract may change before then.

The contract is the OpenAPI document,
[`webhooks/openapi.yaml`](https://docs.hijinks.team/webhooks/openapi.yaml)
(kept in the repository as `docs/api/webhooks.openapi.yaml`).

## Before you start

Expect about an hour if Mattermost already sits behind a reverse proxy with
automatic certificates. You need:

- [ ] **Admin access to the Mattermost System Console**, to create a bot
  account and the `/hijinks` slash command and to change two settings.
- [ ] **The System Admin role for the bot.** Mattermost lets only system
  admins post ephemeral messages (replies only one member sees, such as the
  verdict after a button press). Without it those replies fail with `403`
  and the bridge logs a hint; the rest of the game still works. On an
  edition with custom system roles you can grant a narrower role that has
  the "post ephemeral" permission instead.
- [ ] **A host that keeps the bridge running**: Node 20 or later, or Docker.
  It needs very little memory or CPU. [Run it as a service](#run-it-as-a-service)
  below.
- [ ] **A public HTTPS address for the bridge**, such as
  `https://hijinks-bridge.example.com`: a DNS name, a TLS certificate and a
  reverse-proxy rule pointing at the bridge's port. Hijinks only delivers to
  HTTPS URLs that resolve to public addresses. Mattermost must reach the same
  address (or an internal one for the bridge) for slash commands and buttons.
  **Intranet-only Mattermost?** Skip this and run the bridge in
  [polling mode](#intranet-only-mattermost-polling-mode): it needs outbound
  HTTPS to `api.hijinks.team` and nothing inbound from the internet.
- [ ] **A decision about typed answers.** Word games read answers members
  type. For that, Mattermost forwards **every message posted in the game
  channel** to the bridge, which passes it to Hijinks with the member's id
  and name. Hijinks ignores anything that is not an answer and does not keep
  it. Use a channel just for games and tell its members. Skip step 5 below
  and word games are not playable, everything else is.

Slash commands and button presses send only the command or the press, with
the member's id and name.

## Set it up

Do these in order: the bridge cannot start without the Mattermost tokens and
the Hijinks secret, and the Hijinks test event needs the bridge running.
`<BRIDGE_URL>` below is the address Mattermost reaches the bridge at: the
HTTPS address you chose for it (in polling mode, an internal address will do).

### 1. Mattermost

1. **Site URL.** *System Console → Environment → Web Server → Site URL* must
   be set to the address members use. Without it every slash command fails
   with "SiteURL must be configured to use slash commands".
2. **Bot account.** *System Console → Integrations → Bot Accounts*: enable bot
   accounts, create a bot with the *System Admin* role (see above), copy its
   access token (`MM_BOT_TOKEN`). Add the bot to the team and to the channels
   you play in.
3. **Slash command.** *Integrations → Slash Commands → Add*: trigger word
   `hijinks`, request URL `<BRIDGE_URL>/mattermost/command`, method
   `POST`. Copy its token (`MM_SLASH_TOKEN`).
4. **Buttons.** Buttons call back to `<BRIDGE_URL>/mattermost/action`;
   nothing to create. If the bridge's hostname resolves to a private address
   from the Mattermost server, add that hostname (for example
   `hijinks-bridge.internal`) to *System Console → Environment → Developer →
   Allow untrusted internal connections to*, or Mattermost refuses to call it.
5. **Typed answers (optional, for word games).** Read the privacy note above
   first. *Integrations → Outgoing Webhooks → Add*: content type
   `application/json`, the game channel, no trigger words, callback URL
   `<BRIDGE_URL>/mattermost/message`. Copy its token
   (`MM_OUTGOING_TOKEN`). A reply in a thread is sent with `replyTo` when the
   thread's root is a Hijinks message. Outgoing webhooks fire in public
   channels only.

### 2. Hijinks dashboard

Connect a custom platform with target URL `<BRIDGE_URL>/hijinks`.
The next screen shows three values. Copy them, and **keep the tab open**:
you come back to it in step 4, and the secret is shown only once.

- **Signing secret** → `HIJINKS_SECRET`
- **Workspace ID** → `HIJINKS_WORKSPACE` (also the last part of the inbound URL)
- **Inbound URL**: for reference; the bridge builds it from the workspace ID.

### 3. Start the bridge

Run it with the values from steps 1 and 2, either directly:

```sh
PORT=8080 \
HIJINKS_WORKSPACE=6f0c2b9e-8d4a-4f6b-9c1e-2a7d5e0b4c83 \
HIJINKS_SECRET=… \
MM_URL=https://chat.example.com \
MM_BOT_TOKEN=… \
MM_SLASH_TOKEN=… \
BRIDGE_URL=https://hijinks-bridge.example.com \
node bridge.mjs
```

or as a service ([below](#run-it-as-a-service)). It prints
`Hijinks bridge on :8080, state in …` once it has reached Mattermost.

### 4. Test

Back in the dashboard tab, press *Send test event*. The bridge answers the
`ping` with `200`. Then type `/hijinks` in a channel the bot is in.

## Intranet-only Mattermost (polling mode)

When nothing on your network may accept a connection from the internet, the
bridge fetches Hijinks' events instead of receiving them. It asks
`GET /hooks/<workspace id>/events` every 2 seconds over outbound HTTPS,
signs each request with the same secret, and checks the signature on each
answer. Hijinks keeps a workspace's events for 24 hours, so a bridge that
restarts picks up where it stopped (the cursor is saved in
`BRIDGE_STATE_FILE`; keep that file on a volume). On the way it drops what
went stale while it was down — replies after a minute, a live game's
messages after 10 minutes — and applies only the last update of each
message, so an outage does not end in a flood of old game cards. A bridge
that starts with no state file starts from now, not from a day ago.

What changes in the steps above:

- **No public address.** `BRIDGE_URL` is only the address Mattermost
  uses to reach the bridge for slash commands and buttons, for example
  `http://hijinks-bridge.internal:8080`. Add that hostname to *Allow
  untrusted internal connections to* (step 1.4).
- **Step 2:** connect the custom platform **without a target URL**. The
  workspace then holds its events for the bridge to fetch.
- **Step 3:** start the bridge with `--poll`, or set `BRIDGE_MODE=poll` (the
  form to use in the env file for Docker and systemd):

  ```sh
  BRIDGE_MODE=poll … node bridge.mjs
  # or
  node bridge.mjs --poll
  ```

  It prints `Hijinks bridge on :8080, polling Hijinks, …`.
- **Step 4:** *Send test event* queues a `ping` and reports success once the
  bridge has polled within the last minute.
- **Firewall request:** outbound HTTPS, TCP 443, to `api.hijinks.team` —
  nothing else, and nothing inbound from the internet (plus the bridge's
  usual access to Mattermost).
- **Behind a corporate proxy?** Set `HTTPS_PROXY=http://proxy.example.com:3128`
  and `NO_PROXY` to your Mattermost host, and on Node 24 (the Docker image)
  `NODE_USE_ENV_PROXY=1`, which makes Node's built-in `fetch` use them.
- **Switching to push later?** Let the bridge poll once more first: events
  still held when a target URL is set are never pushed.

Events reach Mattermost within one poll interval, so a question may appear
up to 2 seconds later than with push. Set `BRIDGE_POLL_INTERVAL_MS` to poll
more or less often. A poll counts against the workspace's 250 requests a
second like an answer does; a 2-second interval uses one of them.

## Settings

| Variable | Required | Meaning |
|---|---|---|
| `PORT` | no (8080) | Port the bridge listens on |
| `HIJINKS_URL` | no (`https://api.hijinks.team`) | Hijinks API base URL |
| `HIJINKS_WORKSPACE` | yes | Workspace ID from the dashboard |
| `HIJINKS_SECRET` | yes | Signing secret from the dashboard |
| `MM_URL` | yes | Mattermost server URL |
| `MM_BOT_TOKEN` | yes | Bot access token |
| `MM_SLASH_TOKEN` | yes | Token of the `/hijinks` slash command |
| `BRIDGE_URL` | yes | URL Mattermost reaches this bridge at, for slash commands and buttons (public HTTPS in push mode; may be internal in polling mode) |
| `BRIDGE_MODE` | no (push) | `poll` to fetch events instead of receiving them; same as `--poll` |
| `BRIDGE_POLL_INTERVAL_MS` | no (2000) | Polling mode: wait between polls when nothing more is waiting |
| `MM_OUTGOING_TOKEN` | no | Token of the outgoing webhook; without it typed answers are refused |
| `BRIDGE_STATE_FILE` | no (`./bridge-state.json`) | Where the bridge keeps its state; the directory must be writable |

## Run it as a service

Get the files (or copy them from this directory):

```sh
mkdir hijinks-bridge && cd hijinks-bridge
for f in bridge.mjs package.json Dockerfile hijinks-bridge.service README.md; do
  curl -fsSO "https://docs.hijinks.team/webhooks/mattermost/$f"
done
```

Put the settings in an env file, one `VAR=value` per line, readable only by
root (`chmod 600`). `npm start` is the same as `node bridge.mjs`.

**Docker.** The image keeps its state on the `/data` volume:

```sh
docker build -t hijinks-bridge .
docker run -d --name hijinks-bridge --restart unless-stopped \
  --env-file hijinks-bridge.env -v hijinks-bridge-state:/data \
  -p 127.0.0.1:8080:8080 hijinks-bridge
```

Point the reverse proxy at `127.0.0.1:8080`.

**systemd.** `hijinks-bridge.service` runs `/opt/hijinks-bridge/bridge.mjs`
with `/usr/bin/node` (change `ExecStart` if `which node` says otherwise), reads `/etc/hijinks-bridge.env`, keeps state in
`/var/lib/hijinks-bridge/` and restarts on failure:

```sh
sudo mkdir -p /opt/hijinks-bridge && sudo cp bridge.mjs /opt/hijinks-bridge/
sudo install -m 600 hijinks-bridge.env /etc/hijinks-bridge.env
sudo cp hijinks-bridge.service /etc/systemd/system/
sudo systemctl daemon-reload && sudo systemctl enable --now hijinks-bridge
journalctl -u hijinks-bridge -f
```

## What it does

| Direction | Route | Mattermost side | Hijinks side |
|---|---|---|---|
| Mattermost → Hijinks | `POST /mattermost/command` | `/hijinks …` slash command | signed `command` event |
| Mattermost → Hijinks | `POST /mattermost/action` | message button press | signed `action` event |
| Mattermost → Hijinks | `POST /mattermost/message` (optional) | outgoing webhook | signed `message` event (typed answers) |
| Mattermost → Hijinks | polls `GET /channels/{id}/posts` every 3 s | what members type in the DMs Hijinks opened | signed `message` event with `channel: "dm:<user id>"` |
| Hijinks → Mattermost | `POST /hijinks` (push mode) | posts, patches, ephemeral posts, DMs | `message.create`, `message.update`, `message.ephemeral`, `dm.create`, `ping` |
| Hijinks → Mattermost | polls `GET /hooks/{id}/events` every 2 s (polling mode) | the same | the same events, fetched |

- Every request from Hijinks is checked: `Hijinks-Signature` over the raw
  body, constant-time compare, 5-minute tolerance. Bad ones get `401`. In
  polling mode the same check runs on each answer, and a poll is signed over
  its query string.
- Events are deduplicated on `Hijinks-Event-Id` (retries repeat it), and
  answered `200` before the bridge calls Mattermost — Hijinks waits at most
  5 seconds. An event past its `expiresAt` is dropped.
- Every command, press and typed answer is sent with its own request `id`.
  A timeout, a `5xx` or a `429` is retried (after `Retry-After`, with a
  little jitter) up to three times with the same `id`, so Hijinks acts on it
  once; any other `4xx` is logged and not retried.
- Polling mode: when Mattermost refuses a new post, the bridge keeps its
  cursor and tries that post again on the next poll — up to five times, or
  until it expires. A refused update or ephemeral reply is dropped; the next
  update supersedes it.
- Each Hijinks `message.id` is mapped to the Mattermost post it became, so a
  `message.update` patches the right post. An update older (by `sequence`)
  than one already applied is skipped.
- Action buttons become interactive-message buttons. Mattermost does not sign
  button callbacks, so each button's context carries an HMAC of its message id
  and action (keyed with the Hijinks secret); a forged or edited press is
  refused. Link buttons (the Pictionary room) become Markdown links under the
  text, since Mattermost buttons cannot open URLs.
- Message text is printed as is: Mattermost already reads a bare `\n` as a
  line break, which is what the contract's Markdown expects.

The maps (message ids → posts, DM channels, seen event ids) are written to
`BRIDGE_STATE_FILE` whenever they change, through a temporary file and a
rename so a crash never leaves half a file, and read back on start. Without
them a restarted bridge could not update the posts it made. The newest 5,000
messages and event ids are kept. A state file that exists but cannot be read
stops the bridge at start rather than orphaning every game in flight.

Mattermost's outgoing webhooks do not fire in direct messages, so the bridge
polls the DM channels it opened for Hijinks (every 3 seconds, with the bot
token) and forwards what the member types there as a `message` with
`channel: "dm:<user id>"`. That is how a Know Your Team answer reaches
Hijinks: a typed message in the DM answers the question waiting there. A
button pressed in one of those DMs is sent with the same `dm:` channel, so
Hijinks answers in the DM. A bridge for a busier server may prefer the
WebSocket API to polling; the events it sends are the same.

## Files

| File | What it is |
|---|---|
| `bridge.mjs` | The whole bridge |
| `package.json` | `npm start`, and the Node version it needs |
| `Dockerfile` | Container image, state on the `/data` volume |
| `hijinks-bridge.service` | systemd unit |
