Files
discord-bot/claude.md
T

80 KiB

Discord Bot

Immich Discord bot built with NestJS, discordx, and PostgreSQL (Kysely ORM).

Tech Stack

  • Runtime: Node.js (24.x), TypeScript, CommonJS
  • Framework: NestJS with @nestjs/schedule for cron jobs
  • Discord: discord.js + discordx (decorator-based slash commands, events, modals, buttons)
  • Database: PostgreSQL via Kysely (type-safe query builder), file-based migrations
  • Testing: Vitest with manual mocks (no test database)
  • Build: nest build (SWC compiler), eslint, prettier

Architecture

Layers

  1. Discord layer (src/discord/) - Slash commands, events, help-desk, context menus. These are @Discord() + @Injectable() NestJS classes that use discordx decorators (@Slash, @On, @ModalComponent, @ButtonComponent).
  2. Service layer (src/services/) - Business logic. Injected into discord layer. Services use @Inject(ITokenName) for repository dependencies.
  3. Repository layer (src/repositories/) - External integrations (database, Discord API, GitHub, Zulip, RSS, etc). Each has an interface in src/interfaces/ with a string token (export const IFoo = 'IFoo').
  4. Interface layer (src/interfaces/) - Defines repository contracts and Kysely table types. The Database type in database.interface.ts maps table names to their column types.
  5. Renderer layer (src/renderers/) - Pure functions, no DI, one module per chat platform. Each turns a platform-neutral Notification into that platform's wire shape (toDiscordMessage, toMattermostBlock, toZulipMessage). Consumed only by NotificationService; services never import a renderer, and no renderer imports another.

Dependency Injection

Repositories are provided via NestJS DI tokens in src/repositories/index.ts:

{ provide: IDatabaseRepository, useClass: DatabaseRepository }

Services inject them with @Inject(IDatabaseRepository).

Registration

  • Services: Listed in src/services/index.ts → imported into AppModule
  • Repositories/Providers: Listed in src/repositories/index.ts → imported into AppModule
  • Discord classes: Listed directly in AppModule (DiscordCommands, DiscordEvents, DiscordHelpDesk, DiscordContextMenus)
  • Init order: AppModule.onModuleInit calls each service's init() by hand, in a fixed order. A service that subscribes to Zulip messages (ZulipService.onMessage) must have its init() called there, and before ZulipService.init(), which starts the event loop (see Zulip event queue). Today that is ChatService (the expanders) and ZulipCommandService (the commands). Last, once Zulip is up, ChatService.loginToDiscord logs in to Discord (not with the dev token) and awaits it, so the HTTP server listens only once Discord is ready: a login that hangs holds back the webhooks and the @Cron jobs, while the Zulip event loop, already started, keeps running. DiscordRepository.login resolves at clientReady, once the guilds and so their channels are cached; discord.js's own login resolves at the gateway's READY, before them. Alongside the login, I'm alive, running <version>! goes to team.bot once per process, never on a gateway reconnect: when Discord turns ready, at once with the dev token, or without Discord after 60s (DISCORD_READY_WAIT_MS) of a login still pending. A failed login is reported to team.bot instead (Discord login failed: …, which reaches Zulip alone) and then fails the boot, so a restart retries it.

Database Migrations

Located in src/schema/migrations/ with naming pattern {timestamp}-{description}.ts. Each exports up() and down() functions running raw SQL through Kysely. Migrations run automatically on module init via DatabaseService.runMigrations(), so every migration must be safe on a populated table.

Tables are declared with @immich/sql-tools decorators in src/schema/tables/, and a migration is generated from the difference between those declarations and a database that is at the current schema: npm run build, then DB_URL=postgres://... npm run migrations:generate. The generator writes src/{timestamp}-Migration.ts; move it into src/schema/migrations/ under a descriptive name. To verify one, boot the compiled app once against a scratch database (uri in the environment) and check the schema.

Adding a New Database Table

  1. Declare the table in src/schema/tables/{name}.table.ts and generate the migration (see above)
  2. Add Selectable/Insertable/Updateable types in src/schema/index.ts
  3. Add table to the Database interface there
  4. Add repository methods to IDatabaseRepository interface
  5. Implement methods in src/repositories/database.repository.ts

Adding a New Slash Command

Commands live in src/discord/commands.ts. Use discordx decorators:

  • @Slash({ name, description }) on method
  • @SlashOption({...}) for parameters
  • @SlashChoice(...) for enum choices
  • Autocomplete: set autocomplete: true on option, check interaction.isAutocomplete() in handler

Auth Guard

Legacy commands use authGuard() to restrict to allowed channels (BotSpam, SupportCrew, QQ). New commands should NOT use authGuard — permissions will be configured via Discord's built-in command permissions UI instead.

Cron Jobs

Use @Cron(expression) decorator from @nestjs/schedule. Cron expressions stored in Constants.Cron.

Notifications

Anything posted to a chat channel as a card (GitHub events, GitHub status incidents, purchases, reports, release alerts) goes through one seam. A service never names a platform in a notification path.

  1. The service builds a Notification (src/interfaces/notification.interface.ts): a required kind (feed, release, incident, purchase, report, alert, rss, log), an optional domain-namespaced accent (pr.merged, issue.closed, order.cancelled, ...), plus author, title, url, body, fields and timestamp (an ISO 8601 string that only the rss kind renders).
  2. The service calls NotificationService.notify(destination, notification) with a logical destination such as community.releases or team.purchases. Destinations are audience-scoped: community.* is public, team.* is internal. Business rules like "a private repo skips the community" are expressed by choosing destinations, not platforms.
  3. NotificationRoutes in src/constants.ts maps every destination to the platforms and channels it reaches, including per-route silent (Mattermost), crosspost (Discord) and topic (Zulip). A destination with no route for a platform simply does not post there. The whole matrix is reviewable in that one table. Today every team.* destination except the FHS ones reaches Zulip (stream ImmichThirdParties, one topic per subject; team.release-alerts and team.bot go to ImmichAlerts); community.* destinations are Discord only.
  4. NotificationService (src/services/notification.service.ts) renders the notification once per routed platform with that platform's renderer and sends it, Discord first, then Mattermost, then Zulip, one platform at a time.
  5. A notification whose channel is chosen per row rather than by a route (an RSS feed) goes through NotificationService.notifyTarget(target, notification) instead. NotificationTarget (notification.interface.ts) is one explicit address: { platform: 'discord', channelId }, { platform: 'mattermost', channelId } or { platform: 'zulip', stream, topic }, and toNotificationTarget (exported from notification.service.ts) turns a stored row's service, channelId and topic into one, so the caller never branches on the platform. It uses the same renderers and the same deliver as notify, with the target as the log label (Could not notify channel 123 on discord: …, … stream 107, topic "blog" on zulip: …), and differs from it in one way: it resolves to whether the post went through (false for a failed send, for a Discord target while Discord is not ready, and for a Zulip target while Zulip is not initialised, each skipped without rendering or logging), so the caller can retry what was not delivered. notify still resolves to nothing: nothing acts on its result, and its callers post one event to several destinations in turn.

