Run the bot¶
phpbotscout serve supervises four things: the Discord gateway that watches
every public channel of the servers you name, the message bus that carries what it reads to the parts
that act on it, the scheduled refresh that keeps the knowledge index current,
and a daily purge that deletes stored questions after 180 days and answer text
after 120, as the policy promises. The answering path is wired and
runs: every question addressed to the bot is answered against the index and
recorded.
By default it does not post. Shadow mode is the shipping state, not a test
mode: the answer is composed and recorded exactly as it would have been posted,
with the pages it cited, and the channel stays quiet until a person has read
enough real answers to agree they are worth posting. That review is
phpbotscout review; see
review the bot's answers.
A server posts only when its own entry says so:
Then the bot replies in a thread named after the subject it answered, with its sources and the 👍 / 👎 reactions. There is no process-wide switch, deliberately: a misconfigured deployment that silently posts is worse than one that silently does not, so posting is given to a named server and every other server stays unable to post. The setting takes effect on restart, and the startup log says which servers post:
Moderation is not built. Answering is also available from the CLI via
ask, which is the same path the daemon uses.
Before you start¶
You need the Discord application registered and installed, with the
Message Content intent enabled. Server Members is not required: the bot
asks for message reading and reactions 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:
auth:
env: DISCORD_PHPBOTSCOUT_TOKEN # the NAME of the variable, not the token
guilds:
- space: "1531227937678954747" # the guild (server) ID
exclude_channels: # optional: public channels never to read
- "1531227938622800099"
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¶
The bot is one Discord application with one token, whichever servers it serves,
so the credential is discord.auth, set once. Left out, it defaults to
env: DISCORD_PHPBOTSCOUT_TOKEN.
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 |
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.
Which channels it reads¶
The bot reads every text channel and forum that everyone in the server can
read, and the public threads under them. There is no list of channels to
maintain. exclude_channels names public channels it must never read, and their
threads go with them. Leave it out to exclude nothing. See
which channels the bot reads.
Upgrading from channels¶
Earlier releases read only the channels listed under discord.guilds[].channels.
That key is gone, and a configuration that still names it is refused at
startup:
Starting anyway would widen what the bot reads the moment you upgraded. To
upgrade, delete channels, then list any public channel the bot must not read
under exclude_channels. See
spec 0024.
Upgrading: auth moved to discord.auth¶
Earlier releases took the credential on each guild, as discord.guilds[].auth.
A configuration that still does is refused at startup:
The hint says "The bot is one application with one token. Move the credential
to discord.auth." To upgrade, move the auth block up a level, from the guild
to discord, as in the sample above.
A stage server and a production server¶
guilds takes several servers, each space once. One application is invited
to each, and one connection serves them all. A common shape is a stage server
that posts, and a production server that stays in shadow until thirty answers
have been reviewed, which stage's reviews count towards while the two answer alike:
discord:
auth:
env: DISCORD_PHPBOTSCOUT_TOKEN
guilds:
- space: "1531227937678954747" # stage: the test server
post_answers: true
answer_budget: 20 # its own hourly ceiling
- space: "1519629968768897075" # production: shadow mode
answer_budget: 60
Each server's questions, answers and topics are kept apart, and a topic never
spans two. A server can also have its own ai.provider and ai.model, so a
new model goes to stage first, and its own corpus of GitLab path prefixes.
With its own answer_budget, stage using up its hour never blocks production.
See several servers and
spec 0029.
Questions recorded by an earlier release carry no server. Start once with only their server configured, and they are adopted into it, with the count logged. Started with several servers, they stay unassigned, and the log says so.
Run¶
You should see the scope confirmed at startup:
INFO starting http server tls=false addr=127.0.0.1:8081
INFO reading public channels space=1531227937678954747 excluded=[1531227938622800099]
That line is worth reading. It is how you confirm the bot is serving the right server, and excluding 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,
"state": "running",
"services": [
{"name": "index-refresher", "status": "OK"},
{"name": "question-purge", "status": "OK"},
{"name": "messaging", "status": "OK"},
{"name": "health", "status": "OK"},
{"name": "discord", "status": "OK"},
{"name": "messaging-subscriptions", "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 same holds for everything else serve runs. If the health listener cannot
bind its port, or any other service fails for good, the process exits non-zero
and the log names the service. A listener that dies after starting cleanly is
noticed within about 30 seconds. Without this a dead listener would leave
nothing to answer the probe that should have reported it.
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 and as a degraded
index-staleness check on /healthz, not a reason to take the bot out of
service.
The refresher keeps a clone of every source under
index.clones, beside the database
unless configured otherwise. Back it up or not as you like: a missing clone is
cloned again on the next refresh.
Give the refresher an SSH key. Over HTTPS a refresh of the whole corpus spends the forge's web rate limit, and a rate-limited refresh stops and defers the rest. Over SSH it does not. See fetching over SSH for the two keys and how to build the pinned known-hosts file.
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 the public 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:
| Public channel | A public thread in one | Private, excluded or unknown channel | |
|---|---|---|---|
| Received from Discord | yes | yes | yes |
| Written to the log | no | no | nothing at all |
| Counted | yes | yes | one unlabelled counter |
A thread counts as its channel. Excluding a channel excludes its threads at the same moment, and a thread whose parent cannot be resolved is not read.
Nothing about a message the bot does not read 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 bot is not meant to read.
No message is written to the log, whichever channel it came from: not its text, not its author. What people write is kept only where the bot's own retention reaches it (questions for 180 days, answers for 120), and a log is outside that (spec 0028).
The log still records identifiers, counts and errors, and versions before 0028 wrote authors. Cap how long the host keeps the log, and any database backups, at 30 days, as handling a request to be forgotten sets out. The policy promises that window, and nothing in the binary enforces it.
Changing configuration while it runs¶
| Setting | Behaviour |
|---|---|
discord.guilds[].exclude_channels |
applied immediately |
| adding or removing a server | needs a restart |
discord.auth |
needs a restart |
discord.guilds[].post_answers |
needs a restart |
discord.guilds[].answer_budget, ai.*, corpus |
needs a restart |
everything else (index.*, respond.*, health.*, timeouts) |
needs a restart |
The exclusion list is pure filtering, so it hot-reloads. A gateway reconnect keeps the latest list rather than the one the bot started with. 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.
A change to discord.auth or a server's post_answers, or a server added to or
removed from discord.guilds, logs a warning naming it, so it is not silently
ignored:
WARN configuration change requires a restart field=auth
WARN configuration change requires a restart field=guilds added=1519629968768897075
Every other setting, including a guild's answer_budget, ai.* and corpus, is
read once at startup, and a change to it is not warned about. Restart after
editing it.
A change that does not validate is refused whole, and the running configuration stays. The warning gives the reason:
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, those are 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.