feat(teams): phase 8 — the notifications bridge, and the gate §7.2 could not check #158
Reference in New Issue
Block a user
No description provided.
Delete Branch "feature/teams-phase8-notifications-bridge"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Phase 8 of
docs/website/TEAMS.md. The same Team event as §6, delivered a third time: a content-free push tickle, an email, and now a Discord channel the operator configured. Not a second pipeline —teamNotify.jsalready computed the recipient set once, so the bridge is a sink beside the two that were there, not a subscriber to something new.Pairs with docs#162.
ONE code repo, not the plan's
website+botbotis a workspace insidewebsite— the same correction phase 7 made. Unlike phase 7, nothing here belongs to a module: the four streams are core's own and the bridge reads core's own forum, somodule-uois untouched andMODULE_API_VERSIONdoes not move.Org-lead decisions taken before building
announce_jobs.Five things the tree disagreed with §7.2 about
The DDL cannot hold its own default row.
PRIMARY KEY (platform, team_id)withteam_id INT NULL: MariaDB coerces every primary-key column toNOT NULL, so the deployment-wide default — the base case of the whole override mechanism — is unrepresentable. Proved on a real MariaDB, which silently rewrote the column and then answeredERROR 1048 (23000): Column 'team_id' cannot be null. As built: a surrogateid, a generatedteam_key AS (IFNULL(team_id, 0)) STOREDcarrying the unique key, and theON DELETE CASCADEforeign key the original had no room for — without it a deleted Team leaves its config for whichever Team next lands on the id. Same idiom asteams.active_keyandcontent_reports.open_marker.The visibility gate has no data source on either side, and cannot have one. §7.2 bridges an event only if "its
visibilityispublic, or its destination channel is configured for a members-only Team context". But the fourteam.*streams carry no visibility (onlyteam_activityrows do); forum threads have no public/members column, because a forum is members-only by construction with every thread behindteam_forum_grants; and core cannot see a Discord channel's permissions. So §7.2's own example config,['team.announcement','team.forum.post'], names exactly the two events that are never public.As built: an attributed operator acknowledgement (
members_ack/_by/_at), in the shapeteams_forum_uploads_ackalready uses. Four properties make it a gate rather than a checkbox — it is a precondition (422, not a quiet drop at delivery, because a config that silently does less than it says is worse than one that will not save); it is re-asked at delivery as well as at the save; changing the channel clears it, since an acknowledgement is about a destination and cannot survive the destination changing underneath it; and a roster-only or disabled row needs none, because a dialog that appears on saves that did not need it is one people learn to click through."Identical to
announceandmod-reverse" names two different reliability models.announceridesannounce_jobswith backoff, retries and a per-leg retry button;mod-reverseis one-shot. The bridge is one-shot: a news post is a durable artifact whose Discord copy is expected to exist, while a Team notification is the moment it describes, and one arriving twenty minutes after the conversation moved on is worse than one that never arrives.The author exclusion stops at the channel. Push and email both subtract the post's author; the bridge does not. Excluding is a per-recipient idea and a channel has no per-recipient anything — suppressing the message because the author reads that channel would deprive everyone else in it.
A roster event carries a count and never a name.
memberJoinedgrew an optional{ count }for the bridge only — a channel has no app on the other end to pull anything after a content-free nudge. The tickle beside it is byte-identical, and a character name is game-sourced text screened for a page, not for a channel.What is here
Server.
team_integration_config+ a model/db pair;utils/teamBridge.jsas the sink; three routes on the admin Teams router, admin-only — the one such corner of a staff-wide router, because configuring where a Team's content leaves the site for is deployment configuration rather than the §2.9 kind of decision a moderator files a request for.Bot.
discord/teamNotify.jsandPOST /internal/team-notify. The channel comes from the app, not fromguild_configthe wayannouncefinds#news: which channel a Team's events go to is per-Team configuration, and a bot that resolved it would hold a second copy of a table it cannot see the inputs to.Client. A "Notification bridge" panel under Admin → Teams, with its logic in
lib/teamIntegrations.jsper theteamAdmin.jsconvention. Named for the Team concern rather than for Discord, so phase 10's capability refactor changes what fills it and not where it is.The live rig
Real ServUO tree + the app with module-uo installed + a fake standing in for Discord, against a throwaway database rather than the shared review one (phase 7's note). Everything up to
channel.sendwas production code, including the bot's real route and controller.It proved: the schema as built holding a default row and two overrides under one unique key; the 422 on an unacknowledged forum bridge; the acknowledgement clearing on a repoint, with the stored row still pointing at the originally-vetted channel because a refused save writes nothing; a per-Team override switching the bridge OFF for one Team while the default stays on; the acknowledgement revoked underneath a row silencing it at the next post; forums switched off silencing every sink; and a real announcement arriving as an embed with module-uo's
pageUrlTemplatelink. The three failure modes stay distinguishable in the log —503(not connected),400(that id is not a text channel),status: 0(bot process gone) — and every forum post returned 200 through all of them. A moderator gets 403 on the bridge and 200 on the staff-wide part of the same router.It found both defects, via tests written against the rig's shapes. A re-acknowledgement given for a new channel kept the old attribution — the column was already 1, so "freshly acknowledged" read false and the row went on naming whoever vetted the previous destination, which is the entire audit value of the column. And the embed description was clamped to Discord's limit before the heading was prepended, producing a description one heading over; discord.js rejects that outright, so an over-long forum post would not have arrived at all rather than arriving truncated.
Tests
1119 server (+35), 32 bot (+12), 263 client (+15).
routes.manifest.json/routes.guards.jsonregenerated — three routes, whose guard shape is byte-identical to the existing admin-only ones. Swagger regenerated (+439 lines, nothing else touched).Not done here
No real Discord guild was involved.
channels.fetchagainst a live API and a realchannel.sendare the two things this walk could not exercise — the same gap phase 7 recorded forREST.put.🤖 Generated with Claude Code
https://claude.ai/code/session_01WnDSWzpUjw8t8C2hghysNz