From 5b4006d61c499809d9710557a1a0e7daeb62a5bd Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 31 Oct 2026 18:33:46 +0100 Subject: [PATCH] feat(chat): Hazel channels as alert destinations through the bot The Hazel connector's destinations() reads the channels its bot can post in from Hazel's GET /api/v1/organizations/{id}/bot-channels (every public channel, and the private ones the bot was added to), so a Hazel channel is picked as an ordinary chat alert destination and alerts post as the bot. A 401/403 is the bot's grant (auth), a 404 the workspace (not_found). --- .../src/connectors/hazel/README.md | 14 ++++-- .../src/connectors/hazel/manifest.ts | 3 +- .../src/connectors/hazel/outbound.test.ts | 47 +++++++++++++++++++ .../src/connectors/hazel/outbound.ts | 24 ++++++++-- 4 files changed, 78 insertions(+), 10 deletions(-) diff --git a/packages/chat-platform/src/connectors/hazel/README.md b/packages/chat-platform/src/connectors/hazel/README.md index 0734523e4b..38d4aab087 100644 --- a/packages/chat-platform/src/connectors/hazel/README.md +++ b/packages/chat-platform/src/connectors/hazel/README.md @@ -17,8 +17,11 @@ declared once, in `api.ts`. 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**. +1. **Create the bot** with the scopes `messages:read`, `messages:write`, `channels:write` (opens + threads) and `channels:read` (lists the channels an alert can go to), mark it **public**, and + make it **mentionable**. + A bot created before alerts came through it lacks `channels:read`: add it on each instance's + bot (US and EU), or its channel list answers `auth` and no Hazel channel can be picked. 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. @@ -40,7 +43,10 @@ its own bot, with its own redirect URI. 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 +3. **Send it alerts.** A Hazel channel is an alert destination like any other chat channel. The + picker lists what `GET /api/v1/organizations/{id}/bot-channels` answers: every public channel and the + private ones the bot was added to. Delivery posts as the bot. +4. **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. @@ -75,7 +81,5 @@ pays for them; the host decides whether an unaddressed message is still a turn. - **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/manifest.ts b/packages/chat-platform/src/connectors/hazel/manifest.ts index 66da1062a7..94f82e63b8 100644 --- a/packages/chat-platform/src/connectors/hazel/manifest.ts +++ b/packages/chat-platform/src/connectors/hazel/manifest.ts @@ -9,7 +9,8 @@ import { HAZEL_CONNECTOR_ID } from "./id" 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.", + description: + "Add the Maple bot to a Hazel workspace: ask it questions in threads and post alerts to Hazel channels.", icon: { viewBox: "0 0 266 219", paths: [ diff --git a/packages/chat-platform/src/connectors/hazel/outbound.test.ts b/packages/chat-platform/src/connectors/hazel/outbound.test.ts index f4408220bb..9b58217f8e 100644 --- a/packages/chat-platform/src/connectors/hazel/outbound.test.ts +++ b/packages/chat-platform/src/connectors/hazel/outbound.test.ts @@ -293,4 +293,51 @@ describe("hazel transport", () => { }) }).pipe(Effect.provide(http.layer)) }) + + it.effect("lists the channels the bot can post in as alert destinations", () => { + const http = stub([ + [ + "/organizations/", + { + status: 200, + body: JSON.stringify({ + data: [ + { id: CHANNEL, name: "incidents", private: true }, + { id: THREAD, name: "general", private: false }, + ], + }), + }, + ], + ]) + return Effect.gen(function* () { + const transport = yield* hazelOutbound.transport + expect(yield* transport.destinations(ORG)).toEqual([ + { id: CHANNEL, name: "incidents", private: true }, + { id: THREAD, name: "general", private: false }, + ]) + expect(http.seen[0]?.url).toBe(`https://api.hazel.sh/api/v1/organizations/${ORG}/bot-channels`) + expect(http.seen[0]?.headers.authorization).toBe("Bearer hzl_bot_token") + }).pipe(Effect.provide(http.layer)) + }) + + it.effect("fails the listing as the bot's grant when Hazel refuses it", () => { + const http = stub([["/organizations/", { status: 403, body: "" }]]) + return Effect.gen(function* () { + const transport = yield* hazelOutbound.transport + const refused = yield* Effect.flip(transport.destinations(ORG)) + expect(refused).toMatchObject({ operation: "destinations", reason: "auth", status: 403 }) + }).pipe(Effect.provide(http.layer)) + }) + + it.effect("fails the listing when Hazel answers with something other than channels", () => { + const http = stub([["/organizations/", { status: 200, body: JSON.stringify({ data: [{ id: 1 }] }) }]]) + return Effect.gen(function* () { + const transport = yield* hazelOutbound.transport + const unreadable = yield* Effect.flip(transport.destinations(ORG)) + expect(unreadable).toMatchObject({ + operation: "destinations", + message: "Hazel answered with no list of channels", + }) + }).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 index edc0fea3b0..4b324f3524 100644 --- a/packages/chat-platform/src/connectors/hazel/outbound.ts +++ b/packages/chat-platform/src/connectors/hazel/outbound.ts @@ -10,7 +10,8 @@ * 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=`. + * thread is answered again), the people behind ids are `GET /organizations/{id}/users?ids=`, and + * the channels the bot can post in are `GET /organizations/{id}/bot-channels`. * A thread IS a channel, so once a turn answers in one every call addresses the thread's id. */ import { ChatConversationKey } from "@maple/primitives" @@ -84,6 +85,11 @@ const Users = Schema.Struct({ ), }) +/** The public channels, and the private ones the bot was added to, already sorted by name. */ +const Channels = Schema.Struct({ + data: Schema.Array(Schema.Struct({ id: Schema.String, name: Schema.String, private: Schema.Boolean })), +}) + /** Who an id is, as far as the model's context names them. */ interface Person { readonly displayName: string @@ -353,10 +359,20 @@ export const hazelOutbound: ChatOutbound Effect.succeed([]), + destinations: Effect.fn("Hazel.destinations")(function* (workspaceId: string) { + const response = yield* send( + "destinations", + "/organizations/{organization_id}/bot-channels", + HttpClientRequest.get( + `${API_BASE}/organizations/${encodeURIComponent(workspaceId)}/bot-channels`, + ), + ) + const page = yield* read("destinations", response, Channels, "no list of channels") + return page.data + }), /** * A mention is answered in a thread on it, so a channel keeps reading as a channel. On