From 83e07ead0946dbf008295237295c16f397041f87 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 31 Oct 2026 14:51:00 +0100 Subject: [PATCH 1/5] feat(chat): the Maple bot in Hazel A Hazel connector for @maple/chat-platform, beside Slack and Discord: - install: Hazel's bot-install consent page, redeeming the one-time code with the bot's token for the workspace it was issued against; - ingress: Hazel's bot gateway as a pure socket protocol (HELLO, IDENTIFY or RESUME from the last acknowledged offset, DISPATCH batches ACKed as they arrive, heartbeats), mapping message.create and interaction.create; - outbound: post and edit over /api/v1, a thread per mention, history with authors' names, approvals as embed buttons, mentions defused. No identity half yet: anyone in a linked workspace may ask and approve, as the org-level connector identity. Config: MAPLE_HAZEL_BOT_TOKEN, MAPLE_HAZEL_BOT_ID. Needs Hazel's bot-install, buttons and gateway changes. Co-Authored-By: Claude Opus 5.5 --- .../src/connectors/hazel/README.md | 81 ++++ .../chat-platform/src/connectors/hazel/api.ts | 29 ++ .../src/connectors/hazel/gateway-events.ts | 106 +++++ .../src/connectors/hazel/gateway-payloads.ts | 95 ++++ .../src/connectors/hazel/gateway.test.ts | 265 +++++++++++ .../src/connectors/hazel/gateway.ts | 161 +++++++ .../chat-platform/src/connectors/hazel/id.ts | 7 + .../src/connectors/hazel/index.ts | 26 ++ .../src/connectors/hazel/install.test.ts | 89 ++++ .../src/connectors/hazel/install.ts | 110 +++++ .../src/connectors/hazel/manifest.ts | 25 ++ .../src/connectors/hazel/outbound.test.ts | 246 ++++++++++ .../src/connectors/hazel/outbound.ts | 419 ++++++++++++++++++ .../src/connectors/hazel/render.test.ts | 113 +++++ .../src/connectors/hazel/render.ts | 225 ++++++++++ .../chat-platform/src/connectors/index.ts | 3 +- .../chat-platform/src/connectors/manifests.ts | 6 +- .../src/vendor-isolation.test.ts | 13 +- 18 files changed, 2015 insertions(+), 4 deletions(-) create mode 100644 packages/chat-platform/src/connectors/hazel/README.md create mode 100644 packages/chat-platform/src/connectors/hazel/api.ts create mode 100644 packages/chat-platform/src/connectors/hazel/gateway-events.ts create mode 100644 packages/chat-platform/src/connectors/hazel/gateway-payloads.ts create mode 100644 packages/chat-platform/src/connectors/hazel/gateway.test.ts create mode 100644 packages/chat-platform/src/connectors/hazel/gateway.ts create mode 100644 packages/chat-platform/src/connectors/hazel/id.ts create mode 100644 packages/chat-platform/src/connectors/hazel/index.ts create mode 100644 packages/chat-platform/src/connectors/hazel/install.test.ts create mode 100644 packages/chat-platform/src/connectors/hazel/install.ts create mode 100644 packages/chat-platform/src/connectors/hazel/manifest.ts create mode 100644 packages/chat-platform/src/connectors/hazel/outbound.test.ts create mode 100644 packages/chat-platform/src/connectors/hazel/outbound.ts create mode 100644 packages/chat-platform/src/connectors/hazel/render.test.ts create mode 100644 packages/chat-platform/src/connectors/hazel/render.ts diff --git a/packages/chat-platform/src/connectors/hazel/README.md b/packages/chat-platform/src/connectors/hazel/README.md new file mode 100644 index 0000000000..0734523e4b --- /dev/null +++ b/packages/chat-platform/src/connectors/hazel/README.md @@ -0,0 +1,81 @@ +# The Hazel connector + +Everything Maple knows about Hazel is in this directory. Two host Workers drive it through +`ChatConnector` and neither learns which platform it is talking to: `apps/api` runs the install +half, `apps/chat-bot` runs the ingress and outbound halves. + +## What has to exist before it runs + +One Hazel bot, owned by the Maple team, created in Hazel under Settings → Integrations → Your apps +→ Create. Its secrets are documented here rather than in the repo's `.env.example`; every name is +declared once, in `api.ts`. + +| Config | Where it comes from | Read by | +| ----------------------- | -------------------------------------------- | ------------------------------- | +| `MAPLE_HAZEL_BOT_TOKEN` | The token shown once when the bot is created | ingress, outbound and install | +| `MAPLE_HAZEL_BOT_ID` | The bot's id (not a secret) | install, as the link's `bot_id` | + +A half whose names are unset is skipped rather than fatal, as for every connector. + +1. **Create the bot** with the scopes `messages:read`, `messages:write` and `channels:write` (the + last one opens threads), mark it **public**, and make it **mentionable**. +2. **Register the install redirect URIs** on the bot (Edit Application → Install redirect URIs): + `https://api.maple.dev/oauth/chat/hazel/callback`, plus the equivalent origin of any other + stage that shares this bot. Hazel compares them exactly. +3. **Put the two values on the deployment.** The token reaches `chat-bot` and `api`, the id reaches + `api`. + +**One bot per instance.** Hazel allows one gateway session per bot token, so the EU instance runs +its own bot, with its own redirect URI. + +## Trying it end to end + +1. **Link a Hazel workspace.** In Maple: Settings → Integrations → Hazel → Connect. That sends an org + admin to Hazel's consent page (`https://app.hazel.sh/oauth/bot-install`), where they pick a Hazel + workspace they own or administer and approve. Hazel installs the bot there and sends the browser + back with a one-time code; the install half redeems it with the bot's token + (`POST /api/v1/bot-installs/token`) for the workspace it was issued against. The code is bound to + this bot, the redirect URI and that workspace, is single-use and lives ten minutes, so the + workspace comes from Hazel's answer and never from the callback's query. +2. **Mention it.** `@Maple why is checkout slow?` in any channel. The bot opens a thread on the + message (`POST /api/v1/channels/{id}/threads`) and edits one message in it as the answer streams. + A follow-up inside that thread needs no mention. +3. **Decide what it proposes.** A write the agent wants to make is an embed with two buttons. A + press reaches the bot as an `interaction.create` gateway event, and the card is then edited with + what came of it, without the buttons. + +## Who may ask and approve + +There is no `identity` half yet: Hazel has no way for a person to prove to Maple which Hazel account +is theirs. So anyone in a linked workspace may ask, and anyone who can see a proposal may approve +it, and the change runs as the org-level connector identity — the policy `ChatConnector.identity` +writes down for a connector without one. Linking the workspace is an admin's decision on both +sides, which is what this rests on. + +## The gateway + +`wss://bot-gateway.hazel.sh/bot-gateway/ws`, `gateway.ts`: + +- HELLO carries the heartbeat interval; the connector IDENTIFYs with the token, or RESUMEs the + session it holds, from the last acknowledged offset. A first connection starts at `now`: `-1` + would replay a week of retained events, and every one would be answered. +- READY names the session and the bot's own user, which is what makes `@[userId:…]` a mention of + this bot. A gateway that predates `botUserId` delivers nothing until it names it. +- Events arrive in DISPATCH batches, one in flight at a time; each is acknowledged in the same step + that hands its events to the host. +- One session per token: a second IDENTIFY is refused while one is live, and a RESUME of the live + session replaces it. So the session is kept across every reconnect and deploy, and only a refused + token (`Invalid bot token`) stops the loop. + +Hazel delivers every message in a workspace to an installed bot. The connector drops its own +messages, any other bot's (each message event names its author), and empty ones before the host +pays for them; the host decides whether an unaddressed message is still a turn. + +## What Hazel cannot do (yet) + +- **No private replies.** A whisper is a message in the conversation that mentions the person. +- **No typing indicator** for a bot; the placeholder message says it is working. +- **No alert destinations** through this connector: alerts reach Hazel through Maple's separate + Hazel webhook integration. +- **No uninstall event.** Removing the bot in Hazel does not unlink the workspace in Maple; answers + fail with `auth` until it is unlinked there. diff --git a/packages/chat-platform/src/connectors/hazel/api.ts b/packages/chat-platform/src/connectors/hazel/api.ts new file mode 100644 index 0000000000..18e805ceee --- /dev/null +++ b/packages/chat-platform/src/connectors/hazel/api.ts @@ -0,0 +1,29 @@ +/** + * What all three halves of this connector agree on: where Hazel is, and what its secrets are + * called. + * + * Its own module for the same reason `./id.ts` is: the install flow, the outbound transport and + * the gateway state machine all need these, and none of them should have to import another to get + * them. + */ + +/** The public bot API (`/api/v1`): messages, threads, people, install grants. */ +export const API_BASE = "https://api.hazel.sh/api/v1" + +export const API_HOST = "api.hazel.sh" + +/** The bot gateway: a resumable WebSocket that delivers the bot's events in acknowledged batches. */ +export const GATEWAY_URL = "wss://bot-gateway.hazel.sh/bot-gateway/ws" + +/** The consent page an org admin is sent to: they pick a Hazel workspace and approve the install. */ +export const INSTALL_URL = "https://app.hazel.sh/oauth/bot-install" + +/** + * The bot's token (`hzl_bot_…`), shown once when the bot is created in Hazel. Declared once: the + * gateway identifies with it, the outbound half posts with it, and the install half redeems a + * grant with it — a Hazel install code is only redeemable by the bot it was issued to. + */ +export const BOT_TOKEN_CONFIG = "MAPLE_HAZEL_BOT_TOKEN" + +/** The bot's id, the `bot_id` of its install link. Not a secret: it rides the URL the browser opens. */ +export const BOT_ID_CONFIG = "MAPLE_HAZEL_BOT_ID" diff --git a/packages/chat-platform/src/connectors/hazel/gateway-events.ts b/packages/chat-platform/src/connectors/hazel/gateway-events.ts new file mode 100644 index 0000000000..d7d1b6d50a --- /dev/null +++ b/packages/chat-platform/src/connectors/hazel/gateway-events.ts @@ -0,0 +1,106 @@ +/** + * Hazel gateway envelopes → Maple's normalized inbound events. + * + * Two envelopes produce one: a message starts (or continues) a turn, and a button press applies an + * approval. Everything else the gateway delivers — edits, deletes, channel and member changes, + * slash commands — is dropped here. + */ +import { Option, Schema } from "effect" +import type { InboundEvent } from "../../ingress.ts" +import { decodeInteractionCreate, decodeMessageCreate } from "./gateway-payloads.ts" +import { HAZEL_CONNECTOR_ID } from "./id.ts" + +const decodeEventType = Schema.decodeUnknownOption(Schema.Struct({ eventType: Schema.String })) + +/** `@[userId:]`, Hazel's mention markup (`@hazel/domain`'s mention parser). */ +export const mentionOf = (userId: string): string => `@[userId:${userId}]` + +/** The bot's own mention removed. A literal split: the id comes off the wire. */ +const stripMention = (text: string, botUserId: string): string => + text.split(mentionOf(botUserId)).join(" ").replace(/\s+/gu, " ").trim() + +/** The workspace out of `org::channel:`. */ +const organizationOf = (partitionKey: string): string | undefined => { + const [scope, organizationId] = partitionKey.split(":") + return scope === "org" && organizationId !== undefined && organizationId !== "" + ? organizationId + : undefined +} + +/** + * A message the bot can see, whether or not it was addressed to it. + * + * Hazel delivers every message in the workspace to an installed bot, its own included, so the + * filters come first: its own messages, and any other bot's (two bots must not talk to each other). + * A message with no text that does not mention the bot is not a turn either. + */ +// BOUNDARY: `envelope` is one undecoded event from a DISPATCH batch. +const messageCreate = (envelope: unknown, botUserId: string): ReadonlyArray => { + const decoded = decodeMessageCreate(envelope) + if (Option.isNone(decoded)) return [] + const { payload: message, author, partitionKey } = decoded.value + const workspaceId = organizationOf(partitionKey) + if (workspaceId === undefined) return [] + if (message.authorId === botUserId || author?.isBot === true) return [] + const mentionsBot = message.content.includes(mentionOf(botUserId)) + if (!mentionsBot && message.content.trim() === "") return [] + return [ + { + type: "message", + connector: HAZEL_CONNECTOR_ID, + workspaceId, + // A Hazel thread IS a channel with its own id, and a reply goes to that id — so + // `channelId` already addresses it and `threadId` stays absent. + channelId: message.channelId, + messageId: message.id, + author: { + id: message.authorId, + // Hazel names the author on every message event; one from a gateway that predates + // that still reads as somebody. + displayName: author?.displayName ?? "Someone", + isBot: false, + }, + text: stripMention(message.content, botUserId), + mentionsBot, + }, + ] +} + +/** A press on a button Maple rendered. The id is handed back verbatim; the host decodes it. */ +// BOUNDARY: `envelope` is one undecoded event from a DISPATCH batch. +const interactionCreate = (envelope: unknown): ReadonlyArray => { + const decoded = decodeInteractionCreate(envelope) + if (Option.isNone(decoded)) return [] + const press = decoded.value.payload + return [ + { + type: "action", + connector: HAZEL_CONNECTOR_ID, + workspaceId: press.orgId, + // The thread's own id when the button is in one, which is what names the conversation. + channelId: press.channelId, + messageId: press.messageId, + actionToken: press.actionId, + actor: { id: press.userId, displayName: press.userName }, + }, + ] +} + +/** + * One batch's events. Without the bot's own user id — a gateway that predates READY naming it — + * a mention cannot be told from any other message, so messages are dropped until it is known. + */ +export const mapEvents = ( + envelopes: ReadonlyArray, + botUserId: string | undefined, +): ReadonlyArray => + envelopes.flatMap((envelope) => { + const type = Option.match(decodeEventType(envelope), { + onNone: () => undefined, + onSome: (decoded) => decoded.eventType, + }) + if (type === "message.create") + return botUserId === undefined ? [] : messageCreate(envelope, botUserId) + if (type === "interaction.create") return interactionCreate(envelope) + return [] + }) diff --git a/packages/chat-platform/src/connectors/hazel/gateway-payloads.ts b/packages/chat-platform/src/connectors/hazel/gateway-payloads.ts new file mode 100644 index 0000000000..f6b81af106 --- /dev/null +++ b/packages/chat-platform/src/connectors/hazel/gateway-payloads.ts @@ -0,0 +1,95 @@ +/** + * Hazel's bot gateway frames, as far as this connector reads them. + * + * Decoded at the socket's edge and nowhere else. Every schema names only the fields the state + * machine or the event mapping reads, so a field Hazel adds is ignored rather than fatal. + */ +import { Schema } from "effect" + +export const HelloFrame = Schema.Struct({ + op: Schema.Literal("HELLO"), + heartbeatIntervalMs: Schema.Finite, +}) + +export const ReadyFrame = Schema.Struct({ + op: Schema.Literal("READY"), + sessionId: Schema.String, + /** Who the bot is: a mention names it as `@[userId:]`. Absent from older gateways. */ + botUserId: Schema.optionalKey(Schema.String), +}) + +/** One event in a batch, undecoded: an envelope this connector cannot read is skipped, not fatal. */ +export const DispatchFrame = Schema.Struct({ + op: Schema.Literal("DISPATCH"), + sessionId: Schema.String, + events: Schema.Array(Schema.Unknown), + nextOffset: Schema.String, +}) + +export const HeartbeatAckFrame = Schema.Struct({ op: Schema.Literal("HEARTBEAT_ACK") }) + +export const ReconnectFrame = Schema.Struct({ op: Schema.Literal("RECONNECT"), reason: Schema.String }) + +export const InvalidSessionFrame = Schema.Struct({ + op: Schema.Literal("INVALID_SESSION"), + reason: Schema.String, +}) + +export const ServerFrame = Schema.Union([ + HelloFrame, + ReadyFrame, + DispatchFrame, + HeartbeatAckFrame, + ReconnectFrame, + InvalidSessionFrame, +]) +export type ServerFrame = typeof ServerFrame.Type + +export const decodeServerFrame = Schema.decodeUnknownOption(Schema.fromJsonString(ServerFrame)) + +/** `message.create`, as far as a turn reads it. */ +export const MessageCreateEnvelope = Schema.Struct({ + eventType: Schema.Literal("message.create"), + /** `org::channel:`: the only place the event names its workspace. */ + partitionKey: Schema.String, + payload: Schema.Struct({ + id: Schema.String, + channelId: Schema.String, + authorId: Schema.String, + content: Schema.String, + }), + author: Schema.optionalKey( + Schema.Struct({ id: Schema.String, displayName: Schema.String, isBot: Schema.Boolean }), + ), +}) + +/** `interaction.create`: a button press on one of the bot's messages. */ +export const InteractionCreateEnvelope = Schema.Struct({ + eventType: Schema.Literal("interaction.create"), + payload: Schema.Struct({ + actionId: Schema.String, + messageId: Schema.String, + channelId: Schema.String, + orgId: Schema.String, + userId: Schema.String, + userName: Schema.String, + }), +}) + +export const decodeMessageCreate = Schema.decodeUnknownOption(MessageCreateEnvelope) +export const decodeInteractionCreate = Schema.decodeUnknownOption(InteractionCreateEnvelope) + +/** + * What INVALID_SESSION says when the token itself is refused. Nothing a reconnect fixes, so it + * stops the loop until the host's fatal hold passes. + */ +export const INVALID_TOKEN_REASON = "Invalid bot token" + +/** + * What INVALID_SESSION says while another session holds the token — the previous connection, not + * yet timed out. The gateway frees it on its own; a RESUME of that session replaces it sooner. + */ +export const SESSION_CONFLICT_REASON = "another active session already owns this bot token" + +/** Close code for a reconnect the connector asked for: abnormal, so nothing reads it as a goodbye. */ +export const RECONNECT_CLOSE_CODE = 4000 diff --git a/packages/chat-platform/src/connectors/hazel/gateway.test.ts b/packages/chat-platform/src/connectors/hazel/gateway.test.ts new file mode 100644 index 0000000000..32ca023bd3 --- /dev/null +++ b/packages/chat-platform/src/connectors/hazel/gateway.test.ts @@ -0,0 +1,265 @@ +import { describe, expect, it } from "vitest" +import type { ConnectorConfig } from "../../ingress.ts" +import { socketIngress } from "../../ingress.ts" +import { BOT_TOKEN_CONFIG, GATEWAY_URL } from "./api.ts" +import { gatewayProtocol, type GatewayState } from "./gateway.ts" +import { SESSION_CONFLICT_REASON } from "./gateway-payloads.ts" + +const config: ConnectorConfig = new Map([[BOT_TOKEN_CONFIG, "hzl_bot_token"]]) +const NOW = 1_700_000_000_000 +const BOT_USER = "11111111-1111-4111-8111-111111111111" +const ADA = "22222222-2222-4222-8222-222222222222" +const ORG = "33333333-3333-4333-8333-333333333333" +const CHANNEL = "44444444-4444-4444-8444-444444444444" + +const ready = (overrides: Partial = {}): GatewayState => ({ + awaitingAck: false, + sessionId: "session-1", + offset: "0000000000000007", + heartbeatIntervalMs: 25_000, + botUserId: BOT_USER, + ...overrides, +}) + +const frame = (payload: Record): string => JSON.stringify(payload) +const sent = (raw: string | undefined): Record => JSON.parse(raw ?? "{}") + +const messageCreate = (content: string, overrides: Record = {}) => ({ + schemaVersion: 1, + deliveryId: "d1", + partitionKey: `org:${ORG}:channel:${CHANNEL}`, + occurredAt: NOW, + idempotencyKey: "k1", + eventType: "message.create", + payload: { + id: "55555555-5555-4555-8555-555555555555", + channelId: CHANNEL, + authorId: ADA, + content, + embeds: null, + replyToMessageId: null, + threadChannelId: null, + createdAt: new Date(NOW).toISOString(), + updatedAt: null, + deletedAt: null, + }, + author: { id: ADA, displayName: "Ada Lovelace", isBot: false }, + ...overrides, +}) + +const dispatch = (events: ReadonlyArray, nextOffset = "0000000000000008") => + frame({ op: "DISPATCH", sessionId: "session-1", events, nextOffset }) + +describe("the handshake", () => { + it("waits for HELLO, then identifies from new events only", () => { + const opened = gatewayProtocol.onOpen({ awaitingAck: false }, NOW) + expect(opened.send).toBeUndefined() + expect(opened.heartbeatAt).toBe(NOW + 20_000) + const hello = gatewayProtocol.onFrame( + opened.state, + frame({ op: "HELLO", heartbeatIntervalMs: 25_000 }), + NOW, + config, + ) + expect(hello.send?.map(sent)).toEqual([ + { op: "IDENTIFY", botToken: "hzl_bot_token", resumeOffset: "now" }, + ]) + expect(hello.heartbeatAt).toBe(NOW + 25_000) + expect(gatewayProtocol.connectUrl(hello.state, config)).toBe(GATEWAY_URL) + }) + + it("resumes a held session from the last acknowledged offset", () => { + const hello = gatewayProtocol.onFrame( + ready({ heartbeatIntervalMs: undefined }), + frame({ op: "HELLO", heartbeatIntervalMs: 25_000 }), + NOW, + config, + ) + expect(hello.send?.map(sent)).toEqual([ + { + op: "RESUME", + botToken: "hzl_bot_token", + sessionId: "session-1", + resumeOffset: "0000000000000007", + }, + ]) + }) + + it("learns the session and the bot's own user from READY", () => { + const step = gatewayProtocol.onFrame( + { awaitingAck: false, heartbeatIntervalMs: 25_000 }, + frame({ + op: "READY", + sessionId: "s2", + resumed: false, + resumeOffset: "now", + botId: "b", + botUserId: BOT_USER, + }), + NOW, + config, + ) + expect(step.state).toMatchObject({ sessionId: "s2", botUserId: BOT_USER }) + }) +}) + +describe("dispatch", () => { + it("acknowledges each batch at once and remembers where it got to", () => { + const step = gatewayProtocol.onFrame(ready(), dispatch([]), NOW, config) + expect(step.send?.map(sent)).toEqual([ + { op: "ACK", sessionId: "session-1", nextOffset: "0000000000000008" }, + ]) + expect(step.state.offset).toBe("0000000000000008") + }) + + it("reports a mention without the bot's own markup, named after its author", () => { + const step = gatewayProtocol.onFrame( + ready(), + dispatch([messageCreate(`@[userId:${BOT_USER}] why is checkout slow?`)]), + NOW, + config, + ) + expect(step.events).toEqual([ + { + type: "message", + connector: "hazel", + workspaceId: ORG, + channelId: CHANNEL, + messageId: "55555555-5555-4555-8555-555555555555", + author: { id: ADA, displayName: "Ada Lovelace", isBot: false }, + text: "why is checkout slow?", + mentionsBot: true, + }, + ]) + }) + + it("reports an unaddressed message too, and drops its own, other bots' and empty ones", () => { + const step = gatewayProtocol.onFrame( + ready(), + dispatch([ + messageCreate("and the payments call?"), + messageCreate("I answered", { + payload: { ...messageCreate("").payload, authorId: BOT_USER }, + }), + messageCreate("beep", { author: { id: "x", displayName: "CI", isBot: true } }), + messageCreate(" "), + { eventType: "message.update", payload: {} }, + { eventType: "channel.create", payload: {} }, + "not an envelope", + ]), + NOW, + config, + ) + expect( + step.events?.map((event) => event.type === "message" && [event.text, event.mentionsBot]), + ).toEqual([["and the payments call?", false]]) + }) + + it("drops messages until it knows which mention is its own", () => { + const step = gatewayProtocol.onFrame( + ready({ botUserId: undefined }), + dispatch([messageCreate(`@[userId:${BOT_USER}] hi`)]), + NOW, + config, + ) + expect(step.events).toEqual([]) + expect(step.send).toHaveLength(1) + }) + + it("reports a button press with the id Maple put on it", () => { + const step = gatewayProtocol.onFrame( + ready(), + dispatch([ + { + eventType: "interaction.create", + payload: { + interactionId: "i1", + actionId: "approve:call_1", + messageId: "m1", + channelId: CHANNEL, + orgId: ORG, + userId: ADA, + userName: "Ada Lovelace", + timestamp: NOW, + }, + }, + ]), + NOW, + config, + ) + expect(step.events).toEqual([ + { + type: "action", + connector: "hazel", + workspaceId: ORG, + channelId: CHANNEL, + messageId: "m1", + actionToken: "approve:call_1", + actor: { id: ADA, displayName: "Ada Lovelace" }, + }, + ]) + }) +}) + +describe("keeping the connection", () => { + it("heartbeats on its session and reconnects when one goes unanswered", () => { + const beat = gatewayProtocol.heartbeat(ready(), NOW) + expect(beat.send?.map(sent)).toEqual([{ op: "HEARTBEAT", sessionId: "session-1" }]) + expect(beat.heartbeatAt).toBe(NOW + 25_000) + const acked = gatewayProtocol.onFrame(beat.state, frame({ op: "HEARTBEAT_ACK" }), NOW, config) + expect(acked.state.awaitingAck).toBe(false) + expect(gatewayProtocol.heartbeat(beat.state, NOW + 25_000).directive?._tag).toBe("reconnect") + }) + + it("reconnects without a HELLO, on RECONNECT and on any close, keeping the session", () => { + expect(gatewayProtocol.heartbeat({ awaitingAck: false }, NOW).directive?._tag).toBe("reconnect") + const asked = gatewayProtocol.onFrame( + ready(), + frame({ op: "RECONNECT", reason: "ack_timeout" }), + NOW, + config, + ) + expect(asked).toMatchObject({ state: { sessionId: "session-1" }, directive: { _tag: "reconnect" } }) + expect(gatewayProtocol.onClose(ready(), 1006, "")).toMatchObject({ + state: { sessionId: "session-1" }, + directive: { _tag: "reconnect" }, + }) + }) + + it("stops on a refused token, keeps the session through a conflict, and forgets it otherwise", () => { + const refused = gatewayProtocol.onFrame( + ready(), + frame({ op: "INVALID_SESSION", reason: "Invalid bot token" }), + NOW, + config, + ) + expect(refused.directive?._tag).toBe("stop") + const conflict = gatewayProtocol.onFrame( + ready(), + frame({ op: "INVALID_SESSION", reason: SESSION_CONFLICT_REASON }), + NOW, + config, + ) + expect(conflict).toMatchObject({ + state: { sessionId: "session-1" }, + directive: { _tag: "reconnect" }, + }) + const replaced = gatewayProtocol.onFrame( + ready(), + frame({ op: "INVALID_SESSION", reason: "session resumed by another connection" }), + NOW, + config, + ) + expect(replaced.state.sessionId).toBeUndefined() + expect(replaced.state.offset).toBe("0000000000000007") + }) + + it("survives the host's persistence, and an unreadable frame changes nothing", () => { + const ingress = socketIngress(gatewayProtocol) + const step = ingress.onFrame(ingress.initialState, "{not json", NOW, config) + expect(step.state).toBe(ingress.initialState) + expect(ingress.onFrame("garbage", frame({ op: "HEARTBEAT_ACK" }), NOW, config).state).toBe( + ingress.initialState, + ) + }) +}) diff --git a/packages/chat-platform/src/connectors/hazel/gateway.ts b/packages/chat-platform/src/connectors/hazel/gateway.ts new file mode 100644 index 0000000000..02b2823876 --- /dev/null +++ b/packages/chat-platform/src/connectors/hazel/gateway.ts @@ -0,0 +1,161 @@ +/** + * Hazel's bot gateway protocol, as a pure state machine. + * + * No I/O, no clock: every function takes the state and one input and returns the next state plus + * what the host should do, so the protocol is a table of recorded frames in `gateway.test.ts`. + * + * The protocol (`@hazel/domain`'s `bot-gateway.ts`): the server says HELLO with a heartbeat + * interval; the client IDENTIFYs with its token, or RESUMEs a session it holds, from an offset; + * READY names the session (and the bot's user); events arrive in DISPATCH batches, one batch in + * flight at a time, each acknowledged by echoing its `nextOffset` — an unacknowledged batch is + * redelivered on the next connection, and the gateway asks for a reconnect after a minute without + * one. HEARTBEATs keep the connection; RECONNECT and INVALID_SESSION end it. + * + * One session per bot token: a second IDENTIFY is refused while a session is live, and only a + * RESUME of the live session's own id replaces it. So the session id is kept across every reconnect + * and deploy, and a fresh IDENTIFY happens only when there is none. + */ +import { Option, Schema } from "effect" +import type { ConnectorConfig, SocketDirective, SocketIngressDefinition, SocketStep } from "../../ingress.ts" +import { BOT_TOKEN_CONFIG, GATEWAY_URL } from "./api.ts" +import { mapEvents } from "./gateway-events.ts" +import { + decodeServerFrame, + INVALID_TOKEN_REASON, + RECONNECT_CLOSE_CODE, + SESSION_CONFLICT_REASON, +} from "./gateway-payloads.ts" + +/** How long a connection may go without a HELLO before it is abandoned. Hazel sends it at once. */ +const HELLO_TIMEOUT_MS = 20_000 + +/** + * Where a session with no offset of its own starts: new events only. `-1` would replay the week of + * events the gateway retains, and every one of them would be answered. + */ +const FIRST_OFFSET = "now" + +export const GatewayState = Schema.Struct({ + /** From READY. Kept across reconnects: it is what lets this connection replace a stale one. */ + sessionId: Schema.optional(Schema.String), + /** The last batch's `nextOffset`, acknowledged — where a reconnect resumes. */ + offset: Schema.optional(Schema.String), + /** From HELLO. Absent means the handshake has not happened on this connection. */ + heartbeatIntervalMs: Schema.optional(Schema.Number), + /** A heartbeat is outstanding. Still true at the next one means a dead connection. */ + awaitingAck: Schema.Boolean, + /** From READY. Without it a mention cannot be told from any other message. */ + botUserId: Schema.optional(Schema.String), +}) +export type GatewayState = Schema.Schema.Type + +const initialState: GatewayState = { awaitingAck: false } + +const reconnect: SocketDirective = { _tag: "reconnect", closeCode: RECONNECT_CLOSE_CODE } + +const token = (config: ConnectorConfig): string => config.get(BOT_TOKEN_CONFIG) ?? "" + +const handshakeFrame = (state: GatewayState, config: ConnectorConfig): string => { + const resumeOffset = state.offset ?? FIRST_OFFSET + return JSON.stringify( + state.sessionId === undefined + ? { op: "IDENTIFY", botToken: token(config), resumeOffset } + : { op: "RESUME", botToken: token(config), sessionId: state.sessionId, resumeOffset }, + ) +} + +const heartbeatFrame = (state: GatewayState): string => + JSON.stringify( + state.sessionId === undefined ? { op: "HEARTBEAT" } : { op: "HEARTBEAT", sessionId: state.sessionId }, + ) + +const onOpen = (state: GatewayState, now: number): SocketStep => ({ + state: { ...state, awaitingAck: false, heartbeatIntervalMs: undefined }, + heartbeatAt: now + HELLO_TIMEOUT_MS, +}) + +// BOUNDARY: `frameText` is whatever the socket delivered; decoded at this edge. +const onFrame = ( + state: GatewayState, + frameText: string, + now: number, + config: ConnectorConfig, +): SocketStep => { + const decoded = decodeServerFrame(frameText) + // An unreadable frame is dropped with the state untouched: closing a healthy connection over + // one costs a handshake and fixes nothing. + if (Option.isNone(decoded)) return { state } + const frame = decoded.value + switch (frame.op) { + case "HELLO": + return { + state: { ...state, heartbeatIntervalMs: frame.heartbeatIntervalMs, awaitingAck: false }, + send: [handshakeFrame(state, config)], + heartbeatAt: now + frame.heartbeatIntervalMs, + } + case "READY": + return { + state: { + ...state, + sessionId: frame.sessionId, + botUserId: frame.botUserId ?? state.botUserId, + }, + } + case "DISPATCH": + // Acknowledged as it arrives: the events are handed to the host in this same step, and + // holding the ACK back would only have the gateway redeliver what was already handled. + return { + state: { ...state, sessionId: frame.sessionId, offset: frame.nextOffset }, + send: [ + JSON.stringify({ op: "ACK", sessionId: frame.sessionId, nextOffset: frame.nextOffset }), + ], + events: mapEvents(frame.events, state.botUserId), + } + case "HEARTBEAT_ACK": + return { state: { ...state, awaitingAck: false } } + case "RECONNECT": + return { state, directive: reconnect } + case "INVALID_SESSION": + if (frame.reason === INVALID_TOKEN_REASON) { + return { + state, + directive: { + _tag: "stop", + reason: "Hazel refused the bot token: set MAPLE_HAZEL_BOT_TOKEN to the bot's current token", + }, + } + } + // Another live session for this token (a connection the host has not seen close yet) is + // freed by the gateway's own watchdog: back off and try again with the session kept, so + // a RESUME of it replaces the stale socket. Anything else forgets the session. + return { + state: frame.reason === SESSION_CONFLICT_REASON ? state : { ...state, sessionId: undefined }, + directive: reconnect, + } + } +} + +/** Every close reconnects and resumes: Hazel has no close code that a reconnect cannot fix. */ +const onClose = (state: GatewayState): SocketStep => ({ state, directive: reconnect }) + +/** The timer: no HELLO arrived, the last heartbeat went unanswered, or one is due. */ +const heartbeat = (state: GatewayState, now: number): SocketStep => { + const interval = state.heartbeatIntervalMs + if (interval === undefined || state.awaitingAck) return { state, directive: reconnect } + return { + state: { ...state, awaitingAck: true }, + send: [heartbeatFrame(state)], + heartbeatAt: now + interval, + } +} + +export const gatewayProtocol: SocketIngressDefinition = { + requiredConfig: [{ name: BOT_TOKEN_CONFIG, secret: true }], + stateSchema: Schema.fromJsonString(GatewayState), + initialState, + connectUrl: () => GATEWAY_URL, + onOpen, + onFrame, + onClose, + heartbeat, +} diff --git a/packages/chat-platform/src/connectors/hazel/id.ts b/packages/chat-platform/src/connectors/hazel/id.ts new file mode 100644 index 0000000000..0ee5f8d9b7 --- /dev/null +++ b/packages/chat-platform/src/connectors/hazel/id.ts @@ -0,0 +1,7 @@ +import { chatConnectorId } from "../../connector" + +/** + * Its own module so each half can name the connector without importing the structure that is + * assembled from all of them. + */ +export const HAZEL_CONNECTOR_ID = chatConnectorId("hazel") diff --git a/packages/chat-platform/src/connectors/hazel/index.ts b/packages/chat-platform/src/connectors/hazel/index.ts new file mode 100644 index 0000000000..ed253c5a6b --- /dev/null +++ b/packages/chat-platform/src/connectors/hazel/index.ts @@ -0,0 +1,26 @@ +import type { HttpClient } from "effect/http" +import type { ChatConnector } from "../../connector" +import { socketIngress } from "../../ingress" +import type { ConnectorCredentials } from "../../outbound" +import { gatewayProtocol } from "./gateway" +import { HAZEL_CONNECTOR_ID } from "./id" +import { hazelInstall } from "./install" +import { hazelManifest } from "./manifest" +import { hazelOutbound } from "./outbound" + +/** + * Hazel, every half: how it is installed, how its events arrive, how a turn is carried back. + * + * Its events arrive over Hazel's bot gateway — a resumable WebSocket — so `ingress` is the socket + * kind and the host keeps one connection open for it. There is no `identity` half: Hazel has no + * way yet for a person to prove which Hazel account is theirs to Maple, so anyone in a linked + * workspace may ask and approve, as the org-level connector identity (see `ChatConnector.identity`). + * See `README.md` in this directory for the bot setup all of them depend on. + */ +export const hazel: ChatConnector = { + id: HAZEL_CONNECTOR_ID, + manifest: hazelManifest, + install: hazelInstall, + outbound: hazelOutbound, + ingress: socketIngress(gatewayProtocol), +} diff --git a/packages/chat-platform/src/connectors/hazel/install.test.ts b/packages/chat-platform/src/connectors/hazel/install.test.ts new file mode 100644 index 0000000000..81e0114024 --- /dev/null +++ b/packages/chat-platform/src/connectors/hazel/install.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "@effect/vitest" +import { Effect, Layer, Redacted } from "effect" +import { HttpClient, HttpClientResponse, type HttpClientRequest } from "effect/http" +import type { ChatConnectorConfig } from "../../install" +import { BOT_ID_CONFIG, BOT_TOKEN_CONFIG } from "./api" +import { hazelInstall, hazelInstallUrl } from "./install" + +const config: ChatConnectorConfig = new Map([ + [BOT_ID_CONFIG, Redacted.make("bot-1")], + [BOT_TOKEN_CONFIG, Redacted.make("hzl_bot_token")], +]) +const redirectUri = "https://api.maple.dev/oauth/chat/hazel/callback" + +const stub = (status: number, body: string) => { + const seen: Array = [] + const client = HttpClient.make((request) => { + seen.push(request) + return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(body, { status }))) + }) + return { seen, layer: Layer.succeed(HttpClient.HttpClient)(client) } +} + +/** A request's JSON body, compared whole against what the test expects. */ +type SentBody = Readonly> + +const sentBody = (request: HttpClientRequest.HttpClientRequest): SentBody => { + const body = request.body + if (body._tag !== "Uint8Array") throw new Error("the request carried no JSON body") + // SAFETY: the body under test is one this transport just wrote; a wrong shape fails the assertion. + return JSON.parse(new TextDecoder().decode(body.body)) as SentBody +} + +describe("hazel install", () => { + it("sends the admin to Hazel's consent page for this bot", () => { + const url = new URL(hazelInstallUrl("bot-1", { state: "nonce", redirectUri })) + expect(`${url.origin}${url.pathname}`).toBe("https://app.hazel.sh/oauth/bot-install") + expect(Object.fromEntries(url.searchParams)).toEqual({ + bot_id: "bot-1", + redirect_uri: redirectUri, + state: "nonce", + }) + }) + + it.effect("redeems the code with the bot's own token for the workspace it names", () => { + const http = stub( + 200, + JSON.stringify({ organizationId: "org-1", organizationName: "Acme", installedBy: "u" }), + ) + return Effect.gen(function* () { + const result = yield* hazelInstall.complete({ + config, + params: new URLSearchParams({ + code: "org-1.secret", + state: "nonce", + organizationId: "org-evil", + }), + redirectUri, + }) + expect(result).toEqual({ externalWorkspaceId: "org-1", name: "Acme" }) + const [request] = http.seen + expect(request?.url).toBe("https://api.hazel.sh/api/v1/bot-installs/token") + expect(request?.headers["authorization"]).toBe("Bearer hzl_bot_token") + expect(sentBody(request!)).toEqual({ code: "org-1.secret", redirectUri }) + }).pipe(Effect.provide(http.layer)) + }) + + it.effect("fails a declined install, a missing code and a refused one without calling out twice", () => { + const http = stub(400, JSON.stringify({ _tag: "BotInstallGrantInvalidError", message: "x" })) + return Effect.gen(function* () { + const declined = yield* Effect.flip( + hazelInstall.complete({ + config, + params: new URLSearchParams({ error: "access_denied" }), + redirectUri, + }), + ) + expect(declined.message).toContain("access_denied") + const missing = yield* Effect.flip( + hazelInstall.complete({ config, params: new URLSearchParams(), redirectUri }), + ) + expect(missing._tag).toBe("@maple/chat-platform/ChatInstallFailed") + const refused = yield* Effect.flip( + hazelInstall.complete({ config, params: new URLSearchParams({ code: "c" }), redirectUri }), + ) + expect(refused.message).toContain("expired or was already used") + expect(http.seen).toHaveLength(1) + }).pipe(Effect.provide(http.layer)) + }) +}) diff --git a/packages/chat-platform/src/connectors/hazel/install.ts b/packages/chat-platform/src/connectors/hazel/install.ts new file mode 100644 index 0000000000..fabf816ce8 --- /dev/null +++ b/packages/chat-platform/src/connectors/hazel/install.ts @@ -0,0 +1,110 @@ +import { Effect, Schema } from "effect" +import { HttpClient, HttpClientRequest } from "effect/http" +import { + ChatInstallFailed, + ChatSettingsRejected, + requireConfig, + type ChatConnectorInstall, + type ChatInstallCallback, + type ChatInstallStart, + type ChatWorkspaceSettings, +} from "../../install" +import { API_BASE, BOT_ID_CONFIG, BOT_TOKEN_CONFIG, INSTALL_URL } from "./api" +import { HAZEL_CONNECTOR_ID } from "./id" + +/** + * What Hazel answers a redeemed install grant with: the workspace the admin installed the bot in. + * Only the id and the name are read. + */ +const RedeemedInstall = Schema.Struct({ + organizationId: Schema.String.check(Schema.isMinLength(1)), + organizationName: Schema.String, +}) +const decodeRedeemedInstall = Schema.decodeUnknownEffect(RedeemedInstall) + +/** + * The install link. Hazel's consent page checks `redirect_uri` against the URIs the bot's owner + * registered on the bot (exactly), so the callback has to be one of them; `state` is the host's + * single-use nonce and comes back untouched. + */ +export const hazelInstallUrl = (botId: string, input: { state: string; redirectUri: string }): string => { + const params = new URLSearchParams({ bot_id: botId, redirect_uri: input.redirectUri, state: input.state }) + return `${INSTALL_URL}?${params.toString()}` +} + +const authorizeUrl = (input: ChatInstallStart) => + Effect.map(requireConfig(input.config, HAZEL_CONNECTOR_ID, BOT_ID_CONFIG), (botId) => + hazelInstallUrl(botId, { state: input.state, redirectUri: input.redirectUri }), + ) + +const installFailed = (message: string) => new ChatInstallFailed({ connector: HAZEL_CONNECTOR_ID, message }) + +/** + * Redeem the callback's code for the workspace it was issued against. + * + * The workspace comes from Hazel's answer, never from the callback: the code is bound to this bot, + * this redirect URI and the workspace the admin approved, is single-use and lives ten minutes, and + * only the bot's own token can redeem it. Hazel lets only a workspace's owners and admins approve, + * so a redeemed code is proof that an admin of that workspace installed the bot there. + */ +const complete = Effect.fnUntraced(function* (input: ChatInstallCallback) { + const denied = input.params.get("error") + if (denied !== null) return yield* Effect.fail(installFailed(`Hazel declined the install: ${denied}`)) + const code = input.params.get("code") + if (code === null) return yield* Effect.fail(installFailed("Hazel's callback carried no install code")) + const token = yield* requireConfig(input.config, HAZEL_CONNECTOR_ID, BOT_TOKEN_CONFIG) + const client = yield* HttpClient.HttpClient + const response = yield* client + .execute( + HttpClientRequest.post(`${API_BASE}/bot-installs/token`, { + headers: { accept: "application/json", authorization: `Bearer ${token}` }, + }).pipe(HttpClientRequest.bodyJsonUnsafe({ code, redirectUri: input.redirectUri })), + ) + // The message only: the request carries the bot token. + .pipe( + Effect.mapError((error) => + installFailed(`Hazel install redemption failed: ${error.reason._tag}`), + ), + ) + if (response.status === 400) { + return yield* Effect.fail( + installFailed("Hazel refused the install code: it expired or was already used"), + ) + } + if (response.status < 200 || response.status >= 300) { + return yield* Effect.fail( + installFailed(`Hazel install redemption failed with HTTP ${response.status}`), + ) + } + const json = yield* response.json.pipe( + Effect.mapError(() => installFailed("Hazel returned a non-JSON install response")), + ) + const redeemed = yield* decodeRedeemedInstall(json).pipe( + Effect.mapError(() => installFailed("Hazel returned an unexpected install response")), + ) + return { externalWorkspaceId: redeemed.organizationId, name: redeemed.organizationName } +}) + +const decodeSettings = ( + input: ChatWorkspaceSettings, +): Effect.Effect => + // Checked explicitly rather than through an empty schema: a struct with no keys has nothing to + // call excess, so that decode accepts everything. + Object.keys(input).length === 0 + ? Effect.succeed({}) + : Effect.fail( + new ChatSettingsRejected({ + connector: HAZEL_CONNECTOR_ID, + message: "Hazel has no settings to configure", + }), + ) + +export const hazelInstall: ChatConnectorInstall = { + requiredConfig: [ + { name: BOT_ID_CONFIG, secret: false }, + { name: BOT_TOKEN_CONFIG, secret: true }, + ], + authorizeUrl, + complete, + decodeSettings, +} diff --git a/packages/chat-platform/src/connectors/hazel/manifest.ts b/packages/chat-platform/src/connectors/hazel/manifest.ts new file mode 100644 index 0000000000..66da1062a7 --- /dev/null +++ b/packages/chat-platform/src/connectors/hazel/manifest.ts @@ -0,0 +1,25 @@ +import type { ChatConnectorManifest } from "../../install" +import { HAZEL_CONNECTOR_ID } from "./id" + +/** + * Pure data — imported by the dashboard through `@maple/chat-platform/manifests`, + * which is why this module must stay free of anything that pulls the connector's + * runtime (the install flow, its HTTP client, its outbound REST calls). + */ +export const hazelManifest: ChatConnectorManifest = { + id: HAZEL_CONNECTOR_ID, + name: "Hazel", + description: "Add the Maple bot to a Hazel workspace and link it to your Maple organization.", + icon: { + viewBox: "0 0 266 219", + paths: [ + { + d: "M155 124.5C167.167 122 187.955 109.3 194.355 96.5H194.351C186.184 82.1666 159.851 55 119.851 61C117.851 55.8333 111.351 43.4 101.351 35C89.3534 43 36.8548 167.5 93.3539 219C65.3542 210.167 7.65476 178.6 0.854757 123C-7.64524 53.5 48.8583 8.5 98.8548 8.5C113.448 8.5 132.429 10.7577 151.994 13.0847C199.458 18.7303 250.355 24.7841 250.355 0C270.189 29 289 137.5 172.5 183.5C172.846 182.02 173.227 180.493 173.618 178.927C177.936 161.629 183.427 139.631 155 124.5ZM146 88.5C146 93.1944 142.194 97 137.5 97C132.806 97 129 93.1944 129 88.5C129 83.8056 132.806 80 137.5 80C142.194 80 146 83.8056 146 88.5ZM120.788 136.708C92.0055 138.35 87.7744 156.553 89.2567 165.449C89.2567 169.308 95.9604 172.519 102.089 175.454C107.696 178.138 112.822 180.593 111.895 183.104C109.955 188.359 100.576 188.578 96.1295 188.031C99.9025 198.706 111.006 219.399 125.235 216.772C139.464 214.144 150.029 196.516 153.532 188.031C149.49 188.852 137.767 186.388 137.767 181.461C137.767 179.718 140.551 178.54 144.149 177.018C150.718 174.238 160 170.312 160 159.701C159.03 139.993 136.958 135.339 126.044 135.476C128.307 127.593 134.802 124.254 137.767 123.569C136.796 118.971 132.781 117.274 130.894 117C123.456 122.584 121.057 132.465 120.788 136.708Z", + fill: "#F46F0F", + }, + ], + }, + accent: "#F46F0F", + // Nothing to configure per workspace. + settingsFields: [], +} diff --git a/packages/chat-platform/src/connectors/hazel/outbound.test.ts b/packages/chat-platform/src/connectors/hazel/outbound.test.ts new file mode 100644 index 0000000000..5f07ae8f53 --- /dev/null +++ b/packages/chat-platform/src/connectors/hazel/outbound.test.ts @@ -0,0 +1,246 @@ +/** + * The transport against a stubbed HTTP client: the requests Hazel's bot API expects, a 429 waited + * out, and the conversation a mention opens. + */ +import { describe, expect, it } from "@effect/vitest" +import { Effect, Fiber, Layer } from "effect" +import { HttpClient, HttpClientResponse, type HttpClientRequest } from "effect/http" +import { TestClock } from "effect/testing" +import type { InboundAction, InboundMessage } from "../../ingress" +import { ConnectorCredentials } from "../../outbound" +import { BOT_TOKEN_CONFIG } from "./api" +import { HAZEL_CONNECTOR_ID } from "./id" +import { hazelOutbound } from "./outbound" + +interface Reply { + readonly status: number + readonly body: string +} + +/** Answers each request with the first reply whose route prefix it matches. */ +const stub = (routes: ReadonlyArray]>) => { + const seen: Array = [] + const counts = new Map() + const client = HttpClient.make((request) => { + seen.push(request) + const path = request.url.replace("https://api.hazel.sh/api/v1", "") + const [prefix, replies] = routes.find(([route]) => path.startsWith(route)) ?? [ + "", + { status: 500, body: "" }, + ] + const list = Array.isArray(replies) ? replies : [replies as Reply] + const index = counts.get(prefix) ?? 0 + counts.set(prefix, index + 1) + const reply = list[Math.min(index, list.length - 1)]! + return Effect.succeed( + HttpClientResponse.fromWeb(request, new Response(reply.body, { status: reply.status })), + ) + }) + return { + seen, + layer: Layer.mergeAll( + Layer.succeed(HttpClient.HttpClient)(client), + Layer.succeed(ConnectorCredentials)( + Effect.succeed(new Map([[BOT_TOKEN_CONFIG, "hzl_bot_token"]])), + ), + ), + } +} + +/** A request's JSON body, compared whole against what the test expects. */ +type SentBody = Readonly> + +const sentBody = (request: HttpClientRequest.HttpClientRequest | undefined): SentBody => { + const body = request?.body + if (body?._tag !== "Uint8Array") throw new Error("the request carried no JSON body") + // SAFETY: the body under test is one this transport just wrote; a wrong shape fails the assertion. + return JSON.parse(new TextDecoder().decode(body.body)) as SentBody +} + +const ORG = "33333333-3333-4333-8333-333333333333" +const CHANNEL = "44444444-4444-4444-8444-444444444444" +const THREAD = "66666666-6666-4666-8666-666666666666" +const ADA = "22222222-2222-4222-8222-222222222222" +const BOT = "11111111-1111-4111-8111-111111111111" +const target = { workspaceId: ORG, channelId: THREAD } + +const mention: InboundMessage = { + type: "message", + connector: HAZEL_CONNECTOR_ID, + workspaceId: ORG, + channelId: CHANNEL, + messageId: "m-question", + author: { id: ADA, displayName: "Ada", isBot: false }, + text: "why is checkout slow", + mentionsBot: true, +} + +describe("hazel transport", () => { + it.effect("posts as the bot, and edits the message in its channel", () => { + const http = stub([ + ["/messages", { status: 200, body: JSON.stringify({ data: { id: "m1" }, transactionId: "t" }) }], + ]) + return Effect.gen(function* () { + const transport = yield* hazelOutbound.transport + const ref = yield* transport.post(target, [{ kind: "prose", markdown: "hello" }]) + expect(ref).toEqual({ target, messageId: "m1" }) + yield* transport.edit(ref, [{ kind: "prose", markdown: "hello again" }]) + const [post, edit] = http.seen + expect(post?.headers["authorization"]).toBe("Bearer hzl_bot_token") + expect(sentBody(post)).toEqual({ + channelId: THREAD, + content: "hello", + embeds: null, + replyToMessageId: null, + threadChannelId: null, + }) + expect([edit?.method, edit?.url]).toEqual(["PATCH", "https://api.hazel.sh/api/v1/messages/m1"]) + expect(sentBody(edit)).toEqual({ channelId: THREAD, content: "hello again", embeds: null }) + }).pipe(Effect.provide(http.layer)) + }) + + it.effect("waits out a 429 for as long as Hazel asks", () => { + const http = stub([ + [ + "/messages", + [ + { + status: 429, + body: JSON.stringify({ _tag: "RateLimitExceededError", retryAfterMs: 2000 }), + }, + { status: 200, body: JSON.stringify({ data: { id: "m1" } }) }, + ], + ], + ]) + return Effect.gen(function* () { + const transport = yield* hazelOutbound.transport + const fiber = yield* Effect.forkChild(transport.post(target, [])) + yield* TestClock.adjust("2 seconds") + expect((yield* Fiber.join(fiber)).messageId).toBe("m1") + expect(http.seen).toHaveLength(2) + }).pipe(Effect.provide(http.layer)) + }) + + it.effect("answers a mention in a thread on it, keyed without dashes", () => { + const http = stub([["/channels/", { status: 200, body: JSON.stringify({ data: { id: THREAD } }) }]]) + return Effect.gen(function* () { + const transport = yield* hazelOutbound.transport + const conversation = yield* transport.conversation(mention) + expect(conversation).toEqual({ + conversationKey: THREAD.replaceAll("-", ""), + target: { workspaceId: ORG, channelId: THREAD }, + opened: true, + }) + expect(http.seen[0]?.url).toBe(`https://api.hazel.sh/api/v1/channels/${CHANNEL}/threads`) + expect(sentBody(http.seen[0])).toEqual({ messageId: "m-question" }) + }).pipe(Effect.provide(http.layer)) + }) + + it.effect("opens nothing for a press or an unaddressed message, and falls back to the channel", () => { + const http = stub([["/channels/", { status: 403, body: "" }]]) + return Effect.gen(function* () { + const transport = yield* hazelOutbound.transport + const press: InboundAction = { + type: "action", + connector: HAZEL_CONNECTOR_ID, + workspaceId: ORG, + channelId: THREAD, + messageId: "m1", + actionToken: "approve:call_1", + actor: { id: ADA, displayName: "Ada" }, + } + expect((yield* transport.conversation(press)).opened).toBe(false) + expect((yield* transport.conversation({ ...mention, mentionsBot: false })).target.channelId).toBe( + CHANNEL, + ) + expect(http.seen).toHaveLength(0) + const refused = yield* transport.conversation(mention) + expect(refused).toMatchObject({ target: { channelId: CHANNEL }, opened: false }) + }).pipe(Effect.provide(http.layer)) + }) + + it.effect("reads history newest first with its authors' names and mentions as names", () => { + const http = stub([ + [ + "/messages", + { + status: 200, + body: JSON.stringify({ + data: [ + { + id: "m2", + authorId: BOT, + content: "Looking.", + createdAt: "2026-10-31T10:01:00.000Z", + }, + { + id: "m1", + authorId: ADA, + content: `@[userId:${BOT}] is checkout ok?`, + createdAt: "2026-10-31T10:00:00.000Z", + }, + ], + has_more: false, + }), + }, + ], + [ + "/organizations/", + { + status: 200, + body: JSON.stringify({ + data: [ + { id: ADA, displayName: "Ada", avatarUrl: null, isBot: false }, + { id: BOT, displayName: "Maple", avatarUrl: null, isBot: true }, + ], + }), + }, + ], + ]) + return Effect.gen(function* () { + const transport = yield* hazelOutbound.transport + const history = yield* transport.history(target, { limit: 20, before: "m3" }) + expect(history).toEqual([ + { + displayName: "Maple", + isBot: true, + text: "Looking.", + at: Date.parse("2026-10-31T10:01:00.000Z"), + }, + { + displayName: "Ada", + isBot: false, + text: "@Maple is checkout ok?", + at: Date.parse("2026-10-31T10:00:00.000Z"), + }, + ]) + expect(http.seen[0]?.url).toBe( + `https://api.hazel.sh/api/v1/messages?channel_id=${THREAD}&starting_after=m3&limit=20`, + ) + expect(http.seen[1]?.url).toContain(`/organizations/${ORG}/users?ids=`) + }).pipe(Effect.provide(http.layer)) + }) + + it.effect("whispers by mentioning the person who pressed", () => { + const http = stub([["/messages", { status: 200, body: JSON.stringify({ data: { id: "m9" } }) }]]) + return Effect.gen(function* () { + const transport = yield* hazelOutbound.transport + yield* transport.whisper( + { + type: "action", + connector: HAZEL_CONNECTOR_ID, + workspaceId: ORG, + channelId: THREAD, + messageId: "m1", + actionToken: "approve:call_1", + actor: { id: ADA, displayName: "Ada" }, + }, + [{ kind: "notice", tone: "info", text: "Already decided." }], + ) + expect(sentBody(http.seen[0])).toMatchObject({ + channelId: THREAD, + content: `@[userId:${ADA}] _Already decided._`, + }) + }).pipe(Effect.provide(http.layer)) + }) +}) diff --git a/packages/chat-platform/src/connectors/hazel/outbound.ts b/packages/chat-platform/src/connectors/hazel/outbound.ts new file mode 100644 index 0000000000..ef05e3524a --- /dev/null +++ b/packages/chat-platform/src/connectors/hazel/outbound.ts @@ -0,0 +1,419 @@ +/** + * Hazel's outbound half: its public bot API (`/api/v1`) over Effect's `HttpClient`, authenticated + * by the bot token the host resolves (`ConnectorCredentials`) — this package never reads an + * environment. + * + * Every attempt is a `Client`-kind span carrying `peer.service` and the route TEMPLATE, never the + * concrete path: a channel and a message id are high-cardinality and one of them names the + * conversation. + * + * The API (`@hazel/domain/http` `api-v1-messages`, `api-v1-bots`): create is `POST /messages`, edit + * is `PATCH /messages/{id}` naming its channel, history is `GET /messages?channel_id=` newest first + * with Stripe-style cursors, a thread is `POST /channels/{id}/threads` (idempotent: a message's + * thread is answered again), and the people behind ids are `GET /organizations/{id}/users?ids=`. + * A thread IS a channel, so once a turn answers in one every call addresses the thread's id. + */ +import { ChatConversationKey } from "@maple/primitives" +import { Array as Arr, Duration, Effect, Option, Redacted, Schema } from "effect" +import { HttpClient, HttpClientRequest, type HttpClientResponse } from "effect/http" +import type { ConnectorConfig, InboundAction, InboundMessage } from "../../ingress" +import { + ChatOutboundError, + ConnectorCredentials, + type ChatConversation, + type ChatHistoryMessage, + type ChatMessageRef, + type ChatOutbound, + type ChatOutboundFailureReason, + type ChatOutboundOperation, + type ChatTarget, + type ChatThreadRequest, +} from "../../outbound" +import type { ChatBlock } from "../../render/blocks" +import { API_BASE, API_HOST, BOT_TOKEN_CONFIG } from "./api" +import { mentionOf } from "./gateway-events" +import { HAZEL_CONNECTOR_ID } from "./id" +import { MAX_CONTENT_CHARS, renderHazelMessage } from "./render" + +const botToken = (config: ConnectorConfig): Redacted.Redacted => + Redacted.make(config.get(BOT_TOKEN_CONFIG) ?? "") + +/** Under the renderer's own clamp, so a connector's decoration has somewhere to go. */ +const MAX_MESSAGE_CHARS = MAX_CONTENT_CHARS - 400 + +/** + * Hazel allows a bot 120 message writes a minute, edits included, shared by every conversation it + * is answering in. One edit a second keeps a streaming turn live and leaves room for several. + */ +const MIN_EDIT_INTERVAL = Duration.seconds(1) + +/** How many times a 429 is waited out before the turn gives up on the message. */ +const MAX_RATE_LIMIT_ATTEMPTS = 3 + +/** The longest a 429 may park a turn: a remote number reaching `Effect.sleep` is bounded here. */ +const MAX_RETRY_AFTER = Duration.seconds(10) + +const DEFAULT_RETRY_AFTER = Duration.seconds(1) + +/** Hazel's own ceiling on one page of history. */ +const MAX_HISTORY_LIMIT = 100 + +/** Hazel's 429 body (`RateLimitExceededError`). */ +const RateLimited = Schema.Struct({ + retryAfterMs: Schema.Finite.check(Schema.isBetween({ minimum: 0, maximum: 3_600_000 })), +}) + +const MessageCreated = Schema.Struct({ data: Schema.Struct({ id: Schema.String }) }) + +const ThreadCreated = Schema.Struct({ data: Schema.Struct({ id: Schema.String }) }) + +/** One earlier message, as far as the model's context reads it. */ +const HistoryMessage = Schema.Struct({ + authorId: Schema.String, + content: Schema.String, + /** ISO-8601. A value that will not parse leaves the message out rather than dating it to 1970. */ + createdAt: Schema.String, +}) +const decodeHistoryMessage = Schema.decodeUnknownOption(HistoryMessage) + +const HistoryPage = Schema.Struct({ data: Schema.Array(Schema.Unknown) }) + +const Users = Schema.Struct({ + data: Schema.Array( + Schema.Struct({ id: Schema.String, displayName: Schema.String, isBot: Schema.Boolean }), + ), +}) + +/** The ids a message mentions, so the context can name them rather than show markup. */ +const MENTION = /@\[userId:([0-9a-f-]{36})\]/gu + +const mentionedIds = (text: string): ReadonlyArray => + Array.from(text.matchAll(MENTION)).flatMap((match) => (match[1] === undefined ? [] : [match[1]])) + +/** + * A conversation's key: the channel (or thread) id without its dashes, which the key's charset + * refuses. Hazel ids are UUIDs, so nothing is lost. + */ +const decodeConversationKey = (channelId: string) => + Schema.decodeUnknownOption(ChatConversationKey)(channelId.replaceAll("-", "")) + +/** The thread when the turn is in one, the channel otherwise — on Hazel both are channel ids. */ +const channelOf = (target: ChatTarget): string => target.threadId ?? target.channelId + +const reasonForStatus = (status: number): ChatOutboundFailureReason | undefined => { + if (status === 401 || status === 403) return "auth" + if (status === 404) return "not_found" + if (status === 400) return "rejected" + return undefined +} + +/** Internal to the retry loop: a 429 and how long it asked for. */ +class HazelRateLimited extends Schema.TaggedError()( + "@maple/chat-platform/connectors/hazel/RateLimited", + { wait: Schema.Duration }, +) {} + +export const hazelOutbound: ChatOutbound = { + connectorId: HAZEL_CONNECTOR_ID, + limits: { maxMessageChars: MAX_MESSAGE_CHARS, minEditInterval: MIN_EDIT_INTERVAL }, + requiredConfig: [{ name: BOT_TOKEN_CONFIG, secret: true }], + transport: Effect.gen(function* () { + const client = yield* HttpClient.HttpClient + // Per call, not here: acquiring the transport must not cost the host its credential read. + const token = Effect.map(yield* ConnectorCredentials, botToken) + + const attempt = Effect.fn("Hazel.request", { kind: "client" })(function* ( + bearer: Redacted.Redacted, + operation: ChatOutboundOperation, + route: string, + request: HttpClientRequest.HttpClientRequest, + ) { + yield* Effect.annotateCurrentSpan({ + "peer.service": "hazel", + "http.request.method": request.method, + "server.address": API_HOST, + "url.template": route, + }) + const response = yield* client + .execute( + HttpClientRequest.setHeader(request, "authorization", `Bearer ${Redacted.value(bearer)}`), + ) + .pipe( + Effect.mapError((cause) => + failed(operation, `Hazel request failed (${cause.reason._tag})`, { cause }), + ), + ) + yield* Effect.annotateCurrentSpan({ "http.response.status_code": response.status }) + if (response.status === 429) + return yield* new HazelRateLimited({ wait: yield* retryAfter(response) }) + if (response.status >= 300) { + const reason = reasonForStatus(response.status) + return yield* failed(operation, `Hazel answered ${response.status}`, { + status: response.status, + ...(reason === undefined ? undefined : { reason }), + }) + } + return response + }) + + const send = ( + operation: ChatOutboundOperation, + route: string, + request: HttpClientRequest.HttpClientRequest, + ): Effect.Effect => + Effect.flatMap(token, (bearer) => { + const tryOnce = ( + count: number, + ): Effect.Effect => + attempt(bearer, operation, route, request).pipe( + Effect.catchTag("@maple/chat-platform/connectors/hazel/RateLimited", (limited) => + count >= MAX_RATE_LIMIT_ATTEMPTS + ? Effect.fail( + failed(operation, "Hazel kept rate limiting this message", { + status: 429, + }), + ) + : Effect.andThen(Effect.sleep(limited.wait), tryOnce(count + 1)), + ), + ) + return tryOnce(1) + }) + + /** The reply's JSON in `schema`, failing the operation (without the body) when it is not. */ + const read = ( + operation: ChatOutboundOperation, + response: HttpClientResponse.HttpClientResponse, + schema: Schema.Codec, + what: string, + ) => + response.json.pipe( + Effect.mapError((cause) => failed(operation, "Hazel's reply could not be read", { cause })), + Effect.flatMap((json) => + Schema.decodeUnknownEffect(schema)(json).pipe( + Effect.mapError(() => failed(operation, `Hazel answered with ${what}`)), + ), + ), + ) + + const postBody = ( + channelId: string, + body: { readonly content: string; readonly embeds: ReadonlyArray }, + ) => + HttpClientRequest.bodyJsonUnsafe({ + channelId, + content: body.content, + embeds: body.embeds.length === 0 ? null : body.embeds, + replyToMessageId: null, + threadChannelId: null, + }) + + const post = Effect.fn("Hazel.post")(function* ( + target: ChatTarget, + blocks: ReadonlyArray, + ) { + const response = yield* send( + "post", + "/messages", + HttpClientRequest.post(`${API_BASE}/messages`).pipe( + postBody(channelOf(target), renderHazelMessage(blocks)), + ), + ) + const created = yield* read("post", response, MessageCreated, "no message id") + return { target, messageId: created.data.id } + }) + + const openThread = Effect.fn("Hazel.openThread")(function* (request: ChatThreadRequest) { + const response = yield* send( + "thread", + "/channels/{channel_id}/threads", + HttpClientRequest.post( + `${API_BASE}/channels/${encodeURIComponent(request.channelId)}/threads`, + ).pipe(HttpClientRequest.bodyJsonUnsafe({ messageId: request.anchorMessageId })), + ) + const thread = yield* read("thread", response, ThreadCreated, "no thread id") + return thread.data.id + }) + + /** The people behind `ids` in the workspace; an id Hazel does not know is simply absent. */ + const people = (workspaceId: string, ids: ReadonlyArray) => + ids.length === 0 + ? Effect.succeed(new Map()) + : send( + "history", + "/organizations/{organization_id}/users", + HttpClientRequest.get( + `${API_BASE}/organizations/${encodeURIComponent(workspaceId)}/users?ids=${encodeURIComponent(ids.join(","))}`, + ), + ).pipe( + Effect.flatMap((response) => read("history", response, Users, "no list of people")), + Effect.map((users) => new Map(users.data.map((user) => [user.id, user]))), + ) + + return { + post, + + edit: (ref: ChatMessageRef, blocks: ReadonlyArray) => { + const body = renderHazelMessage(blocks) + return send( + "edit", + "/messages/{message_id}", + HttpClientRequest.patch(`${API_BASE}/messages/${encodeURIComponent(ref.messageId)}`).pipe( + HttpClientRequest.bodyJsonUnsafe({ + channelId: channelOf(ref.target), + content: body.content, + embeds: body.embeds.length === 0 ? null : body.embeds, + }), + ), + ).pipe(Effect.asVoid) + }, + + /** + * Hazel cannot show a message to one person, so a whisper is a message in the conversation + * that mentions them: they are notified, and anyone else reading sees what it was about. + */ + whisper: Effect.fn("Hazel.whisper")(function* ( + action: InboundAction, + blocks: ReadonlyArray, + ) { + const body = renderHazelMessage(blocks) + yield* send( + "whisper", + "/messages", + HttpClientRequest.post(`${API_BASE}/messages`).pipe( + postBody(action.channelId, { + ...body, + content: `${mentionOf(action.actor.id)} ${body.content}`, + }), + ), + ) + }), + + /** + * A no-op: Hazel's typing indicator is a channel member's, and the bot is not one in a + * public channel. The placeholder the driver posts first already says it is working. + */ + typing: () => Effect.void, + + openThread, + + /** + * What was said in this conversation before a message, newest first — Hazel's own order — + * with each author's name read once for the page, and mentions shown as names. + */ + history: Effect.fn("Hazel.history")(function* ( + target: ChatTarget, + options: { readonly limit: number; readonly before: string }, + ) { + const limit = Math.min(options.limit, MAX_HISTORY_LIMIT) + const response = yield* send( + "history", + "/messages", + HttpClientRequest.get( + `${API_BASE}/messages?channel_id=${encodeURIComponent(channelOf(target))}&starting_after=${encodeURIComponent(options.before)}&limit=${limit}`, + ), + ) + const page = yield* read("history", response, HistoryPage, "no list of messages") + const messages = Arr.getSomes(page.data.map((raw) => decodeHistoryMessage(raw))) + const ids = [ + ...new Set( + messages.flatMap((message) => [message.authorId, ...mentionedIds(message.content)]), + ), + ] + const names = yield* people(target.workspaceId, ids) + return Arr.getSomes( + messages.map((message): Option.Option => { + const at = Date.parse(message.createdAt) + if (Number.isNaN(at)) return Option.none() + const author = names.get(message.authorId) + return Option.some({ + displayName: author?.displayName ?? "Someone", + isBot: author?.isBot ?? false, + text: message.content.replace( + MENTION, + (_, id: string) => `@${names.get(id)?.displayName ?? "someone"}`, + ), + at, + }) + }), + ) + }), + + /** + * None: alerts reach Hazel through Maple's Hazel webhook integration, which provisions a + * channel webhook per destination. The bot API lists no channels for a bot to pick from. + */ + destinations: () => Effect.succeed([]), + + /** + * A mention is answered in a thread on it, so a channel keeps reading as a channel. On + * Hazel a thread is a channel, so its id is the key and every later call's address, and a + * follow-up mention inside it arrives with that id and lands on the same session. A + * mention that is already in a thread answers with that thread (Hazel replies in it), and + * is a conversation the bot was brought into — so both are the bot's own. + * + * A press opens nothing: it happened inside a conversation, and Hazel's own channel id for + * it names that conversation. Read from the event rather than the button, which is what + * lets the host refuse a control naming another conversation. An unaddressed message + * opens nothing either and costs no request. + */ + conversation: (event: InboundMessage | InboundAction) => + (event.type === "action" || !event.mentionsBot + ? Effect.succeed({ channelId: event.channelId, opened: false }) + : openThread({ + workspaceId: event.workspaceId, + channelId: event.channelId, + anchorMessageId: event.messageId, + title: event.text, + }).pipe( + Effect.map((channelId) => ({ channelId, opened: true })), + Effect.tapError((error) => + Effect.logWarning("No thread was opened for this mention").pipe( + Effect.annotateLogs({ + "error.message": error.message, + "http.response.status_code": error.status ?? 0, + }), + ), + ), + Effect.orElseSucceed(() => ({ channelId: event.channelId, opened: false })), + ) + ).pipe( + Effect.flatMap(({ channelId, opened }) => + Option.match(decodeConversationKey(channelId), { + onNone: () => + Effect.fail( + failed("thread", "Hazel named a conversation Maple cannot address"), + ), + onSome: (conversationKey): Effect.Effect => + Effect.succeed({ + conversationKey, + target: { workspaceId: event.workspaceId, channelId }, + opened, + }), + }), + ), + ), + } + }), +} + +const failed = ( + operation: ChatOutboundOperation, + message: string, + extra: { + readonly status?: number + readonly reason?: ChatOutboundFailureReason + readonly cause?: unknown + } = {}, +) => new ChatOutboundError({ message, connectorId: HAZEL_CONNECTOR_ID, operation, ...extra }) + +/** How long Hazel asked us to wait, clamped: its body's `retryAfterMs`, else the `retry-after` header. */ +const retryAfter = (response: HttpClientResponse.HttpClientResponse) => + response.json.pipe( + Effect.flatMap(Schema.decodeUnknownEffect(RateLimited)), + Effect.map((limited) => Duration.millis(limited.retryAfterMs)), + Effect.orElseSucceed(() => { + const seconds = Number(response.headers["retry-after"]) + return Number.isFinite(seconds) && seconds >= 0 ? Duration.seconds(seconds) : DEFAULT_RETRY_AFTER + }), + Effect.map(Duration.min(MAX_RETRY_AFTER)), + ) diff --git a/packages/chat-platform/src/connectors/hazel/render.test.ts b/packages/chat-platform/src/connectors/hazel/render.test.ts new file mode 100644 index 0000000000..b5b0bfb4e4 --- /dev/null +++ b/packages/chat-platform/src/connectors/hazel/render.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest" +import { encodeChatActionToken } from "../../action-token" +import { EMPTY_CONTENT, renderHazelMessage } from "./render" + +describe("hazel render", () => { + it("writes prose as markdown and never lets it mention anybody", () => { + const body = renderHazelMessage([ + { kind: "prose", markdown: "Checkout is slow. cc @[directive:channel] @[userId:abc]" }, + ]) + expect(body.embeds).toEqual([]) + expect(body.content).toBe("Checkout is slow. cc @​[directive:channel] @​[userId:abc]") + }) + + it("puts an open approval on an embed with two buttons carrying Maple's control ids", () => { + const body = renderHazelMessage([ + { + kind: "approval", + toolName: "restart_service", + summary: "Restart checkout in production", + token: encodeChatActionToken("call_1"), + outcome: null, + }, + ]) + expect(body.embeds).toEqual([ + { + title: "Approve restart_service?", + description: "Restart checkout in production", + actions: [ + { id: "approve:call_1", label: "Run restart_service", style: "primary" }, + { id: "deny:call_1", label: "Skip", style: "secondary" }, + ], + }, + ]) + }) + + it("leaves the buttons off an id Hazel would refuse, and off a decided proposal", () => { + const long = renderHazelMessage([ + { + kind: "approval", + toolName: "restart_service", + summary: "", + token: encodeChatActionToken("c".repeat(120)), + outcome: null, + }, + ]) + expect(long.embeds[0]).toEqual({ + title: "Approve restart_service?", + footer: { text: "This one has to be approved in Maple." }, + }) + const decided = renderHazelMessage([ + { + kind: "approval", + toolName: "restart_service", + summary: "", + token: encodeChatActionToken("call_1"), + outcome: { approved: true, decision: "Approved by Ada.", text: "Restarted.", url: null }, + }, + ]) + expect(decided.embeds).toEqual([]) + expect(decided.content).toBe("Restarted.\n_Approved by Ada._") + }) + + it("shows a chart with an image as a card, and one without as its summary", () => { + const chart = (imageUrl: string | null) => + renderHazelMessage([ + { + kind: "chart", + spec: {} as never, + unit: "ms", + title: "p95 latency", + summary: "p95 over 24h", + imageUrl, + }, + ]) + expect(chart("https://img/1.png").embeds).toEqual([ + { title: "p95 latency", description: "p95 over 24h", image: { url: "https://img/1.png" } }, + ]) + expect(chart(null).content).toBe("**p95 latency** — p95 over 24h") + }) + + it("renders an alert as a card with its link, facts and bar colour", () => { + const body = renderHazelMessage([ + { + kind: "alert", + color: "#ff0000", + title: "Error rate high", + summary: "checkout errors at 5%", + fields: [{ label: "Service", value: "checkout" }], + imageUrl: null, + imageAlt: "", + links: [{ label: "Open alert", url: "https://app.maple.dev/a/1", primary: true }], + footer: ["`prod`"], + sentAtMs: 0, + }, + ]) + expect(body.embeds[0]).toEqual({ + title: "Error rate high", + url: "https://app.maple.dev/a/1", + color: 0xff0000, + description: "checkout errors at 5%", + fields: [ + { name: "Service", value: "checkout", inline: true }, + { name: "Links", value: "[Open alert](https://app.maple.dev/a/1)", inline: false }, + ], + footer: { text: "prod" }, + timestamp: "1970-01-01T00:00:00.000Z", + }) + }) + + it("says something when there is nothing to say yet", () => { + expect(renderHazelMessage([])).toEqual({ content: EMPTY_CONTENT, embeds: [] }) + }) +}) diff --git a/packages/chat-platform/src/connectors/hazel/render.ts b/packages/chat-platform/src/connectors/hazel/render.ts new file mode 100644 index 0000000000..28a50f8e6d --- /dev/null +++ b/packages/chat-platform/src/connectors/hazel/render.ts @@ -0,0 +1,225 @@ +/** + * Blocks → a Hazel message body: markdown `content` plus `embeds`. + * + * Pure, and the only place Hazel's dialect is written down: its embed shape (`@hazel/domain`'s + * `MessageEmbed`), the buttons an embed carries (`actions`, at most five, each id 1–100 + * characters), and its mention markup, which nothing the model writes may produce. + */ +import { chatActionControlId, type ChatActionToken } from "../../action-token" +import type { ChatAlertBlock, ChatBlock, ChatToolActivity } from "../../render/blocks" + +/** A button on an embed. Pressing it sends the bot an `interaction.create` carrying `id`. */ +export interface HazelEmbedAction { + readonly id: string + readonly label: string + readonly style?: "primary" | "secondary" | "danger" +} + +/** `MessageEmbed`, as far as this connector writes one. */ +export interface HazelEmbed { + readonly title?: string + readonly description?: string + readonly url?: string + /** The bar down the side, as a 24-bit integer. */ + readonly color?: number + readonly fields?: ReadonlyArray<{ + readonly name: string + readonly value: string + readonly inline: boolean + }> + readonly image?: { readonly url: string } + readonly footer?: { readonly text: string } + /** ISO 8601. */ + readonly timestamp?: string + readonly actions?: ReadonlyArray +} + +export interface HazelMessageBody { + readonly content: string + readonly embeds: ReadonlyArray +} + +/** + * Hazel sets no limit on `content`; this is the one the neutral cutting is held under (with room + * for decoration), so a turn still splits into messages a reader can take in. + */ +export const MAX_CONTENT_CHARS = 4000 +const MAX_EMBED_TITLE_CHARS = 256 +const MAX_EMBED_DESCRIPTION_CHARS = 4096 +const MAX_EMBED_FIELDS = 25 +const MAX_FIELD_NAME_CHARS = 256 +const MAX_FIELD_VALUE_CHARS = 1024 +const MAX_FOOTER_CHARS = 2048 +export const MAX_ACTION_ID_CHARS = 100 +const MAX_ACTION_LABEL_CHARS = 80 + +/** + * A message with nothing in it: the driver posts an empty placeholder before the first words, and + * empties a message a retraction shrank a turn past. + */ +export const EMPTY_CONTENT = "—" + +/** + * Hazel's mention markup, defused. `@[userId:…]` pings a person and `@[directive:channel]` a whole + * channel; a model quoting either, or a log line holding one, must not notify anybody. A zero-width + * space after the `@` leaves the text readable and the markup inert. + */ +export const defuseMentions = (text: string): string => text.replaceAll("@[", "@​[") + +/** What one message's worth of blocks becomes. */ +export const renderHazelMessage = (blocks: ReadonlyArray): HazelMessageBody => { + const lines: Array = [] + const embeds: Array = [] + + for (const block of blocks) { + switch (block.kind) { + case "prose": + lines.push(block.markdown) + break + case "chart": { + if (block.imageUrl !== null) { + embeds.push({ + ...(block.title === null + ? undefined + : { title: clamp(block.title, MAX_EMBED_TITLE_CHARS) }), + description: clamp(block.summary, MAX_EMBED_DESCRIPTION_CHARS), + image: { url: block.imageUrl }, + }) + break + } + lines.push(block.title === null ? block.summary : `**${block.title}** — ${block.summary}`) + break + } + case "entity": { + const label = block.url === null ? block.label : `[${block.label}](${block.url})` + const detail = block.detail === null ? "" : `\n> ${block.detail}` + lines.push(`> **${ENTITY_LABELS[block.entity]}** ${label}${detail}`) + break + } + case "activity": + if (block.tools.length > 0) lines.push(`_${block.tools.map(toolLabel).join(" · ")}_`) + break + case "approval": { + const { outcome } = block + if (outcome !== null) { + // A decided proposal loses its buttons, and what it asked for gives way to what came + // of it, with who decided underneath. + const link = outcome.url === null ? "" : ` [Open in Maple](${outcome.url})` + lines.push( + outcome.text === "" + ? `**\`${block.toolName}\`** ${outcome.decision}${link}` + : `${outcome.text}${link}\n_${outcome.decision}_`, + ) + break + } + const actions = approvalActions(block.token, block.toolName) + embeds.push({ + title: clamp(`Approve ${block.toolName}?`, MAX_EMBED_TITLE_CHARS), + ...(block.summary === "" + ? undefined + : { description: clamp(block.summary, MAX_EMBED_DESCRIPTION_CHARS) }), + ...(actions === null + ? { footer: { text: "This one has to be approved in Maple." } } + : { actions }), + }) + break + } + case "notice": + lines.push(block.tone === "error" ? `**${block.text}**` : `_${block.text}_`) + break + case "alert": + embeds.push(alertEmbed(block)) + break + default: + // A block kind added to the neutral model but not to this dialect would otherwise + // render as nothing at all. + block satisfies never + } + } + + const content = defuseMentions(lines.join("\n\n")) + return { + content: content === "" && embeds.length === 0 ? EMPTY_CONTENT : clamp(content, MAX_CONTENT_CHARS), + embeds: embeds.map(defuseEmbed), + } +} + +const defuseEmbed = (embed: HazelEmbed): HazelEmbed => ({ + ...embed, + ...(embed.title === undefined ? undefined : { title: defuseMentions(embed.title) }), + ...(embed.description === undefined ? undefined : { description: defuseMentions(embed.description) }), + ...(embed.fields === undefined + ? undefined + : { + fields: embed.fields.map((field) => ({ + ...field, + name: defuseMentions(field.name), + value: defuseMentions(field.value), + })), + }), + ...(embed.footer === undefined ? undefined : { footer: { text: defuseMentions(embed.footer.text) } }), +}) + +/** An alert as a card: the title linking into Maple, the summary, the facts, the chart, a footer. */ +const alertEmbed = (block: ChatAlertBlock): HazelEmbed => { + const primary = block.links.find((link) => link.primary) ?? block.links[0] + const links = block.links.map((link) => `[${link.label}](${link.url})`).join(" · ") + const fields = [ + ...block.fields.map((field) => ({ name: field.label, value: field.value, inline: true })), + ...(links === "" ? [] : [{ name: "Links", value: links, inline: false }]), + ] + .filter((field) => field.name !== "" && field.value !== "") + .slice(0, MAX_EMBED_FIELDS) + .map((field) => ({ + name: clamp(field.name, MAX_FIELD_NAME_CHARS), + value: clamp(field.value, MAX_FIELD_VALUE_CHARS), + inline: field.inline, + })) + const footer = block.footer.map((part) => part.replaceAll("`", "")).join(" · ") + return { + title: clamp(block.title, MAX_EMBED_TITLE_CHARS), + ...(primary === undefined ? undefined : { url: primary.url }), + color: Number.parseInt(block.color.slice(1), 16), + ...(block.summary === "" + ? undefined + : { description: clamp(block.summary, MAX_EMBED_DESCRIPTION_CHARS) }), + ...(fields.length === 0 ? undefined : { fields }), + ...(block.imageUrl === null ? undefined : { image: { url: block.imageUrl } }), + ...(footer === "" ? undefined : { footer: { text: clamp(footer, MAX_FOOTER_CHARS) } }), + ...(block.sentAtMs === null ? undefined : { timestamp: new Date(block.sentAtMs).toISOString() }), + } +} + +const clamp = (text: string, max: number): string => + text.length <= max ? text : `${text.slice(0, max - 1)}…` + +const ENTITY_LABELS = { + trace: "Trace", + service: "Service", + error: "Error", + log: "Log", +} as const + +const toolLabel = (tool: ChatToolActivity): string => { + const detail = tool.detail === null ? "" : ` (${tool.detail})` + const status = tool.status === "failed" ? "… (failed)" : "…" + return `${tool.label}${detail}${status}` +} + +/** + * The two buttons, carrying the driver's action control id as the action's `id`. A tool call id is + * the model provider's, so its length is not ours to bound: a pair that would not fit Hazel's 100 + * characters is left off, because Hazel rejects the whole message over one oversized id. + */ +const approvalActions = ( + token: ChatActionToken, + toolName: string, +): ReadonlyArray | null => { + const approve = chatActionControlId("approve", token) + const deny = chatActionControlId("deny", token) + if (approve.length > MAX_ACTION_ID_CHARS || deny.length > MAX_ACTION_ID_CHARS) return null + return [ + { id: approve, label: clamp(`Run ${toolName}`, MAX_ACTION_LABEL_CHARS), style: "primary" }, + { id: deny, label: "Skip", style: "secondary" }, + ] +} diff --git a/packages/chat-platform/src/connectors/index.ts b/packages/chat-platform/src/connectors/index.ts index 78e28d9bb5..c778b6583b 100644 --- a/packages/chat-platform/src/connectors/index.ts +++ b/packages/chat-platform/src/connectors/index.ts @@ -10,6 +10,7 @@ * the one an incoming event belongs to by id. */ import { discord } from "./discord" +import { hazel } from "./hazel" import { slack } from "./slack" -export const connectors = [discord, slack] +export const connectors = [discord, hazel, slack] diff --git a/packages/chat-platform/src/connectors/manifests.ts b/packages/chat-platform/src/connectors/manifests.ts index e86ca23d23..fea5accb81 100644 --- a/packages/chat-platform/src/connectors/manifests.ts +++ b/packages/chat-platform/src/connectors/manifests.ts @@ -1,13 +1,17 @@ import type { ChatConnectorManifest } from "../install" import { discordManifest } from "./discord/manifest" +import { hazelManifest } from "./hazel/manifest" import { slackManifest } from "./slack/manifest" export type { ChatConnectorManifest } from "../install" +/** Short, so the registry stays one line: `vendor-isolation.test.ts` allows vendor names only there. */ +type Manifest = ChatConnectorManifest + /** * The dashboard's half of the registry: every connector's presentation data, * with none of its runtime. Imported through `@maple/chat-platform/manifests`, * so the browser bundle never pulls a connector's install flow or its HTTP * client just to draw a card. */ -export const chatConnectorManifests: ReadonlyArray = [discordManifest, slackManifest] +export const chatConnectorManifests: ReadonlyArray = [discordManifest, hazelManifest, slackManifest] diff --git a/packages/chat-platform/src/vendor-isolation.test.ts b/packages/chat-platform/src/vendor-isolation.test.ts index 832e19c932..38c6f9e626 100644 --- a/packages/chat-platform/src/vendor-isolation.test.ts +++ b/packages/chat-platform/src/vendor-isolation.test.ts @@ -53,7 +53,14 @@ const GUARDED_FILES = [ "apps/web/src/page/integrations/view-chat-card.ts", ] -const VENDORS = ["discord", "slack", "teams", "telegram", "whatsapp"] +const VENDORS = ["discord", "hazel", "slack", "teams", "telegram", "whatsapp"] + +/** + * Vendor names a guarded file may hold for an integration that is not a chat connector. The + * catalog lists Hazel's alert-webhook integration, which predates (and is separate from) the Hazel + * chat connector. + */ +const OTHER_INTEGRATIONS = new Map([["apps/web/src/page/integrations/catalog.ts", ["hazel"]]]) const SELF = fileURLToPath(import.meta.url) @@ -136,7 +143,9 @@ describe("vendor isolation", () => { } it("keeps the files this feature owns elsewhere free of vendor names", () => { - const offenders = GUARDED_FILES.flatMap((path) => offendingLines(join(repoRoot, path), [], never)) + const offenders = GUARDED_FILES.flatMap((path) => + offendingLines(join(repoRoot, path), OTHER_INTEGRATIONS.get(path) ?? [], never), + ) expect(offenders).toEqual([]) }) From 08ea6f7c4b23a5df956f9b0636ca35ef0f33b529 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 31 Oct 2026 15:01:42 +0100 Subject: [PATCH 2/5] fix(chat): Hazel history is best effort; the install call is traced - A failed name lookup keeps the history page, with "Someone" for authors. - History reads fail fast on a 429 instead of waiting before the turn starts. - The install redemption runs as a Hazel.request client span with peer.service. Co-Authored-By: Claude Opus 5.5 --- .../src/connectors/hazel/install.ts | 45 +++++++++++++------ .../src/connectors/hazel/outbound.test.ts | 39 ++++++++++++++++ .../src/connectors/hazel/outbound.ts | 22 +++++++-- 3 files changed, 88 insertions(+), 18 deletions(-) diff --git a/packages/chat-platform/src/connectors/hazel/install.ts b/packages/chat-platform/src/connectors/hazel/install.ts index fabf816ce8..c7fd674f6a 100644 --- a/packages/chat-platform/src/connectors/hazel/install.ts +++ b/packages/chat-platform/src/connectors/hazel/install.ts @@ -9,7 +9,7 @@ import { type ChatInstallStart, type ChatWorkspaceSettings, } from "../../install" -import { API_BASE, BOT_ID_CONFIG, BOT_TOKEN_CONFIG, INSTALL_URL } from "./api" +import { API_BASE, API_HOST, BOT_ID_CONFIG, BOT_TOKEN_CONFIG, INSTALL_URL } from "./api" import { HAZEL_CONNECTOR_ID } from "./id" /** @@ -37,6 +37,35 @@ const authorizeUrl = (input: ChatInstallStart) => hazelInstallUrl(botId, { state: input.state, redirectUri: input.redirectUri }), ) +/** The redemption POST, as one client span on the service map. */ +const redeem = Effect.fn("Hazel.request", { kind: "client" })(function* ( + token: string, + code: string, + redirectUri: string, +) { + yield* Effect.annotateCurrentSpan({ + "peer.service": "hazel", + "http.request.method": "POST", + "server.address": API_HOST, + "url.template": "/bot-installs/token", + }) + const client = yield* HttpClient.HttpClient + const response = yield* client + .execute( + HttpClientRequest.post(`${API_BASE}/bot-installs/token`, { + headers: { accept: "application/json", authorization: `Bearer ${token}` }, + }).pipe(HttpClientRequest.bodyJsonUnsafe({ code, redirectUri })), + ) + // The message only: the request carries the bot token. + .pipe( + Effect.mapError((error) => + installFailed(`Hazel install redemption failed: ${error.reason._tag}`), + ), + ) + yield* Effect.annotateCurrentSpan({ "http.response.status_code": response.status }) + return response +}) + const installFailed = (message: string) => new ChatInstallFailed({ connector: HAZEL_CONNECTOR_ID, message }) /** @@ -53,19 +82,7 @@ const complete = Effect.fnUntraced(function* (input: ChatInstallCallback) { const code = input.params.get("code") if (code === null) return yield* Effect.fail(installFailed("Hazel's callback carried no install code")) const token = yield* requireConfig(input.config, HAZEL_CONNECTOR_ID, BOT_TOKEN_CONFIG) - const client = yield* HttpClient.HttpClient - const response = yield* client - .execute( - HttpClientRequest.post(`${API_BASE}/bot-installs/token`, { - headers: { accept: "application/json", authorization: `Bearer ${token}` }, - }).pipe(HttpClientRequest.bodyJsonUnsafe({ code, redirectUri: input.redirectUri })), - ) - // The message only: the request carries the bot token. - .pipe( - Effect.mapError((error) => - installFailed(`Hazel install redemption failed: ${error.reason._tag}`), - ), - ) + const response = yield* redeem(token, code, input.redirectUri) if (response.status === 400) { return yield* Effect.fail( installFailed("Hazel refused the install code: it expired or was already used"), diff --git a/packages/chat-platform/src/connectors/hazel/outbound.test.ts b/packages/chat-platform/src/connectors/hazel/outbound.test.ts index 5f07ae8f53..021e7ca5e4 100644 --- a/packages/chat-platform/src/connectors/hazel/outbound.test.ts +++ b/packages/chat-platform/src/connectors/hazel/outbound.test.ts @@ -221,6 +221,45 @@ describe("hazel transport", () => { }).pipe(Effect.provide(http.layer)) }) + it.effect("keeps the history when the names cannot be read, and never waits out a 429 for it", () => { + const page = JSON.stringify({ + data: [ + { + id: "m1", + authorId: ADA, + content: "is checkout ok?", + createdAt: "2026-10-31T10:00:00.000Z", + }, + ], + has_more: false, + }) + const http = stub([ + [ + "/messages", + [ + { status: 200, body: page }, + { status: 429, body: JSON.stringify({ retryAfterMs: 5000 }) }, + ], + ], + ["/organizations/", { status: 403, body: "" }], + ]) + return Effect.gen(function* () { + const transport = yield* hazelOutbound.transport + const history = yield* transport.history(target, { limit: 20, before: "m3" }) + expect(history).toEqual([ + { + displayName: "Someone", + isBot: false, + text: "is checkout ok?", + at: Date.parse("2026-10-31T10:00:00.000Z"), + }, + ]) + const limited = yield* Effect.flip(transport.history(target, { limit: 20, before: "m3" })) + expect(limited.status).toBe(429) + expect(http.seen.filter((request) => request.url.includes("/messages?"))).toHaveLength(2) + }).pipe(Effect.provide(http.layer)) + }) + it.effect("whispers by mentioning the person who pressed", () => { const http = stub([["/messages", { status: 200, body: JSON.stringify({ data: { id: "m9" } }) }]]) return Effect.gen(function* () { diff --git a/packages/chat-platform/src/connectors/hazel/outbound.ts b/packages/chat-platform/src/connectors/hazel/outbound.ts index ef05e3524a..868c7397ca 100644 --- a/packages/chat-platform/src/connectors/hazel/outbound.ts +++ b/packages/chat-platform/src/connectors/hazel/outbound.ts @@ -84,6 +84,12 @@ const Users = Schema.Struct({ ), }) +/** Who an id is, as far as the model's context names them. */ +interface Person { + readonly displayName: string + readonly isBot: boolean +} + /** The ids a message mentions, so the context can name them rather than show markup. */ const MENTION = /@\[userId:([0-9a-f-]{36})\]/gu @@ -167,7 +173,9 @@ export const hazelOutbound: ChatOutbound => attempt(bearer, operation, route, request).pipe( Effect.catchTag("@maple/chat-platform/connectors/hazel/RateLimited", (limited) => - count >= MAX_RATE_LIMIT_ATTEMPTS + // History is CONTEXT read before the turn starts: waiting out a rate limit + // there delays the answer for background the model can do without. + count >= MAX_RATE_LIMIT_ATTEMPTS || operation === "history" ? Effect.fail( failed(operation, "Hazel kept rate limiting this message", { status: 429, @@ -235,9 +243,12 @@ export const hazelOutbound: ChatOutbound) => + const people = ( + workspaceId: string, + ids: ReadonlyArray, + ): Effect.Effect, ChatOutboundError> => ids.length === 0 - ? Effect.succeed(new Map()) + ? Effect.succeed(new Map()) : send( "history", "/organizations/{organization_id}/users", @@ -319,7 +330,10 @@ export const hazelOutbound: ChatOutbound [message.authorId, ...mentionedIds(message.content)]), ), ] - const names = yield* people(target.workspaceId, ids) + // Names are best effort: a page with "Someone" in it beats no context at all. + const names = yield* people(target.workspaceId, ids).pipe( + Effect.orElseSucceed((): ReadonlyMap => new Map()), + ) return Arr.getSomes( messages.map((message): Option.Option => { const at = Date.parse(message.createdAt) From 2a0673e48ba15057d47d89c22d07426f8e34152e Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 31 Oct 2026 15:03:42 +0100 Subject: [PATCH 3/5] fix(chat): read Hazel's retry-after as seconds or an HTTP-date A blank, unparseable or past header waits the default instead of retrying at once into the same limit. Co-Authored-By: Claude Opus 5.5 --- .../src/connectors/hazel/outbound.test.ts | 15 ++++++++++-- .../src/connectors/hazel/outbound.ts | 23 +++++++++++++++---- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/packages/chat-platform/src/connectors/hazel/outbound.test.ts b/packages/chat-platform/src/connectors/hazel/outbound.test.ts index 021e7ca5e4..f4408220bb 100644 --- a/packages/chat-platform/src/connectors/hazel/outbound.test.ts +++ b/packages/chat-platform/src/connectors/hazel/outbound.test.ts @@ -3,14 +3,14 @@ * out, and the conversation a mention opens. */ import { describe, expect, it } from "@effect/vitest" -import { Effect, Fiber, Layer } from "effect" +import { Duration, Effect, Fiber, Layer } from "effect" import { HttpClient, HttpClientResponse, type HttpClientRequest } from "effect/http" import { TestClock } from "effect/testing" import type { InboundAction, InboundMessage } from "../../ingress" import { ConnectorCredentials } from "../../outbound" import { BOT_TOKEN_CONFIG } from "./api" import { HAZEL_CONNECTOR_ID } from "./id" -import { hazelOutbound } from "./outbound" +import { hazelOutbound, headerRetryAfter } from "./outbound" interface Reply { readonly status: number @@ -75,6 +75,17 @@ const mention: InboundMessage = { mentionsBot: true, } +describe("retry-after header", () => { + const now = Date.parse("2026-10-31T10:00:00.000Z") + it("reads seconds and HTTP-dates, and never reads blank or past as zero", () => { + expect(Duration.toMillis(headerRetryAfter("3", now))).toBe(3000) + expect(Duration.toMillis(headerRetryAfter("Sat, 31 Oct 2026 10:00:05 GMT", now))).toBe(5000) + for (const value of [undefined, "", " ", "soon", "Sat, 31 Oct 2026 09:00:00 GMT"]) { + expect(Duration.toMillis(headerRetryAfter(value, now))).toBe(1000) + } + }) +}) + describe("hazel transport", () => { it.effect("posts as the bot, and edits the message in its channel", () => { const http = stub([ diff --git a/packages/chat-platform/src/connectors/hazel/outbound.ts b/packages/chat-platform/src/connectors/hazel/outbound.ts index 868c7397ca..edc0fea3b0 100644 --- a/packages/chat-platform/src/connectors/hazel/outbound.ts +++ b/packages/chat-platform/src/connectors/hazel/outbound.ts @@ -14,7 +14,7 @@ * A thread IS a channel, so once a turn answers in one every call addresses the thread's id. */ import { ChatConversationKey } from "@maple/primitives" -import { Array as Arr, Duration, Effect, Option, Redacted, Schema } from "effect" +import { Array as Arr, Clock, Duration, Effect, Option, Redacted, Schema } from "effect" import { HttpClient, HttpClientRequest, type HttpClientResponse } from "effect/http" import type { ConnectorConfig, InboundAction, InboundMessage } from "../../ingress" import { @@ -425,9 +425,22 @@ const retryAfter = (response: HttpClientResponse.HttpClientResponse) => response.json.pipe( Effect.flatMap(Schema.decodeUnknownEffect(RateLimited)), Effect.map((limited) => Duration.millis(limited.retryAfterMs)), - Effect.orElseSucceed(() => { - const seconds = Number(response.headers["retry-after"]) - return Number.isFinite(seconds) && seconds >= 0 ? Duration.seconds(seconds) : DEFAULT_RETRY_AFTER - }), + Effect.catch(() => + Effect.map(Clock.currentTimeMillis, (now) => + headerRetryAfter(response.headers["retry-after"], now), + ), + ), Effect.map(Duration.min(MAX_RETRY_AFTER)), ) + +/** + * A `retry-after` header: delay-seconds or an HTTP-date. Absent, blank, unparseable or already past + * is the default rather than zero, which would retry at once into the same limit. + */ +export const headerRetryAfter = (value: string | undefined, now: number): Duration.Duration => { + const trimmed = value?.trim() ?? "" + if (trimmed === "") return DEFAULT_RETRY_AFTER + if (/^\d+$/u.test(trimmed)) return Duration.seconds(Number(trimmed)) + const at = Date.parse(trimmed) + return Number.isNaN(at) || at <= now ? DEFAULT_RETRY_AFTER : Duration.millis(at - now) +} From 35bba6ec084c063520e90a28e3477c8e2c2de42c Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 31 Oct 2026 15:07:33 +0100 Subject: [PATCH 4/5] fix(chat): keep a Hazel question's line breaks when stripping the mention Co-Authored-By: Claude Opus 5.5 --- .../src/connectors/hazel/gateway-events.ts | 15 +++++++++++++-- .../src/connectors/hazel/gateway.test.ts | 12 ++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/chat-platform/src/connectors/hazel/gateway-events.ts b/packages/chat-platform/src/connectors/hazel/gateway-events.ts index d7d1b6d50a..bf2e2b9f08 100644 --- a/packages/chat-platform/src/connectors/hazel/gateway-events.ts +++ b/packages/chat-platform/src/connectors/hazel/gateway-events.ts @@ -15,9 +15,20 @@ const decodeEventType = Schema.decodeUnknownOption(Schema.Struct({ eventType: Sc /** `@[userId:]`, Hazel's mention markup (`@hazel/domain`'s mention parser). */ export const mentionOf = (userId: string): string => `@[userId:${userId}]` -/** The bot's own mention removed. A literal split: the id comes off the wire. */ +/** + * The bot's own mention removed, a literal split because the id comes off the wire. Only the spaces + * beside each mention go: line breaks and indentation stay, so a pasted log or code block reaches + * the model as it was written. + */ const stripMention = (text: string, botUserId: string): string => - text.split(mentionOf(botUserId)).join(" ").replace(/\s+/gu, " ").trim() + text + .split(mentionOf(botUserId)) + .map((part, index, parts) => { + const start = index === 0 ? part : part.replace(/^[ \t]+/u, "") + return index === parts.length - 1 ? start : start.replace(/[ \t]+$/u, "") + }) + .join(" ") + .trim() /** The workspace out of `org::channel:`. */ const organizationOf = (partitionKey: string): string | undefined => { diff --git a/packages/chat-platform/src/connectors/hazel/gateway.test.ts b/packages/chat-platform/src/connectors/hazel/gateway.test.ts index 32ca023bd3..433e27dabb 100644 --- a/packages/chat-platform/src/connectors/hazel/gateway.test.ts +++ b/packages/chat-platform/src/connectors/hazel/gateway.test.ts @@ -133,6 +133,18 @@ describe("dispatch", () => { ]) }) + it("keeps the question's line breaks and indentation when it strips the mention", () => { + const step = gatewayProtocol.onFrame( + ready(), + dispatch([ + messageCreate(`@[userId:${BOT_USER}] what does this mean?\n\n Error: pool exhausted\n`), + ]), + NOW, + config, + ) + expect(step.events?.[0]).toMatchObject({ text: "what does this mean?\n\n Error: pool exhausted" }) + }) + it("reports an unaddressed message too, and drops its own, other bots' and empty ones", () => { const step = gatewayProtocol.onFrame( ready(), From a541f93f330e556cb981a88b1f1c0f5fd52e37cc Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 31 Oct 2026 15:11:48 +0100 Subject: [PATCH 5/5] fix(chat): keep the first line's indentation when the Hazel mention sits above it Co-Authored-By: Claude Opus 5.5 --- .../src/connectors/hazel/gateway-events.ts | 23 +++++++++++-------- .../src/connectors/hazel/gateway.test.ts | 12 ++++++++++ 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/packages/chat-platform/src/connectors/hazel/gateway-events.ts b/packages/chat-platform/src/connectors/hazel/gateway-events.ts index bf2e2b9f08..b4845fae75 100644 --- a/packages/chat-platform/src/connectors/hazel/gateway-events.ts +++ b/packages/chat-platform/src/connectors/hazel/gateway-events.ts @@ -20,15 +20,20 @@ export const mentionOf = (userId: string): string => `@[userId:${userId}]` * beside each mention go: line breaks and indentation stay, so a pasted log or code block reaches * the model as it was written. */ -const stripMention = (text: string, botUserId: string): string => - text - .split(mentionOf(botUserId)) - .map((part, index, parts) => { - const start = index === 0 ? part : part.replace(/^[ \t]+/u, "") - return index === parts.length - 1 ? start : start.replace(/[ \t]+$/u, "") - }) - .join(" ") - .trim() +const stripMention = (text: string, botUserId: string): string => { + const parts = text.split(mentionOf(botUserId)).map((part, index, all) => { + const start = index === 0 ? part : part.replace(/^[ \t]+/u, "") + return index === all.length - 1 ? start : start.replace(/[ \t]+$/u, "") + }) + // A space only where the mention sat between words, never at a line's edge. + let joined = "" + for (const part of parts) { + if (joined !== "" && part !== "" && !joined.endsWith("\n") && !part.startsWith("\n")) joined += " " + joined += part + } + // A mention alone on its line leaves a blank one; the next line keeps its indentation. + return joined.replace(/^(?:[ \t]*\r?\n)+/u, "").trimEnd() +} /** The workspace out of `org::channel:`. */ const organizationOf = (partitionKey: string): string | undefined => { diff --git a/packages/chat-platform/src/connectors/hazel/gateway.test.ts b/packages/chat-platform/src/connectors/hazel/gateway.test.ts index 433e27dabb..74aea85bf3 100644 --- a/packages/chat-platform/src/connectors/hazel/gateway.test.ts +++ b/packages/chat-platform/src/connectors/hazel/gateway.test.ts @@ -145,6 +145,18 @@ describe("dispatch", () => { expect(step.events?.[0]).toMatchObject({ text: "what does this mean?\n\n Error: pool exhausted" }) }) + it("keeps the first line's indentation when the mention sits alone above it", () => { + const texts = [ + `@[userId:${BOT_USER}]\n Error: pool exhausted`, + `is @[userId:${BOT_USER}] around?`, + ` @[userId:${BOT_USER}] hi`, + ].map((content) => { + const step = gatewayProtocol.onFrame(ready(), dispatch([messageCreate(content)]), NOW, config) + return step.events?.[0]?.type === "message" ? step.events[0].text : undefined + }) + expect(texts).toEqual([" Error: pool exhausted", "is around?", "hi"]) + }) + it("reports an unaddressed message too, and drops its own, other bots' and empty ones", () => { const step = gatewayProtocol.onFrame( ready(),