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:
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¶
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:
{
"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:
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:
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.