No description
  • Go 94.6%
  • PHP 4.9%
  • Dockerfile 0.5%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
surdeus f83cc3c31e Add deployment-example.md: decisions, failures, and the working sequence
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.
2026-10-25 19:28:17 +05:00
cmd/announce Log every HTTP request and expose request counters 2026-10-25 17:56:19 +05:00
deploy Fix state volume ownership, add deploy runbook 2026-10-25 16:24:55 +05:00
internal Accept an empty JSON array for message parameters 2026-10-25 18:06:05 +05:00
nextcloud-app/talk_onboarding Fix app namespace: Application belongs in OCA\TalkOnboarding\AppInfo 2026-10-25 19:00:02 +05:00
.env.example Initial release: Talk announcement bot 2026-10-25 16:08:24 +05:00
.gitignore Initial release: Talk announcement bot 2026-10-25 16:08:24 +05:00
deployment-example.md Add deployment-example.md: decisions, failures, and the working sequence 2026-10-25 19:28:17 +05:00
docker-compose.example.yml Initial release: Talk announcement bot 2026-10-25 16:08:24 +05:00
Dockerfile Fix state volume ownership, add deploy runbook 2026-10-25 16:24:55 +05:00
go.mod Initial release: Talk announcement bot 2026-10-25 16:08:24 +05:00
README.md Add deployment-example.md: decisions, failures, and the working sequence 2026-10-25 19:28:17 +05:00

Nextcloud Talk announcement bot

A one-way announcement channel for Nextcloud Talk:

  1. Each employee receives a private one-to-one message with the greeting — the groups they can join and the people they can contact — once.
  2. 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 webhook is what delivers messages from the console room.
  • --feature response is 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 + body with 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 200 before 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 own BotService uses.
  • 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:

  1. create a group holding the employees to onboard, e.g. staff;
  2. make the service account subadmin of only that group;
  3. 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 poll reconciles anything missed; !onboard handles 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 -l inside the app container after installing, and watch for talk_onboarding entries in nextcloud.log on 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.