Delivery policy, covered by notification.service.spec.ts:

  • Unconfigured platforms are skipped. A Zulip route is attempted only when zulip.isInitialised() (the same notion ZulipRepository throws on: init never ran because the dev sentinel keys skipped it), and a Discord route only when discord.isReady() (discord.js's isReady: never with the dev token or after a failed login, and not before clientReady, when a send would find no guild channel and post nothing). Local dev therefore never throws on team notifications. Mattermost is always configured.
  • A platform outage never rejects. A platform that fails does not stop the ones after it, and notify resolves even when every attempted platform failed. Each failure is logged as an error with the destination and platform; when no platform took the notification, one fatal line (Could not notify <destination> on any platform: notification dropped) says so. src/main.ts enables the fatal level for that reason.
    • The trade-off: a webhook now answers success even if every chat post failed, and the log is the only place a dropped notification shows.
    • The alternative, rejecting on a total failure, silences Zulip whenever Discord is down. The handlers post one event to several destinations in sequence (await notify('community.pull-requests'), then await notify('team.pull-requests')), and community.* destinations route to Discord alone, so the first call's rejection would skip the team post and Zulip with it. That is worse now that Zulip is the team's primary platform, and it cannot be fixed in the services, which this seam keeps unchanged.
  • Rendering is not a platform failure. Each platform's payload is rendered immediately before that platform's own send, in platform order and outside the isolation above. A renderer bug propagates to the caller as the programming error it is instead of being logged as an outage, and a bug in a later platform's renderer cannot undo the posts already made before it. Nothing is rendered for a skipped platform.

Rules that keep the seam clean:

  • Renderers derive every layout decision (title size, whether a body slot exists, truncation, fields layout, author style, whether the title links) from kind, never from which keys a notification has or what its values are: a feed title links even when its url is ''. Services never pass render options. Only whether an existing slot is filled depends on the data: a feed always has a body slot, which renders empty when there is no body.
    • One exception is rss. A feed post's title, link and date are optional content that toRSSNotification drops when they are missing or invalid, so each renderer shows those slots only when they are filled: the title links only when there is a url, an untitled post is labelled with its url, and the Zulip date appears only with a timestamp. The Discord toRSSEmbed sets only the keys the post has, which keeps its embed identical to the one EmbedBuilder built before the seam. The rest of rss's layout still comes from its kind (optionalTitleAndLink and timestampSlot in the renderers' tables).
    • The other exception is log (below): every renderer shows its detail, the : body on Discord and the quote on Zulip, only when there is a non-empty body. Every other kind follows the rule above.
  • log is not a card: it is the bot's own startup line and every error logError and withErrorLogging (src/util.ts) report, all posted to team.bot (Discord #bot-spam, Zulip ImmichAlerts topic bot). toDiscordMessage sends it as plain text, title: body (the title alone without a body); toZulipMessage posts the title as a line and quotes the body (an error's text, which can hold anything), both mention-neutralised. Mattermost has no route for it. A failed post is logged by NotificationService and never goes back through logError, so reporting an error cannot recurse.
  • Truncation that applies on every platform is content and belongs in the service (feed bodies are shortened to 500 before rendering). Truncation that applies on one platform is presentation and belongs in that renderer (release descriptions are shortened to 500 on Mattermost only).
  • An accent token names the event at its call site, never a colour. src/renderers/palette.ts maps tokens to RGB numbers; the Discord and Mattermost renderers read it. Zulip has no colours, so src/renderers/zulip.renderer.ts keeps its own token-to-emoji table (Emoji) chosen by what the token means, not by the colour it shares. Several tokens sharing a colour or an emoji is expected.
  • webhook.service.ts, schedule.service.ts and rss.service.ts never call discord.sendMessage, mattermost.send or zulip.sendMessage for a notification. The Zulip release announcement in handleReleaseNotification and the pull request topic messages in handlePullRequestZulipTopic are bespoke plain-text messages, not Notifications, and stay direct calls, exactly as the Discord forum-thread messages in handlePullRequestTeamUpdate do.

Adding a New Notification Platform

  1. Add src/renderers/{platform}.renderer.ts: a pure to{Platform}Message(notification: Notification) that switches on kind for layout and maps accent to the platform's affordance (read Palette for a colour, or keep a token-to-emoji table for a platform without colours). Do not import another renderer or discord.js, directly or through src/util (which depends on it); string helpers such as shorten and asHexColor come from src/format.ts, as do the Zulip markdown guards (neutraliseZulipMentions, neutraliseZulipLabel, toZulipQuote), which live there rather than in the renderer because the bespoke PR-topic messages in webhook.service.ts and the similar command need them too and a service never imports a renderer.
  2. Add an optional {platform} entry to NotificationRoute and fill in the routes in NotificationRoutes (src/constants.ts). Destinations that share a channel today (issues and discussions, purchases and reports) are separate on purpose so they can land in different places; on Zulip they already are.
  3. Inject the platform's repository interface into NotificationService, add a to{Platform} method that renders and sends through deliver, and call it in notify, after the platforms already there, when the destination has a route for it (and the platform is configured, if it can be unconfigured). deliver takes a render thunk and a send and handles the lazy rendering, logging and failure isolation. Add the platform's address to NotificationTarget, a case to notifyTarget and, if rows can store it, a branch to toNotificationTarget.

Nothing in webhook.service.ts or schedule.service.ts should change.

Zulip

The bot talks to Zulip through a typed openapi-fetch client, not an SDK.

  • Generated types: src/generated/zulip.ts is generated from Zulip's OpenAPI spec and must never be edited by hand; it is excluded from prettier and eslint. Regenerate it with npm run zulip:types. That script in package.json is the only place the Zulip release tag is pinned; bump it there when the server is upgraded, rerun the script and commit the output.
  • Transport: src/repositories/zulip.client.ts builds a Client<paths> per identity (createZulipClient). Its rules, all covered by zulip.client.spec.ts:
    • Base URL is ${ZULIP_DOMAIN}/api/v1; a realm ending in / or /api is normalised.
    • Every request body is sent application/x-www-form-urlencoded, which is what the spec declares for every endpoint we call (POST /messages, later PATCH /messages/{id} and POST /register). Strings go as they are; number, boolean, arrays and objects are JSON.stringify'd; undefined is omitted. Query strings follow the same rule, so an array such as narrow becomes one JSON value, never repeated keys. A parameter the spec declares as a JSON-encoded string (narrow and message_ids on GET /messages are typed string) is passed already stringified.
    • Multipart (POST /realm/emoji/{emoji_name}): spread multipart({ field: file }) into the call. Every part is a File, so it carries a filename with an extension and a content type; fetch sets the boundary. Do not set Content-Type yourself.
    • Every call rejects with ZulipApiError (status, code, msg) on a non-2xx response or a result: "error" body, so data is always set when a call resolves. Network errors and timeouts reject too; every request has a timeout.
    • A 429 is retried after the body's retry-after (falling back to the Retry-After header), with a bounded attempt count and a bounded maximum wait; the retried request re-sends its body. Nothing else is retried: a 429 was not processed, but retrying a 5xx on POST /messages could double-post.
    • Credentials and the Authorization header are never logged.
  • Two identities, three clients: ZulipRepository holds a bot client (posts messages) and a user client (uploads emoji) because Zulip only lets human accounts upload emoji (This endpoint does not accept bot requests). Config keeps zulip.bot and zulip.user for that reason. A third client, events, is the bot identity again with a long timeout, used for GET /events alone: the server holds that request open on purpose, for up to the event_queue_longpoll_timeout_seconds it returns from POST /register (90 by default), so it cannot share the 30s that every other request should fail within. The server answers a quiet queue with a heartbeat about that many seconds after it received the poll, so the client's timeout is that value plus a 30s margin (longpollTimeoutMs, 120s by default): a client timeout equal to the server's would lose the race on every quiet poll and make an idle channel look like a dead connection. registerQueue rebuilds the events client with the server's value plus the margin. Every request also carries the caller's own signal when one is passed (getEvents passes the event loop's): the client composes it with the attempt's timeout in init (AbortSignal.any), since fetch(request, init) replaces the request's signal with init.signal rather than adding to it. All are created once, in ZulipService.init (skipped with the dev sentinel keys); calling a repository method before that throws Zulip client not initialised.
  • Endpoints: ZulipRepository exposes only what the bot uses today: sendMessage (resolves to the new message's { id }), getMessage (the message's ID and current topic, asked with allow_empty_topic_name so the empty "general chat" topic comes back as '' rather than as the realm's translated display name), updateMessage (PATCH /messages/{id}: a content edit or a topic move with a propagateMode, never both in one call, which Zulip rejects), createEmote, listEmoji (GET /realm/emoji, deactivated ones included), getSubscriptions (the bot's streams), the event queue's getOwnUser (the bot's ID and full name; throws Zulip returned no user ID for the bot rather than resolve without the ID the loop filters its own messages by, while a missing name comes back as '', since only the command router needs it), getMessages (GET /messages: the numBefore newest messages of one stream and topic, anchor: newest, as raw markdown, with the narrow passed as one JSON string; the similar command reads the topic with it), registerQueue (the queue plus the streams it will carry, each with whether it is private), getEvents (with the loop's abort signal) and deleteQueue (see below), and isInitialised, which NotificationService checks before routing to Zulip. Each phase adds only the endpoints it needs, a few lines each thanks to the generated types; do not add unused methods.
  • Streams: Constants.Zulip.Streams holds the channels the bot posts to by numeric ID, named after the channel (ImmichThirdParties: 111 carries every team notification, ImmichPullRequests: 112 one topic per pull request, ImmichAlerts: 113 the release workflow alerts and the bot's own startup and error lines). Constants.Zulip.TeamStreams holds the ones it listens in: every private immich-* stream, 107 to 113; the two are separate maps because Streams is pinned by a characterization test. Constants.Zulip.Expanders (one list per expander) and Constants.Zulip.Commands (where commands are taken) are each that set today. IDs survive a rename; the dev server mirrors the names but not the IDs. Topic strings live in NotificationRoutes, not here.
  • Startup subscription check: those three are private streams, and Zulip lets an unsubscribed bot post to public streams only. The bot is a plain member and cannot subscribe itself to a private stream, so ZulipService.init fetches its subscriptions right after the clients exist and logs one warn per stream in Constants.Zulip.RequiredSubscriptions it is missing, naming the stream. It never throws and never self-subscribes: a missing subscription is visible at deploy time instead of failing the first post at 3am, and it must not stop the bot from booting. RequiredSubscriptions lists those three and not FUTOStaff (2), where the holiday notice has posted since before the check existed: whether that stream is public or the bot was subscribed by hand, its subscription is not one the deploy needs to prove, so it is not asserted on a guess. The streams the bot listens in are checked separately, by the event loop at every queue registration (see below): they are not in RequiredSubscriptions, whose three entries and warn text are pinned by characterization tests.
  • Renderer: toZulipMessage (src/renderers/zulip.renderer.ts) flattens a Notification into one message of Zulip markdown. zulip.renderer.spec.ts pins all of the following.
    • Shape: {emoji} **[title](url)** — [author](url), then the body, then the fields. A line field (every kind but incident) is one **name:** value line; a block field (incident) is a bold name line with the value quoted beneath it.
    • Feed bodies are quoted: a GitHub markdown body goes inside a tilde quote fence so its headings and lists stay subordinate to the title. Zulip closes a fence on a line equal to the opening one, so the fence is always one tilde longer than the longest tilde run inside it and no line can close it; a backtick fence or a shorter tilde run just opens a nested block inside the quote. An incident field value is multi-line prose and gets the same quote.
    • Release bodies are inline: a release body is one of the one-line ReleaseMessages slogans or nothing, never the release notes. Its 500-character cap mirrors Mattermost's and is insurance that never fires today.
    • Zulip markdown only: Zulip renders only */** emphasis (no _ forms), treats a single newline as a line break and shows an unknown :name: literally, so the renderer emits Unicode emoji characters and nothing Discord-only.
    • Nothing in a notification was written for Zulip: titles and bodies come from any GitHub user, order messages from any buyer, incident text from GitHub Status, and Zulip has no backslash escaping (its Markdown disables Python-Markdown's escape pattern, so \] is a backslash and a ]). So the renderer neutralises instead of escaping, with a zero-width space or a character reference where a character must be broken up and with containment where a value must stay in its slot:
      • neutraliseMentions puts a zero-width space after the sigil of every @**user**, @_**user**, @*group* and #**stream** in every interpolated string, quoted body included. Nobody can ping the channel through a notification.
      • neutraliseLabel (the renderer's name for neutraliseZulipLabel in src/format.ts, shared with the similar command) also runs over the title and author name, which are interpolated into the heading's [label](url). Python-Markdown ends a label where its count of [ and ] returns to zero, so a single unbalanced bracket either closes the label early (an issue titled Click here ](https://evil.example) thanks would make evil.example the heading's link) or keeps it from closing (A lone ] bracket and lone [ bracket lose the title link). It therefore writes every [ as &#91; and every ] as &#93;: Zulip passes a character reference through as HTML, so it renders, copies and searches as the bracket, but the link pattern never counts it. Tested against a Zulip 12.3 server; the one blemish is inside inline code, where Zulip shows the reference as written (a title Fix `arr[0]` shows arr&#91;0&#93;). Bodies and field values are not link labels and keep their brackets, so an alert body's own [text](url) still links.
      • A field value cannot leave its slot. A line value shares a line with its name, so its line breaks become spaces (a field name's too, in either layout): nothing in a merch order message ever starts a line, so it can open no fence, heading or list and cannot pose as the next field. A block value is quoted like a feed body, with the same fence rule.
      • Other markup in those strings renders as markdown, which can only garble a heading or a line, never notify anyone, forge a link or escape a slot.

Zulip pull request topics

The Zulip analogue of the #team-pull-requests Discord forum: one topic per public immich-app/immich PR in stream 112, kept by WebhookService.handlePullRequestZulipTopic. It mirrors handlePullRequestTeamUpdate, runs right after it whether or not it succeeded, and never changes it: the Discord path is pinned by its characterization tests and stays byte-identical. handlePullRequestTeamPlatforms settles the Discord path, runs the Zulip path, then rethrows the Discord rejection as the same object, so a Discord outage or rate limit cannot leave a topic uncreated or out of sync, and the webhook still answers exactly as it did (Discord's error, Discord's status). Both paths persist on the same pull_request row (discordThreadId, zulipMessageId); a PR may have either, both or neither. The backfill (backfillPullRequests, below under Zulip commands) does not go through handlePullRequestTeamPlatforms: it runs each path on its own, and only for the platform whose side the row lacks, because an opened replay through the Discord path of a PR that has a thread rewrites the thread's name and starter message.

  • Model: the topic is named #{number}: {title}, cut to 58 code points, Zulip's MAX_TOPIC_NAME_LENGTH of 60 less the two of the resolved prefix, so that resolving a topic the bot named never overflows the limit (shortenCodePoints, never the Discord 100), and trimmed, since Zulip strips a name before storing it. On opened (not by a bot, as on Discord) the bot posts one message with the PR's full title linked to the PR (**[title](url)**, through neutraliseZulipLabel), since the topic name may have cut it, and the body in a quote fence, mentions neutralised, and stores the returned message ID. Zulip has no pin, so the link Discord pins as a second message is folded into the first. Later events post plain notices: closed posts merged/closed by whom, then resolves the topic (rename to ✔ {topic} with propagate_mode: change_all, Zulip's archiving; the resolved name is sent untruncated: a human may have renamed the topic to the full 60, and then the prefix takes it to 62 and Zulip truncates the stored name itself, because Zulip only recognises a resolve when the name it was sent, before its own ... truncation, is ✔ plus the current name, and a pre-truncated name stores the same string as a plain move: no resolved notice, and checked against the move permission and time limit instead of can_resolve_topics_group, which is exempt from the limit); reopened posts the notice, then unresolves (strips the prefix); converted_to_draft posts the notice. Reviews and comments are not echoed there, as on Discord.
  • The message ID is the key, never a topic string. Topics are mutable strings with no ID: a human may rename or resolve one at any time, and a reply to a stale name silently opens a new, empty topic beside the conversation. So pull_request stores only zulipMessageId, every event first calls getMessage for the topic as it is now, and every post and rename is derived from that: the close notice lands in the renamed topic, resolving keeps the human's name (✔ + current), and a topic a human already resolved is not resolved again. A #{number}: {title} name is only ever computed for an opened post or a title change.
  • Title and body sync follows the pull_request.edited payload's changes: changes.title renames the topic to the new name (keeping it resolved if it was), and either change rewrites the first message, which carries both the full title and the body. The rename and the edit are separate updateMessage calls, because Zulip refuses a content edit and a topic move in one request. An edited review or review comment carries changes about itself, not the PR, and is ignored before the topic is read, as is a PR edit that changed neither title nor body (touchesZulipTopic): those events cost no Zulip call. This is a deliberate departure from the Discord thread, which is renamed to #{number}: {title} on every PR and review event: on Zulip the current topic name is the only record of what a human did to it, so a rename on every event would clobber a human rename or resolve on the next label or push, and "a human rename is respected" wins. The cost is that a lost or refused pull_request.edited leaves the name stale until the title is edited again; the topic still works, since every post finds it through the message ID, and a human can rename it by hand. Any other event on a PR with a topic reads nothing and posts nothing.
  • Degrading without permissions. Realm settings can refuse any of this: message_content_edit_limit_seconds (default ten minutes) blocks an old message's edit, move_messages_within_stream_limit_seconds blocks an old topic's rename, can_resolve_topics_group and can_move_messages_between_topics_group can block the bot outright. PRs live for weeks, so these will be hit. Every edit, rename and resolve is therefore best effort, and isZulipRefusal in webhook.service.ts tells a refusal from an outage on PATCH /messages/{id}: MOVE_MESSAGES_TIME_LIMIT_EXCEEDED (a change_all move whose older messages are past the limit) is a refusal by code alone; every other refusal is a 400 BAD_REQUEST, Zulip's catch-all code, and is told apart by msg. The check is a denylist, not an allowlist: a 400 BAD_REQUEST is a refusal unless its msg is one of the documented answers that are not (Nothing to change and Topic can't be empty are programming errors, Invalid message(s) is a deleted message; all three are outages). The 12.3 spec's msg enum for the endpoint cannot be used as an allowlist: it lists the content-edit refusals only, with a stale typo (has past where the 12.3 server sends The time limit for editing this message has passed), and none of the move refusals the server raises (You don't have permission to resolve topics in this channel., The time limit for editing this message's topic has passed., You don't have permission to move this message). So an unknown or translated refusal degrades to the fallback message, never to a lost notice, and the strings are not pinned to the Zulip version. The empty topic name (Zulip's "general chat") cannot be resolved, so a PR topic a human moved there gets its notices, posted with topic: '' (valid since Zulip 10), and no rename. A refused rename or resolve is logged once as a warn and answered with a plain message in the topic that could not be moved (Pull request has been renamed to: … with the full title, not the cut topic name, The topic could not be resolved automatically: <Zulip's reason>, likewise for unresolving), so the information is not lost; a fallback post that fails in its turn is logged as an error and cannot reject either. A refused edit of the first message is logged and nothing more: the title and body are on GitHub. Any other failure (a 5xx, a timeout, a rate limit that outlasted the client's retries, a topic that cannot be read) is an outage, logged as an error without a fallback message, since that post would fail too. One read failure is not an outage: GET /messages/{id} answering 400 BAD_REQUEST with the msg Invalid message(s) means the stored first message was deleted or is no longer visible to the bot, and retrying that ID on every later event would lose every notice forever. isZulipMessageGone requires both: BAD_REQUEST is Zulip's catch-all and it has no distinct code for a missing message, so the documented text is the only thing that tells it from a transient or permission failure, and the failure direction is deliberate: if Zulip rewords it, the read counts as an outage and the update is skipped (safe, the next event retries the same ID) rather than opening a second topic and overwriting the key (a permanent split). readOrRebuildZulipTopic then logs a warn, posts the first message again under the PR's current #{number}: {title}, stores the new ID and carries on with the event in that topic (the close notice and the resolve land there). If the topic still exists with other messages under another name, the rebuild opens a new topic beside it: the key is gone, so there is nothing better to do, and a human can move the messages together. Nothing in the Zulip path can reject: handlePullRequestZulipTopic catches everything, so a blocked edit never fails the GitHub webhook, and the Discord path has already run by then; a Discord rejection is held and rethrown only after the Zulip path, so it cannot stop it either. Its catch tells a Zulip failure (isZulipFailure: a ZulipApiError, undici's fetch failed, the client's timeout; logged as Zulip failed while updating the topic of pull request #N) from anything else (Unexpected error while updating the Zulip topic of pull request #N), so an outage and a bug are not triaged from the same line. Zulip not being initialised (local dev) skips the path entirely.

Zulip event queue

Zulip has no push transport: a client registers an event queue (POST /register) and long-polls it (GET /events with queue_id and last_event_id), which the server holds open until an event or its heartbeat. ZulipService runs that loop; ZulipRepository only wraps the four endpoints. The loop is where zulip-js failed: its loop spun at one request per second against a dead queue, forever, without a log line, and every rule below exists so that this one cannot.

  • Registration: event_types: ["message"] and apply_markdown: false, so handlers see the markdown the sender typed rather than rendered HTML. The bot receives messages from the streams it is subscribed to, which for private streams is the only way to see them; all_public_streams is deliberately not set, since it would add every public channel of the FUTO realm, which the allowlists below would discard anyway. That makes the bot's subscriptions the only thing that puts a stream's messages on the queue, so the register call also asks for them (fetch_event_types: ["subscription"], which adds the subscriptions list to the answer and changes nothing about the events the queue receives), and ZulipService.registerQueue logs one warn per stream in any list of Constants.Zulip.Expanders the queue cannot see (The Zulip bot is not subscribed to stream 107 (ImmichGeneral): its event queue carries no messages from it, so nothing is expanded there until an admin subscribes it). The set it checks is the union of the expander lists and Constants.Zulip.Commands, not TeamStreams, so a stream added to one expander's list alone, or to the command list alone, is checked too (named by its bare ID if no constant map names it). Without it a bot never subscribed to immich-general would register, poll and receive nothing, deaf in the main team channel with no line in the log. It is checked at every registration, not once at boot, so a subscription added while the bot runs is confirmed at the next re-registration; the loop carries on with the streams it can see. The loop also reads getOwnUser once, before its first poll, and drops every message whose senderId is the bot's own, or it would answer its own replies; the account is exposed as ZulipService.ownUser (undefined until then, so always set when a handler runs), which is where the command router gets the name it matches a mention against.
  • Other bots are dropped too, by email. Discord skips every bot (message.author.bot) and Mattermost skips every bot (post.props.from_bot); a message event on Zulip carries no is_bot flag, so isBotSender in zulip.service.ts goes by the sender's API email instead: Zulip creates every bot as {short_name}-bot@{realm host} and its own as notification-bot@… and welcome-bot@…, and a bot's API email is never replaced by the user{id}@… placeholder that the realm's email visibility setting puts in a human's. So -bot@ before the domain is a bot. Without it a GitHub integration, a CI notifier or another team's bot posting into immich-pull-requests would get the #1234 references of every post expanded under it, the noise the allowlist keeps out of immich-third-parties. The failure direction is a human whose address ends in -bot, who gets no expansions; a bot cannot be created on any other address, so the other direction does not exist. The filter is in the loop's dispatch, next to the own-user one, not in the expanders: the command router must not take commands from a bot either, and does not, without a check of its own.
  • Polling: every event's ID raises the queue's lastEventId, heartbeats included, before that event's handlers run, and the next poll sends the raised cursor, which acknowledges everything up to it. Events are not guaranteed consecutive; the cursor never moves backwards.
  • A dead queue re-registers, at once. Zulip garbage-collects an idle queue and drops every queue on restart, so BAD_EVENT_QUEUE_ID is routine, not a failure: the loop clears its queue, logs it at log level, registers a new one and polls that, and it never asks about the dead ID again. The one immediate retry is exactly one: if the fresh queue is reported dead too, or registering it fails, the failure count carries into the backoff below, so even a server that answers BAD_EVENT_QUEUE_ID to everything is polled a handful of times in ten minutes.
  • Every other error backs off. A rejected getOwnUser, registerQueue or getEvents (a 5xx, fetch failed, a rate limit that outlasted the client's retries) is logged as an error every time, with the pause, and the pause doubles from 1s to a cap of 60s across consecutive failures; a successful poll resets it. zulip.service.spec.ts asserts the count: at most 16 polls in a ten-minute outage, against the 600 that a one-second loop would make.
  • A poll that times out is polled again straight away. The client aborts a GET /events that outlives the server's own long-poll timeout, which means the connection died silently, not that the queue did; the loop logs it at debug and polls again on a fresh connection. That is not a spin either: each such poll took the whole long-poll timeout to fail.
  • No two polls start within a second of each other. The loop starts a one-second floor with every poll and waits for it before the next one. A real long poll takes far longer, so this never delays one; it only bounds the loop against a server that answers at once, which the rules above do not cover: long polling disabled or a proxy that does not hold requests (every poll answers [] immediately; without the floor that was measured at ~5,800 polls a second), or requests balanced across servers of which only one holds the queue (every other poll is BAD_EVENT_QUEUE_ID, and every success in between resets the backoff, so the immediate re-registration is taken every time; measured at ~1,300 registrations a second). With the floor those are one poll a second and one registration every two, each logged. The floor and the backoff overlap rather than add: the first backoff pause is the same second.
  • A loop stuck at that rate says so. Everything the floor bounds is routine on its own: a dead queue is logged at log and an instant empty answer not at all, so a server that has lost its queues for good, or a proxy that never holds a request, would leave the bot at a steady request a second with nothing in the log but routine lines. The loop therefore counts the rounds since the server last held a poll open past the floor (the one thing that proves the queue and the connection work) and, at every UNHEALTHY_STREAK (10) of them, logs a warn saying so and what it is doing (The Zulip event loop is not healthy: the server has not held a poll open for the last 10 rounds (0 dead queues re-registered, 10 polls answered at once with nothing); it keeps trying, at most once a second, but receives nothing in the meantime). A round counts when the queue is reported dead or when a poll is answered within the floor with no events; a poll held open resets the count. A poll answered at once with events counts for nothing either way: a busy queue answers at once and is healthy, so a flood cannot trigger it, and a server restart costs one dead queue, not a warning. Errors and timeouts are already logged as they happen and leave the count alone. It changes nothing about what the loop does; it only makes the existing state visible, and zulip.service.spec.ts (health) pins that a healthy loop never logs it.
  • Handlers cannot kill the loop, nor stall it silently. ZulipService.onMessage(handler) subscribes a handler to every message no bot sent; handlers run in registration order, one message at a time, and a handler that throws is logged (A Zulip message handler failed on message N) and skipped for that message only. The catch covers a rejection; it does not cover a promise that never settles, and no poll goes out while a message is being handled (the expanders await GitHub in that chain, outside the Zulip client's timeout), so a hung handler would leave every stream unheard with no line in the log until Zulip garbage-collected the idle queue ten minutes later. runHandler therefore waits on each handler for at most 30s (HANDLER_TIMEOUT_MS), then logs A Zulip message handler has not finished message N after 30000ms; the loop is moving on without it as an error and goes on to the next handler and the next poll. The handler cannot be cancelled, so it runs on, never awaited again, and its own outcome is logged when it settles: a warn if it finishes late, the failure line with after the loop had stopped waiting for it if it fails late. A slow but healthy expansion costs one spurious error line and a late reply, never deafness; the timer is cleared as soon as the handler settles, so a handler that finishes in time leaves nothing pending. ChatService.init registers the expanders this way and ZulipCommandService.init the command router, one more onMessage call from its own service, bounded the same way; both run before ZulipService.init starts the loop, so nothing is missed. A registration must happen in that service's init(), and AppModule.onModuleInit must call that init() before ZulipService.init(), exactly as it calls chatService.init() and zulipCommandService.init() before zulipService.init() today. onModuleInit calls each service's init() by hand, in the order written there; nothing else runs them. A handler registered in an init() that AppModule never calls never fires, and one registered after ZulipService.init() misses every message received before it, both silently: onMessage only appends to a list, and the loop cannot tell a handler that was never registered from one that has nothing to say.
  • Shutdown cancels the poll and deletes the queue. ZulipService.onModuleDestroy aborts the loop, waits for a registration in flight if there is one, deletes the queue (DELETE /events), then waits for the loop to exit. The loop's signal travels into every GET /events, so the abort cancels the poll in flight itself and the loop returns at once, without registering another; it does not depend on the server ending the poll when the queue is deleted (a server that has lost connectivity, or a failed DELETE, would otherwise hold the process on the orphaned socket for the whole long-poll timeout, until the orchestrator's SIGKILL). A registration in flight (POST /register, right after a dead queue or at boot) is the one request the abort does not cancel: the server creates the queue whether or not the client reads the answer, so cancelling would leave an unknown queue behind, and giving up at the grace would leave a known one. registerQueue keeps its promise in registration and stores the queue as soon as the answer arrives, onModuleDestroy awaits it (bounded by that request's own 30s timeout, and settled long ago in the common case) and then deletes what it registered; a registration that fails leaves nothing to delete. The wait for the loop itself is bounded (5s) all the same, for a handler mid-message or a poll that ignores the cancel; a queue that was not deleted is garbage-collected by the server after its idle timeout. A backoff pause and the poll floor end at once on abort, with their timers cleared. src/main.ts calls app.enableShutdownHooks() because Nest does not run onModuleDestroy on SIGTERM without it. In local dev the dev sentinel keys skip init entirely, so no loop runs and onModuleDestroy is a no-op.
  • Tests (zulip.service.spec.ts, event loop): fake timers, with a getEvents mock whose every call stays pending until the test settles it, so the poll in flight is under the test's control and no real time passes during a backoff. A settled poll alone never starts the next one under fake timers: the floor has to elapse first (nextPoll() advances it), which is what pins the floor; a poll settled before the floor elapsed is one the server answered at once, which is what the health tests use, and a healthy long poll is one settled after advance(90_000). The pre-existing init tests park the loop in such a poll and end it from deleteQueue, as a real server would, so their assertions are untouched; their registerQueue mock reports every listening stream as private, so the registration checks stay quiet there.

Zulip message expanders

ChatService.onZulipMessage mirrors onMessageCreate on Discord and onMattermostPosted on Mattermost, reusing the same GitHub expansion (handleGithubThreadReferences: #1234, owner/repo#1234 and issue, PR and discussion URLs to titles and links; handleGithubFileReferences: file permalinks to code snippets; the two halves of handleGithubReferences, called separately here so that only the first is neutralised, see below) and handleTwitterReferences (an x.com link to its nitter.net mirror). That logic is platform-neutral and returns plain strings; it is shared, never forked. What differs on Zulip:

  • The reply is a new message, not an edit. Mattermost appends the expansion to the user's own post; a Zulip bot cannot edit another user's message, so the expansion is posted into the same stream and topic, where the topic is the conversation and needs no backlink. All of one message's expansions go in one reply, GitHub first; there is no silent flag on Zulip. Only message events are subscribed, so editing the source message later does not expand it again; do not add update_message handling.
  • Each expander runs only in an allowlisted stream. Constants.Zulip.Expanders holds one list of stream IDs per expander (GithubReferences, TwitterMirror), both the Immich stream (54) plus Constants.Zulip.TeamStreams today (every immich-* stream, 107 to 113). A message in any other stream, and a direct message, is ignored by that expander without a GitHub call. Adding a stream is one line in zulipTeamStreams (or in one expander's list, to differ); the bot must also be subscribed to it, which the event loop checks for every list at every registration (see above). There is no runtime toggle.
  • Privileged in every allowlisted stream. Private-repository details are gated behind isPrivileged in the GitHub repository; the GitHub expander passes true, as Mattermost does, because the FUTO realm is not open to the public, so every stream on its list, Immich included, is readable by realm members only. Stream privacy is not checked: putting a stream on the list is the decision to show private repository titles and code there.
  • The reply is neutralised where that protects something. GitHub titles and links are written by anyone, so neutraliseZulipMentions runs over each of them, as over a notification, and over the nitter mirror, which is built from whatever the sender typed: nobody can ping the stream through an issue title. A code snippet is left exactly as GitHub has it: it sits inside a code fence, where Zulip renders no mention, so neutralising it protects nothing and the zero-width space would silently corrupt the code (${file#*.} in a shell script reads as a mention to the neutraliser). That is why the handler calls the two halves of handleGithubReferences separately rather than the combined method.
  • Bots are filtered before the handler, by email, not by a flag. onMessageCreate checks message.author.bot and onMattermostPosted checks post.props.from_bot in the handler itself; onZulipMessage checks nothing, because the event loop has already dropped the bot's own messages and every other bot's (isBotSender, the -bot@ rule under Zulip event queue above) before any handler sees them. A handler therefore never needs its own bot check, and a message from a sender the server gave no email for is treated as a human's.

Zulip commands

ZulipCommandService (src/services/zulip-command.service.ts) is how the team drives the bot from Zulip. Zulip has no registrable slash commands, no modals, no buttons, no autocomplete and no ephemeral replies; the whole interaction model is "mention the bot, it answers in the topic", chosen over keeping these commands on Discord. The Discord slash commands are untouched and keep working.

  • A command is a message that starts with a mention of the bot, @**Name** or the silent @_**Name**, either with Zulip's |user_id suffix, matched by the name from ZulipService.ownUser (which the loop read before its first poll) without regard to case. Only newlines may come before the mention: a first line indented by four spaces or a tab is a Markdown code block, which Zulip renders as code and notifies nobody of, so a command in one is ignored exactly as one in a fence, a quote block or inline code is (the mention regex is anchored on ^[\r\n]*, not ^\s*, and parseCommand's spec pins each of those forms). A mention anywhere else (thanks @**Immich**) is not a command and gets no reply, so the bot cannot be summoned by accident mid-sentence; the cost is that @**Immich** thanks is answered with the unknown-command line. A mention alone is answered with the help. Zulip's own "Quote and reply" is not a command either: it starts the reply with a silent mention of the quoted author and [said](<link>): before the quote fence, so a reply that quotes the bot starts with a mention of it; parseCommand ignores a mention followed by [said]( (the QUOTE_AND_REPLY regex), or the bot would answer the most natural way of replying to it with Unknown command … noise in the topic. A command typed after such a quote is not seen; mention the bot in a message of its own. The parseCommand, splitArguments and tokenize functions are exported and pinned by zulip-command.service.spec.ts.
  • Parsing: after the mention, the first token is the command (case-insensitive) and the rest its arguments (parseCommand yields the name and the tokens). A double-quoted run, straight or curly (phone keyboards curl them), keeps its spaces anywhere in a token, so text="two words" and "two words" are one token each. Options are sorted once the command is known (splitArguments): a token key=value (the key a word) is a named option only when the command declares that key; any other word=value is a positional argument, in its place, so similar the upload fails when CORS=strict on nginx compares the whole sentence (word=value is everywhere in the error text similar exists to match: LOG_LEVEL=debug, uid=1000, error=ENOENT), while backfill-pull-requests pr=1234 still reaches that command as an argument it cannot read. A quote that is never closed is answered with what went wrong, an unknown command with a pointer to help, a command with the wrong arguments with its usage line; never a stack trace. An argument the command does not take is answered, never dropped: each entry in the command table declares how many positionals it takes and which options it reads, and the dispatcher answers more positionals than that with the usage line before the command runs, and the command answers a positional it cannot read the same way (emote-sync now, backfill-pull-requests pr=1234, fourthwall update ORD-1 id=ORD-2, similar text="a" b). With no autocomplete and no confirmation step, a typo in number= must not turn a backfill of one pull request into a backfill of every one. help lists every command with its arguments, which matters more than on Discord, since there is no autocomplete to find them with.
  • Every reply is public, in the same stream and topic as the command (sendMessage with the message's streamId and topic, the empty topic included). There is no ephemeral equivalent on Zulip: several of these commands reply ephemerally on Discord (/fourthwall, /backfill-pull-requests, the private "Find similar issues"), and on Zulip the whole team sees the reply. Replies are kept short for that reason, and every interpolated string a human wrote (a command name, an order ID, the message similar compared, GitHub titles, emote names, an error message) goes through neutraliseZulipMentions, so a reply can ping nobody; the ones that are echoed as typed (a command name, an order ID, the text similar compared, and an error's message, which some service wrote) go through code(), inline code with every backtick stripped and every whitespace run collapsed to a space, so nothing in them can close the span early or start a line as a heading or a fence in the bot's voice; an unknown command name and the text similar compared are also cut to 80 characters (ECHO_LENGTH), an error's message to 300, so a pasted wall of text is not posted back whole. GitHub titles in a similar line also go through neutraliseZulipLabel, the renderer's link-label containment, so a title cannot add a link of its own next to the bot's. Every reply also goes through the one reply() seam, which cuts it to Zulip's max_message_length (10000, shortenCodePoints, fit): a reply the server refuses is one the topic never sees, and a long list of hits would otherwise make the similar result unpostable. A cut that lands inside a code span would leave the rest of the reply rendered as code, so a cut reply with an odd number of backticks (the bot's own spans hold none) is cut one code point shorter and, if that did not close the span, given a closing backtick, within the limit. The list help posts ends with a blank line, or Markdown would render the line after it as a continuation of the last item.
  • Authorisation is the stream list. Zulip has no per-channel bot permissions and no role a bot can cheaply check, so commands are taken only in Constants.Zulip.Commands (every immich-* stream, 107 to 113) and a command anywhere else, Immich included, is ignored without a reply. Whether a stream is private is not checked; the list is kept to team streams because these commands act (backfill-pull-requests creates threads and topics, emote-sync writes realm emoji through the user account, fourthwall update mutates orders). Direct messages are ignored too: there is no cheap way to tell that a DM sender belongs to the team, and these commands are administrative. The event loop has already dropped every bot's messages (isBotSender) before the handler sees one, so no bot can drive a command.
  • A failure never reaches the loop. Every command runs inside a catch: a handler that throws is logged as an error (The Zulip command <name> failed on message N) and answered with `<name>` failed: <the error's message, shortened>; a reply that cannot be posted is logged (Could not reply to the Zulip command in message N) and the handler resolves all the same. The loop's own catch and 30s handler timeout stay as the backstop.
  • Slow commands run detached from the loop, because the loop polls nothing while a handler runs and stops waiting after 30s: emote-sync, backfill-pull-requests all and fourthwall update all post an acknowledgement in the topic at once, do the work in the background and post the outcome, or the failure line, when it is done; the handler returns after the acknowledgement. inBackground takes the acknowledgement and the work, and posts the acknowledgement before any of the work starts: the backfill lists the open pull requests inside the work, not before the acknowledgement (Going through every open pull request, creating the Discord thread and the Zulip topic each one lacks; this can take a while…), because a rate-limited GitHub client waits out the limit (an hour, seen in testing) before it answers, which left no acknowledgement and the handler stuck at the loop's 30s cap. A listing that fails is posted as the job's failure line; the count is in the outcome. The wide form is asked for by name. There is no confirmation step and no way to stop a run once it has started (the acknowledgement arrives after the work is committed to), and the wide form is the expensive one: backfill-pull-requests all reads every open PR's row and creates a forum thread and a Zulip topic for every one that lacks them, which after a fresh Zulip rollout is one topic per open PR, hundreds of posts; fourthwall update all fetches every order from Fourthwall again. So neither is the bare command: backfill-pull-requests and fourthwall update alone get the usage line, the narrow repair (backfill-pull-requests 1234, fourthwall update ORD-1) is one word away, and the fan-out takes the explicit all (any case, positional or number=all / id=all). One run of each command at a time, inline or in the background: the narrow forms take the same lock (underLock) as the wide ones, so backfill-pull-requests 1234 during a running backfill-pull-requests all is answered with `backfill-pull-requests` is already running; wait for it to finish. and does nothing, rather than reaching handlePullRequestTeamPlatforms for #1234 concurrently with the background run, where both would read no thread and no topic ID, both would create, and only one ID would survive in the row, an orphaned forum thread or topic that no later event touches. An acknowledgement that cannot be posted does not start the work, and an outcome that cannot be posted is logged (Could not post the outcome of the Zulip command <name>); neither leaves the command locked.
  • Commands (community commands, /link*, /messages*, the help desk, /prune, /age, /release-notes, stay Discord-only):
    • help: the list above.

    • emote-sync: ChatService.syncEmotes(guildId) for one fixed server, Constants.Discord.EmoteSyncServer ({ id: '979116623879368755', name: 'Immich' }; the Discord command syncs the guild it is run in, and the second guild in Constants.Discord.Servers is not reachable from Zulip). Because the target is not where the command is run, the acknowledgement, the outcome and the help line all name it (Syncing the emotes of the Immich Discord server (979116623879368755) to Zulip and Mattermost…). The sync is platform-neutral and returns an EmoteSyncReport; formatEmoteSyncReport(report, subject?) turns it into the one report line both platforms post (Done syncing: 3 emotes, 1 uploaded to Zulip, 2 uploaded to Mattermost, 1 failed: …, 1 renamed: …, 1 already on Zulip: …, or Done syncing the emotes of the Immich Discord server (979116623879368755): … with the subject the Zulip command passes), which the Discord command caps at 2000 characters and the Zulip one at 10000, Zulip's max_message_length, after neutraliseZulipMentions, since emote names are Discord's. The Discord reply text is pinned by the Phase 0 characterization assertions in chat.service.spec.ts, which drive the sync through DiscordCommands.handleEmoteSync.

    • backfill-pull-requests <number|all>: WebhookService.backfillPullRequests(pullRequests, { discord: true, zulip: true }), which creates, for each PR given, the Discord forum thread and the Zulip topic it lacks, and touches nothing that exists. Until this branch the command, on Discord, was a silent no-op: GithubService.getOpenPullRequests overwrote the GraphQL node id with the numeric fullDatabaseId and never set node_id, so getPullRequestById(pull_request.node_id) missed every row, both team paths returned early, and /backfill-pull-requests answered Successfully backfilled pull requests having created nothing. toPullRequestEvent in github.service.ts now keeps the node ID as node_id (pinned by github.service.spec.ts), which means the command really creates threads and topics now: one thread per open, human-opened PR in the pull_request table that has none, and one topic per such PR that has none, which after the Zulip rollout is every open PR. It is still admin-only and manually invoked, and there is no auto-run; do not add one. The backfill reads each PR's row first (node_id): a PR that is not in the table (its opened webhook never arrived) or was opened by a bot is skipped without a call, since neither path would create for it, and so is one that already has what every platform asked would create; for the rest, each platform missing its side gets the PR as an opened event through its own path (handlePullRequestTeamUpdate, handlePullRequestZulipTopic), never through handlePullRequestTeamPlatforms, since an opened replay through the Discord path of a PR that has a thread would rewrite the thread's name and starter message (hundreds of them, for the PRs that only lack a topic). The row is read again afterwards and what was created is what it now holds: a platform that was asked and stored no ID counts as failed, whether its path rejected (logged as Could not backfill pull request #N), resolved without a thread (…: Discord created no thread) or, as the Zulip path does, logged its own failure. As in the webhook, a Discord rejection does not stop the Zulip side of the same PR, and one PR failing does not stop the rest. The result is a BackfillReport (threads and topics created, by number, each undefined for a platform not asked, or Zulip not initialised; skipped with a reason, not tracked, opened by a bot or already complete; failed), and formatBackfillReport is the one line both commands post: Backfill of 187 open pull requests done: created 0 Discord threads and 150 Zulip topics; skipped 35 (33 already complete, 2 opened by a bot); failed 2 (#123, #456), see the log. Every count is what happened; neither command posts a blanket success. With all the Zulip command acknowledges, then lists the open immich-app/immich PRs (GithubService.getOpenPullRequests) and backfills them in the background. With a number (1234, #1234 or number=1234), the common repair, that one PR is fetched directly (GithubService.getOpenPullRequest, one GraphQL call, never a page through every open PR to find it) and done inline, under the same lock, and answered with the same report (Backfill of pull request #1234 done: created 1 Discord thread and 1 Zulip topic; skipped 0; failed 0., or Pull request #1234 is not open in immich-app/immich, so there is nothing to backfill. when it is closed, merged or does not exist); no argument, a number that is not one, or one given twice, gets the usage. Discord's /backfill-pull-requests runs the same loop asked for Discord alone ({ discord: true, zulip: false }): it creates the forum thread each open PR lacks, never a Zulip topic, carries on past a PR that fails, and answers ephemerally with the report, cut to 2000 characters; a failure to list the PRs still rejects as before (pinned by the /backfill-pull-requests on Discord tests in webhook.service.spec.ts). The Zulip command is the one that catches both platforms up.

    • fourthwall update <id|all>: ChatService.updateFourthwallOrders(id). With an ID the order is refreshed inline, under the command's lock, and answered; with all every order is, in the background; with neither, the usage. FourthwallRepository.getOrder returns whatever JSON Fourthwall answered, so an answer that is not an order (a wrong ID, refused credentials, an outage) fails with Fourthwall did not return order <id>: … and writes nothing, rather than a TypeError about reading value.

    • similar [text]: the "Find similar issues" message context menu, which Zulip does not have. With text it compares that; without, it reads the topic back (getMessages, the ten newest) and takes the last message a human wrote, skipping the command itself, the bot's own messages, other bots' and other commands, and says when there is none. The reply echoes what it compared, on one line and shortened, then the hits (ChatService.handleFindSimilarIssuesOrDiscussions, shared with Discord; the Zulip command passes it neutraliseZulipLabel as the title neutraliser, which Discord's callers leave out, so their output is unchanged), or that nothing was found. Discord's ephemeral "Find similar issues (private)" variant has no Zulip equivalent and stays Discord-only.

    • schedule-add <name> cron=<expression> message=<text> [topic=<topic>] [suppress-embeds=<true|false>], schedule-list, schedule-edit <name> [cron=…] [message=…] [topic=…] [suppress-embeds=…] and schedule-remove <name>: the Zulip scheduled messages (service: 'zulip' rows, see Scheduled messages). A message is posted in the stream the command is given in, in the topic= given or the command's own topic (the empty "general chat" topic included); schedule-edit takes key=value arguments where Discord opens a modal, needs at least one of cron, message and topic, and reschedules the running job at once. Each looks a name up among the Zulip rows only, so a Discord or Mattermost message can be neither edited nor removed from Zulip; names are unique across every platform (scheduled_message_name_uq), so a name taken elsewhere is refused with the database's error. suppress-embeds is accepted, validated as true or false, ignored and said to be ignored, in help and in the reply: Zulip link previews are a realm setting, and the per-message flag Zulip has is per recipient. schedule-list lists every Zulip scheduled message with its stream, topic, schedule and the start of its text, mentions neutralised. A message's own text is posted as the team wrote it, mentions included; that is its point.

    • rss-subscribe <url> [topic=<topic>], rss-unsubscribe <url> and rss-list: the Zulip RSS feeds of the stream the command is given in (service: 'zulip' rows, see RSS). rss-subscribe answers a feed the stream already has with the topic it goes to; otherwise it fetches the feed in the background (a feed's server can take longer than the loop waits for a handler), acknowledged at once and answered with the outcome: the newest post is posted to the topic= given or the command's topic, and a feed that cannot be fetched or posted leaves no row. rss-list exists because Zulip has no autocomplete to find a feed's URL with, which is what Discord's /rss-unsubscribe offers. One feed per stream: the topic is an attribute of the row, not part of its key.

Scheduled messages

ScheduledMessageService (src/services/scheduled-message.service.ts) posts each scheduled_message row on its cron expression, one CronJob per row, registered in init for every row and whenever one is created or edited. A scheduled message is plain text with a per-platform preview flag, not a rendered card, so it does not go through the notification seam: the send is the senders table in the service, one entry per service (discord sends content with SuppressEmbeds when suppressEmbeds, mattermost sends message with remove_link_preview, zulip sends content to stream: Number(channelId) and topic, '' when the row has none). Adding a platform is one entry there, not another branch. A failed send is logged (Failed to send scheduled message <id>: …) and the job carries on.

  • Columns: service (discord, mattermost or zulip, default discord) and a nullable topic, which only a Zulip row sets. A Zulip row keeps the numeric stream ID in channelId. Every row that existed before the topic column has it NULL and keeps its platform.
  • suppressEmbeds is Discord and Mattermost only. Zulip cannot turn off link previews for one message (previews are a realm setting, and the per-message flag is per recipient), so the Zulip sender ignores it; the Zulip commands accept it and say so.
  • Every platform manages its own rows: the Discord slash commands the discord rows, the Mattermost commands the mattermost rows, the Zulip commands (above) the zulip rows.
  • An edit reschedules on every platform. updateScheduledMessage(name, service, changes) validates a new cron expression before anything is written, updates the row only if it belongs to that platform, then stops the running job and registers the updated row (reschedule), so the next tick sends the new text on the new schedule. The Zulip schedule-edit and the Mattermost dialog use it; the Mattermost dialog stores only the fields it edits (cronExpression, message, suppressEmbeds), not the rest of the dialog response, and a failure there is logged (Failed to edit scheduled message <name>: …) rather than left as an unhandled rejection. The Discord modal keeps its own path and reschedules the same way.

RSS

RSSService (src/services/rss.service.ts) polls every rss_feed row every 15 minutes and posts each new post as an rss notification through notifyTarget, to the row's own target (toNotificationTarget): a Discord channel, or a Zulip stream and topic. It never names a platform when it posts.

  • Columns: service (discord or zulip, default discord) and a nullable topic; the primary key is (url, channelId, service). A Zulip row keeps the numeric stream ID in channelId. Every row that existed before these columns is a discord row with no topic and is polled and posted exactly as before. The repository addresses a row by its whole key, service included: getRSSFeeds({ channelId, service }), removeRSSFeed(url, channelId, service) (which resolves to whether a row was removed) and updateRSSFeed({ url, channelId, service, … }); getRSSFeeds() returns every feed on every service.
  • The Discord embed is what it always was, byte for byte, key order included: toRSSEmbed in the Discord renderer sets only the keys the post has, always in the same order (author, title, description, timestamp, URL). The feed is the author, linked to the subscribed feed URL, with its image as the icon. rss.service.spec.ts pins the JSON. On Zulip the post is one message: the title linked, the feed linked as the author, the date as a <time:…> that every reader sees in their own time zone, and the summary in a quote fence. A post without a title is labelled with its own link, so it still links to itself, and a post whose link was dropped has its title unlinked. Link targets in a Zulip heading have (, ) and whitespace percent-encoded, so a post's link, which anyone can write, cannot end its link early and mention the stream.
  • A bad post does not jam the feed. toRSSNotification builds the notification from sanitised data: a title or feed title over Discord's 256 is shortened, the summary is shortened to 4096 (on every platform, so it is content), and a link that is not absolute http(s), a feed image that is not http(s) or attachment, or a date that does not parse is dropped; an empty title is left out. The post is delivered without the bad field instead of throwing on every poll. A post left with no title, link, summary or date, from a feed with no title, has nothing to post (Discord refuses an empty embed), so it is skipped with a warn (Skipping <id> of the RSS feed <url>: it has nothing to post) and counts as delivered. A post with only a feed author or only a date is still posted: on Discord as that author or timestamp alone, exactly as before, and on Zulip as a heading without a title.
  • Delivery is at least once, never lossy, never duplicated. A feed's new posts are posted oldest first; at the first one that is not delivered (notifyTarget resolved false), the feed stops, lastId is stored as the last post that was, and a warn says the post is retried on the next poll. Nothing before it is posted again, and it is not skipped.
  • One feed cannot stop the others. Each feed is polled inside its own catch: a feed that cannot be fetched or stored is logged with its URL (Could not update the RSS feed <url> in <service> channel <id>: …) and the poll moves on to the next one.
  • No orphan rows. Subscribing inserts the row, then fetches the feed and posts its newest post; if the feed has no posts, cannot be fetched or the post is not delivered (Could not post the newest post of <url>), the row it inserted is removed and the error rethrown, so the command reports it. A row that could not be inserted is never removed.
  • Discord's /rss-subscribe and /rss-unsubscribe manage the discord rows of the channel they are run in, the Zulip commands (above) the zulip rows of their stream. searchRSSFeeds, the /rss-unsubscribe autocomplete, returns at most 25 feeds, the most Discord accepts in an autocomplete response; before, a channel with 26 or more feeds got no suggestions at all.

Emote sync

/emote-sync on Discord and emote-sync on Zulip (ChatService.syncEmotes(guildId), which returns an EmoteSyncReport that formatEmoteSyncReport renders for both, with a subject naming the server on Zulip) upload every Discord emote to Zulip and Mattermost. The report counts what was read from Discord and what each platform took (3 emotes, 1 uploaded to Zulip, 2 uploaded to Mattermost) and says so plainly when the server has no emotes (the Discord server has no emotes, so nothing was uploaded). A server the bot cannot see (getEmotes answers undefined: not logged in to Discord, or not a member) is an error, Cannot read the emotes of Discord server <id>: …, which the Zulip command posts as its failure line, never a report of nothing done. The Mattermost side takes the Discord name as it is, and is idempotent the same way as the Zulip side below: it pages through the realm's custom emoji first (MattermostRepository.listEmoji, GET /emoji 200 at a time) and skips a name that is already there, listed as N already on Mattermost: …; an upload Mattermost refuses as a duplicate (api.emoji.create.duplicate.app_error, a name taken since the listing) counts the same, not as a failure. Nothing is ever overwritten or deleted. If the listing fails, it is logged once and every emote is uploaded, a duplicate still counting as already there. The Zulip side does not take the name as it is:

  • Names: the 12.3 spec for POST /realm/emoji/{emoji_name} says a name can only contain letters, numbers, dashes and spaces, that upper and lower case are treated the same and that underscores are treated the same as spaces. toZulipEmojiName in chat.service.ts implements that documented rule: lowercase (case is one name to Zulip, so the sync must settle on one spelling to compare with listEmoji()), every other character becomes _ (the identifier fallback for a nameless emote carries a :), a trailing run of _/- is dropped because the server refuses a name ending in one (Emoji names must end with either a letter or digit., which the spec's description leaves out), and an empty result falls back to emote. A dot is outside the documented set and is not relied on.
  • Collisions: two Discord emotes can normalise to one name (catJAM and CatJam). claimZulipEmojiName appends 2, 3, … in Discord order, deciding the suffix from the run alone, so it comes out the same on every sync. The reply lists renames (nameless:3 → nameless_3, CatJam → catjam2); a change of case only is not reported, since Zulip does not tell the two apart.
  • Idempotency: before uploading, the sync reads listEmoji() and skips any name that an active realm emoji already holds, counting it as already synced and listing it by Discord name (N already on Zulip: catJAM, CatJam → catjam2, …). That is what makes a second sync a no-op instead of re-uploading catjam as catjam2, catjam3, … on every run; a deactivated emoji frees its name. The corollary is that an emoji a human uploaded by hand under a name a Discord emote normalises to is taken to be that emote and is never duplicated or suffixed: the sync cannot tell its own uploads from a human's (that would take the uploading account's user ID, an endpoint it does not have), so it chooses idempotence and makes the shadowing visible in the reply instead. If the listing itself fails, the Zulip side of the whole run is skipped, logged once and said once in the reply (0 uploaded to Zulip (skipped: its emoji could not be listed)) rather than blamed on each emote; Mattermost still syncs and its failures are still listed. An upload Zulip answers with 401 (the user account's key refused, Malformed API key in production while the bot account listed the emoji fine) would be refused for every emote, so it stops the Zulip side for the rest of the run instead: one error line with Zulip's reason, never the key, naming ZULIP_USER_USERNAME and ZULIP_USER_API_KEY, and N uploaded to Zulip (skipped: Zulip refused the credentials of the user account that uploads emoji) in the reply, counting what Zulip took before and blaming no emote. getConfig trims every ZULIP_* value, since a key stored with its trailing newline is the likely cause of that answer. The reply stays within Discord's 2000 characters (10000 on Zulip).

Commands

npm run build        # Build with nest
npm run check        # TypeScript type check
npm run lint         # ESLint
npm run format       # Prettier check
npm run test         # Vitest
npm run check:all    # format + lint + check + test:cov
npm run zulip:types  # Regenerate src/generated/zulip.ts from the pinned Zulip OpenAPI spec

Key Files

  • src/app.module.ts - Root NestJS module
  • src/main.ts - Bootstrap, Discord client init
  • src/config.ts - Environment variable loading
  • src/constants.ts - Enums, channel IDs, role IDs, cron expressions, notification route table (NotificationRoutes)
  • src/format.ts - String helpers with no platform imports (shorten, shortenCodePoints, plural, asHexColor, and the Zulip markdown guards neutraliseZulipMentions, neutraliseZulipLabel and toZulipQuote); the only helper module renderers may import
  • src/util.ts - Discord-aware helpers (error reporting to team.bot, hyperlinks, report field builders)
  • src/discord/commands.ts - All slash commands
  • src/discord/events.ts - Discord event handlers
  • src/services/discord.service.ts - Core bot logic
  • src/interfaces/database.interface.ts - DB schema types + repository interface
  • src/repositories/database.repository.ts - Kysely DB queries
  • src/interfaces/notification.interface.ts - Platform-neutral Notification model (kind, accent, author, title, url, body, fields)
  • src/services/notification.service.ts - Destination-to-platform fan-out for notifications
  • src/services/zulip.service.ts - Zulip event queue loop (onMessage handlers, re-registration, backoff, shutdown) and the holiday notice
  • src/services/zulip-command.service.ts - The Zulip commands: mention parsing, stream gating, the command table and its replies
  • src/services/scheduled-message.service.ts - Scheduled message jobs and the per-platform senders table
  • src/services/rss.service.ts - RSS polling, post sanitising (toRSSNotification) and delivery through notifyTarget
  • src/renderers/ - Per-platform Notification renderers (discord, mattermost, zulip) and the shared accent palette
  • src/generated/zulip.ts - Generated Zulip API types (npm run zulip:types), never edited by hand
  • src/repositories/zulip.client.ts - Typed Zulip transport: form/JSON encoding, multipart, errors, 429 retry, timeout