Skip to content

Run the bot

At this phase phpbotscout serve supervises two things: the Discord gateway that watches the channels you name, and the scheduled refresh that keeps the knowledge index current. It cannot post, moderate, or answer in Discord — not because those are switched off, but because the code that would do them is not built. Answering is available from the CLI via ask.

Before you start

You need the Discord application registered and installed, with the Message Content intent enabled. Server Members is not required at this phase — the ingest service declares message reading and nothing else, so the privileged GUILD_MEMBERS intent is never requested. See Register the Discord application.

Configure

Create .phpbotscout.yaml in the working directory, or use any source the config store reads:

discord:
  guilds:
    - space: "1531227937678954747"      # the guild (server) ID
      channels:                          # the allowlist — empty permits nothing
        - "1531227938622800055"
      auth:
        env: DISCORD_PHPBOTSCOUT_TOKEN   # the NAME of the variable, not the token

health:
  host: "127.0.0.1"                      # explicit interface, never 0.0.0.0
  port: 8081

index:
  path: phpbotscout.db                   # shared with the `index` commands
  refresh_interval: 1h                   # minimum 1m

gitlab:
  auth:
    env: GITLAB_TOKEN                    # the NAME of the variable, not the token

Then export the tokens themselves:

export DISCORD_PHPBOTSCOUT_TOKEN='…'
export GITLAB_TOKEN='…'

The GitLab token needs the Maintainer role on the group. Discovery reads each repository's Pages settings to locate its documentation site, and GitLab gates that endpoint — with a weaker token every candidate is reported unresolved rather than silently dropped.

The credential

auth takes exactly one of three forms, following the toolkit's convention:

Form Meaning
env: NAME the name of an environment variable holding the token — recommended, and the only form permitted under CI
keychain: service/account an OS keychain entry
value: … the token itself; refused when CI=true

Setting two is an error rather than a preference, because guessing which one you meant is the wrong kind of helpful where a secret is concerned.

The allowlist fails closed

An empty channels list permits nothing and is rejected at startup. A watchlist that silently meant everywhere would be the wrong default for reading people's messages.

One guild, for now

guilds is a list, but exactly one entry is accepted. Multi-tenancy is a v1 non-goal; the list shape exists so supporting several later is a configuration change rather than a migration of everyone's config file.

Run

phpbotscout serve

You should see the allowlist confirmed at startup:

INFO starting http server tls=false addr=127.0.0.1:8081
INFO watching channels space=1531227937678954747 channels=[1531227938622800055]

That line is worth reading. It is how you confirm the bot is watching what you meant before it has seen a single message.

Stop it with SIGTERM or Ctrl-C. It closes the gateway and exits cleanly; a second signal forces the exit.

Check it is healthy

Three endpoints, mounted on the health listener:

curl -s localhost:8081/healthz | jq
{
  "overall_healthy": true,
  "services": [
    {"name": "index-refresher", "status": "OK"},
    {"name": "messaging", "status": "OK"},
    {"name": "health", "status": "OK"},
    {"name": "discord", "status": "OK"}
  ]
}

messaging is the bus the gateway publishes onto, and which every consumer of Discord messages reads from. It is one service however many subscribers there are, which is deliberate: a service whose readiness fails takes the whole process out of rotation, so a single broken subscriber would otherwise take every other one down with it. A subscriber that has failed for good shows up as a degraded health check named messaging-subscriptions, which an operator can see and a probe ignores.

Endpoint Answers
/livez should this process be restarted?
/readyz is the bot currently seeing messages?
/healthz both, per service

The distinction matters. A bot that is up but disconnected looks exactly like a quiet channel, so readiness tracks the gateway connection: reconnecting is live but not ready, and so is the gap while the supervisor rebuilds the connection.

Liveness means the supervisor has not given up. The gateway library reconnects itself from a blip; a gateway that stays down past the health threshold (60s by default, longer than Discord's own heartbeat) gets the whole transport rebuilt on a fresh connection, with backoff, up to the configured restart allowance. Through all of that /livez stays green, so an orchestrator's liveness check cannot pre-empt the restart policy. Once the allowance is exhausted the process exits non-zero, and /livez fails first, so a container platform and a bare process converge on replacing it. The numbers are the discord supervision keys.

The listener binds loopback by default and refuses to start on 0.0.0.0. Binding every interface and relying on a firewall to be correct is how private services become public ones.

The scheduled index refresh

The daemon re-reads the corpus every index.refresh_interval, calling exactly the same code as index refresh. One implementation, so the scheduled and manual routes cannot drift.

It refreshes once at startup as well, so a daemon restarted after downtime does not wait a full interval to catch up. Startup does not block on that first run — indexing the corpus takes minutes, and a supervisor waiting on it would report the daemon as failing to come up while it was working correctly.

Check what it has done with index status.

A forge you cannot reach is not an outage

A refresh that fails is logged and counted, and the bot keeps running. Liveness always passes and readiness does not fail on a stale index: answering from what is already indexed, with a caveat, beats not answering, and a restart would not reach the forge either.

This is the same call the gateway makes about a reconnect that lost events. Staleness is a thing to act on, surfaced in index status — not a reason to take the bot out of service.

Shutdown waits for a refresh already in flight, bounded by the shutdown deadline, so the process cannot exit between writing a document and recording the commit it came from.

Build the index once by hand before starting the daemon, so the bot is not answering from nothing while its first refresh runs:

phpbotscout index build

What it reads, and what it keeps

Discord's intents are guild-wide, not per-channel. There is no way to ask the gateway for only your allowlisted channels, so the bot receives every message in the guild and discards the ones it must not read. The filtering is necessarily done by the bot.

That makes "what it reads" and "what it keeps" different answers:

Allowlisted channel A thread in one Any other channel
Received from Discord yes yes yes
Written to the log yes, content redacted yes, content redacted nothing at all
Counted yes yes one unlabelled counter

A thread counts as its channel. You do not list thread IDs — Discord gives a thread its own ID, so matching the allowlist exactly would leave the bot able to open a thread and never hear a reply in it. Removing the parent channel removes its threads at the same moment.

Nothing about a message from an unwatched channel is retained — not its content, not its channel, not its author. A per-channel counter would build an activity profile of exactly the channels the allowlist exists to exclude.

Message content that is logged passes through redact first, because it is untrusted input from a public server.

Changing configuration while it runs

Setting Behaviour
discord.guilds[].channels applied immediately
discord.guilds[].space needs a restart
discord.guilds[].auth needs a restart
index.refresh_interval needs a restart
index.path needs a restart

The allowlist is pure filtering, so it hot-reloads. Identity is not: applying a changed credential live would mean tearing down a working session on a config write, and a typo would take the bot off Discord until somebody noticed — at whatever time the file happened to be edited.

An ignored change is never silent. Editing either logs a warning naming the field:

WARN configuration change requires a restart field=auth

Testing

just test-e2e        # BDD scenarios; no credentials, no network
just test-discord    # forced-disconnect regression test; needs live credentials

test-discord cuts a real TCP connection to prove the session resumes rather than starting fresh. A reconnect that re-identified would drop every message buffered during the gap — for a support bot, questions nobody will ever answer, with no error and nothing to notice. The bot reports that case as a warning and a counter, and deliberately stays ready: the session is usable, and restarting it would not bring the lost messages back.