Repository navigation
feat(chat): the Maple bot in Hazel #2016
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
83e07ea
feat(chat): the Maple bot in Hazel
Makisuo 08ea6f7
fix(chat): Hazel history is best effort; the install call is traced
Makisuo 2a0673e
fix(chat): read Hazel's retry-after as seconds or an HTTP-date
Makisuo 35bba6e
fix(chat): keep a Hazel question's line breaks when stripping the men…
Makisuo a541f93
fix(chat): keep the first line's indentation when the Hazel mention s…
Makisuo File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" |
122 changes: 122 additions & 0 deletions
122
packages/chat-platform/src/connectors/hazel/gateway-events.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,122 @@ | ||
| /** | ||
| * 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:<id>]`, 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 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 => { | ||
| 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:<orgId>:channel:<channelId>`. */ | ||
| 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<InboundEvent> => { | ||
| 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<InboundEvent> => { | ||
| 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<unknown>, | ||
| botUserId: string | undefined, | ||
| ): ReadonlyArray<InboundEvent> => | ||
| 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 [] | ||
| }) | ||
95 changes: 95 additions & 0 deletions
95
packages/chat-platform/src/connectors/hazel/gateway-payloads.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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:<botUserId>]`. 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:<orgId>:channel:<channelId>`: 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 |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Preserve indentation after a leading mention.
If a user pastes an indented block after the mention, this removes its first line’s indentation. For example,
@[userId:<bot>] first()\n second()becomesfirst()\n second(), changing the code structure. Preserve indentation beyond the mention separator and add a test for this case.