feat(bots): Mattermost payload compatibility + 100% coverage of the bot platform - #214
Open
habibaltaf wants to merge 14 commits into
Open
feat(bots): Mattermost payload compatibility + 100% coverage of the bot platform#214habibaltaf wants to merge 14 commits into
habibaltaf wants to merge 14 commits into
Conversation
shiv122
approved these changes
Aug 6, 2026
github.com/modelcontextprotocol/go-sdk v1.7.0 (ex MCP server) + transitive deps. Ignores cmd/cliffylocaluser and run-cliffy-dev.sh (machine-local). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bot accounts (bot_ ids), admin CRUD + webhook config, and event dispatch: maybeDispatchToBots routes @mentions / thread replies to a registered BotHandler — in-process responder or HMAC-signed outgoing webhook (SSRF- guarded, replay-bound). Resolves both @handle and rich @[bot_…] mention forms. Identity: replies post as the bot, access-checked against the attested asker; ex never impersonates. SendBotCard + dispatch hook wired into MessageService.Send. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Cliffy is a real bot_cliffy account handled through dispatch (cliffy_inchat implements BotHandler) instead of a hardcoded @cliffy path. Keeps the CliffHub identity bridge (HS256->Sanctum mint, server-side token, SSRF- guarded writes, cost caps, revoke-on-logout). Confirm-first in-chat writes claim the pending action atomically (GETDEL) and constrain agent repairs to the approved method+path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Streamable-HTTP MCP server (official go-sdk) at /api/v1/mcp behind AuthWithBots. postMessage + readChannel act as the authenticated caller, access-checked like the REST equivalent; plus whoami/ping. Proven by an in-process round-trip and an HTTP test driving the real SDK client. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Registers bot admin routes + the MCP endpoint, provisions bot_cliffy (EnsureBot) and registers it as a BotHandler, sets the outgoing-webhook bot directory. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Cliffy panel/launcher: draggable mascot (position persisted), hide with a shortcut (Cmd/Ctrl+Shift+C) to bring it back, full-screen on mobile, and /cliffy composer command. Chat views detect @cliffy / /cliffy. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ot platform Makes ex's three integration payloads byte-compatible with Mattermost, so an existing MM integration receiver works unchanged, and brings the whole bot platform (including the parts that predated this work) to the repo's 100% statement gate. ## Mattermost compatibility (docs/rfc-generic-bots-mcp.md §2, D4/D5) - Outgoing webhooks: per-bot `transport`. "mattermost" POSTs MM's form-encoded fields with the shared secret as the body `token`; "ex" (the default for new bots) keeps the HMAC-signed JSON, which is strictly better authentication. - Trigger words: MM's outgoing-webhook trigger model, including `trigger_when`. Read on the synchronous send path from an atomically-swapped snapshot, never a per-message DB read. - Slash commands: admin-registered trigger + request URL, MM's form-encoded invocation and response shape, and a working `response_url` for delayed replies (30-minute TTL, matching MM's documented window). - Interactive message actions: attachment buttons and select menus that call back into the posting integration and apply `update` / `ephemeral_text`. - Admin APIs are snake_case and accept MM's field spellings (`username`, `display_name`, `trigger_word`). Still not a drop-in MM server: ex serves /api/v1, so MM's client libraries and plugins do not work against it. That remains a non-goal (D1). Two documented approximations, centralized in service/mmcompat.go: team_id/team_domain are a single synthetic team (ex has no teams) and user_name is derived from the email local part (ex has no usernames) — a display label, never an identifier. ## Security-relevant decisions - An action's callback URL and context are never serialized to clients: the client sends only an action id and ex resolves the stored integration server-side, so integration config cannot leak and a client cannot forge a call with a context of its choosing. - Every integration URL goes through the same SSRF boundary as unfurling (public https only, re-checked at dial time), on posting and on invocation. - MM defaults an unset command `response_type` to ephemeral; ex matches that, so a command answering with bare text does not publish to the channel. - A retired bot fails closed: DeleteBot clears its callback URL and triggers, so an @mention or trigger word can no longer reach it. ## Coverage Every package is now at 100%. Files that had none before: store/bot.go and store/redis_cliffy_inchat.go (0%), handler/bot.go and handler/cliffy_inchat.go (4%), service/botdispatch.go's async half (42%), handler/cliffy.go (54%). Three seams were added so untestable behaviour became reachable, each narrowing a real bug class rather than just moving a number: - handler.cliffyPendingStore makes the confirm-first race (two "yes" replies, only one may execute a cross-app write) deterministic instead of timed. Note the trap it introduced: assigning a nil *store.CliffyInChatStore to an interface yields a NON-nil interface, so NewCliffyHandler assigns only when non-nil — otherwise every `h.inchat != nil` guard passes and dereferences. - handler.MessageActionInvoker and service.CommandResponseStore test the HTTP contract and the response_url path without the message service / Redis. Per COVERAGE.md, provably-dead guards were deleted rather than annotated: three json.Marshal-over-strings sites, HMAC signing with a []byte key (now service.mustSigned), re-marshalling a value that just came from Unmarshal (now handler.mustJSON), and a duplicated not-configured branch in ProxyAPI that its own fail-fast guard already covers. CreateCommand uses the existing randRead seam so its randomness-failure arm stays reachable. ## Bot admin UI Includes the in-flight bot-admin UI (BotsPage, BotsPanel, useBots, the nav item and route) that the feature depends on, extended with the transport/trigger-word controls and snake_case wire types. Delete and revoke failures are now surfaced inline — previously they failed silently, which reads as a dead button, and a 404 additionally hints that the API build may be stale. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI runs golangci-lint at `version: latest`, which surfaced seven issues — four in this branch's new code and three pre-existing ones in files it touches (newer staticcheck checks that did not exist when they were written). - service/bot.go: tagged switch on bot.CallbackURL (QF1002) - model/message.go: convert messageActionWire directly rather than enumerating fields (S1016) — also safer, since an enumerated literal silently drops a field added to only one of the two structs - handler/mcp_test.go: check the Close error on the three session defers (errcheck) - middleware/bot_auth_test.go: drop a redundant `var _ *auth.JWTManager` assertion (QF1011) and its now-unused import — newTestJWTManager's signature already guarantees the type Verified: golangci-lint 2.12.2 reports 0 issues; the backend coverage gate still passes at 100%. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Frontend CI job runs in ghcr.io/digitaltolk/docker-builder-playwright:1.61.1, whose browsers live at PLAYWRIGHT_BROWSERS_PATH=/ms-playwright. This branch's lockfile resolved playwright to 1.59.1, which looks for a browser build the image does not ship: browserType.launch: Executable doesn't exist at /ms-playwright/chromium_headless_shell-1217/chrome-headless-shell-linux64/... The browser project therefore could not launch, so only the jsdom project ran (268 of 799 test files) and merged coverage came out at 88% against a 100% threshold — the gate was failing on missing browser coverage, not on genuinely uncovered code. main resolves playwright to 1.61.1 and matches the image; the downgrade came in with this branch's frontend dependency additions. This takes main's exact playwright + playwright-core entries, so the lockfile and the pinned container agree again. Verified locally: the browser project launches and MessageItem.branches.browser.test.tsx passes 108/108. Note for follow-up: the container tag in ci-checks.yml and the lockfile's playwright version have to move together. Worth a comment or a CI assertion so the next dependency bump can't silently disable every browser test again. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
AuthContext.browser.test.tsx asserted that the FIRST fetch on logout is /auth/logout. Since 8166236 added revoke-on-logout, logout POSTs /api/v1/cliffy/revoke first — deliberately, while the access token is still valid to authenticate it — and only then /auth/logout. The test was never updated, so it failed on all three browser instances (3 of the 5 failing frontend files). Rather than loosen the assertion to "contains", this pins the full call order, since that order is the intended behaviour and worth protecting: swapping it would revoke with an already-invalidated token. Verified: 36/36 pass across chromium-desktop, chromium-mobile and webkit-iphone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…r it The mascot was anchored with `bottom-24` (96px) while the docked composer owns the bottom ~140px of the shell, so its 72px box overlapped the composer band — near the editor's top-right corner, the newest message's hover toolbar, and a /threads ThreadCard's Send. Its art made that worse: the SVG is overflow-visible and the cat leans up to 120px toward the cursor when idle, so the *drawing* was hit-testable well outside the button box and could steal clicks wherever it wandered. - ANCHOR_BOTTOM/ANCHOR_RIGHT are now the single source of truth: the CSS anchor is driven from the same constants as the drag bounds, so the two can no longer drift apart. dragConstraints.bottom is 0, not ANCHOR_BOTTOM - EDGE, so a drag can't put the cat back over the band. - pointer-events-none on the art leaves the button as the only target. - group-focus-within on the ×, or `display:none` makes dismissing Cliffy impossible without a pointer. - drop the dead `typeof window !== 'undefined'` ternary (ex is a Vite SPA and this file only runs after mount). - cliff-bot.tsx: drop three console.log calls that shipped to production. Tests: CliffyLauncher had zero coverage. New browser suite asserts the geometry invariant against the REAL MessageInput — the launcher rect must not intersect [data-message-composer], and Send must actually fire — plus open/close, the Cmd/Ctrl+Shift+C toggle (including as the way back from a dismissal), dismissal persistence, and a real Motion drag whose trailing click does not open the panel. resetCliffyWidgetForTests clears the persisted widget between tests: browser test files share one origin, so a dismissal or drag in one file otherwise leaks into every later file's run. The two route harnesses pinned a 1280x800 box. The real shell fills the viewport, so an 800px box in a 900px viewport floats the docked composer 100px above the bottom and misplaces every viewport-anchored fixed element relative to it — which is why the invite prompt's dismiss × ended up under the mascot there and nowhere else. 100dvh instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The frontend gate measured for the first time on the last push (the
playwright/container mismatch had been keeping the browser projects from
running at all) and came in at 98.29% statements against a 100% gate.
Almost all of the debt was the Cliffy feature this branch introduced and
never tested: CliffyPanel at 2%, cliffy-tools at 7.8%, cliffy-mark at 0%.
- CliffyPanel: the session probe (loading / ready / 403-no-account /
retryable error / retry), both post-unmount cancel arms, the transport's
headers+body closures, the /cliffy seed handoff, the welcome prompts,
the scope chip, the composer, and both message shapes. assistant-ui's
primitives are stubbed to render their children and CALL their
`components` render props — standing up a live streaming runtime would
test assistant-ui, not ex.
- cliffy-tools: CliffHub link rewriting (including that //host is another
origin, not a CliffHub path), every read-tool label, the whole writeApi
approval card — approve, reject, 4xx-with-body, unparseable body,
transport failure, oversized-payload truncation — and sharing a
completed action back into the conversation.
- cliff-bot: controlled states, reduced motion, proximity, eye tracking
and its drift home, the five-minute wander and the greeting on return,
both the CTM and rect coordinate paths, and cleanup.
- cliffy-store: persistence round-trip, corrupt JSON, a storage that
refuses to write, setScope.
- The bot/Cliffy wiring: /cliffy interception in ChannelView and
ConversationView (the prompt must NOT be posted), a slash command's
ephemeral_text and goto_location, a non-Error action failure, Cliffy
named in both typing indicators, the /bots lazy route, the Bots menu
item, and eight BotsPanel branch arms.
Fixes a real bug the tests refused to pass without: <CliffBot/> handed
dangerouslySetInnerHTML a fresh {__html} literal, and React re-sets
innerHTML whenever that object's IDENTITY changes — even for a
byte-identical string. So every re-render rebuilt the SVG: its CSS
animations restarted mid-flight, and the cursor-tracking effect (which
captures the node once and does not re-run) was left driving a detached
element. The mascot froze for good after the first store update. One
module-scope constant fixes it; a test pins the node identity.
Three dead branches are removed rather than tested — `if (raf)` before
cancelAnimationFrame (a no-op for 0 per spec), a redundant `|| 1` on a
hypotenuse that provably cannot be 0 there, and the revoke dialog's
unreachable null-id guard (the dialog now mounts only while a token is
actually pending, mirroring the delete dialog). The one guard that is
genuinely unreachable but worth keeping — a handler that cannot fire
because its control is `disabled` — carries an `istanbul ignore` with the
reason, the convention already used in ThemeContext and UnreadContext.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
habibaltaf
force-pushed
the
feat/cliffy-ex-bridge
branch
from
August 6, 2026 15:11
d327ef8 to
608fa94
Compare
The Frontend job's tests all passed (806 files, 9243 tests) and merged coverage came out at 100% across statements, branches, functions and lines, with coverage-universe confirming all 231 non-excluded src files were graded. The job then exited 1 posting the report to coveralls.io, which was returning 503 at the time; the Coverage check failed with it, since it waits on that upload. Nothing to fix in the tree — coveralls.io has recovered and re-running the job needs admin rights this fork does not have, so this empty commit is the trigger. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Mattermost payload compatibility + 100% coverage of the bot platform
Answers "is the bot API compatible with Mattermost?" — it now is, at the payload
level, for the three integration surfaces third-party bots actually use. Also
brings the whole bot platform to the repo's 100% statement gate, including the
parts that predated this branch and had no tests at all.
Design record:
docs/rfc-generic-bots-mcp.md(§2 rewritten, §8a added, D4/D5).What an existing Mattermost integration can now do against ex
trigger_word+trigger_whenresponse_urltransport."mattermost"sends MM'sform-encoded fields with the shared secret as the body
token."ex"(thedefault for new bots) keeps HMAC-signed JSON, which is strictly better
authentication — a signature bound to a timestamp rather than a bearer token
in the body. Opt in per bot, so an existing MM bot works unchanged without
weakening anything new.
and response shape (
response_type,text,attachments,goto_location),and a working
response_urlfor delayed replies (30-min TTL, matching MM).actionswithintegration.{url,context}, its action request andupdate/ephemeral_textresponse.username,display_name,trigger_word).Still not a drop-in MM server. ex serves
/api/v1, somattermost-driver,mmpy_botand MM plugins do not work against it — that stays a non-goal (D1).Reaching it would need a real unique
usernameonmodel.User(a datamigration; ex has only email + display name), a synthetic team threaded through
every team-scoped route, an id format MM clients accept (
bot_<ulid>is 30chars where MM validates 26 lowercase alphanumerics), and a
/api/v4/websocketspeaking MM's frame protocol.Two documented approximations, centralized in
service/mmcompat.go:team_id/team_domainare a single synthetic team (ex has no teams), anduser_nameis derived from the email local part (ex has no usernames) — adisplay label, never an identifier. Receivers must key on
user_id.Security-relevant decisions worth a reviewer's attention
sends only an action id; ex resolves the stored integration server-side. That
keeps integration-internal config off the wire and stops a client forging a
call with a context of its choosing. Implemented as an asymmetric
MessageAction— inbound JSON populatesintegration,json:"-"means it isnever emitted. Pinned by a test.
(public https only, re-checked at dial time to defeat DNS rebinding), both
when persisting an action and when invoking it. An update's actions are
re-validated, so an unsafe URL can't be smuggled in via a callback response.
response_typeto ephemeral. ex matchesthat, so a command answering with bare text does not publish to the channel.
Getting this backwards would leak output integrations expect to stay private.
DeleteBotnow clears its callback URL andtriggers, so an @mention or trigger word can no longer reach a deactivated
bot's endpoint.
response_urlis unauthenticated by design (MM's contract). Everything itmay do — which chat, as whom — is pinned server-side at mint time and
re-access-checked before posting, so a stolen token can only do what the
original invocation could, and only until the TTL.
Coverage: 100%
Files that had no coverage before this PR:
store/bot.goandstore/redis_cliffy_inchat.go(0%),handler/bot.goandhandler/cliffy_inchat.go(4%),service/botdispatch.go's async half (42%),handler/cliffy.go(54%).Three seams added so untestable behaviour became reachable — each narrowing a
real bug class, not just moving a number:
handler.cliffyPendingStore— makes the confirm-first race (two "yes" replieswhere only one may execute a cross-app write) deterministic instead of
timing-dependent.
handler.MessageActionInvoker,service.CommandResponseStore— test the HTTPcontract and the
response_urlpath without the message service / Redis.Per
COVERAGE.md, provably-dead guards were deleted rather than annotated:three
json.Marshal-over-strings sites, HMAC signing with a[]bytekey (nowservice.mustSigned, with a panic test), re-marshalling a value that just camefrom
Unmarshal(nowhandler.mustJSON), and a duplicated not-configuredbranch in
ProxyAPIits own fail-fast guard already covers.CreateCommandmoved onto the existing
randReadseam so its randomness-failure arm staysreachable.
Bugs found and fixed along the way
A typed-nil trap (introduced by the interface seam, then fixed): assigning a
nil
*store.CliffyInChatStoreto an interface field yields a non-nilinterface holding a nil pointer, so every
h.inchat != nilguard passed andthen dereferenced.
NewCliffyHandlernow assigns only when non-nil, with aregression test.
Silent bot-admin failures: delete and revoke surfaced nothing on failure,
which reads as a dead button. They now show the server's reason, and a 404 hints
that the API build may be stale.
Pre-existing failures this PR does not fix
Both trace to
8166236("Cliffy in-chat UI + draggable/dismissable launcher")and fail deterministically, independent of this work:
conversation-view.browser.test.tsx > sends a new conversation message—the draggable Cliffy launcher (
fixed bottom-24 right-5 z-40) has a Lottie<path>that intercepts clicks on the composer's Send button. This is areal UI bug, not just a test failure: users can't click Send where the
launcher overlaps. The fix is a design call (
pointer-events-noneon theanimation layer, or reposition), so it's left for the owner.
AuthContext.browser.test.tsx > logout POSTs to /auth/logout— thatcommit added revoke-on-logout, so logout now POSTs
/api/v1/cliffy/revokebefore
/auth/logout(deliberately, while the token is still valid). Thetest still asserts the first fetch is
/auth/logoutand was never updated.The code is right; the test is stale.
Verification
go build,go vet(both tag sets),gofmt,make check-types-drift— clean-tags=integrationagainst DynamoDB Local + Redis — 17/17packages pass
tsc -b,eslint src/— cleannumber is not verified here — Playwright's chromium/webkit were not
installed locally, so every
.browser.test.tsxhad been contributing zerocoverage; after installing them the suite could only be run on a machine at
load 59, where 147 of 152 failures were
Error: Test timed out. The samefiles pass in isolation (
Sidebar.test.tsx73/73,MessageItem.branches.browser.test.tsx108/108 in 28s). This needs CI or anidle machine to confirm.