Skip to content

feat(bots): Mattermost payload compatibility + 100% coverage of the bot platform - #214

Open
habibaltaf wants to merge 14 commits into
DigitalTolk:mainfrom
habibaltaf:feat/cliffy-ex-bridge
Open

feat(bots): Mattermost payload compatibility + 100% coverage of the bot platform#214
habibaltaf wants to merge 14 commits into
DigitalTolk:mainfrom
habibaltaf:feat/cliffy-ex-bridge

Conversation

@habibaltaf

Copy link
Copy Markdown

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

Surface Before Now
Incoming webhooks ✅ already compatible unchanged
Outgoing webhooks ex-only signed JSON ✅ MM form-encoded (opt-in per bot)
Trigger words ❌ none trigger_word + trigger_when
Slash commands ❌ none ✅ MM request/response + response_url
Interactive actions ❌ none ✅ buttons/selects + callback
  • Outgoing webhooks — per-bot transport. "mattermost" sends MM's
    form-encoded fields with the shared secret as the body token. "ex" (the
    default 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.
  • Slash commands — admin-registered trigger + request URL, MM's invocation
    and response shape (response_type, text, attachments, goto_location),
    and a working response_url for delayed replies (30-min TTL, matching MM).
  • Interactive actions — MM's attachment actions with
    integration.{url,context}, its action request and update /
    ephemeral_text response.
  • Admin APIs are snake_case and accept MM's spellings (username,
    display_name, trigger_word).

Still not a drop-in MM server. ex serves /api/v1, so mattermost-driver,
mmpy_bot and MM plugins do not work against it — that stays a non-goal (D1).
Reaching it would need a real unique username on model.User (a data
migration; ex has only email + display name), a synthetic team threaded through
every team-scoped route, an id format MM clients accept (bot_<ulid> is 30
chars where MM validates 26 lowercase alphanumerics), and a
/api/v4/websocket speaking MM's frame protocol.

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. Receivers must key on user_id.

Security-relevant decisions worth a reviewer's attention

  • An action's callback URL and context never reach a client. The client
    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 populates integration, json:"-" means it is
    never emitted. Pinned by a test.
  • Every integration URL goes through the same SSRF boundary as unfurling
    (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.
  • 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.
    Getting this backwards would leak output integrations expect to stay private.
  • A retired bot fails closed. DeleteBot now clears its callback URL and
    triggers, so an @mention or trigger word can no longer reach a deactivated
    bot's endpoint.
  • response_url is unauthenticated by design (MM's contract). Everything it
    may 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%

Total coverage threshold (100%) satisfied:  PASS
Total test coverage: 100% (13088/13088)

Files that had no coverage before this PR: 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 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" replies
    where only one may execute a cross-app write) deterministic instead of
    timing-dependent.
  • handler.MessageActionInvoker, 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, with a panic test), re-marshalling a value that just came
from Unmarshal (now handler.mustJSON), and a duplicated not-configured
branch in ProxyAPI its own fail-fast guard already covers. CreateCommand
moved onto the existing randRead seam so its randomness-failure arm stays
reachable.

Bugs found and fixed along the way

A typed-nil trap (introduced by the interface seam, then fixed): assigning a
nil *store.CliffyInChatStore to an interface field yields a non-nil
interface holding a nil pointer, so every h.inchat != nil guard passed and
then dereferenced. NewCliffyHandler now assigns only when non-nil, with a
regression 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:

  1. 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 a
    real UI bug, not just a test failure: users can't click Send where the
    launcher overlaps. The fix is a design call (pointer-events-none on the
    animation layer, or reposition), so it's left for the owner.
  2. AuthContext.browser.test.tsx > logout POSTs to /auth/logout — that
    commit added revoke-on-logout, so logout now POSTs /api/v1/cliffy/revoke
    before /auth/logout (deliberately, while the token is still valid). The
    test still asserts the first fetch is /auth/logout and 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
  • Full Go suite with -tags=integration against DynamoDB Local + Redis — 17/17
    packages pass
  • Backend coverage gate — PASS at 100%
  • tsc -b, eslint src/ — clean
  • Frontend suite: the jsdom project passes. The merged jsdom+browser coverage
    number is not verified here
    — Playwright's chromium/webkit were not
    installed locally, so every .browser.test.tsx had been contributing zero
    coverage; 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 same
    files pass in isolation (Sidebar.test.tsx 73/73,
    MessageItem.branches.browser.test.tsx 108/108 in 28s). This needs CI or an
    idle machine to confirm.

habibaltaf and others added 13 commits August 6, 2026 20:10
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
habibaltaf force-pushed the feat/cliffy-ex-bridge branch from d327ef8 to 608fa94 Compare August 6, 2026 15:11
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants