- Go 94.6%
- PHP 4.9%
- Dockerfile 0.5%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
Write up the first real deployment end to end, with every identifier and secret replaced by a placeholder. Part 1 records the architectural decisions and what each one ruled out: the service-account/bot split and why no bot lives in employee DMs, the console room as the only ACL, why a one-to-one room can never gain a third member, why the greeting is human-authored and versioned, and why push listens on UserLoggedInEvent rather than the more obvious UserFirstTimeLoggedInEvent. Part 2 documents the fourteen problems hit, each as symptom, cause and fix: the talk container being the signalling backend rather than the chat app, trusted_domains ignoring the env var on an existing instance, a root-owned state volume under distroless, a panic that killed the process on a bare command, absent logging on the happy path, three separate Talk payload type mismatches, a PSR-4 namespace error that broke the entire occ CLI, restart not reloading .env, --generate-password discarding the password, disk exhaustion corrupting the Redis AOF, and three misleading occ outputs. Part 3 is the sequence that worked; Part 4 covers building off the server and verifying the chain; Part 5 lists what is still open, including the missing license. |
||
| cmd/announce | ||
| deploy | ||
| internal | ||
| nextcloud-app/talk_onboarding | ||
| .env.example | ||
| .gitignore | ||
| deployment-example.md | ||
| docker-compose.example.yml | ||
| Dockerfile | ||
| go.mod | ||
| README.md | ||
Nextcloud Talk announcement bot
A one-way announcement channel for Nextcloud Talk:
- Each employee receives a private one-to-one message with the greeting — the groups they can join and the people they can contact — once.
- The same conversation stays in place, and every later announcement is posted into it.
Two identities are involved, split by function:
- A service account (
announcements) creates the one-to-one conversations and posts into them. It is plumbing; it never makes decisions. - A bot lives in one private "console" room only. It receives announcements and commands from that room and acknowledges them there.
Because no bot is enabled in the employee conversations, employee messages cannot reach this service at all. One-way is a property of the topology, not a promise in a handler.
Nextcloud
first login ──talk_onboarding app──┐
│ signed POST /login
v
console room -> webhook /hook -> [this service] -> fan-out to every DM
^ | \
+-------- bot replies ----------+ +-- create 1:1, greet --+
v
employee DM <-post- [acct: announcements]
Onboarding can be pushed (the companion app reports a first login),
polled (announce poll sweeps the provisioning API), or explicit
(!onboard). All three call the same idempotent operation, so they can be
mixed freely.
What the employee sees
Hi Ada, welcome to the team.
Open conversations: IT, 3D, orthotics — https://cloud/call/...
Questions: Ana Ruiz, ana@example.com
Wording is whatever an announcer sets with !greeting set. Nothing is
generated from the directory, so the message contains exactly what was approved.
Setup
1. Service account
docker compose exec -u www-data app php occ user:add \
--display-name="Announcements" --group=announcements announcements
docker compose exec -u www-data app php occ user:auth-tokens:add \
announcements --name="announce-bot"
Copy the printed password into TALK_APP_PASSWORD.
Give it a name staff will recognise. It appears as the author of every welcome and announcement, and people can DM it. Replies go nowhere: say so in the greeting text.
2. Conversation creation permissions
If "who can start conversations" is restricted, the account must be in one of the allowed groups:
docker compose exec -u www-data app php occ config:app:get spreed start_conversations
3. Install the bot
SECRET=$(openssl rand -hex 32)
docker compose exec -u www-data app php occ talk:bot:install \
--feature webhook --feature response \
"Announcements" "$SECRET" "http://bot:8088/hook" \
"Announcements for employees"
docker compose exec -u www-data app php occ talk:bot:list
--feature webhookis what delivers messages from the console room.--feature responseis required to reply there.- Do not pass
--no-setup: that sets the bot to state "locked", and Talk then refuses API-based enabling (used in step 5).
4. Create the console room
In Talk, create a private group conversation (not a 1:1), add the announcers, and enable the bot in it (conversation settings → Bots), or:
docker compose exec -u www-data app php occ talk:bot:setup <bot-id> <token>
Membership of this room is the permission model. Adding an announcer means adding them to the room; nothing else grants announcement rights.
5. Run the service
cp .env.example .env # fill in the secret and app password
docker compose up -d bot
Then, in the console room, as a moderator:
!console init
This binds the console room permanently. Until it happens, every message is ignored.
6. Set the greeting and onboard
!greeting set Hi {name}, welcome to the team.
Open conversations: IT, 3D, orthotics — https://cloud/call/...
Questions: Ana Ruiz, ana@example.com
(This channel is one-way; replies are not read.)
!greeting preview # see it rendered for yourself first
!onboard ada # create the DM and send it
!onboard bo cy # several at once
!users # who has been greeted
Onboarding is idempotent: running it again with the same greeting version does
nothing. Setting a new greeting bumps the version, so a later !onboard for the
same person re-sends the new text deliberately.
Commands
| Command | Effect |
|---|---|
!console init |
bind this room as the console (moderator/owner only, once) |
!console status |
binding, greeting version, counters |
!greeting set <text> |
replace the greeting, bump the version |
!greeting show |
current text, author, version |
!greeting preview |
render it for you, with your own name |
!greeting reset |
clear it; onboarding pauses |
!onboard <uid>... |
create the DM and send the greeting (max 20) |
!users |
per-employee state |
!help |
the list above |
Anything else typed in the console room is sent to every onboarded employee
and summarised back (📣 Sent to 137 employee(s)). A message that must begin
with a literal ! is escaped as \!.
Placeholders: {name}, {first_name}, {uid}. {name} is resolved from the
one-to-one conversation's display name, which is why no directory access is
needed.
CLI
Every console command has a CLI equivalent, useful for cron or bulk work:
announce status
announce greeting show
announce greeting set "Hi {name}, ..."
announce greeting preview ada
announce onboard ada bo cy
announce announce "All-hands at 10:00"
announce users
announce poll --dry-run
The CLI and the running service share /data/state.json. Safe because the file
is rewritten atomically under a lock, but do not run two serve processes
against the same file.
Automatic onboarding by push (no polling)
Nextcloud has no built-in push for "this account just logged in for the first
time" that covers every login method. The bundled webhook_listeners app can
only forward events implementing IWebhookCompatibleEvent, and among the user
events only UserFirstTimeLoggedInEvent does — which Session.php dispatches
only for password logins ($firstTimeLogin = false in the token branch).
UserLoggedInEvent, by contrast, is dispatched for every login, including
token and SSO logins, and IUser::getFirstLogin() has already been populated by
the time it fires. A six-file companion app in nextcloud-app/talk_onboarding
listens there and forwards the first login to this service.
Install the companion app
# from the repository root
docker compose cp nextcloud-app/talk_onboarding app:/var/www/html/custom_apps/talk_onboarding
docker compose exec -u www-data app php occ app:enable talk_onboarding
Configure it (app config wins over environment):
docker compose exec -u www-data app php occ config:app:set talk_onboarding \
url --value="http://bot:8088/login"
docker compose exec -u www-data app php occ config:app:set talk_onboarding \
secret --value="$(openssl rand -hex 32)"
The same values may be supplied as TALK_ONBOARDING_URL and
TALK_ONBOARDING_SECRET in the app service environment. Put the secret into
TALK_LOGIN_SECRET for the bot service as well; leaving it empty disables the
/login endpoint entirely.
TALK_ONBOARDING_EXCLUDE (or exclude app config) is a comma-separated list of
user ids never to greet. The service account is excluded automatically on the
Go side.
How the push path behaves
- Signed over
timestamp + bodywith HMAC-SHA256, so a captured request can be replayed neither with a new clock nor with an edited payload. Clocks more than five minutes apart are rejected. - Acknowledged with
200before any room is created, so a login never waits on Talk. - Reached over a private address without touching
allow_local_remote_servers: the app opts in per request with'nextcloud' => ['allow_local_address' => true], the same option Talk's ownBotServiceuses. - Later logins are silent. If the greeting already landed, the service returns without doing anything.
- A failure inside the listener never breaks the login. It is caught and logged.
The one gap
The notice is sent once. If the service is down or no greeting is set at that
moment, nothing retries it — firstLogin is already recorded, so the push will
not fire again. That is what the poller is for:
# once a day, as a reconciler rather than the primary mechanism
announce poll --max-new 25
With push working, a daily sweep normally reports onboarded=0.
Automatic onboarding by polling
announce poll sweeps the provisioning API for accounts that have logged in at
least once and have no local record, then greets them. This is what makes
onboarding automatic for every login method — password, SSO or app
password — because it reads lastLogin, which Nextcloud updates on all of them.
The webhook alternative (UserFirstTimeLoggedInEvent via the bundled
webhook_listeners app) is deliberately not used: lib/private/User/Session.php
suppresses that event for token logins ($firstTimeLogin = false), so it would
work today with passwords and silently stop the day SSO is added.
announce poll --dry-run # who would be greeted
announce poll # greet them, at most 25 this run
announce poll --watch --interval 5m
announce poll --group staff --max-new 5 --exclude admin,announcements
What makes an account eligible
| Condition | Outcome |
|---|---|
listed in TALK_POLL_GROUP (when set) |
candidate |
enabled: false |
skipped, counted as disabled |
lastLogin == 0 |
skipped, counted as never logged in |
| already in the state file | skipped, counted as already onboarded |
enabled field absent |
skipped, counted as not visible |
| otherwise | greeted |
Accounts arrive as a bare {"id": ...} when the credential lacks rights over
them. Those are counted separately and logged as a warning, so a permissions
mistake shows up as a number instead of looking like "everything is already
done".
The per-run cap
--max-new defaults to 25. This is deliberate protection: pointing a fresh
poller at a mature instance would otherwise greet every existing employee at
once, and a chat message cannot be unsent. Always run --dry-run first on a new
installation. Leftover candidates are reported and picked up by the next run, so
a --watch loop drains a backlog gradually. --max-new -1 removes the cap.
Credential requirements
/cloud/users/details only returns full records to an admin, delegated admin,
or a subadmin of the target's group. The least-privilege option is therefore:
- create a group holding the employees to onboard, e.g.
staff; - make the service account subadmin of only that group;
- run the poller with
--group staff.
A subadmin can reset passwords for its groups' members, so this is still a
meaningful privilege. If that is unacceptable, keep onboarding manual
(!onboard, announce onboard) — nothing else in the service needs directory
rights, and the greeting itself is plain text set by an announcer.
docker compose exec bot /announce poll --watch --interval 5m
Or as a second long-running service that shares the state volume:
poller:
build: .
command: ["poll", "--watch", "--interval", "5m"]
env_file: .env
volumes:
- bot-state:/data
networks: [talk]
restart: unless-stopped
Do not run two serve processes against the same state file; one serve plus
one poll writer is fine, because each write is an atomic rename.
Deployment
deployment-example.md— a real deployment written up end to end: the architectural decisions and what they ruled out, the fourteen problems encountered with symptom, cause and fix, the sequence that worked, and the open items. Read this before deploying; it documents several traps that are not obvious from the Nextcloud docs.deploy/nextcloud-remote.md— a shorter runbook for a specific compose stack (db,redis,nextcloud,cron,talk).
Security notes
- Inbound webhooks are verified with HMAC-SHA256 over
random + raw body, compared in constant time. Failures return 401 and are logged; nothing is dispatched. - Only the console room's messages are interpreted. A message from any other conversation is dropped before its body is parsed, so an employee cannot change the greeting from their own DM. Covered by a test.
- Bot replies are signed over
random + message text— Talk signs inbound and outbound differently, and getting this wrong yields silent 401s. - The acknowledgment is sent before the work starts. Talk's budget is five seconds and anything but 200/202 increments the bot's error counter.
- The bot secret and the app password are separate credentials. Rotating the bot secret requires reinstalling the bot (Talk has no edit command); the app password can be reissued independently.
- Plain
http://between containers means the HMAC is the only integrity protection. Keep the service on a private network, or terminate TLS with a certificate Nextcloud trusts. - The state file holds user ids and conversation tokens, no message bodies. It is written with mode 0600.
Limits worth knowing
- A greeting is a snapshot. Someone onboarded in March keeps March's text. A one-way channel cannot correct itself — prefer links to documents over inlining content that will change.
- Talk messages are capped at 32000 characters. The CLI warns above 8000.
- Deleted users leave their one-to-one room behind; Talk converts it to a "former" room, which cannot host a bot. Records for deleted users are not pruned automatically yet.
- Onboarding is pushed, polled, or explicit. The companion app sends a
notice on first login;
announce pollreconciles anything missed;!onboardhandles one-offs. A pushed notice that arrives while no greeting is set (or while the service is down) is not retried automatically — the sweep covers it. - The companion app is PHP and was not executed here. The Go side is
covered by tests; the PHP listener is reviewed against the Nextcloud source
but not syntax-checked. Run
php -linside theappcontainer after installing, and watch fortalk_onboardingentries innextcloud.logon the first real login. - Fan-out is best-effort: failures are reported in the console summary and recorded, but not retried automatically.
Development
go test ./...
go vet ./...
go build ./cmd/announce
No third-party dependencies: standard library only, so the binary is static
(CGO_ENABLED=0) and the image is distroless.